<?php

/**
 * @package   bdk/debug
 * @author    Brad Kent <bkfake-github@yahoo.com>
 * @license   http://opensource.org/licenses/MIT MIT
 * @copyright 2014-2025 Brad Kent
 * @since     3.0b1
 */

namespace bdk\Debug\Utility;

use ArrayAccess;
use bdk\Debug\Utility\ArrayUtilHelperTrait;
use InvalidArgumentException;
use UnexpectedValueException;

/**
 * Array Utilities
 */
class ArrayUtil
{
    use ArrayUtilHelperTrait;

    /**
     * Assert array contains only the specified type
     *
     * @param array  $array        Array to test
     * @param string $expectedType php type(s) to check for
     * @param string $paramName    (null) parameter name
     * @param string $method       (null) method to report
     *
     * @return void
     *
     * @throws InvalidArgumentException
     */
    public static function assertContainsOnly(array $array, $expectedType, $paramName = null, $method = null)
    {
        list($i, $val) = [0, null];
        try {
            foreach ($array as $i => $val) {
                \bdk\Debug\Utility\PhpType::assertType($val, $expectedType);
            }
            return;
        } catch (\Exception $e) {
            // empty catch
        }
        $msg = $paramName
            ? 'exception.method-expects-only-param'
            : 'exception.method-expects-only';
        $frame = \debug_backtrace(\DEBUG_BACKTRACE_IGNORE_ARGS, 2)[1];
        $methodDefault = isset($frame['class'])
            ? $frame['class'] . '::' . $frame['function']
            : $frame['function'];
        throw new InvalidArgumentException(\bdk\Debug\Utility::trans($msg, array(
            'actual' => \bdk\Debug\Utility\Php::getDebugType($val),
            'expect' => $expectedType,
            'index' => $i,
            'method' => ($method ?: $methodDefault) . '()',
            'param' => '$' . $paramName,
        )));
    }

    /**
     * "dereference" array
     * returns a copy of the array with references removed
     *
     * @param array $source source array
     * @param bool  $deep   (true) deep copy
     *
     * @return array
     */
    public static function copy(array $source, $deep = true)
    {
        $arr = array();
        /** @var mixed $val */
        foreach ($source as $key => $val) {
            if ($deep && \is_array($val)) {
                $arr[$key] = self::copy($val);
                continue;
            }
            /** @var mixed */
            $arr[$key] = $val;
        }
        return $arr;
    }

    /**
     * Recursively compare arrays
     *
     * Returns an array containing all the values from array that are not present in any of the other arrays.
     *
     * @param array $array1    array to compare from
     * @param array ...$array2 arrays to compare against
     *
     * @return array
     *
     * @throws InvalidArgumentException
     */
    public static function diffAssocRecursive(array $array1, $array2) // phpcs:ignore Generic.CodeAnalysis.UnusedFunctionParameter
    {
        $arrays = \array_slice(\func_get_args(), 1);
        foreach ($arrays as $i => $array) {
            \bdk\Debug\Utility\PhpType::assertType($array, 'array', 'array' . ($i + 2));
        }
        return \array_reduce($arrays, [__CLASS__, 'diffAssocRecursiveWalk'], $array1);
    }

    /**
     * Remove values from array that are present in any of the other arrays.
     *
     * Returns an array containing all the values from array that are not present in any of the other arrays.
     * Returned array will be reindexed if input array is a list
     *
     * unlike `array_diff`, does not complains about array to string conversion
     *
     * @param array $array1    array to compare from
     * @param array ...$array2 arrays to compare against
     *
     * @return array
     *
     * @throws InvalidArgumentException
     *
     * @since 3.3
     */
    public static function diffStrict(array $array1, $array2) // phpcs:ignore Generic.CodeAnalysis.UnusedFunctionParameter
    {
        $arrays = \array_slice(\func_get_args(), 1);
        foreach ($arrays as $i => $array) {
            \bdk\Debug\Utility\PhpType::assertType($array, 'array', 'array' . ($i + 2));
        }
        $array = \array_reduce($arrays, [__CLASS__, 'diffStrictWalk'], $array1);
        if (self::isList($array1)) {
            $array = \array_values($array);
        }
        return $array;
    }

