<?php
declare(strict_types=1);

namespace Ekesto\CacheWarmup\Settings;

use Ekesto\CacheWarmup\WarmupRuntime;
use Ekesto\CacheWarmup\Settings;
use Ekesto\CacheWarmup\Sitemap;

/**
 * Option schema, defaults, getters and sanitization.
 */
final class Repository
{
    private const NULLABLE_GROUPS = ['advanced', 'logging'];
    private const RAW_GROUPS = ['advanced', 'logging', 'diagnostics'];

    /**
     * Parse sitemap URLs from settings into a normalized list.
     *
     * @return array<int,string> Configured sitemap URLs.
     */
    public static function getSitemaps(): array
    {
        $raw = self::getString('general.sitemaps');
        if ($raw === '') {
            return [];
        }

        $lines = preg_split('/\R/', $raw) ?: [];
        $out = [];
        foreach ($lines as $line) {
            $line = trim((string) $line);
            if ($line === '') {
                continue;
            }
            $out[] = $line;
        }

        return self::normalizeSitemapUrls($out);
    }

    /**
     * Parse and canonicalize manually excluded URLs from settings.
     *
     * @return array<int,string> Canonicalized manual exclusion URLs.
     */
    public static function getManualExclusions(): array
    {
        $raw = self::getString('general.manual_urls');
        if ($raw === '') {
            return [];
        }

        $lines = preg_split('/\R/', $raw) ?: [];
        $out = [];
        foreach ($lines as $line) {
            $line = trim((string) $line);
            if ($line === '') {
                continue;
            }
            $canon = Sitemap::canonicalizeUrl($line);
            if ($canon !== '') {
                $out[] = $canon;
            }
        }

        return self::normalizeManualExclusions($out);
    }

    /**
     * Normalize sitemap URLs into a trimmed, unique list.
     *
     * @param mixed $sitemaps Raw sitemap list.
     * @return array<int,string> Normalized sitemap URLs.
     */
    public static function normalizeSitemapUrls($sitemaps): array
    {
        if (!is_array($sitemaps)) {
            return [];
        }

        $normalized = [];
        $seen = [];

        foreach ($sitemaps as $url) {
            $url = trim((string) $url);
            if ($url === '') {
                continue;
            }

            $url = esc_url_raw($url);
            if ($url === '' || isset($seen[$url])) {
                continue;
            }

            $seen[$url] = true;
            $normalized[] = $url;
        }

        return $normalized;
    }

    /**
     * Normalize manual exclusion URLs into canonicalized unique URLs.
     *
     * @param mixed $urls Raw manual exclusion list.
     * @return array<int,string> Canonicalized URLs.
     */
    public static function normalizeManualExclusions($urls): array
    {
        if (!is_array($urls)) {
            return [];
        }

        $normalized = [];
        $seen = [];

        foreach ($urls as $url) {
            $canon = Sitemap::canonicalizeUrl((string) $url);
            if ($canon === '' || isset($seen[$canon])) {
                continue;
            }

            $seen[$canon] = true;
            $normalized[] = $canon;
        }

        return $normalized;
    }

    /**
     * Normalize configured priority post type order.
     *
     * @param mixed $postTypeOrder Raw post type order list.
     * @return array<int,string> Sanitized post type keys.
     */
    public static function normalizePostTypeOrderArray($postTypeOrder): array
    {
        if (!is_array($postTypeOrder)) {
            return [];
        }

        $normalized = [];
        $seen = [];

        foreach ($postTypeOrder as $postType) {
            $postType = sanitize_key((string) $postType);
            if ($postType === '' || isset($seen[$postType])) {
                continue;
            }

            $seen[$postType] = true;
            $normalized[] = $postType;
        }

        return $normalized;
    }

    /**
     * Normalize configured priority page IDs.
     *
     * @param mixed $priorityPageIds Raw page ID list.
     * @return array<int,int> Sanitized positive page IDs.
     */
    public static function normalizePriorityPageIdsArray($priorityPageIds): array
    {
        if (!is_array($priorityPageIds)) {
            return [];
        }

        $normalized = [];
        $seen = [];

        foreach ($priorityPageIds as $pageId) {
            $pageId = absint($pageId);
            if ($pageId < 1 || isset($seen[$pageId])) {
                continue;
            }

            $seen[$pageId] = true;
            $normalized[] = $pageId;
        }

        return $normalized;
    }

