<?php

/**
 * League.Uri (https://uri.thephpleague.com)
 *
 * (c) Ignace Nyamagana Butera <nyamsprod@gmail.com>
 *
 * For the full copyright and license information, please view the LICENSE
 * file that was distributed with this source code.
 */

declare(strict_types=1);

namespace League\Uri\Components;

use ArgumentCountError;
use BackedEnum;
use Closure;
use Countable;
use Deprecated;
use Iterator;
use IteratorAggregate;
use League\Uri\Contracts\QueryInterface;
use League\Uri\Contracts\Transformable;
use League\Uri\Contracts\UriComponentInterface;
use League\Uri\Contracts\UriException;
use League\Uri\Contracts\UriInterface;
use League\Uri\Exceptions\SyntaxError;
use League\Uri\KeyValuePair\Converter;
use League\Uri\QueryString;
use League\Uri\StringCoercionMode;
use League\Uri\Uri;
use League\Uri\UriString;
use Psr\Http\Message\UriInterface as Psr7UriInterface;
use Stringable;
use Uri\Rfc3986\Uri as Rfc3986Uri;
use Uri\WhatWg\Url as WhatWgUrl;

use function array_is_list;
use function array_key_exists;
use function array_keys;
use function array_map;
use function count;
use function func_get_arg;
use function func_num_args;
use function get_object_vars;
use function is_array;
use function is_bool;
use function is_iterable;
use function is_object;
use function is_scalar;
use function iterator_to_array;
use function spl_object_hash;
use function str_starts_with;
use function substr;

/**
 * @see https://url.spec.whatwg.org/#interface-urlsearchparams
 *
 * @implements IteratorAggregate<array{0:string, 1:string}>
 */
final class URLSearchParams implements Countable, IteratorAggregate, UriComponentInterface, Transformable
{
    private QueryInterface $pairs;

    /**
     * New instance.
     *
     * A string, which will be parsed from application/x-www-form-urlencoded format. A leading '?' character is ignored.
     * A literal sequence of name-value string pairs, or any object with an iterator that produces a sequence of string pairs.
     * A record of string keys and string values. Note that nesting is not supported.
     */
    public function __construct(object|array|string|null $init = '')
    {
        $pairs = self::filterPairs(match (true) {
            $init instanceof self,
            $init instanceof QueryInterface => $init,
            $init instanceof BackedEnum => self::parsePairs($init),
            $init instanceof UriComponentInterface => self::parsePairs($init->value()),
            is_iterable($init) => self::formatIterable($init),
            $init instanceof Stringable, !is_object($init) => self::parsePairs(self::formatQuery($init)),
            default => self::yieldPairs($init),
        });

        $this->pairs = Query::fromPairs($pairs);
    }

    /**
     * @return array<int, array{0:string, 1:string|null}>
     */
    private static function parsePairs(BackedEnum|Stringable|string|null $query): array
    {
        return QueryString::parseFromValue($query, Converter::fromFormData());
    }

    /**
     * @return iterable<array{0:string, 1:string}>
     */
    private static function formatIterable(iterable $iterable): iterable
    {
        if (!is_array($iterable)) {
            $iterable = iterator_to_array($iterable);
        }

        return match (true) {
            array_is_list($iterable) => $iterable,
            default => self::yieldPairs($iterable)
        };
    }

    /**
     * Generates an Iterator containing pairs as items from an object or array.
     *
     * If an iterable is given, foreach will loop over the iterable structure
     * If an object is give, foreach will loop over the object public properties if they are defined
     *
     * @param object|iterable<array-key, mixed> $associative
     *
     * @return Iterator<int, array{0:string, 1:string}>
     */
    private static function yieldPairs(object|array $associative): Iterator
    {
        foreach ($associative as $key => $value) { /* @phpstan-ignore-line */
            yield [self::usvString($key), self::usvString($value)];
        }
    }

    /**
     * @return Iterator<int, array{0:string, 1:string}>
     */
    private static function filterPairs(iterable $pairs): iterable
    {
        $filter = static fn ($pair): ?array => match (true) {
            !is_array($pair),
            [0, 1] !== array_keys($pair) => throw new SyntaxError('A pair must be a sequential array starting at `0` and containing two elements.'),
            null !== $pair[1] => [self::usvString($pair[0]), self::usvString($pair[1])],
            '' !== $pair[0] => [self::usvString($pair[0]), ''],
            default => null,
        };

        foreach ($pairs as $pair) {
            if (null !== ($filteredPair = $filter($pair))) {
                yield $filteredPair;
            }
        }
    }

