<?php
declare(strict_types=1);

namespace App\Widgets;

use App\Security\HtmlSanitizer;
use App\Security\UrlPolicy;
use App\Support\CoreHooks;
use App\Support\Hooks;
use InvalidArgumentException;
use JsonException;

final class WidgetRegistry
{
    public function __construct(
        private readonly HtmlSanitizer $sanitizer,
    ) {
    }

    /** @return array<int, string> */
    public function types(bool $includeTopics = false): array
    {
        $types = [
            'html_block',
            'latest_questions',
            'categories',
            'top_contributors',
            'most_viewed',
            'most_useful',
            'most_answered',
            'unanswered',
            'active_tags',
            'trending',
            'ad_slot',
            'link_list',
            'about_box',
            'social_links',
            'cta_button',
            'site_stats',
            'category_questions',
            'search_box',
            'image_banner',
            'text_block',
        ];
        if ($includeTopics) $types[] = 'latest_topics';
        return array_values(array_unique(array_merge($types, array_keys($this->extensions()))));
    }

    /** @return array<string,mixed>|null */
    public function extension(string $type): ?array
    {
        $extensions = $this->extensions();
        return isset($extensions[$type]) && is_array($extensions[$type]) ? $extensions[$type] : null;
    }

    /** @return array<string,array<string,mixed>> */
    private function extensions(): array
    {
        $filtered = Hooks::filter(CoreHooks::WIDGETS_REGISTER, [], ['registry' => $this]);
        if (!is_array($filtered)) return [];
        $safe = [];
        foreach ($filtered as $type => $definition) {
            $type = strtolower(trim((string) $type));
            if (preg_match('/^[a-z][a-z0-9_]{1,63}$/', $type) !== 1 || !is_array($definition)) continue;
            if (!is_callable($definition['validate'] ?? null) || !is_callable($definition['render'] ?? null)) continue;
            $definition['fields'] = is_array($definition['fields'] ?? null) ? array_values(array_filter($definition['fields'], 'is_array')) : [];
            $definition['translatable_fields'] = is_array($definition['translatable_fields'] ?? null) ? array_values(array_filter(array_map('strval', $definition['translatable_fields']))) : [];
            $safe[$type] = $definition;
        }
        return $safe;
    }

    /** @return array<string, mixed> */
    public function definition(string $type): array
    {
        return ['fields' => array_map(
            static fn (array $field): string => (string) $field['name'],
            $this->fields($type)
        )];
    }

    /** @return list<string> */
    public function translatableFields(string $type): array
    {
        if (($extension = $this->extension($type)) !== null) {
            return array_values(array_filter(array_map('strval', (array) ($extension['translatable_fields'] ?? []))));
        }
        return match ($type) {
            'html_block' => ['content_html'],
            'link_list' => ['links'],
            'about_box' => ['description', 'button_text'],
            'cta_button' => ['heading', 'subheading', 'button_text'],
            'search_box' => ['placeholder', 'button_text'],
            'image_banner' => ['alt_text', 'caption'],
            'text_block' => ['text'],
            default => [],
        };
    }

    /** @param array<string,mixed> $base @param array<string,mixed> $translation @return array<string,mixed> */
    public function applyTranslation(string $type, array $base, array $translation): array
    {
        foreach ($this->translatableFields($type) as $field) {
            if (!array_key_exists($field, $translation)) continue;
            if ($field === 'links') {
                $labels = is_array($translation[$field]) ? array_values($translation[$field]) : [];
                $links = is_array($base['links'] ?? null) ? array_values($base['links']) : [];
                foreach ($links as $index => $link) {
                    if (!is_array($link) || !isset($labels[$index])) continue;
                    $label = is_array($labels[$index]) ? (string) ($labels[$index]['label'] ?? '') : (string) $labels[$index];
                    if (trim($label) !== '') $links[$index]['label'] = trim($label);
                }
                $base['links'] = $links;
                continue;
            }
            $value = (string) $translation[$field];
            $base[$field] = $field === 'content_html' ? $this->sanitizer->sanitize($value) : trim($value);
        }
        return $base;
    }

