<?php
/**
 * @package    Joomla.Component.Builder
 *
 * @created    4th September, 2020
 * @author     Llewellyn van der Merwe <https://dev.vdm.io>
 * @git        Joomla Component Builder <https://git.vdm.dev/joomla/Component-Builder>
 * @copyright  Copyright (C) 2015 Vast Development Method. All rights reserved.
 * @license    GNU General Public License version 2 or later; see LICENSE.txt
 */

namespace VDM\Joomla\Data;


use Joomla\CMS\Factory;
use Joomla\CMS\User\User;
use VDM\Joomla\Interfaces\Data\ItemsInterface as Items;
use VDM\Joomla\Data\Guid;
use VDM\Joomla\Componentbuilder\Utilities\UserHelper;
use VDM\Joomla\Componentbuilder\Utilities\Exception\NoUserIdFoundException;
use VDM\Joomla\Utilities\Component\Helper as Component;
use VDM\Joomla\Interfaces\Data\GuidInterface;
use VDM\Joomla\Interfaces\Data\SubformInterface;


/**
 * CRUD the user data of any sub-form to another view (table)
 * 
 * @since  5.0.2
 */
final class UsersSubform implements GuidInterface, SubformInterface
{
	/**
	 * The Globally Unique Identifier.
	 *
	 * @since 5.0.2
	 */
	use Guid;

	/**
	 * The Items Class.
	 *
	 * @var   Items
	 * @since 3.2.2
	 */
	protected Items $items;

	/**
	 * Table Name
	 *
	 * @var    string
	 * @since 3.2.1
	 */
	protected string $table;

	/**
	 * The user properties
	 *
	 * @var    array
	 * @since 5.0.2
	 */
	protected array $user;

	/**
	 * The current active user
	 *
	 * @var    User
	 * @since 5.0.2
	 */
	protected User $identity;

	/**
	 * The active users
	 *
	 * @var    array
	 * @since 5.0.2
	 */
	protected array $activeUsers = [];

	/**
	 * Constructor.
	 *
	 * @param Items       $items   The items Class.
	 * @param string|null $table   The table name.
	 *
	 * @since 3.2.2
	 */
	public function __construct(Items $items, ?string $table = null)
	{
		$this->items = $items;
		if ($table !== null)
		{
			$this->table = $table;
		}

		$this->identity = Factory::getApplication()->getIdentity();

		// Retrieve the user properties
		$this->initializeUserProperties();
	}

	/**
	 * Set the current active table
	 *
	 * @param string $table The table that should be active
	 *
	 * @return self
	 * @since  3.2.2
	 */
	public function table(string $table): self
	{
		$this->table = $table;

		return $this;
	}

	/**
	 * Get a subform items
	 *
	 * @param string   $linkValue  The value of the link key in child table.
	 * @param string   $linkKey    The link key on which the items where linked in the child table.
	 * @param string   $field      The parent field name of the subform in the parent view.
	 * @param array    $get        The array get:set of the keys of each row in the subform.
	 * @param bool     $multi      The switch to return a multiple set.
	 *
	 * @return array|null   The subform
	 * @since  3.2.2
	 */
	public function get(string $linkValue, string $linkKey, string $field, array $get, bool $multi = true): ?array
	{
		if (($items = $this->items->table($this->getTable())->get([$linkValue], $linkKey)) !== null)
		{
			return $this->converter(
				$this->getUsersDetails($items),
				$get,
				$field,
				$multi
			);
		}

		return null;
	}

	/**
	 * Set a subform items
	 *
	 * @param mixed    $items      The list of items from the subform to set
	 * @param string   $indexKey   The index key on which the items should be observed as it relates to insert/update/delete.
	 * @param string   $linkKey    The link key on which the items where linked in the child table.
	 * @param string   $linkValue  The value of the link key in child table.
	 *
	 * @return bool
	 * @since  3.2.2
	 */
	public function set(mixed $items, string $indexKey, string $linkKey, string $linkValue): bool
	{
		$items = $this->process($items, $indexKey, $linkKey, $linkValue);

		$this->purge($items, $indexKey, $linkKey, $linkValue);

		if (empty($items))
		{
			return true; // nothing to set (already purged)
		}

		return $this->items->table($this->getTable())->set(
			$items, $indexKey
		);
	}