    private static function formatQuery(BackedEnum|Stringable|string|null $query): string
    {
        $query = (string) StringCoercionMode::Native->coerce($query);
        if (str_starts_with($query, '?')) {
            return substr($query, 1);
        }

        return $query;
    }

    /**
     * Normalizes type to USVString.
     *
     * @see https://webidl.spec.whatwg.org/#idl-USVString
     */
    private static function usvString(mixed $value): string
    {
        return (string) StringCoercionMode::Ecmascript->coerce($value);
    }

    /**
     * Returns a new instance from a string or a stringable object.
     *
     * The input will be parsed from application/x-www-form-urlencoded format.
     * The leading '?' character if present is ignored.
     */
    public static function new(BackedEnum|Stringable|string|null $query = null): self
    {
        return new self(Query::fromFormData(self::formatQuery($query)));
    }

    /**
     * Create a new instance from a string.or a stringable structure or returns null on failure.
     */
    public static function tryNew(BackedEnum|Stringable|string|null $uri = null): ?self
    {
        try {
            return self::new($uri);
        } catch (UriException) {
            return null;
        }
    }

    /**
     * Returns a new instance from a literal sequence of name-value string pairs,
     * or any object with an iterator that produces a sequence of string pairs.
     *
     * @param iterable<int, array{0:string, 1:string|null}> $pairs
     */
    public static function fromPairs(iterable $pairs): self
    {
        return new self(Query::fromPairs($pairs, coercionMode: StringCoercionMode::Ecmascript));
    }

    /**
     * Returns a new instance from a record of string keys and string values.
     *
     * A record can be, an iterable or any object with scalar or null public properties. Nesting is not supported.
     *
     * @param object|iterable<array-key, Stringable|string|float|int|bool|null> $associative
     */
    public static function fromAssociative(object|array $associative): self
    {
        return new self(Query::fromPairs(self::yieldPairs($associative), coercionMode: StringCoercionMode::Ecmascript));
    }

    /**
     * Returns a new instance from a URI.
     */
    public static function fromUri(WhatWgUrl|Rfc3986Uri|BackedEnum|Stringable|string $uri): self
    {
        $query = match (true) {
            $uri instanceof Rfc3986Uri => $uri->getRawQuery(),
            $uri instanceof WhatWgUrl, $uri instanceof UriInterface => $uri->getQuery(),
            $uri instanceof Psr7UriInterface => UriString::parse($uri)['query'],
            default => Uri::new($uri)->getQuery(),
        };

        return new self(Query::fromPairs(QueryString::parseFromValue($query, Converter::fromFormData()), coercionMode: StringCoercionMode::Ecmascript));
    }

    /**
     * Returns a new instance from the input of PHP's http_build_query.
     */
    public static function fromVariable(object|array $parameters): self
    {
        return self::fromPairs(self::parametersToPairs($parameters));
    }

    private static function parametersToPairs(array|object $data, string|int $prefix = '', array &$recursive = []): array
    {
        $yieldParameters = static fn (object|array $data): array => is_array($data) ? $data : get_object_vars($data);

        $pairs = [];
        foreach ($yieldParameters($data) as $name => $value) {
            if (is_object($data)) {
                $id = spl_object_hash($data);
                if (!array_key_exists($id, $recursive)) {
                    $recursive[$id] = 1;
                }
            }

            if (is_object($value)) {
                $id = spl_object_hash($value);
                if (array_key_exists($id, $recursive)) {
                    return [];
                }

                $recursive[$id] = 1;
            }

            if ('' !== $prefix) {
                $name = $prefix.'['.$name.']';
            }

            $pairs = match (true) {
                is_array($value),
                is_object($value) => [...$pairs, ...self::parametersToPairs($value, $name, $recursive)],
                is_scalar($value) => [...$pairs, [$name, self::usvString($value)]],
                default => $pairs,
            };
        }

        return $pairs;
    }

    public function value(): ?string
    {
        return $this->pairs->toFormData();
    }