    /** @param array<string,mixed> $input @return array<string,mixed> */
    public function validateTranslation(string $type, array $input): array
    {
        $allowed = $this->translatableFields($type);
        $this->assertKeys($input, $allowed);
        $result = [];
        foreach ($allowed as $field) {
            if (!array_key_exists($field, $input)) continue;
            if ($field === 'links') {
                $lines = is_array($input[$field]) ? $input[$field] : (preg_split('/\r\n|\r|\n/', trim((string) $input[$field])) ?: []);
                $result[$field] = array_values(array_map(static fn (mixed $label): array => ['label' => trim(is_array($label) ? (string) ($label['label'] ?? '') : (string) $label)], $lines));
                continue;
            }
            $value = (string) $input[$field];
            if (strlen($value) > 100000) throw new InvalidArgumentException('Widget translation is too long.');
            $result[$field] = $field === 'content_html' ? $this->sanitizer->sanitize($value) : trim($value);
        }
        return $result;
    }

    /**
     * Form descriptors for a widget type's configuration.
     *
     * The admin panel used to ask an operator to hand-write the config as raw
     * JSON, which is unusable for anyone who has not read the validator. These
     * descriptors let the editor generate real inputs, and they stay next to
     * validate() so a field cannot be added in one place and forgotten in the
     * other.
     *
     * @return array<int, array<string, mixed>>
     */
    public function fields(string $type): array
    {
        if (($extension = $this->extension($type)) !== null) {
            return (array) ($extension['fields'] ?? []);
        }
        $limit = ['name' => 'limit', 'input' => 'number', 'min' => 1, 'max' => 20, 'default' => 5, 'required' => true];
        $categoryLimit = ['name' => 'limit', 'input' => 'number', 'min' => 1, 'max' => 200, 'default' => 50, 'required' => true];

        return match ($type) {
            'html_block' => [
                ['name' => 'content_html', 'input' => 'html', 'default' => '<p></p>', 'required' => true],
            ],
            'latest_questions' => [
                $limit,
                ['name' => 'category_id', 'input' => 'category', 'default' => null, 'required' => false],
            ],
            'latest_topics' => [$limit],
            'category_questions' => [
                $limit,
                ['name' => 'category_id', 'input' => 'category', 'default' => null, 'required' => true],
            ],
            'categories' => [$categoryLimit],
            'ad_slot' => [
                ['name' => 'placement', 'input' => 'fixed', 'default' => 'homepage', 'required' => true],
            ],
            'top_contributors', 'most_viewed', 'most_useful', 'most_answered', 'unanswered', 'active_tags', 'trending'
                => [$limit],
            'link_list' => [
                ['name' => 'links', 'input' => 'textarea', 'default' => '', 'required' => true],
                $limit,
            ],
            'about_box' => [
                ['name' => 'description', 'input' => 'textarea', 'default' => '', 'required' => true],
                ['name' => 'button_text', 'input' => 'text', 'default' => '', 'required' => false],
                ['name' => 'button_url', 'input' => 'text', 'default' => '', 'required' => false],
            ],
            'social_links' => [
                ['name' => 'twitter', 'input' => 'text', 'default' => '', 'required' => false],
                ['name' => 'facebook', 'input' => 'text', 'default' => '', 'required' => false],
                ['name' => 'github', 'input' => 'text', 'default' => '', 'required' => false],
                ['name' => 'linkedin', 'input' => 'text', 'default' => '', 'required' => false],
                ['name' => 'telegram', 'input' => 'text', 'default' => '', 'required' => false],
                ['name' => 'youtube', 'input' => 'text', 'default' => '', 'required' => false],
                ['name' => 'discord', 'input' => 'text', 'default' => '', 'required' => false],
            ],
            'cta_button' => [
                ['name' => 'heading', 'input' => 'text', 'default' => '', 'required' => true],
                ['name' => 'subheading', 'input' => 'textarea', 'default' => '', 'required' => false],
                ['name' => 'button_text', 'input' => 'text', 'default' => '', 'required' => true],
                ['name' => 'button_url', 'input' => 'text', 'default' => '', 'required' => true],
            ],
            'site_stats' => [
                ['name' => 'show_questions', 'input' => 'boolean', 'default' => true, 'required' => false],
                ['name' => 'show_answers', 'input' => 'boolean', 'default' => true, 'required' => false],
                ['name' => 'show_users', 'input' => 'boolean', 'default' => true, 'required' => false],
                ['name' => 'show_views', 'input' => 'boolean', 'default' => true, 'required' => false],
            ],
            'search_box' => [
                ['name' => 'placeholder', 'input' => 'text', 'default' => '', 'required' => false],
                ['name' => 'button_text', 'input' => 'text', 'default' => '', 'required' => false],
            ],
            'image_banner' => [
                ['name' => 'image_url', 'input' => 'text', 'default' => '', 'required' => true],
                ['name' => 'alt_text', 'input' => 'text', 'default' => '', 'required' => true],
                ['name' => 'link_url', 'input' => 'text', 'default' => '', 'required' => false],
                ['name' => 'caption', 'input' => 'text', 'default' => '', 'required' => false],
            ],
            'text_block' => [
                ['name' => 'text', 'input' => 'textarea', 'default' => '', 'required' => true],
            ],
            default => throw new InvalidArgumentException('Unknown widget type.'),
        };
    }

