<?php

declare(strict_types=1);

namespace CliqThemes\UI\Controls;

/**
 * Registry for controls supplied by a host plugin or optional module.
 *
 * Cliq UI owns the schema contract and rendering engine. Product plugins own
 * the actual control definitions, labels, defaults, conditions and
 * availability.
 */
final class ControlRegistry
{
    /** @var array<string, array<string, mixed>> */
    private array $controls = [];

    /**
     * @param array<string, mixed> $control
     */
    public function register(array $control): void
    {
        $control = $this->normalize($control);
        $this->controls[$control['id']] = $control;
    }

    /**
     * @param iterable<int, array<string, mixed>> $controls
     */
    public function registerMany(iterable $controls): void
    {
        foreach ($controls as $control) {
            $this->register($control);
        }
    }

    /**
     * @return array<int, array<string, mixed>>
     */
    public function all(): array
    {
        $controls = array_values($this->controls);
        usort(
            $controls,
            static fn (array $left, array $right): int =>
                [$left['section'], $left['order'], $left['id']]
                <=>
                [$right['section'], $right['order'], $right['id']]
        );

        return $controls;
    }

    /**
     * Returns controls available for the supplied runtime context.
     *
     * @param array<string, mixed> $context
     * @return array<int, array<string, mixed>>
     */
    public function forContext(array $context): array
    {
        return array_values(array_filter(
            $this->all(),
            static function (array $control) use ($context): bool {
                $availableCapabilities = self::availableCapabilities($context['capabilities'] ?? []);
                foreach ($control['capabilities'] as $requiredCapability) {
                    if (! in_array($requiredCapability, $availableCapabilities, true)) {
                        return false;
                    }
                }

                return ConditionEvaluator::matches($control['conditions'], $context);
            }
        ));
    }

    public function has(string $id): bool
    {
        return isset($this->controls[$id]);
    }

    /**
     * Sanitizes a submitted value according to a registered control.
     */
    public function sanitize(string $id, mixed $value): mixed
    {
        if (! $this->has($id)) {
            throw new \InvalidArgumentException(esc_html(sprintf('Control "%s" is not registered.', $id)));
        }

        $control = $this->controls[$id];
        $sanitized = match ($control['sanitize']) {
            'boolean' => filter_var($value, FILTER_VALIDATE_BOOLEAN),
            'integer' => (int) $value,
            'number' => (float) $value,
            'key' => preg_replace('/[^a-zA-Z0-9_\-]/', '', (string) $value) ?? '',
            'css_length' => is_scalar($value) && preg_match(
                '/^(?:0|(?:\d+(?:\.\d+)?|\.\d+)(?:px|%|em|rem|vh|vw))$/',
                trim((string) $value)
            ) === 1
                ? trim((string) $value)
                : $control['default'],
            'css_color' => is_scalar($value) && preg_match(
                '/^(?:#[0-9a-fA-F]{3,8}|(?:rgba?|hsla?)\([0-9.%\s,\/+-]+\)|transparent|currentColor|inherit|var\(--[a-zA-Z0-9_-]+\))$/',
                trim((string) $value)
            ) === 1
                ? trim((string) $value)
                : $control['default'],
            'text', 'textarea' => is_scalar($value)
                ? trim(strip_tags((string) $value))
                : $control['default'],
            // Same as 'text', but edge spaces survive: a prefix/suffix is
            // concatenated straight onto a value, so " stores" trimmed to
            // "stores" renders as "20stores".
            'text_spaced' => is_scalar($value)
                ? strip_tags((string) $value)
                : $control['default'],
            'choices' => self::sanitizeChoices($value),
            'keys' => self::sanitizeKeys($value, $control['default']),
            'url' => self::sanitizeUrl($value, $control['default']),
            default => is_scalar($value) || $value === null ? $value : '',
        };

        if (in_array($control['sanitize'], ['integer', 'number'], true)) {
            if (isset($control['min']) && $sanitized < $control['min']) {
                $sanitized = $control['min'];
            }
            if (isset($control['max']) && $sanitized > $control['max']) {
                $sanitized = $control['max'];
            }
        }

        $allowed = array_values(array_filter(array_map(
            static fn (mixed $option): mixed =>
                is_array($option) && array_key_exists('value', $option) ? $option['value'] : null,
            $control['options']
        ), static fn (mixed $option): bool => $option !== null));

        if ($allowed !== [] && ! in_array($sanitized, $allowed, true)) {
            return $control['default'];
        }

        return $sanitized;
    }