    /**
     * Return the settings schema used for defaults and sanitization.
     *
     * @return array<string,mixed> Settings schema grouped by section.
     */
    public static function schema(): array
    {
        return [
            'general' => [
                'title' => self::translateLabel('General'),
                'fields' => [
                    'enabled' => ['type' => 'boolean', 'default' => Settings::DEFAULT_ENABLED],
                    'sitemaps' => ['type' => 'textarea', 'default' => Settings::DEFAULT_SITEMAPS],
                    'manual_urls' => ['type' => 'textarea', 'default' => ''],
                ],
            ],
            'advanced' => [
                'title' => self::translateLabel('Warmup Engine'),
                'fields' => [
                    'pacing_mode' => ['type' => 'text', 'default' => Settings::DEFAULT_PACING_MODE],
                    'pages_per_batch' => ['type' => 'number', 'default' => Settings::DEFAULT_PAGES_PER_BATCH, 'min' => 1, 'max' => Settings::DEFAULT_DYNAMIC_BATCH_MAX_URLS],
                    'throttle_seconds' => ['type' => 'number', 'default' => 0, 'min' => 0, 'max' => 5],
                    'max_batch_seconds' => ['type' => 'number', 'default' => 45, 'min' => 5],
                    'user_agent' => ['type' => 'text', 'default' => ''],
                    'stale_run_minutes' => ['type' => 'number', 'default' => 15, 'min' => 5, 'max' => 1440],
                    'max_urls_per_run' => ['type' => 'number', 'default' => 5000, 'min' => 100, 'max' => 100000],
                ],
            ],
            'logging' => [
                'title' => self::translateLabel('Logging'),
                'fields' => [
                    'retention_days' => ['type' => 'number', 'default' => Settings::DEFAULT_RETENTION_DAYS, 'min' => 1, 'max' => 365],
                ],
            ],
            'priorities' => [
                'title' => self::translateLabel('Priorities'),
                'fields' => [
                    'post_type_order' => ['type' => 'array', 'default' => ['page']],
                    'priority_pages' => ['type' => 'array', 'default' => []],
                ],
            ],
            'diagnostics' => [
                'title' => self::translateLabel('Diagnostics'),
                'fields' => [
                    'probe_results' => ['type' => 'json', 'default' => null],
                ],
            ],
        ];
    }

    /**
     * Register the settings option and REST schema with WordPress.
     */
    public static function registerOption(): void
    {
        register_setting(
            Settings::OPTION_GROUP,
            Settings::OPTION,
            [
                'type' => 'object',
                'sanitize_callback' => [self::class, 'sanitizeOption'],
                'default' => self::defaults(),
                'show_in_rest' => [
                    'name' => Settings::OPTION,
                    'schema' => self::restSchema(),
                ],
            ]
        );
    }

    /**
     * Build default option values from the schema.
     *
     * @return array<string,mixed> Default settings payload.
     */
    public static function defaults(): array
    {
        $out = [];
        foreach (self::schema() as $groupKey => $group) {
            $out[$groupKey] = [];
            foreach (($group['fields'] ?? []) as $fieldKey => $field) {
                $type = (string) ($field['type'] ?? 'text');
                if (in_array($groupKey, self::NULLABLE_GROUPS, true) && $type === 'number') {
                    $out[$groupKey][$fieldKey] = null;
                } else {
                    $out[$groupKey][$fieldKey] = $field['default'] ?? null;
                }
            }
        }
        return $out;
    }

    /**
     * Return runtime fallback defaults used by warmup execution.
     *
     * @return array<string,mixed> Runtime default values.
     */
    public static function runtimeDefaults(): array
    {
        return [
            'advanced' => [
                'pacing_mode'       => Settings::DEFAULT_PACING_MODE,
                'pages_per_batch'   => Settings::DEFAULT_PAGES_PER_BATCH,
                'throttle_seconds'  => Settings::DEFAULT_THROTTLE_SECONDS,
                'max_batch_seconds' => Settings::DEFAULT_MAX_BATCH_SECONDS,
                'user_agent'        => Settings::DEFAULT_USER_AGENT,
                'stale_run_minutes' => Settings::DEFAULT_STALE_RUN_MINUTES,
                'max_urls_per_run'  => Settings::DEFAULT_MAX_URLS_PER_RUN,
            ],
            'logging' => [
                'retention_days' => Settings::DEFAULT_RETENTION_DAYS,
            ],
        ];
    }