	/**
	 * Get the current active table
	 *
	 * @return  string
	 * @since   3.2.2
	 */
	public function getTable(): string
	{
		return $this->table;
	}

	/**
	 * Initializes the user properties.
	 *
	 * @return void
	 * @since  5.0.2
	 */
	private function initializeUserProperties(): void
	{
		$user = UserHelper::getUserById(0);

		// Populate user properties array excluding the 'id'
		foreach (get_object_vars($user) as $property => $value)
		{
			if ($property !== 'id')
			{
				$this->user[$property] = $property;
			}
		}
		$this->user['password2'] = 'password2';
	}

	/**
	 * Purge all items no longer in subform
	 *
	 * @param array    $items      The list of items to set.
	 * @param string   $indexKey   The index key on which the items should be observed as it relates to insert/update/delete
	 * @param string   $linkKey    The link key on which the items where linked in the child table.
	 * @param string   $linkValue  The value of the link key in child table.
	 *
	 * @return void
	 * @since  3.2.2
	 */
	private function purge(array $items, string $indexKey, string $linkKey, string $linkValue): void
	{
		// Get the current index values from the database
		$currentIndexValues = $this->items->table($this->getTable())->values([$linkValue], $linkKey, $indexKey);

		if ($currentIndexValues !== null)
		{
			// Check if the items array is empty
			if (empty($items))
			{
				// Set activeIndexValues to an empty array if items is empty
				$activeIndexValues = [];
			}
			else
			{
				// Extract the index values from the items array
				$activeIndexValues = array_values(array_map(function($item) use ($indexKey) {
					return $item[$indexKey] ?? null;
				}, $items));
			}

			// Find the index values that are no longer in the items array
			$inactiveIndexValues = array_diff($currentIndexValues, $activeIndexValues);

			// Delete the inactive index values
			if (!empty($inactiveIndexValues))
			{
				$this->items->table($this->getTable())->delete($inactiveIndexValues, $indexKey);

				// $this->deleteUsers($inactiveIndexValues); (soon)
			}
		}
	}

	/**
	 * Get the users details found in the user table.
	 *
	 * @param array  $items  Array of objects or arrays to be filtered.
	 *
	 * @return array
	 * @since  5.0.2
	 */
	private function getUsersDetails(array $items): array
	{
		foreach ($items as $index => &$item)
		{
			$item = (array) $item;
			$this->getUserDetails($item);
		}

		return $items;
	}

	/**
	 * Get the user details found in the user table.
	 *
	 * @param array  $item  The user map array
	 *
	 * @return void
	 * @since  5.0.2
	 */
	private function getUserDetails(array &$item): void
	{
		// Validate the user_id and ensure it is numeric and greater than 0
		if (empty($item['user_id']) || !is_numeric($item['user_id']) || $item['user_id'] <= 0)
		{
			return;
		}

		// Retrieve the user by ID
		$user = UserHelper::getUserById((int)$item['user_id']);

		// Verify if the user exists and the ID matches
		if ($user && $user->id === (int) $item['user_id'])
		{
			// Iterate over public properties of the user object
			foreach (get_object_vars($user) as $property => $value)
			{
				// Avoid overwriting the id in the item
				if ($property !== 'id')
				{
					$item[$property] = $value;
				}
			}
		}
	}

