<?php 
 
/* 
 * This file is part of Chevere. 
 * 
 * (c) Rodolfo Berrios <[email protected]> 
 * 
 * For the full copyright and license information, please view the LICENSE 
 * file that was distributed with this source code. 
 */ 
 
declare(strict_types=1); 
 
namespace Chevere\Parameter; 
 
use ArgumentCountError; 
use ArrayAccess; 
use Chevere\Parameter\Interfaces\ArgumentsInterface; 
use Chevere\Parameter\Interfaces\CastInterface; 
use Chevere\Parameter\Interfaces\ParametersInterface; 
use Chevere\Parameter\Traits\ExceptionErrorMessageTrait; 
use InvalidArgumentException; 
use OutOfBoundsException; 
use ReflectionClass; 
use Throwable; 
use TypeError; 
use function Chevere\Message\message; 
 
final class Arguments implements ArgumentsInterface 
{ 
    use ExceptionErrorMessageTrait; 
 
    private ParametersInterface $iterable; 
 
    /** 
     * @var array<int|string, mixed> 
     */ 
    private array $arguments = []; 
 
    /** 
     * @var array<string> 
     */ 
    private array $null = []; 
 
    /** 
     * @var array<string> 
     */ 
    private array $reflected = []; 
 
    /** 
     * @var string[] 
     */ 
    private array $errors = []; 
 
    // @phpstan-ignore-next-line 
    public function __construct( 
        private ParametersInterface $parameters, 
        array|ArrayAccess $arguments 
    ) { 
        if ($arguments instanceof ArrayAccess) { 
            $arguments = $this->getArrayAccessArray($arguments); 
        } 
        $isIterable = $parameters->keys() === ['K', 'V']; 
        $countArguments = count($arguments); 
        if (array_is_list($arguments) && ! $isIterable) { 
            $parametersMap = array_slice($this->parameters->keys(), 0, $countArguments); 
            $arguments = array_combine($parametersMap, $arguments); 
        } 
        $this->setArguments($arguments); 
        if ($isIterable) { 
            $pairs = []; 
            foreach (array_keys($arguments) as $key) { 
                $key = strval($key); 
                $pairs[$key] = $parameters->get('V'); 
            } 
            $this->iterable = new Parameters(...$pairs); 
        } 
        if (! $this->parameters->isVariadic()) { 
            $this->ignoreExtraArguments(); 
        } 
        $this->handleDefaults(); 
        $this->assertRequired(); 
        $this->assertMinimumOptional(); 
        $this->handleParameters(); 
        if ($this->errors !== []) { 
            throw new InvalidArgumentException( 
                implode("\n", $this->errors) 
            ); 
        } 
    } 
 
    public function parameters(): ParametersInterface 
    { 
        return $this->iterable ?? $this->parameters; 
    } 
 
    // @phpstan-ignore-next-line 
    public function toArray(): array 
    { 
        return $this->arguments; 
    } 
 
    // @phpstan-ignore-next-line 
    public function toArrayFill(mixed $fill): array 
    { 
        $filler = array_fill_keys($this->null, $fill); 
 
        return array_merge($filler, $this->arguments); 
    } 
 
    /** 
     * @throws OutOfBoundsException 
     * @throws TypeError 
     * @throws InvalidArgumentException 
     */ 
    public function withPut(string $name, mixed $value): ArgumentsInterface 
    { 
        $new = clone $this; 
        $new->assertSetArgument($name, $value); 
        $new->arguments[$name] = $value; 
 
        return $new; 
    } 
 
    public function has(string ...$name): bool 
    { 
        foreach ($name as $key) { 
            if (! array_key_exists($key, $this->arguments)) { 
                return false; 
            } 
        } 
 
        return true; 
    } 
 
    public function get(string $name): mixed 
    { 
        $this->parameters()->assertHas($name); 
 
        return $this->arguments[$name] ?? null; 
    } 
 
    public function required(string $name): CastInterface 
    { 
        if ($this->parameters()->optionalKeys()->contains($name)) { 
            throw new InvalidArgumentException( 
                (string) message( 
                    'Argument `%name%` is optional', 
                    name: $name 
                ) 
            ); 
        } 
 
        return new Cast($this->arguments[$name]); 
    } 
 
    public function optional(string $name): ?CastInterface 
    { 
        if (! $this->parameters()->optionalKeys()->contains($name)) { 
            throw new InvalidArgumentException( 
                (string) message( 
                    'Argument `%name%` is required', 
                    name: $name 
                ) 
            ); 
        } 
 
        if ($this->has($name)) { 
            return new Cast($this->arguments[$name]); 
        } 
 
        return null; 
    } 
 
    private function ignoreExtraArguments(): void 
    { 
        $this->arguments = array_intersect_key( 
            $this->arguments, 
            array_flip($this->parameters()->keys()) 
        ); 
    } 
 