    public function equals(mixed $value): bool
    {
        if (!StringCoercionMode::Ecmascript->isCoercible($value)) {
            return false;
        }

        if (!$value instanceof UriComponentInterface) {
            $value = self::tryNew(StringCoercionMode::Ecmascript->coerce($value));
            if (null === $value) {
                return false;
            }
        }

        return $value->getUriComponent() === $this->getUriComponent();
    }

    /**
     * Returns a query string suitable for use in a URL.
     */
    public function toString(): string
    {
        return $this->value() ?? '';
    }

    public function decoded(): string
    {
        return (string) Query::fromPairs($this->pairs)->decoded();
    }

    public function __toString(): string
    {
        return $this->toString();
    }

    public function jsonSerialize(): string
    {
        return $this->toString();
    }

    public function getUriComponent(): string
    {
        $value = $this->value() ?? '';

        return match ('') {
            $value => $value,
            default => '?'.$value,
        };
    }

    /**
     * Returns an iterator allowing iteration through all keys contained in this object.
     *
     * @return iterable<string>
     */
    public function keys(): iterable
    {
        foreach ($this->pairs as [$key, $__]) {
            yield $key;
        }
    }

    /**
     * Returns an iterator allowing iteration through all values contained in this object.
     *
     * @return iterable<string>
     */
    public function values(): iterable
    {
        foreach ($this->pairs as [$__, $value]) {
            yield $value ?? '';
        }
    }

    /**
     * Tells whether the specified parameter is in the search parameters.
     *
     * The method requires at least one parameter as the pair name (string or null)
     * and an optional second and last parameter as the pair value (Stringable|string|float|int|bool|null)
     * <code>
     * $params = new URLSearchParams('a=b&c);
     * $params->has('c');      // return true
     * $params->has('a', 'b'); // return true
     * $params->has('a', 'c'); // return false
     * </code>
     */
    public function has(?string $name): bool
    {
        $name = self::usvString($name);

        return match (func_num_args()) {
            1 => $this->pairs->has($name),
            2 => $this->pairs->hasPair($name, self::usvString(func_get_arg(1))),
            default => throw new ArgumentCountError(__METHOD__.' requires at least one argument as the pair name and a second optional argument as the pair value.'),
        };
    }

    /**
     * Tells whether the specified pair is in the search parameters.
     *
     * The method requires at least one parameter as the pair name (string or null)
     * and an optional second and last parameter as the pair value (Stringable|string|float|int|bool|null)
     * <code>
     * $params = new URLSearchParams('a=b&c);
     * $params->has('a', 'b'); // return true
     * </code>
     */
    public function hasValue(?string $name, mixed $value): bool
    {
        return $this->has($name, $value);
    }

    /**
     * Returns the first value associated to the given search parameter or null if none exists.
     */
    public function get(?string $name): ?string
    {
        return match (true) {
            $this->has($name) => $this->pairs->get(self::usvString($name)) ?? '',
            default => null,
        };
    }

    public function first(?string $name): ?string
    {
        return $this->get($name);
    }

    public function last(?string $name): ?string
    {
        if (!$this->has($name)) {
            return null;
        }

        $res = $this->pairs->getAll(self::usvString($name));

        return $res[count($res) - 1] ?? null;
    }

    /**
     * Returns all the values associated with a given search parameter as an array.
     *
     * @return array<string>
     */
    public function getAll(?string $name): array
    {
        return array_map(
            fn (?string $value): string => $value ?? '',
            $this->pairs->getAll(self::usvString($name))
        );
    }

    /**
     * Tells whether the instance has some parameters.
     */
    public function isNotEmpty(): bool
    {
        return ! $this->isEmpty();
    }

    /**
     * Tells whether the instance has no parameters.
     */
    public function isEmpty(): bool
    {
        return 0 === $this->size();
    }

    /**
     * Returns the total number of distinct search parameter keys.
     */
    public function countDistinctKeys(): int
    {
        return $this->pairs->countDistinctKeys();
    }

    /**
     * Returns the total number of search parameter entries.
     */
    public function size(): int
    {
        return count($this->pairs);
    }

    /**
     * @see URLSearchParams::size()
     */
    public function count(): int
    {
        return $this->size();
    }

    /**
     * Allowing iteration through all key/value pairs contained in this object.
     *
     * The iterator returns key/value pairs in the same order as they appear in the query string.
     * The key and value of each pair are string objects.
     */
    public function entries(): Iterator
    {
        yield from $this->pairs;
    }