	/**
	 * Filters the specified keys from an array of objects or arrays, converts them to arrays,
	 * and sets them by association with a specified key and an incrementing integer.
	 *
	 * @param array  $items  Array of objects or arrays to be filtered.
	 * @param array  $keySet Array of keys to retain in each item.
	 * @param string $field  The field prefix for the resulting associative array.
	 * @param bool   $multi  The switch to return a multiple set.
	 *
	 * @return array Array of filtered arrays set by association.
	 * @since  3.2.2
	 */
	private function converter(array $items, array $keySet, string $field, bool $multi): array
	{
		/**
		 * Filters keys for a single item and converts it to an array.
		 *
		 * @param object|array $item   The item to filter.
		 * @param array        $keySet The keys to retain.
		 *
		 * @return array The filtered array.
		 * @since 3.2.2
		 */
		$filterKeys = function ($item, array $keySet) {
			$filteredArray = [];
			foreach ($keySet as $key) {
				if (is_object($item) && property_exists($item, $key)) {
					$filteredArray[$key] = $item->{$key};
				} elseif (is_array($item) && array_key_exists($key, $item)) {
					$filteredArray[$key] = $item[$key];
				}
			}
			return $filteredArray;
		};

		$result = [];
		foreach ($items as $index => $item)
		{
			if (!$multi)
			{
				return $filterKeys($item, $keySet);
			}
			$filteredArray = $filterKeys($item, $keySet);
			$result[$field . $index] = $filteredArray;
		}

		return $result;
	}

	/**
	 * Processes an array of arrays based on the specified key.
	 *
	 * @param mixed    $items      Array of arrays to be processed.
	 * @param string   $indexKey   The index key on which the items should be observed as it relates to insert/update/delete
	 * @param string   $linkKey    The link key on which the items where linked in the child table.
	 * @param string   $linkValue  The value of the link key in child table.
	 *
	 * @return array  The processed array of arrays.
	 * @since  3.2.2
	 */
	private function process($items, string $indexKey, string $linkKey, string $linkValue): array
	{
		$items = is_array($items) ? $items : [];
		if ($items !== [] && !$this->isMultipleSets($items))
		{
			$items = [$items];
		}

		foreach ($items as $n => &$item)
		{
			$value = $item[$indexKey] ?? '';
			switch ($indexKey) {
				case 'guid':
					if (empty($value))
					{
						// set INDEX
						$item[$indexKey] = $this->getGuid($indexKey);
					}
					break;
				case 'id':
					if (empty($value))
					{
						$item[$indexKey] = 0;
					}
					break;
				default:
					// No action for other keys if empty
					break;
			}

			// set LINK
			$item[$linkKey] = $linkValue;

			// create/update user
			$item['user_id'] = $this->setUserDetails(
				$item,
				$this->getActiveUsers(
					$linkKey,
					$linkValue
				)
			);

			// remove empty row (means no user linked)
			if ($item['user_id'] == 0)
			{
				unset($items[$n]);
			}
		}

		return array_values($items);
	}

	/**
	 * Get current active Users Linked to this entity
	 *
	 * @param string   $linkKey    The link key on which the items where linked in the child table.
	 * @param string   $linkValue  The value of the link key in child table.
	 *
	 * @return array   The IDs of all active users.
	 * @since  5.0.2
	 */
	private function getActiveUsers(string $linkKey, string $linkValue): array
	{
		if (isset($this->activeUsers[$linkKey . $linkValue]))
		{
			return $this->activeUsers[$linkKey . $linkValue];
		}

		if (($users = $this->items->table($this->getTable())->values([$linkValue], $linkKey, 'user_id')) !== null)
		{
			$this->activeUsers[$linkKey . $linkValue] = $users;
			return $users;
		}

		return [];
	}

	/**
	 * Handles setting user details and saving them.
	 *
	 * This function retrieves the user by ID, sets the user details, 
	 * and adds appropriate user groups before saving the user.
	 *
	 * @param array $item        The user details passed by reference.
	 * @param array $activeUsers The current active user linked to this entity.
	 *
	 * @return int The ID of the saved user, or 0 on failure.
	 * @since  5.0.2
	 */
	private function setUserDetails(array &$item, array $activeUsers): int
	{
		$user = $this->loadUser($item, $activeUsers);
		$details = $this->extractUserDetails($item, $user);

		if ($this->identity->id != $user->id)
		{
			$this->assignUserGroups($details, $user, $item);
		}

		return $this->saveUserDetails($details, $details['id'] ?? 0);
	}

