<?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\KeyValuePair;

use League\Uri\Contracts\UriComponentInterface;
use League\Uri\Exceptions\SyntaxError;
use Stringable;

use function array_combine;
use function explode;
use function implode;
use function is_float;
use function is_int;
use function is_string;
use function json_encode;
use function preg_match;
use function str_replace;

use const JSON_PRESERVE_ZERO_FRACTION;
use const PHP_QUERY_RFC1738;
use const PHP_QUERY_RFC3986;

final class Converter
{
    private const REGEXP_INVALID_CHARS = '/[\x00-\x1f\x7f]/';

    /**
     * @param non-empty-string $separator the query string separator
     * @param array<string> $fromRfc3986 contains all the RFC3986 encoded characters to be converted
     * @param array<string> $toEncoding contains all the expected encoded characters
     */
    private function __construct(
        private readonly string $separator,
        private readonly array $fromRfc3986 = [],
        private readonly array $toEncoding = [],
    ) {
        if ('' === $this->separator) {
            throw new SyntaxError('The separator character must be a non empty string.');
        }
    }

    /**
     * @param non-empty-string $separator
     */
    public static function new(string $separator): self
    {
        return new self($separator);
    }

    /**
     * @param non-empty-string $separator
     */
    public static function fromRFC3986(string $separator = '&'): self
    {
        return self::new($separator);
    }

    /**
     * @param non-empty-string $separator
     */
    public static function fromRFC1738(string $separator = '&'): self
    {
        return self::new($separator)
            ->withEncodingMap(['%20' => '+']);
    }

    /**
     * @param non-empty-string $separator
     *
     * @see https://url.spec.whatwg.org/#application/x-www-form-urlencoded
     */
    public static function fromFormData(string $separator = '&'): self
    {
        return self::new($separator)
            ->withEncodingMap(['%20' => '+', '%2A' => '*']);
    }

    public static function fromEncodingType(int $encType): self
    {
        return match ($encType) {
            PHP_QUERY_RFC3986 => self::fromRFC3986(),
            PHP_QUERY_RFC1738 => self::fromRFC1738(),
            default => throw new SyntaxError('Unknown or Unsupported encoding.'),
        };
    }

    /**
     * @return non-empty-string
     */
    public function separator(): string
    {
        return $this->separator;
    }

    /**
     * @return array<string, string>
     */
    public function encodingMap(): array
    {
        return array_combine($this->fromRfc3986, $this->toEncoding);
    }

    /**
     * @return array<non-empty-list<string|null>>
     */
    public function toPairs(Stringable|string|int|float|bool|null $value): array
    {
        $value = match (true) {
            $value instanceof UriComponentInterface => $value->value(),
            $value instanceof Stringable, is_int($value) => (string) $value,
            false === $value => '0',
            true === $value => '1',
            default => $value,
        };

        if (null === $value) {
            return [];
        }

        $value = match (1) {
            preg_match(self::REGEXP_INVALID_CHARS, (string) $value) => throw new SyntaxError('Invalid query string: `'.$value.'`.'),
            default => str_replace($this->toEncoding, $this->fromRfc3986, (string) $value),
        };

        return array_map(
            fn (string $pair): array => explode('=', $pair, 2) + [1 => null],
            explode($this->separator, $value)
        );
    }

    private static function vString(Stringable|string|bool|int|float|null $value): ?string
    {
        return match (true) {
            $value => '1',
            false === $value => '0',
            null === $value => null,
            is_float($value) => (string) json_encode($value, JSON_PRESERVE_ZERO_FRACTION),
            default => (string) $value,
        };
    }

    /**
     * @param iterable<array{0:string|null, 1:Stringable|string|bool|int|float|null}> $pairs
     */
    public function toValue(iterable $pairs): ?string
    {
        $filteredPairs = [];
        foreach ($pairs as $pair) {
            $filteredPairs[] = match (true) {
                !is_string($pair[0]) => throw new SyntaxError('the pair key MUST be a string;, `'.gettype($pair[0]).'` given.'),
                null === $pair[1] => self::vString($pair[0]),
                default => self::vString($pair[0]).'='.self::vString($pair[1]),
            };
        }

        return match ([]) {
            $filteredPairs => null,
            default => str_replace($this->fromRfc3986, $this->toEncoding, implode($this->separator, $filteredPairs)),
        };
    }

    /**
     * @param non-empty-string $separator
     */
    public function withSeparator(string $separator): self
    {
        return match ($this->separator) {
            $separator => $this,
            default => new self($separator, $this->fromRfc3986, $this->toEncoding),
        };
    }

    /**
     * Sets the conversion map.
     *
     * Each key from the iterable structure represents the RFC3986 encoded characters as string,
     * while each value represents the expected output encoded characters
     */
    public function withEncodingMap(iterable $encodingMap): self
    {
        $fromRfc3986 = [];
        $toEncoding = [];
        foreach ($encodingMap as $from => $to) {
            [$fromRfc3986[], $toEncoding[]] = match (true) {
                !is_string($from) => throw new SyntaxError('The encoding output must be a string; `'.gettype($from).'` given.'),
                $to instanceof Stringable,
                is_string($to) => [$from, (string) $to],
                default => throw new SyntaxError('The encoding output must be a string; `'.gettype($to).'` given.'),
            };
        }

        return match (true) {
            $fromRfc3986 !== $this->fromRfc3986,
            $toEncoding !== $this->toEncoding => new self($this->separator, $fromRfc3986, $toEncoding),
            default => $this,
        };
    }
}
