<?php

namespace XF\Util;

use function array_key_exists, array_slice, call_user_func_array, count, func_get_args, intval, is_array, is_string, strlen;

/**
 * Basic array utility functions. These replace or extend primitive-level functions
 * and thus can be called statically.
 */
class Arr
{
	/**
	 * Returns a version of the input $data that contains only the array keys defined in $keys
	 *
	 * Example: arrayFilterKeys(['a' => 1, 'b' => 2, 'c' => 3), ['b', 'c'])
	 * Returns: ['b' => 2, 'c' => 3]
	 *
	 * @param array $data
	 * @param array $keys
	 *
	 * @return array $data
	 */
	public static function arrayFilterKeys(array $data, array $keys, $checkIsSet = false)
	{
		// this version will not warn on undefined indexes:
		// return array_intersect_key($data, array_flip($keys));

		$array = [];

		foreach ($keys AS $key)
		{
			if ($checkIsSet)
			{
				if (!isset($data[$key]))
				{
					continue;
				}
			}
			$array[$key] = $data[$key];
		}

		return $array;
	}

	/**
	 * This is a simplified version of a function similar to array_merge_recursive. It is
	 * designed to recursively merge associative arrays (maps). If each array shares a key,
	 * that key is recursed and the child keys are merged.
	 *
	 * This function does not handle merging of non-associative arrays (numeric keys) as
	 * a special case.
	 *
	 * More than 2 arguments may be passed if desired.
	 *
	 * @param array $first
	 * @param array $second
	 * @param array ...$arrays
	 * @return array
	 */
	public static function mapMerge(array $first, array $second)
	{
		$args = func_get_args();
		unset($args[0]);

		foreach ($args AS $arg)
		{
			if (!is_array($arg) || !$arg)
			{
				continue;
			}
			foreach ($arg AS $key => $value)
			{
				if (is_array($value) && isset($first[$key]) && is_array($first[$key]))
				{
					$first[$key] = static::mapMerge($first[$key], $value);
				}
				else
				{
					$first[$key] = $value;
				}
			}
		}

		return $first;
	}

	/**
	 * Recursively returns the difference between two associative arrays.
	 * Returns any key that is in array 1 but not 2; returns any value (from
	 * array 1) where the value is different in array 2.
	 *
	 * @param array $array1
	 * @param array $array2
	 * @return array
	 */
	public static function mapDiff(array $array1, array $array2)
	{
		$diff = [];

		foreach ($array1 AS $key => $value)
		{
			if (
				!array_key_exists($key, $array2) // not in the other
				|| (is_array($value) && !is_array($array2[$key])) // different type
				|| (!is_array($value) && $value !== $array2[$key]) // not equal
			)
			{
				$diff[$key] = $value;
			}
			else if (is_array($value))
			{
				// $array2[$key] will be an array as well
				$result = static::mapDiff($value, $array2[$key]);
				if ($result)
				{
					$diff[$key] = $result;
				}
			}
		}

		return $diff;
	}

	public static function columnSort(array $values, $column, $cmpFn = null)
	{
		/** @var \Closure|null $cmpFn */

		$f = function ($a1, $a2) use ($column, $cmpFn)
		{
			$exists1 = isset($a1[$column]);
			$exists2 = isset($a2[$column]);

			if ($exists1 && !$exists2)
			{
				return 1;
			}
			else if (!$exists1 && $exists2)
			{
				return -1;
			}
			else if (!$exists1 && !$exists2)
			{
				return 0;
			}

			$v1 = $a1[$column];
			$v2 = $a2[$column];

			if ($cmpFn)
			{
				return $cmpFn($v1, $v2);
			}

			return $v1 <=> $v2;
		};

		uasort($values, $f);
		return $values;
	}

	/**
	 * Useful if we need to sort an array of strings
	 * alphabetically in a case-insensitive way.
	 *
	 * @param array $values
	 *
	 * @return array
	 */
	public static function deaccentSort(array $values)
	{
		uasort($values, function ($a, $b)
		{
			$a = Str::transliterate($a);
			$b = Str::transliterate($b);
			return strcmp($a, $b);
		});

		return $values;
	}