    /**
     * Is passed argument a simple array with all-integer keys in sequence from 0 to n?
     *
     * @param mixed $val       Value to check
     * @param bool  $inclEmpty Whether to consider an empty array as a list
     *
     * @return bool
     *
     * @SuppressWarnings(PHPMD.UnusedLocalVariable)
     *
     * @psalm-assert-if-true list<mixed> $val
     */
    public static function isList($val, $inclEmpty = true)
    {
        if (\is_array($val) === false) {
            return false;
        }
        if (\count($val) === 0) {
            return $inclEmpty;
        }
        $i = -1;
        /** @var mixed $v */
        foreach ($val as $k => $v) { // phpcs:ignore SlevomatCodingStandard.Variables.UnusedVariable
            ++$i;
            if ($k !== $i) {
                return false;
            }
        }
        return true;
    }

    /**
     * `array_map`, but callable will receive key as second parameter / keys will be preserved
     * if additional arrays are passed, they will be passed as additional parameters
     *
     * If callback is null:
     *   - we will perform the "zip" operation without the keys..
     *   - returned array will be keyed based on the first array
     *
     * @param callable $callback A callable to run for each element
     * @param array    $...array An array to run thru the callable
     *
     * @return array
     */
    public static function mapWithKeys($callback, array $array)
    {
        $keys = \array_keys($array);
        $args = \func_get_args();
        if ($callback !== null) {
            // only inject keys if callback is not null (don't include keys for "zip" operation)
            \array_splice($args, 2, 0, [$keys]);
        }
        $mappedValues = \call_user_func_array('array_map', $args);
        return \array_combine($keys, $mappedValues);
    }

    /**
     * Applies the callback to all leafs of the given array
     *
     * Callable will receive the value and key
     *
     * keys are preserved
     *
     * @param callable $callback Callable to be applied
     * @param array    $input    Input array
     *
     * @return array
     */
    public static function mapRecursive($callback, array $input)
    {
        return self::mapWithKeys(static function ($val, $key) use ($callback) {
            return \is_array($val)
                ? self::mapRecursive($callback, $val)
                : $callback($val, $key);
        }, $input);
    }

    /**
     * Recursively merge arrays
     *
     * @param array $array1    default array
     * @param array ...$array2 arrays to merge
     *
     * @return array
     *
     * @throws InvalidArgumentException
     */
    public static function mergeDeep(array $array1, $array2) // phpcs:ignore Generic.CodeAnalysis.UnusedFunctionParameter
    {
        $arrays = \array_slice(\func_get_args(), 1);
        foreach ($arrays as $i => $array) {
            \bdk\Debug\Utility\PhpType::assertType($array, 'array', 'array' . ($i + 2));
        }
        return \array_reduce($arrays, [__CLASS__, 'mergeDeepWalk'], $array1);
    }

    /**
     * Get value from array (or obj with array access)
     *
     * @param array|ArrayAccess $array   array to traverse
     * @param array|string|null $path    key path
     *                              path may contain special keys:
     *                                * __count__ : return count() (traversal will cease)
     *                                * __end__ : last value
     *                                * __pop__ : pop value
     *                                * __reset__ : first value
     * @param mixed             $default default value
     *
     * @return mixed
     *
     * @throws UnexpectedValueException
     */
    public static function pathGet(&$array, $path, $default = null)
    {
        $path = \array_reverse(self::pathToArray($path));
        $curPath = array();
        while ($path) {
            self::assertArrayAccess($array, $curPath, __METHOD__);
            $key = \array_pop($path);
            $curPath[] = $key;
            if (isset($array[$key])) {
                $array = &$array[$key];
                continue;
            } elseif ($key === '__count__') {
                self::assertCountable($array, \array_slice($curPath, 0, -1), __METHOD__);
                return \count($array);
            } elseif ($key === '__pop__') {
                /** @var mixed */
                $arrayNew = \array_pop($array);
                $array = &$arrayNew;
                continue;
            } elseif (self::specialKey($key, $path, $array, __METHOD__)) {
                continue;
            }
            return $default;
        }
        return $array;
    }