    /**
     * Translate a settings label only after init to avoid early textdomain loading notices.
     *
     * @param string $label Source label.
     * @return string Translated label when safe, otherwise source label.
     */
    private static function translateLabel(string $label): string
    {
        $translate = static fn(string $source): string => match ($source) {
            'General' => __('General', 'cache-autopilot'),
            'Warmup Engine' => __('Warmup Engine', 'cache-autopilot'),
            'Logging' => __('Logging', 'cache-autopilot'),
            'Priorities' => __('Priorities', 'cache-autopilot'),
            'Diagnostics' => __('Diagnostics', 'cache-autopilot'),
            default => $source,
        };

        if (function_exists('did_action') && function_exists('doing_action')) {
            if (did_action('init') > 0 || doing_action('init')) {
                return $translate($label);
            }
            return $label;
        }

        return $translate($label);
    }

    /**
     * Return the REST schema for exposing the settings option.
     *
     * @return array<string,mixed> REST option schema.
     */
    private static function restSchema(): array
    {
        return [
            'type' => 'object',
            'properties' => [
                'general' => [
                    'type' => 'object',
                    'properties' => [
                        'enabled' => ['type' => 'boolean', 'default' => Settings::DEFAULT_ENABLED],
                        'sitemaps' => ['type' => 'string', 'default' => Settings::DEFAULT_SITEMAPS],
                        'manual_urls' => ['type' => 'string', 'default' => ''],
                    ],
                ],
                'advanced' => [
                    'type' => 'object',
                    'properties' => [
                        'pacing_mode' => ['type' => 'string', 'enum' => ['auto', 'manual'], 'default' => Settings::DEFAULT_PACING_MODE],
                        'pages_per_batch' => ['type' => ['integer', 'null'], 'minimum' => 1, 'maximum' => Settings::DEFAULT_DYNAMIC_BATCH_MAX_URLS, 'default' => null],
                        'throttle_seconds' => ['type' => ['integer', 'null'], 'minimum' => 0, 'maximum' => 5, 'default' => null],
                        'max_batch_seconds' => ['type' => ['integer', 'null'], 'minimum' => 5, 'maximum' => 120, 'default' => null],
                        'user_agent' => ['type' => 'string', 'default' => ''],
                        'stale_run_minutes' => ['type' => ['integer', 'null'], 'minimum' => 5, 'maximum' => 1440, 'default' => null],
                        'max_urls_per_run' => ['type' => ['integer', 'null'], 'minimum' => 100, 'maximum' => 100000, 'default' => null],
                    ],
                ],
                'logging' => [
                    'type' => 'object',
                    'properties' => [
                        'retention_days' => ['type' => 'integer', 'minimum' => 1, 'maximum' => 365, 'default' => Settings::DEFAULT_RETENTION_DAYS],
                    ],
                ],
                'priorities' => [
                    'type' => 'object',
                    'properties' => [
                        'post_type_order' => ['type' => 'array', 'items' => ['type' => 'string'], 'default' => ['page']],
                        'priority_pages' => ['type' => 'array', 'items' => ['type' => 'integer'], 'default' => []],
                    ],
                ],
                'diagnostics' => [
                    'type' => 'object',
                    'properties' => [
                        'probe_results' => ['type' => ['object', 'null'], 'default' => null],
                    ],
                ],
            ],
        ];
    }

    /**
     * Return merged settings (saved values over schema defaults).
     *
     * @return array<string,mixed> Current settings payload.
     */
    public static function all(): array
    {
        $saved = get_option(Settings::OPTION, []);
        $saved = is_array($saved) ? $saved : [];
        $defaults = self::defaults();

        foreach ($defaults as $g => $fields) {
            if (!isset($saved[$g]) || !is_array($saved[$g])) {
                $saved[$g] = [];
            }
            $isRaw = in_array($g, self::RAW_GROUPS, true);
            foreach ($fields as $k => $v) {
                if (!array_key_exists($k, $saved[$g])) {
                    $saved[$g][$k] = $isRaw ? null : $v;
                }
                if (!$isRaw && is_array($v) && is_array($saved[$g][$k])) {
                    $saved[$g][$k] = array_values(array_unique(array_merge($v, $saved[$g][$k])));
                }
            }
        }

        return $saved;
    }