    private static function sanitizeUrl(mixed $value, mixed $default): mixed
    {
        if (! is_scalar($value)) {
            return $default;
        }

        $url = trim((string) $value);
        if ($url === '' || str_starts_with($url, '/')) {
            return $url;
        }

        $parts = parse_url($url);
        if (! is_array($parts) || ! in_array(strtolower((string) ($parts['scheme'] ?? '')), ['http', 'https'], true)) {
            return $default;
        }

        return filter_var($url, FILTER_VALIDATE_URL) !== false ? $url : $default;
    }

    /**
     * @return array<int, array{value: string, label: string}>
     */
    private static function sanitizeChoices(mixed $value): array
    {
        if (! is_array($value)) {
            return [];
        }

        $choices = [];
        foreach ($value as $choice) {
            if (! is_array($choice)) {
                continue;
            }

            $choiceValue = isset($choice['value']) && is_scalar($choice['value'])
                ? trim(strip_tags((string) $choice['value']))
                : '';
            $choiceLabel = isset($choice['label']) && is_scalar($choice['label'])
                ? trim(strip_tags((string) $choice['label']))
                : $choiceValue;
            if ($choiceValue === '') {
                continue;
            }

            $choices[] = [
                'value' => $choiceValue,
                'label' => $choiceLabel !== '' ? $choiceLabel : $choiceValue,
            ];
        }

        return $choices;
    }

    /**
     * @return string[]
     */
    private static function sanitizeKeys(mixed $value, mixed $default): array
    {
        $fallback = is_array($default) ? array_values(array_map('strval', $default)) : [];
        if (! is_array($value)) {
            return $fallback;
        }

        $keys = array_values(array_unique(array_filter(array_map(
            static fn (mixed $item): string => is_scalar($item)
                ? (preg_replace('/[^a-zA-Z0-9_\-]/', '', (string) $item) ?? '')
                : '',
            $value
        ), static fn (string $item): bool => $item !== '')));

        return $keys !== [] ? $keys : $fallback;
    }

    /**
     * @param array<string, mixed> $control
     * @return array<string, mixed>
     */
    private function normalize(array $control): array
    {
        foreach (['id', 'type', 'scope', 'path', 'label'] as $required) {
            if (! isset($control[$required]) || trim((string) $control[$required]) === '') {
                throw new \InvalidArgumentException(esc_html(
                    sprintf('A registered control requires a non-empty "%s".', $required)
                ));
            }
        }

        $id = trim((string) $control['id']);
        if (! preg_match('/^[a-z][a-z0-9._-]*$/', $id)) {
            throw new \InvalidArgumentException(esc_html(sprintf('Control id "%s" is invalid.', $id)));
        }

        $conditions = $control['conditions'] ?? [];
        if (! is_array($conditions)) {
            throw new \InvalidArgumentException(esc_html(sprintf('Control "%s" conditions must be an array.', $id)));
        }

        $capabilities = $control['capabilities'] ?? [];
        if (! is_array($capabilities)) {
            throw new \InvalidArgumentException(esc_html(sprintf('Control "%s" capabilities must be an array.', $id)));
        }

        return array_merge(
            [
                'section' => 'general',
                'order' => 10,
                'help' => '',
                'default' => null,
                'conditions' => [],
                'sanitize' => 'text',
                'capabilities' => [],
                'options' => [],
            ],
            $control,
            [
                'id' => $id,
                'type' => (string) $control['type'],
                'scope' => (string) $control['scope'],
                'path' => (string) $control['path'],
                'label' => (string) $control['label'],
                'section' => (string) ($control['section'] ?? 'general'),
                'order' => (int) ($control['order'] ?? 10),
                'conditions' => $conditions,
                'capabilities' => array_values(array_unique(array_filter(
                    array_map('strval', $capabilities),
                    static fn (string $capability): bool => $capability !== ''
                ))),
            ]
        );
    }

    /**
     * Accept either a list of capability names or an associative
     * capability-name => enabled map in the runtime context.
     *
     * @return string[]
     */
    private static function availableCapabilities(mixed $capabilities): array
    {
        if (! is_array($capabilities)) {
            return [];
        }

        if (array_is_list($capabilities)) {
            return array_values(array_unique(array_map('strval', $capabilities)));
        }

        return array_values(array_map(
            'strval',
            array_keys(array_filter($capabilities, static fn (mixed $enabled): bool => (bool) $enabled))
        ));
    }
}