    /**
     * Update/Set an array value via "path"
     *
     * @param array             $array array to edit
     * @param array|string|null $path  path may contain special keys:
     *                                 * __end__ : last value
     *                                 * __push__ : append value
     *                                 * __reset__ : first value
     * @param mixed             $val   value to set
     *                                 * __unset__ : remove by key
     *
     * @return void
     */
    public static function pathSet(array &$array, $path, $val)
    {
        $path = \array_reverse(self::pathToArray($path));
        while ($path) {
            $key = \array_pop($path);
            if (self::specialKey($key, $path, $array, __METHOD__)) {
                continue;
            } elseif ($val === '__unset__' && empty($path)) {
                unset($array[$key]);
                /** @psalm-suppress ReferenceConstraintViolation */
                return;
            } elseif (!isset($array[$key]) || !\is_array($array[$key])) {
                $array[$key] = array(); // initialize this level
            }
            $array = &$array[$key];
        }
        /** @var mixed */
        $array = $val;
    }

    /**
     * Searches array structure for value and returns the path to the first match
     *
     * @param mixed $search Needle / value or key to search for
     * @param array $array  Haystack / array structure to search
     * @param bool  $byKey  (false) whether to search for key vs value
     *
     * @return array|false
     */
    public static function searchRecursive($search, array $array, $byKey = false)
    {
        $key = \array_search($search, $array, true);
        if ($key !== false) {
            return [$key];
        }
        if ($byKey && self::isValidKey($search) && \array_key_exists($search, $array)) {
            return [$search];
        }
        return self::searchRecursiveWalk($search, $array, $byKey);
    }

    /**
     * Sort array, using `$order`
     * Keys will be preserved
     *
     * @param array      $array Array to sort
     * @param array|null $order values that define order / should come first
     * @param string     $what  ("value") or "key" - Whether to sort sort by value or key
     *
     * @return void
     */
    public static function sortWithOrder(array &$array, $order = array(), $what = 'value')
    {
        if ($order === null) {
            $order = array();
        }
        $callback = static function ($valA, $valB) use ($order) {
            $aPos = \array_search($valA, $order, true);
            $bPos = \array_search($valB, $order, true);
            if ($aPos === $bPos) {
                return \strnatcasecmp($valA, $valB);
            }
            if ($aPos === false) {   // $a is a don't care
                return 1;            //   $a > $b
            }
            if ($bPos === false) {   // $b is a don't care
                return -1;           //   $a < $b
            }
            return $aPos < $bPos
                ? -1
                : 1;
        };
        $what === 'value'
            ? \uasort($array, $callback)
            : \uksort($array, $callback);
    }

    /**
     * Much like `array_splice` but for associative arrays
     *
     * @param array  $array       The input array
     * @param string $key         "Offset" key.
     * @param int    $length      How many values to remove
     * @param mixed  $replacement replacement array
     *
     * @return array removed values
     */
    public static function spliceAssoc(array &$array, $key, $length = null, $replacement = array())
    {
        $offset = \array_search($key, \array_keys($array), true);
        $count = \count($array);
        if ($offset === false) {
            // merge replacement onto end of array
            $offset = $count;
            $length = 0;
        }
        if ($length === null) {
            $length = $count - $offset;
        } elseif ($length < 0) {
            $length = $count + $length - $offset;
            $length = \max($length, 0);
        }
        $ret = \array_slice($array, $offset, $length);
        $array = \array_merge(
            \array_slice($array, 0, $offset, true),
            (array) $replacement,
            \array_slice($array, $offset + $length, null, true)
        );
        return $ret;
    }

    /**
     * Iterate over haystack to find needle
     *
     * @param mixed $search Needle / value or key to search for
     * @param array $array  Haystack / array structure to search
     * @param bool  $byKey  (false) whether to search for key vs value
     *
     * @return array|false
     */
    private static function searchRecursiveWalk($search, array $array, $byKey)
    {
        foreach ($array as $key => $val) {
            if (\is_array($val) === false) {
                continue;
            }
            $pathTest = self::searchRecursive($search, $val, $byKey);
            if ($pathTest) {
                return \array_merge([$key], $pathTest);
            }
        }
        return false;
    }
}