    /**
     * Read a single setting by `group.field` path.
     *
     * @param string $path Dot-path key.
     * @return mixed Setting value or null if not found.
     */
    public static function get(string $path)
    {
        $all = self::all();
        [$g, $k] = array_pad(explode('.', $path, 2), 2, '');
        if ($g === '' || $k === '') {
            return null;
        }
        return $all[$g][$k] ?? null;
    }

    /**
     * Read a setting value as string.
     *
     * @param string $path Dot-path key.
     * @return string Normalized string value.
     */
    public static function getString(string $path): string
    {
        $val = self::get($path);
        if (is_string($val)) {
            return $val;
        }
        if (is_numeric($val)) {
            return (string) $val;
        }
        return '';
    }

    /**
     * Read a setting value as integer.
     *
     * @param string $path Dot-path key.
     * @param int $fallback Fallback when value is not numeric.
     * @return int Normalized integer value.
     */
    public static function getInt(string $path, int $fallback = 0): int
    {
        $val = self::get($path);
        if (is_int($val)) {
            return $val;
        }
        if (is_numeric($val)) {
            return (int) $val;
        }
        return $fallback;
    }

    /**
     * Read a setting value as boolean.
     *
     * @param string $path Dot-path key.
     * @return bool Boolean interpretation of the value.
     */
    public static function isEnabled(string $path): bool
    {
        return (bool) self::get($path);
    }

    /**
     * Persist the warmup enabled flag via the existing option sanitization pathway.
     *
     * @param bool $enabled Whether warmup should be enabled.
     * @return void
     */
    public static function setGeneralEnabled(bool $enabled): void
    {
        $current = self::all();
        $general = isset($current['general']) && is_array($current['general'])
            ? $current['general']
            : [];

        $payload = [
            '__active_group' => 'general',
            'general' => [
                'enabled' => $enabled ? '1' : '0',
                'sitemaps' => isset($general['sitemaps']) ? (string) $general['sitemaps'] : '',
                'manual_urls' => isset($general['manual_urls']) ? (string) $general['manual_urls'] : '',
            ],
        ];

        $sanitized = self::sanitizeOption($payload);
        update_option(Settings::OPTION, $sanitized);
    }

    /**
     * Extract sitemap URLs from a sanitized option payload.
     *
     * @param array<string,mixed> $option Option payload.
     * @return array<int,string> Unique sitemap URL list.
     */
    public static function extractSitemapsFromOption(array $option): array
    {
        $raw = '';
        if (isset($option['general']) && is_array($option['general']) && isset($option['general']['sitemaps'])) {
            $raw = (string) $option['general']['sitemaps'];
        }

        if ($raw === '') {
            return [];
        }

        $lines = preg_split('/\R/', $raw) ?: [];
        $out = [];
        foreach ($lines as $line) {
            $line = trim((string) $line);
            if ($line === '') {
                continue;
            }
            $out[] = $line;
        }

        return array_values(array_unique($out));
    }

    /**
     * Sanitize an array field based on field semantics.
     *
     * @param string $fieldKey Field key being sanitized.
     * @param array<mixed> $vals Raw array values.
     * @return array<mixed> Sanitized array values.
     */
    private static function sanitizeArrayField(string $fieldKey, array $vals): array
    {
        if ($fieldKey === 'priority_pages') {
            return array_values(array_map('intval', $vals));
        }

        if ($fieldKey === 'post_type_order') {
            return array_values(array_map('sanitize_key', $vals));
        }

        return array_values(array_map('sanitize_text_field', $vals));
    }