	/**
	 * Load the user based on the user ID from the item array.
	 *
	 * @param array $item         The array containing user details.
	 * @param array $activeUsers  The current active user linked to this entity.
	 * 
	 * @return User|null The user object if found, null otherwise.
	 * @since  5.0.2
	 */
	private function loadUser(array $item, array $activeUsers): ?User
	{
		if (!isset($item['user_id']) || !is_numeric($item['user_id']) || $item['user_id'] <= 0)
		{
			return null;
		}

		// only allow update to linked users
		if (!in_array($item['user_id'], $activeUsers))
		{
			return null;
		}

		$user = UserHelper::getUserById((int) $item['user_id']);

		if ($user && $user->id == $item['user_id'])
		{
			return $user;
		}

		return null;
	}

	/**
	 * Extract user details from the item array and prepare them for saving.
	 *
	 * @param array     $item The array containing user details.
	 * @param User|null $user The user object if found, null otherwise.
	 * 
	 * @return array The prepared user details array.
	 * @since  5.0.2
	 */
	private function extractUserDetails(array &$item, ?User $user): array
	{
		$details = [];

		if ($user !== null)
		{
			$details['id'] = (int) $item['user_id'];
		}

		foreach ($this->user as $property)
		{
			if (isset($item[$property]))
			{
				$details[$property] = $item[$property];
				unset($item[$property]);
			}
		}

		return $details;
	}

	/**
	 * Assigns user groups based on existing groups and entity type.
	 *
	 * @param array     &$details The array to store user details including groups.
	 * @param User|null $user     The user object if found, null otherwise.
	 * @param array     $item     The array containing additional user details.
	 *
	 * @return void
	 * @since 5.0.2
	 */
	private function assignUserGroups(array &$details, ?User $user, array $item): void
	{
		$groups = $user !== null ? (array) $user->groups : [];

		if (!empty($item['entity_type']))
		{
			$global_entity_groups = Component::getParams()->get($item['entity_type'] . '_groups', []);
			foreach ($global_entity_groups as $group)
			{
				if (!in_array($group, $groups))
				{
					$groups[] = $group;
				}
			}
		}

		// Ensure $details['groups'] is an array if it exists, else default to an empty array
		$detailsGroups = isset($details['groups']) ? (array) $details['groups'] : [];

		// Merge the arrays and remove duplicates
		$mergedGroups = array_unique(array_merge($detailsGroups, $groups));

		// Only set $details['groups'] if the merged array is not empty
		if (!empty($mergedGroups))
		{
			$details['groups'] = $mergedGroups;
		}
		else
		{
			unset($details['groups']);
		}
	}

	/**
	 * Save the user details using UserHelper and handle exceptions.
	 *
	 * @param array $details The prepared user details array.
	 * @param int   $userId  The ID of the user being processed.
	 * 
	 * @return int The ID of the saved user, or 0 on failure.
	 * @since 5.0.2
	 */
	private function saveUserDetails(array $details, int $userId): int
	{
		try {
			return UserHelper::save($details, 0, ['useractivation' => 0, 'sendpassword' => 1, 'allowUserRegistration' => 1]);
		} catch (NoUserIdFoundException $e) {
			Factory::getApplication()->enqueueMessage($e->getMessage(), 'error');
		} catch (\Exception $e) {
			Factory::getApplication()->enqueueMessage($e->getMessage(), 'warning');
			return $userId;
		}

		return 0;
	}

	/**
	 * Function to determine if the array consists of multiple data sets (arrays of arrays).
	 * 
	 * @param array $array The input array to be checked.
	 * 
	 * @return bool True if the array contains only arrays (multiple data sets), false otherwise.
	 * @since  5.0.2
	 */
	private function isMultipleSets(array $array): bool
	{
		foreach ($array as $element)
		{
			// As soon as we find a non-array element, return false
			if (!is_array($element))
			{
				return false;
			}
		}

		// If all elements are arrays, return true
		return true;
	}
}