    /** @return array<string, mixed> */
    public function validate(string $type, mixed $config): array
    {
        if (!is_array($config)) {
            throw new InvalidArgumentException('Widget config must be an object.');
        }
        if (($extension = $this->extension($type)) !== null) {
            $validated = ($extension['validate'])($config, $this);
            if (!is_array($validated)) throw new InvalidArgumentException('Plugin widget validator must return an array.');
            return $validated;
        }
        if (!in_array($type, $this->types(true), true)) {
            throw new InvalidArgumentException('Unknown widget type.');
        }

        return match ($type) {
            'html_block' => $this->validateHtmlBlock($config),
            'latest_questions', 'category_questions' => $this->validateLatestQuestions($config),
            'latest_topics' => $this->validateLimitedList($config),
            'categories' => $this->validateCategories($config),
            'ad_slot' => $this->validateAdSlot($config),
            'top_contributors', 'most_viewed', 'most_useful', 'most_answered', 'unanswered', 'active_tags', 'trending'
                => $this->validateLimitedList($config),
            'link_list' => $this->validateLinkList($config),
            'about_box' => $this->validateAboutBox($config),
            'social_links' => $this->validateSocialLinks($config),
            'cta_button' => $this->validateCtaButton($config),
            'site_stats' => $this->validateSiteStats($config),
            'search_box' => $this->validateSearchBox($config),
            'image_banner' => $this->validateImageBanner($config),
            'text_block' => $this->validateTextBlock($config),
        };
    }

    public function validateForPersistence(string $type, mixed $config): string
    {
        try {
            return json_encode($this->validate($type, $config), JSON_THROW_ON_ERROR | JSON_UNESCAPED_UNICODE | JSON_UNESCAPED_SLASHES);
        } catch (JsonException $exception) {
            throw new InvalidArgumentException('Widget config could not be encoded.', 0, $exception);
        }
    }

    /** @param array<string, mixed> $config */
    private function validateHtmlBlock(array $config): array
    {
        $this->assertKeys($config, ['content_html']);
        $content = $config['content_html'] ?? null;

        if (!is_string($content) || trim($content) === '') {
            throw new InvalidArgumentException('html_block requires non-empty content_html.');
        }

        $safeContent = $this->sanitizer->sanitize($content);

        if (trim($safeContent) === '') {
            throw new InvalidArgumentException('html_block content cannot be empty.');
        }

        return ['content_html' => $safeContent];
    }