    /**
     * Sanitize settings payload before persistence.
     *
     * @param mixed $raw Raw option input.
     * @return array<string,mixed> Sanitized option payload.
     */
    public static function sanitizeOption($raw): array
    {
        $raw = is_array($raw) ? $raw : [];

        $schema = self::schema();
        $defaults = self::defaults();

        $activeGroup = isset($raw['__active_group'])
            ? sanitize_key((string) $raw['__active_group'])
            : '';

        unset($raw['__active_group']);

        if ($activeGroup === '' || !isset($schema[$activeGroup])) {
            $out = $defaults;

            foreach ($schema as $groupKey => $group) {
                $fields = is_array($group['fields'] ?? null) ? $group['fields'] : [];
                $groupIn = isset($raw[$groupKey]) && is_array($raw[$groupKey]) ? $raw[$groupKey] : [];
                $isNullable = in_array($groupKey, self::NULLABLE_GROUPS, true);

                foreach ($fields as $fieldKey => $field) {
                    $type = (string) ($field['type'] ?? 'text');

                    if ($type === 'boolean') {
                        $out[$groupKey][$fieldKey] = !empty($groupIn[$fieldKey]);
                        continue;
                    }

                    if ($type === 'textarea') {
                        $val = isset($groupIn[$fieldKey]) ? (string) $groupIn[$fieldKey] : '';
                        $out[$groupKey][$fieldKey] = trim(wp_kses_post($val));
                        continue;
                    }

                    if ($type === 'text') {
                        $val = isset($groupIn[$fieldKey]) ? (string) $groupIn[$fieldKey] : '';
                        $val = sanitize_text_field($val);

                        if ($fieldKey === 'pacing_mode') {
                            $val = in_array($val, ['auto', 'manual'], true) ? $val : Settings::DEFAULT_PACING_MODE;
                        }

                        $out[$groupKey][$fieldKey] = $val;
                        continue;
                    }

                    if ($type === 'number') {
                        if ($isNullable) {
                            $raw_val = $groupIn[$fieldKey] ?? null;
                            if ($raw_val === null || $raw_val === '') {
                                $out[$groupKey][$fieldKey] = null;
                                continue;
                            }
                        }
                        $val = isset($groupIn[$fieldKey]) ? (int) $groupIn[$fieldKey] : (int) ($field['default'] ?? 0);
                        $min = isset($field['min']) ? (int) $field['min'] : null;
                        $max = isset($field['max']) ? (int) $field['max'] : null;

                        if ($min !== null) {
                            $val = max($min, $val);
                        }
                        if ($max !== null) {
                            $val = min($max, $val);
                        }

                        $out[$groupKey][$fieldKey] = $val;
                        continue;
                    }

                    if ($type === 'array') {
                        $vals = isset($groupIn[$fieldKey]) && is_array($groupIn[$fieldKey]) ? $groupIn[$fieldKey] : [];
                        $out[$groupKey][$fieldKey] = self::sanitizeArrayField($fieldKey, $vals);
                        continue;
                    }

                    if ($type === 'json') {
                        $val = $groupIn[$fieldKey] ?? null;
                        $out[$groupKey][$fieldKey] = is_array($val) ? self::sanitizeDiagnosticsPayload($val) : null;
                        continue;
                    }

                    $val = isset($groupIn[$fieldKey]) ? (string) $groupIn[$fieldKey] : '';
                    $out[$groupKey][$fieldKey] = sanitize_text_field($val);
                }
            }

            return $out;
        }

        $saved = get_option(Settings::OPTION, []);
        $saved = is_array($saved) ? $saved : [];

        $out = array_replace_recursive($defaults, $saved);

        $groupIn = isset($raw[$activeGroup]) && is_array($raw[$activeGroup]) ? $raw[$activeGroup] : [];
        $fields = $schema[$activeGroup]['fields'] ?? [];
        $isNullable = in_array($activeGroup, self::NULLABLE_GROUPS, true);

        foreach ($fields as $fieldKey => $field) {
            $type = (string) ($field['type'] ?? 'text');

            if ($type === 'boolean') {
                $out[$activeGroup][$fieldKey] = !empty($groupIn[$fieldKey]);
                continue;
            }

            if ($type === 'textarea') {
                $val = isset($groupIn[$fieldKey]) ? (string) $groupIn[$fieldKey] : '';
                $out[$activeGroup][$fieldKey] = trim(wp_kses_post($val));
                continue;
            }

            if ($type === 'text') {
                $val = isset($groupIn[$fieldKey]) ? (string) $groupIn[$fieldKey] : '';
                $val = sanitize_text_field($val);

                if ($fieldKey === 'pacing_mode') {
                    $val = in_array($val, ['auto', 'manual'], true) ? $val : Settings::DEFAULT_PACING_MODE;
                }

                $out[$activeGroup][$fieldKey] = $val;
                continue;
            }

            if ($type === 'number') {
                if ($isNullable) {
                    $raw_val = $groupIn[$fieldKey] ?? null;
                    if ($raw_val === null || $raw_val === '') {
                        $out[$activeGroup][$fieldKey] = null;
                        continue;
                    }
                }
                $val = isset($groupIn[$fieldKey]) ? (int) $groupIn[$fieldKey] : (int) ($field['default'] ?? 0);
                $min = isset($field['min']) ? (int) $field['min'] : null;
                $max = isset($field['max']) ? (int) $field['max'] : null;

                if ($min !== null) {
                    $val = max($min, $val);
                }
                if ($max !== null) {
                    $val = min($max, $val);
                }

                $out[$activeGroup][$fieldKey] = $val;
                continue;
            }

            if ($type === 'array') {
                $vals = isset($groupIn[$fieldKey]) && is_array($groupIn[$fieldKey]) ? $groupIn[$fieldKey] : [];
                $out[$activeGroup][$fieldKey] = self::sanitizeArrayField($fieldKey, $vals);
                continue;
            }

            if ($type === 'json') {
                $val = $groupIn[$fieldKey] ?? null;
                $out[$activeGroup][$fieldKey] = is_array($val) ? self::sanitizeDiagnosticsPayload($val) : null;
                continue;
            }

            if ($type === 'plugins') {
                $presentKey = $fieldKey . '__present';
                $present = !empty($groupIn[$presentKey]);
                unset($groupIn[$presentKey]);

                if (!$present) {
                    continue;
                }

                $vals = isset($groupIn[$fieldKey]) ? $groupIn[$fieldKey] : [];
                $vals = is_array($vals) ? array_values($vals) : [];
                $vals = array_map('sanitize_key', $vals);

                $available = array_keys(self::getPlugins());
                $vals = array_values(array_intersect($vals, $available));

                $out[$activeGroup][$fieldKey] = $vals;
                continue;
            }

            $val = isset($groupIn[$fieldKey]) ? (string) $groupIn[$fieldKey] : '';
            $out[$activeGroup][$fieldKey] = sanitize_text_field($val);
        }

        return $out;
    }

