<?php

declare(strict_types=1);

namespace App\Framework\Database\ValueObjects;

use InvalidArgumentException;

/**
 * Value Object for SQL query parameters
 * Provides type-safe parameter binding for prepared statements
 */
final readonly class QueryParameters
{
    /**
     * @param array<string, mixed> $parameters
     */
    private readonly array $parameters;

    public function __construct(array $parameters = [])
    {
        $this->validateParameters($parameters);

        // Normalize parameter names for consistent access
        $normalized = [];
        foreach ($parameters as $name => $value) {
            // Keep numeric keys as-is for positional parameters
            if (is_int($name)) {
                $normalized[$name] = $value;
            } else {
                $normalizedName = $this->normalizeParameterName($name);
                $normalized[$normalizedName] = $value;
            }
        }
        $this->parameters = $normalized;
    }

    public static function empty(): self
    {
        return new self([]);
    }

    /**
     * @param array<string, mixed> $parameters
     */
    public static function fromArray(array $parameters): self
    {
        return new self($parameters);
    }

    public function with(string $name, mixed $value): self
    {
        $name = $this->normalizeParameterName($name);
        $parameters = $this->parameters;
        $parameters[$name] = $value;

        return new self($parameters);
    }

    /**
     * @param array<string, mixed> $newParameters
     */
    public function merge(array $newParameters): self
    {
        $merged = $this->parameters;
        foreach ($newParameters as $name => $value) {
            if (is_int($name)) {
                $merged[$name] = $value;
            } else {
                $merged[$this->normalizeParameterName($name)] = $value;
            }
        }

        return new self($merged);
    }

    public function without(string $name): self
    {
        $name = $this->normalizeParameterName($name);
        $parameters = $this->parameters;
        unset($parameters[$name]);

        return new self($parameters);
    }

    public function has(string $name): bool
    {
        $name = $this->normalizeParameterName($name);

        return array_key_exists($name, $this->parameters);
    }

    public function get(string $name, mixed $default = null): mixed
    {
        $name = $this->normalizeParameterName($name);

        return $this->parameters[$name] ?? $default;
    }

    /**
     * @return array<string, mixed>
     */
    public function toArray(): array
    {
        return $this->parameters;
    }

    public function isEmpty(): bool
    {
        return empty($this->parameters);
    }

    public function count(): int
    {
        return count($this->parameters);
    }

    /**
     * Get parameters for PDO binding (with : prefix)
     * @return array<string, mixed>
     */
    public function toPdoArray(): array
    {
        $pdoParams = [];
        foreach ($this->parameters as $name => $value) {
            if (is_int($name)) {
                // Positional parameters use numeric keys as-is
                $pdoParams[$name] = $value;
            } else {
                // Named parameters get : prefix if needed
                $key = str_starts_with($name, ':') ? $name : ":{$name}";
                $pdoParams[$key] = $value;
            }
        }

        return $pdoParams;
    }

    /**
     * Get parameter names that are used in the SQL
     * @param string $sql
     * @return array<string>
     */
    public function getUsedParameters(string $sql): array
    {
        $used = [];
        foreach (array_keys($this->parameters) as $name) {
            $pattern = "/:{$name}\b/";
            if (preg_match($pattern, $sql)) {
                $used[] = $name;
            }
        }

        return $used;
    }

    /**
     * Get parameter names that are defined but not used in SQL
     * @param string $sql
     * @return array<string>
     */
    public function getUnusedParameters(string $sql): array
    {
        $used = $this->getUsedParameters($sql);

        return array_values(array_diff(array_keys($this->parameters), $used));
    }

    /**
     * Validate that all parameters referenced in SQL are defined
     */
    public function validateForSql(string $sql): void
    {
        // Find all parameter placeholders in SQL
        preg_match_all('/:([a-zA-Z_][a-zA-Z0-9_]*)/', $sql, $matches);
        $requiredParams = array_unique($matches[1]);

        $missing = [];
        foreach ($requiredParams as $param) {
            if (! $this->has($param)) {
                $missing[] = $param;
            }
        }

        if (! empty($missing)) {
            throw new InvalidArgumentException(
                'Missing required parameters: ' . implode(', ', $missing)
            );
        }
    }

    /**
     * Get PDO parameter type for a value
     */
    public function getPdoType(string $name): int
    {
        $value = $this->get($name);

        return match (true) {
            $value === null => \PDO::PARAM_NULL,
            is_bool($value) => \PDO::PARAM_BOOL,
            is_int($value) => \PDO::PARAM_INT,
            default => \PDO::PARAM_STR
        };
    }

    /**
     * Normalize parameter name (remove : prefix if present)
     */
    private function normalizeParameterName(string $name): string
    {
        return ltrim($name, ':');
    }

    /**
     * @param array<string, mixed> $parameters
     */
    private function validateParameters(array $parameters): void
    {
        foreach ($parameters as $name => $value) {
            // Allow numeric keys for positional parameters (e.g., with ? placeholders)
            if (! is_string($name) && ! is_int($name)) {
                throw new InvalidArgumentException('Parameter names must be strings or integers');
            }

            // Skip validation for numeric keys (positional parameters)
            if (is_int($name)) {
                continue;
            }

            $normalizedName = $this->normalizeParameterName($name);
            if (empty($normalizedName)) {
                throw new InvalidArgumentException('Parameter name cannot be empty');
            }

            if (! preg_match('/^[a-zA-Z_][a-zA-Z0-9_]*$/', $normalizedName)) {
                throw new InvalidArgumentException(
                    "Invalid parameter name '{$normalizedName}'. Must start with letter or underscore and contain only alphanumeric characters and underscores"
                );
            }

            // Validate value types (allow only scalar values and null)
            if (! is_scalar($value) && $value !== null) {
                throw new InvalidArgumentException(
                    "Parameter '{$normalizedName}' must be scalar or null, " . gettype($value) . ' given'
                );
            }
        }
    }
}