    /** @param array<string, mixed> $config */
    private function validateLatestQuestions(array $config): array
    {
        $this->assertKeys($config, ['limit', 'category_id']);

        return [
            'limit' => $this->boundedInteger($config['limit'] ?? 5, 1, 20, 'limit'),
            'category_id' => $this->nullablePositiveInteger($config['category_id'] ?? null, 'category_id'),
        ];
    }

    /** @param array<string, mixed> $config */
    private function validateCategories(array $config): array
    {
        $this->assertKeys($config, ['limit']);

        return ['limit' => $this->boundedInteger($config['limit'] ?? 50, 1, 200, 'limit')];
    }


    /** @param array<string, mixed> $config */
    private function validateAdSlot(array $config): array
    {
        $this->assertKeys($config, ['placement']);
        $placement = trim((string) ($config['placement'] ?? 'homepage'));
        if ($placement !== 'homepage') {
            throw new InvalidArgumentException('Homepage ad widgets must use the homepage placement.');
        }
        return ['placement' => 'homepage'];
    }

    /** @param array<string, mixed> $config */
    private function validateLimitedList(array $config): array
    {
        $this->assertKeys($config, ['limit']);

        return ['limit' => $this->boundedInteger($config['limit'] ?? 5, 1, 20, 'limit')];
    }

    /** @param array<string, mixed> $config */
    private function validateLinkList(array $config): array
    {
        $this->assertKeys($config, ['links', 'limit']);
        $rawLinks = $config['links'] ?? '';
        $links = [];
        if (is_array($rawLinks)) {
            foreach ($rawLinks as $item) {
                if (!is_array($item)) continue;
                $label = trim((string) ($item['label'] ?? ''));
                $url = trim((string) ($item['url'] ?? ''));
                if ($label !== '' && $url !== '') {
                    $links[] = ['label' => $label, 'url' => $this->requireUrl($url)];
                }
            }
        } elseif (is_string($rawLinks)) {
            $lines = preg_split('/\r\n|\r|\n/', trim($rawLinks)) ?: [];
            foreach ($lines as $line) {
                $line = trim($line);
                if ($line === '') continue;
                $parts = explode('|', $line, 2);
                $label = trim($parts[0]);
                $url = isset($parts[1]) ? trim($parts[1]) : $label;
                if ($label !== '') {
                    $links[] = ['label' => $label, 'url' => $this->requireUrl($url)];
                }
            }
        }

        return [
            'links' => $links,
            'limit' => $this->boundedInteger($config['limit'] ?? 5, 1, 20, 'limit'),
        ];
    }

    /** @param array<string, mixed> $config */
    private function validateAboutBox(array $config): array
    {
        $this->assertKeys($config, ['description', 'button_text', 'button_url']);
        $description = trim((string) ($config['description'] ?? ''));
        if ($description === '') {
            throw new InvalidArgumentException('about_box requires a description.');
        }

        return [
            'description' => $description,
            'button_text' => trim((string) ($config['button_text'] ?? '')),
            'button_url' => $this->optionalUrl((string) ($config['button_url'] ?? '')),
        ];
    }

    /** @param array<string, mixed> $config */
    private function validateSocialLinks(array $config): array
    {
        $allowed = ['twitter', 'facebook', 'github', 'linkedin', 'telegram', 'youtube', 'discord'];
        $this->assertKeys($config, $allowed);
        $result = [];
        foreach ($allowed as $network) {
            $val = trim((string) ($config[$network] ?? ''));
            if ($val !== '') {
                $result[$network] = $this->requireUrl($val, false);
            }
        }
        return $result;
    }