    /**
     * Recursively sanitize diagnostics JSON while preserving scalar types.
     *
     * @param array<mixed> $payload Raw diagnostics payload.
     * @return array<mixed> Sanitized diagnostics payload.
     */
    public static function sanitizeDiagnosticsPayload(array $payload): array
    {
        $out = [];
        $isList = self::isListArray($payload);

        foreach ($payload as $key => $value) {
            $sanitizedKey = $isList ? (int) $key : sanitize_key((string) $key);
            if (!$isList && $sanitizedKey === '') {
                continue;
            }

            if (is_array($value)) {
                $out[$sanitizedKey] = self::sanitizeDiagnosticsPayload($value);
                continue;
            }

            if (is_bool($value) || is_int($value) || is_float($value) || $value === null) {
                $out[$sanitizedKey] = $value;
                continue;
            }

            if (is_numeric($value)) {
                $stringValue = (string) $value;
                $out[$sanitizedKey] = str_contains($stringValue, '.') ? (float) $value : (int) $value;
                continue;
            }

            $stringValue = trim((string) $value);
            if (preg_match('#^https?://#i', $stringValue) === 1) {
                $out[$sanitizedKey] = esc_url_raw($stringValue);
                continue;
            }

            $out[$sanitizedKey] = sanitize_text_field($stringValue);
        }

        return $isList ? array_values($out) : $out;
    }

    /**
     * Return whether an array is a sequential zero-based list.
     *
     * @param array<mixed> $value Array to inspect.
     * @return bool
     */
    private static function isListArray(array $value): bool
    {
        $expected = 0;
        foreach (array_keys($value) as $key) {
            if ($key !== $expected) {
                return false;
            }
            ++$expected;
        }

        return true;
    }

    /**
     * Return plugin integrations available for diagnostics/settings.
     *
     * @return array<string,string> Plugin key to label map.
     */
    private static function getPlugins(): array
    {
        return [];
    }
}