    /**
     * @see URLSearchParams::entries()
     */
    public function getIterator(): Iterator
    {
        return $this->entries();
    }

    /**
     * Allows iteration through all values contained in this object via a callback function.
     *
     * @param Closure(string $value, string $key): void $callback
     */
    public function each(Closure $callback): void
    {
        foreach ($this->pairs->pairs() as $key => $value) {
            $callback($value ?? '', $key);
        }
    }

    private function updateQuery(QueryInterface $query): void
    {
        if ($query->value() !== $this->pairs->value()) {
            $this->pairs = $query;
        }
    }

    /**
     * Sets the value associated with a given search parameter to the given value.
     *
     * If there were several matching values, this method deletes the others.
     * If the search parameter doesn't exist, this method creates it.
     */
    public function set(?string $name, mixed $value): void
    {
        $this->updateQuery($this->pairs->withPair(self::usvString($name), self::usvString($value)));
    }

    /**
     * Appends a specified key/value pair as a new search parameter.
     */
    public function append(?string $name, mixed $value): void
    {
        $this->updateQuery($this->pairs->appendTo(self::usvString($name), self::usvString($value)));
    }

    /**
     * Deletes specified parameters and their associated value(s) from the list of all search parameters.
     *
     * The method requires at least one parameter as the pair name (string or null)
     * and an optional second and last parameter as the pair value (Stringable|string|float|int|bool|null)
     * <code>
     * $params = new URLSearchParams('a=b&c);
     * $params->delete('c'); //delete all parameters with the key 'c'
     * $params->delete('a', 'b') //delete all pairs with the key 'a' and the value 'b'
     * </code>
     */
    public function delete(?string $name): void
    {
        $name = self::usvString($name);

        $this->updateQuery(match (func_num_args()) {
            1 => $this->pairs->withoutPairByKey($name),
            2 => $this->pairs->withoutPairByKeyValue($name, self::usvString(func_get_arg(1))),
            default => throw new ArgumentCountError(__METHOD__.' requires at least one argument as the pair name and a second optional argument as the pair value.'),
        });
    }

    /**
     * Deletes all pair with the specified name and associated value from the list of all search parameters.
     *
     * <code>
     * $params->deleteValue('a', 'b') //delete all pairs with the key 'a' and the value 'b'
     * </code>
     */
    public function deleteValue(?string $name, mixed $value): void
    {
        $this->updateQuery(
            $this->pairs->withoutPairByKeyValue(
                self::usvString($name),
                self::usvString($value),
            )
        );
    }

    /**
     * Sorts all key/value pairs contained in this object in place and returns undefined.
     *
     * The sort order is according to Unicode code points of the keys. This method
     * uses a stable sorting algorithm (i.e. the relative order between
     * key/value pairs with equal keys will be preserved).
     */
    public function sort(): void
    {
        $this->updateQuery($this->pairs->sort());
    }

    public function when(callable|bool $condition, callable $onSuccess, ?callable $onFail = null): static
    {
        if (!is_bool($condition)) {
            $condition = $condition($this);
        }

        return match (true) {
            $condition => $onSuccess($this) ?? $this,
            null !== $onFail => $onFail($this) ?? $this,
            default => $this,
        };
    }

    /**
     * Executes the given callback with the current instance
     * and returns the current instance.
     *
     * @param callable(self): self $callback
     */
    public function transform(callable $callback): static
    {
        return $callback($this);
    }

    /**
     * DEPRECATION WARNING! This method will be removed in the next major point release.
     *
     * @deprecated Since version 7.8.0
     * @see URLSearchParams::countDistinctKeys()
     *
     * @codeCoverageIgnore
     */
    #[Deprecated(message:'use League\Uri\Components\URLSearchParams::countDistinctKeys() instead', since:'league/uri-components:7.8.0')]
    public function uniqueKeyCount(): int
    {
        return $this->countDistinctKeys();
    }

    /**
     * DEPRECATION WARNING! This method will be removed in the next major point release.
     *
     * @deprecated Since version 7.4.0
     * @see URLSearchParams::fromVariable()
     *
     * @codeCoverageIgnore
     */
    #[Deprecated(message:'use League\Uri\Components\URLSearchParams::fromVariable() instead', since:'league/uri-components:7.4.0')]
    public static function fromParameters(object|array $parameters): self
    {
        return new self(Query::fromParameters($parameters));
    }
}