    /** @param array<string, mixed> $config */
    private function validateCtaButton(array $config): array
    {
        $this->assertKeys($config, ['heading', 'subheading', 'button_text', 'button_url']);
        $heading = trim((string) ($config['heading'] ?? ''));
        $buttonText = trim((string) ($config['button_text'] ?? ''));
        $buttonUrl = trim((string) ($config['button_url'] ?? ''));
        if ($heading === '' || $buttonText === '' || $buttonUrl === '') {
            throw new InvalidArgumentException('cta_button requires heading, button_text, and button_url.');
        }

        return [
            'heading' => $heading,
            'subheading' => trim((string) ($config['subheading'] ?? '')),
            'button_text' => $buttonText,
            'button_url' => $this->requireUrl($buttonUrl),
        ];
    }

    /** @param array<string, mixed> $config */
    private function validateSiteStats(array $config): array
    {
        $this->assertKeys($config, ['show_questions', 'show_answers', 'show_users', 'show_views']);
        return [
            'show_questions' => !empty($config['show_questions']),
            'show_answers' => !empty($config['show_answers']),
            'show_users' => !empty($config['show_users']),
            'show_views' => !empty($config['show_views']),
        ];
    }

    /** @param array<string, mixed> $config */
    private function validateSearchBox(array $config): array
    {
        $this->assertKeys($config, ['placeholder', 'button_text']);
        return [
            'placeholder' => trim((string) ($config['placeholder'] ?? '')),
            'button_text' => trim((string) ($config['button_text'] ?? '')),
        ];
    }

    /** @param array<string, mixed> $config */
    private function validateImageBanner(array $config): array
    {
        $this->assertKeys($config, ['image_url', 'alt_text', 'link_url', 'caption']);
        $imageUrl = trim((string) ($config['image_url'] ?? ''));
        $altText = trim((string) ($config['alt_text'] ?? ''));
        if ($imageUrl === '' || $altText === '') {
            throw new InvalidArgumentException('image_banner requires image_url and alt_text.');
        }

        return [
            'image_url' => $this->requireUrl($imageUrl),
            'alt_text' => $altText,
            'link_url' => $this->optionalUrl((string) ($config['link_url'] ?? '')),
            'caption' => trim((string) ($config['caption'] ?? '')),
        ];
    }

    /** @param array<string, mixed> $config */
    private function validateTextBlock(array $config): array
    {
        $this->assertKeys($config, ['text']);
        $text = trim((string) ($config['text'] ?? ''));
        if ($text === '') {
            throw new InvalidArgumentException('text_block requires non-empty text.');
        }

        return ['text' => $text];
    }

    /** @param array<string, mixed> $config @param array<int, string> $allowed */
    private function assertKeys(array $config, array $allowed): void
    {
        foreach (array_keys($config) as $key) {
            if (!is_string($key) || !in_array($key, $allowed, true)) {
                throw new InvalidArgumentException('Widget config contains an unsupported key.');
            }
        }
    }

    private function boundedInteger(mixed $value, int $min, int $max, string $field): int
    {
        if (is_string($value) && preg_match('/^\d+$/D', $value) !== 1) {
            throw new InvalidArgumentException(sprintf('%s must be an integer.', $field));
        }

        if (!is_int($value) && !is_string($value)) {
            throw new InvalidArgumentException(sprintf('%s must be an integer.', $field));
        }

        $integer = (int) $value;

        if ($integer < $min || $integer > $max) {
            throw new InvalidArgumentException(sprintf('%s is outside the allowed range.', $field));
        }

        return $integer;
    }

    private function nullablePositiveInteger(mixed $value, string $field): ?int
    {
        if ($value === null || $value === '') {
            return null;
        }

        return $this->boundedInteger($value, 1, PHP_INT_MAX, $field);
    }

    private function optionalUrl(string $value, bool $allowRelative = true): string
    {
        $value = trim($value);
        return $value === '' ? '' : $this->requireUrl($value, $allowRelative);
    }

    private function requireUrl(string $value, bool $allowRelative = true): string
    {
        $safe = UrlPolicy::sanitize($value, $allowRelative);
        if ($safe === '') throw new InvalidArgumentException('Widget URL is invalid.');
        return $safe;
    }
}
