<?php

declare(strict_types=1);

namespace App\Framework\LiveComponents\ParameterBinding;

use App\Framework\LiveComponents\ComponentEventDispatcher;
use App\Framework\LiveComponents\ValueObjects\ActionParameters;
use App\Framework\LiveComponents\ValueObjects\ParameterMatch;

/**
 * Parameter Binder for LiveComponent Actions
 *
 * Handles advanced parameter binding including:
 * - Builtin type casting (int, string, bool, float, array)
 * - DTO instantiation via constructor promotion
 * - Framework service injection (ActionParameters, ComponentEventDispatcher)
 * - Multiple naming conventions (camelCase, snake_case, kebab-case)
 * - Detailed error messages with type information
 */
final readonly class ParameterBinder
{
    public function __construct(
        private ComponentEventDispatcher $eventDispatcher
    ) {
    }

    /**
     * Bind parameters to method arguments
     *
     * @param \ReflectionMethod $method Method to bind parameters for
     * @param ActionParameters $params Action parameters from request
     * @return array<mixed> Bound arguments ready for method invocation
     * @throws ParameterBindingException if binding fails
     */
    public function bindParameters(
        \ReflectionMethod $method,
        ActionParameters $params
    ): array {
        $parameters = $method->getParameters();
        $args = [];

        foreach ($parameters as $param) {
            $args[] = $this->bindParameter($param, $params, $method);
        }

        return $args;
    }

    /**
     * Bind single parameter
     *
     * @param \ReflectionParameter $param Parameter to bind
     * @param ActionParameters $params Action parameters
     * @param \ReflectionMethod $method Method context (for error messages)
     * @return mixed Bound value
     * @throws ParameterBindingException if binding fails
     */
    private function bindParameter(
        \ReflectionParameter $param,
        ActionParameters $params,
        \ReflectionMethod $method
    ): mixed {
        $paramName = $param->getName();
        $paramType = $param->getType();

        // 1. Framework service injection
        $injected = $this->tryInjectFrameworkService($paramType, $params);
        if ($injected !== null) {
            return $injected;
        }

        // 2. Special case: 'params' array
        if ($paramName === 'params' &&
            $paramType instanceof \ReflectionNamedType &&
            $paramType->getName() === 'array') {
            return $params->toArray();
        }

        // 3. Try to find parameter value from ActionParameters
        $match = $this->findParamValue($params->toArray(), $paramName);

        if (! $match->isFound()) {
            // Parameter not found - check if optional
            if ($param->isDefaultValueAvailable()) {
                return $param->getDefaultValue();
            }

            // Required parameter missing
            throw ParameterBindingException::missingParameter(
                parameterName: $paramName,
                methodName: $method->getName(),
                className: $method->getDeclaringClass()->getName(),
                expectedType: $paramType?->__toString()
            );
        }

        $value = $match->getValue();

        // 4. Type binding based on parameter type
        if (! $paramType instanceof \ReflectionNamedType) {
            // No type hint - use value as-is
            return $value;
        }

        $typeName = $paramType->getName();

        // 5. Builtin type casting
        if ($paramType->isBuiltin()) {
            try {
                return $this->castToBuiltinType($value, $typeName);
            } catch (\Throwable $e) {
                throw ParameterBindingException::typeMismatch(
                    parameterName: $paramName,
                    expectedType: $typeName,
                    actualValue: $value,
                    methodName: $method->getName(),
                    className: $method->getDeclaringClass()->getName()
                );
            }
        }

        // 6. DTO instantiation (non-builtin types)
        try {
            return $this->instantiateDTO($typeName, $value, $params);
        } catch (\Throwable $e) {
            throw ParameterBindingException::dtoInstantiationFailed(
                parameterName: $paramName,
                dtoClass: $typeName,
                methodName: $method->getName(),
                className: $method->getDeclaringClass()->getName(),
                reason: $e->getMessage()
            );
        }
    }

    /**
     * Try to inject framework service
     *
     * @param \ReflectionType|null $paramType Parameter type
     * @param ActionParameters $params Action parameters
     * @return mixed|null Injected service or null if not a framework service
     */
    private function tryInjectFrameworkService(
        ?\ReflectionType $paramType,
        ActionParameters $params
    ): mixed {
        if (! $paramType instanceof \ReflectionNamedType) {
            return null;
        }

        $typeName = $paramType->getName();

        // ActionParameters injection
        if ($typeName === ActionParameters::class) {
            return $params;
        }

        // ComponentEventDispatcher injection
        if ($typeName === ComponentEventDispatcher::class) {
            return $this->eventDispatcher;
        }

        return null;
    }

    /**
     * Cast value to builtin type
     *
     * @param mixed $value Value to cast
     * @param string $typeName Target type name
     * @return mixed Cast value
     */
    private function castToBuiltinType(mixed $value, string $typeName): mixed
    {
        return match ($typeName) {
            'int' => (int) $value,
            'float' => (float) $value,
            'string' => (string) $value,
            'bool' => $this->castToBool($value),
            'array' => (array) $value,
            default => $value
        };
    }

    /**
     * Cast value to boolean with smart conversion
     *
     * Handles common string representations: 'true', 'false', '1', '0', 'yes', 'no'
     */
    private function castToBool(mixed $value): bool
    {
        if (is_bool($value)) {
            return $value;
        }

        if (is_string($value)) {
            $lower = strtolower($value);
            if (in_array($lower, ['true', '1', 'yes', 'on'], true)) {
                return true;
            }
            if (in_array($lower, ['false', '0', 'no', 'off', ''], true)) {
                return false;
            }
        }

        return (bool) $value;
    }

    /**
     * Instantiate DTO from value
     *
     * Supports:
     * - Direct value passing (if DTO accepts single constructor param)
     * - Constructor promotion with parameter mapping
     * - Nested parameter extraction from arrays
     *
     * @param string $className DTO class name
     * @param mixed $value Value to instantiate from
     * @param ActionParameters $allParams All action parameters (for nested mapping)
     * @return object Instantiated DTO
     * @throws \ReflectionException if class doesn't exist
     * @throws ParameterBindingException if instantiation fails
     */
    private function instantiateDTO(
        string $className,
        mixed $value,
        ActionParameters $allParams
    ): object {
        // Check if class exists
        if (! class_exists($className)) {
            throw new \InvalidArgumentException("Class {$className} does not exist");
        }

        $reflection = new \ReflectionClass($className);

        // Check if class is instantiable
        if (! $reflection->isInstantiable()) {
            throw new \InvalidArgumentException("Class {$className} is not instantiable");
        }

        $constructor = $reflection->getConstructor();

        // No constructor - try direct instantiation
        if ($constructor === null) {
            return new $className();
        }

        // Get constructor parameters
        $constructorParams = $constructor->getParameters();

        // Single constructor param - pass value directly
        if (count($constructorParams) === 1 && ! is_array($value)) {
            return new $className($value);
        }

        // Multiple constructor params - map from array or ActionParameters
        $args = [];

        foreach ($constructorParams as $param) {
            $paramName = $param->getName();

            // Try to find value in provided value array
            if (is_array($value) && array_key_exists($paramName, $value)) {
                $paramValue = $value[$paramName];
            } else {
                // Try to find in all ActionParameters with naming conventions
                $match = $this->findParamValue($allParams->toArray(), $paramName);

                if ($match->isFound()) {
                    $paramValue = $match->getValue();
                } elseif ($param->isDefaultValueAvailable()) {
                    $args[] = $param->getDefaultValue();

                    continue;
                } else {
                    throw new \InvalidArgumentException(
                        "Missing required constructor parameter '{$paramName}' for DTO {$className}"
                    );
                }
            }

            // Type cast constructor parameter
            $paramType = $param->getType();

            if ($paramType instanceof \ReflectionNamedType && $paramType->isBuiltin()) {
                $args[] = $this->castToBuiltinType($paramValue, $paramType->getName());
            } else {
                $args[] = $paramValue;
            }
        }

        return new $className(...$args);
    }

    /**
     * Find parameter value supporting multiple naming conventions
     *
     * Tries: camelCase, snake_case, kebab-case, lowercase
     *
     * @param array<string, mixed> $params Parameters to search
     * @param string $paramName Parameter name to find
     * @return ParameterMatch Match result
     */
    private function findParamValue(array $params, string $paramName): ParameterMatch
    {
        // Try exact match first
        if (array_key_exists($paramName, $params)) {
            return ParameterMatch::found($params[$paramName]);
        }

        // Try snake_case (rowId -> row_id)
        $snakeCase = strtolower(preg_replace('/([a-z])([A-Z])/', '$1_$2', $paramName));
        if (array_key_exists($snakeCase, $params)) {
            return ParameterMatch::found($params[$snakeCase]);
        }

        // Try kebab-case (rowId -> row-id)
        $kebabCase = strtolower(preg_replace('/([a-z])([A-Z])/', '$1-$2', $paramName));
        if (array_key_exists($kebabCase, $params)) {
            return ParameterMatch::found($params[$kebabCase]);
        }

        // Try lowercase (rowId -> rowid)
        $lowercase = strtolower($paramName);
        if (array_key_exists($lowercase, $params)) {
            return ParameterMatch::found($params[$lowercase]);
        }

        return ParameterMatch::notFound();
    }
}