	/**
	 * @param string[] $values
	 *
	 * @return array
	 */
	public static function lengthSort(array $values): array
	{
		usort($values, function ($a, $b)
		{
			if (strlen($a) === strlen($b))
			{
				return strcmp($a, $b);
			}

			return strlen($a) - strlen($b);
		});

		return $values;
	}

	/**
	 * @param array $array
	 * @param callable|null $callable
	 * @param $args
	 * @param array ...$arguments
	 * @return array|mixed
	 */
	public static function arrayFilterArgs($array, $callable, $args = null)
	{
		if (!is_callable($callable))
		{
			return $array;
		}

		if ($args === null)
		{
			return array_filter($array, $callable);
		}

		if (!is_array($args))
		{
			$args = array_slice(func_get_args(), 2);
		}

		foreach ($array AS $key => $value)
		{
			if (call_user_func_array($callable, array_merge([$value], $args)) === false)
			{
				unset($array[$key]);
			}
		}

		return $array;
	}

	public static function arrayGroup($array, $groupers)
	{
		if (!is_array($groupers))
		{
			$groupers = [$groupers];
		}

		if (!$groupers)
		{
			throw new \InvalidArgumentException("Must have at least one grouper");
		}

		$groupBy = array_shift($groupers);

		$grouped = [];
		foreach ($array AS $k => $v)
		{
			if ($groupBy instanceof \Closure)
			{
				$groupValue = $groupBy($v, $k);
			}
			else
			{
				$groupValue = $v[$groupBy];
			}
			$grouped[$groupValue][$k] = $v;
		}

		if ($groupers)
		{
			foreach ($grouped AS $k => $reGroup)
			{
				$grouped[$k] = static::arrayGroup($reGroup, $groupers);
			}
		}

		return $grouped;
	}

	/**
	 * Parses a query string (x=y&a=b&c[]=d) into a structured array format.
	 *
	 * Note that this can handle very long query strings, but it has problems
	 * if there are conflicting elements that split the "chunks" that are made
	 * internally. Workaround this using distinct keys for each input whenever possible.
	 *
	 * @param string $string
	 *
	 * @return array
	 */
	public static function parseQueryString($string)
	{
		$max = intval(@ini_get('max_input_vars'));
		if ($max && substr_count($string, '&') >= $max)
		{
			$partCounter = [];
			$string = preg_replace_callback('/(?<=^|&)([^=&]+)(\\[\\]|%5B%5D)/U', function (array $match) use (&$partCounter)
			{
				$key = $match[1];
				if (!isset($partCounter[$key]))
				{
					$partCounter[$key] = 0;
				}

				$output = $key . '[' . $partCounter[$key] . ']';
				$partCounter[$key]++;

				return $output;
			}, $string);

			$chunks = array_chunk(explode('&', $string), $max, true);

			$output = [];
			foreach ($chunks AS $chunk)
			{
				parse_str(implode('&', $chunk), $values);
				$output = static::mapMerge($output, $values);
			}
		}
		else
		{
			parse_str($string, $output);
		}
		return $output;
	}

	public static function arrayDelete($needles, $haystack)
	{
		if (!$haystack)
		{
			return [];
		}
		foreach ((array) $needles AS $needle)
		{
			if (($key = array_search($needle, $haystack)) !== false)
			{
				unset($haystack[$key]);
			}
		}
		return $haystack;
	}

	public static function arrayKeyIsearch($key, $array)
	{
		$keys = array_keys($array);

		$index = array_search(strtolower($key), array_map('strtolower', $keys));

		if ($index !== false)
		{
			return $keys[$index];
		}

		return false;
	}

	/**
	 * Filters an array recursively, using an optional callback function.
	 */
	public static function filterRecursive(
		array $array,
		?callable $callback = null,
		int $mode = 0
	): array
	{
		foreach ($array AS &$value)
		{
			if (is_array($value))
			{
				$value = static::filterRecursive($value, $callback, $mode);
			}
		}

		return $callback
			? array_filter($array, $callback, $mode)
			: array_filter($array, null, $mode);
	}