    private function handleDefaults(): void 
    { 
        foreach ($this->parameters() as $name => $parameter) { 
            if ($this->has($name)) { 
                continue; 
            } 
            if ($parameter->default() === null) { 
                $this->null[] = $name; 
 
                continue; 
            } 
            $this->arguments[$name] = $parameter->default(); 
        } 
    } 
 
    private function assertRequired(): void 
    { 
        $values = array_keys($this->arguments); 
        $missing = array_diff( 
            $this->parameters()->requiredKeys()->toArray(), 
            $values, 
        ); 
        if ($missing !== []) { 
            throw new ArgumentCountError( 
                (string) message( 
                    'Missing required argument(s): `%missing%`', 
                    missing: implode(', ', $missing) 
                ) 
            ); 
        } 
    } 
 
    private function assertMinimumOptional(): void 
    { 
        $optional = $this->parameters()->optionalKeys()->toArray(); 
        $providedOptionals = array_intersect( 
            $optional, 
            array_keys($this->arguments) 
        ); 
        $countProvided = count($providedOptionals); 
        if ($countProvided < $this->parameters()->optionalMinimum()) { 
            throw new ArgumentCountError( 
                (string) message( 
                    'Requires minimum **%minimum%** optional argument(s), **%provided%** provided', 
                    minimum: strval($this->parameters()->optionalMinimum()), 
                    provided: strval($countProvided) 
                ) 
            ); 
        } 
    } 
 
    /** 
     * @infection-ignore-all 
     */ 
    private function assertSetArgument(string $name, mixed $argument, ?string $key = null): void 
    { 
        $parameter = $this->parameters()->get($name); 
        $property = $name; 
        if ($key !== null) { 
            $property = $key . '...' . $name; 
        } 
        if ( 
            version_compare(PHP_VERSION, '8.2.0', '>=') 
            && class_exists('SensitiveParameterValue') 
            && $argument instanceof \SensitiveParameterValue 
        ) { 
            $argument = $argument->getValue(); 
        } 
 
        try { 
            $this->arguments[$key ?? $name] = $parameter->__invoke($argument); 
        } catch (TypeError $e) { 
            throw new $e( 
                $this->getExceptionPropertyMessage($property, $e) 
            ); 
        } catch (Throwable $e) { 
            throw new InvalidArgumentException( 
                $this->getExceptionPropertyMessage($property, $e) 
            ); 
        } 
    } 
 
    private function getExceptionPropertyMessage(string $name, Throwable $e): string 
    { 
        $message = $this->getExceptionMessage($e); 
 
        return "[{$name}]: {$message}"; 
    } 
 
    private function handleParameters(): void 
    { 
        $lastPos = array_key_last($this->parameters()->keys()); 
        foreach ($this->parameters()->keys() as $pos => $name) { 
            if ($pos === $lastPos && $this->parameters->isVariadic()) { 
                $variadicKeys = array_diff_key( 
                    $this->arguments, 
                    array_flip($this->parameters->keys()) 
                ); 
                foreach ($variadicKeys as $key => $value) { 
                    $key = strval($key); 
 
                    try { 
                        $this->assertSetArgument($name, $value, $key); 
                    } catch (Throwable $e) { 
                        $this->errors[] = $e->getMessage(); 
                    } 
                } 
 
                break; 
            } 
            if ($this->isSkipOptional($name)) { 
                continue; 
            } 
 
            try { 
                $this->assertSetArgument($name, $this->get($name)); 
            } catch (Throwable $e) { 
                $this->errors[] = $e->getMessage(); 
            } 
        } 
    } 
 
    private function isSkipOptional(string $name): bool 
    { 
        return $this->parameters()->optionalKeys()->contains($name) 
            && ! $this->has($name); 
    } 
 
    /** 
     * @param array<int|string, mixed> $arguments 
     */ 
    private function setArguments(array $arguments): void 
    { 
        $keys = array_keys($arguments); 
        foreach ($keys as $name) { 
            $name = strval($name); 
            $this->arguments[$name] = $arguments[$name]; 
        } 
    } 
 
    /** 
     * @param ArrayAccess<int|string, mixed> $arguments 
     * @return array<int|string, mixed> 
     */ 
    private function getArrayAccessArray(ArrayAccess $arguments): array 
    { 
        $array = []; 
        $cast = (array) $arguments; 
        $reflector = new ReflectionClass($arguments); 
        $properties = $reflector->getProperties(); 
        foreach ($properties as $property) { 
            $name = $property->getName(); 
            $array[$name] = $property->getValue($arguments); 
            $this->reflected[] = $name; 
        } 
        $this->fixScopeArrayCast($array, $cast); 
 
        return $array; 
    } 
 
    /** 
     * @param array<int|string, mixed> $array 
     * @param array<int|string, mixed> $cast 
     */ 
    private function fixScopeArrayCast(array &$array, array $cast): void 
    { 
        foreach ($cast as $key => $value) { 
            $key = strval($key); 
            if (str_contains($key, "\x00") 
                || in_array($key, $this->reflected, true)) { 
                continue; 
            } 
            $array[$key] = $value; 
        } 
    } 
} 
 
 |