	/**
	 * Removes null values from the array.
	 *
	 * @param array $array
	 *
	 * @return array
	 */
	public static function filterNull(array $array, bool $recursive = false)
	{
		$callback = function ($v)
		{
			return $v !== null;
		};

		if ($recursive)
		{
			return static::filterRecursive($array, $callback);
		}

		return array_filter($array, $callback);
	}

	/**
	 * Clones an array of objects. That is, clones the individual entries of the array.
	 *
	 * @param array $array
	 *
	 * @return array
	 */
	public static function cloneArray(array $array): array
	{
		return array_map(
			function ($object) { return clone $object; },
			$array
		);
	}

	/**
	 * Split a string to an array based on pattern. Defaults to space/line break pattern.
	 *
	 * @param $string
	 * @param string $pattern
	 * @param int $limit
	 *
	 * @return array
	 */
	public static function stringToArray($string, $pattern = '/\s+/', $limit = -1)
	{
		if ($string === null || $string === '')
		{
			return [];
		}

		return (array) preg_split($pattern, trim($string), $limit, PREG_SPLIT_NO_EMPTY);
	}

	public static function htmlSpecialCharsDecodeArray($value)
	{
		if (is_array($value))
		{
			foreach ($value AS $key => $arrayValue)
			{
				$value[$key] = static::htmlSpecialCharsDecodeArray($arrayValue);
			}
		}
		else if (is_string($value))
		{
			$value = htmlspecialchars_decode($value);
		}

		return $value;
	}

	public static function paginateArray(array $array, int &$page, int $perPage, &$total = 0): array
	{
		$total = count($array);

		if ($total <= $perPage)
		{
			return $array;
		}

		if ($page < 1)
		{
			$page = 1;
		}

		$lastPage = ceil($total / $perPage);
		if ($page > $lastPage)
		{
			$page = $lastPage;
		}

		return array_slice($array, ($page - 1) * $perPage, $perPage, true);
	}

	/**
	 * Sum the (numeric) values of two or more associative arrays.
	 * For example, ['a' => 10, 'b' => 100, 'c' => 123] and ['a' => 90, 'b' => 900, 'd' => 321]
	 * results in ['a' => 100, 'b' => 1000, 'c' => 123, 'd' => 321]
	 * Keys with non-numeric values are retained in the first array and discarded from the subsequent arrays.
	 *
	 * @param array $array
	 * @param array $array2
	 * @param array ...$arguments
	 * @return array
	 */
	public static function arrayAssocSum(array $array, array $array2): array
	{
		$args = func_get_args();
		unset($args[0]);

		foreach ($args AS $arr)
		{
			foreach ($arr AS $key => $value)
			{
				if (is_numeric($value))
				{
					$array[$key] = $array[$key] ?? 0;
					$array[$key] += $value;
				}
			}
		}

		return $array;
	}

	/**
	 * Returns true if the array is a list (sequential integer keys starting from 0).
	 *
	 * @param array $array
	 *
	 * @return bool
	 */
	public static function arrayIsList(array $array): bool
	{
		if (function_exists('array_is_list'))
		{
			return array_is_list($array);
		}

		if ($array === [] || $array === array_values($array))
		{
			return true;
		}

		$nextKey = -1;

		foreach ($array AS $k => $v)
		{
			if ($k !== ++$nextKey)
			{
				return false;
			}
		}

		return true;
	}

	/**
	 * Returns the first value from an array, or null when empty.
	 *
	 * @param array $array
	 *
	 * @return mixed|null
	 */
	public static function arrayFirst(array $array)
	{
		if (function_exists('array_first'))
		{
			return array_first($array);
		}

		foreach ($array AS $value)
		{
			return $value;
		}

		return null;
	}

	/**
	 * Returns the last value from an array, or null when empty.
	 *
	 * @param array $array
	 *
	 * @return mixed|null
	 */
	public static function arrayLast(array $array)
	{
		if (function_exists('array_last'))
		{
			return array_last($array);
		}

		return $array ? current(array_slice($array, -1)) : null;
	}
}
