<?php
declare(strict_types=1);

namespace Ekesto\CacheInvalidator\Settings;

use Ekesto\CacheInvalidator\Core\Config;
use Ekesto\CacheInvalidator\Core\FeatureFlags;
use Ekesto\CacheInvalidator\Resolvers\RelationshipGraphResolver;
use Ekesto\CacheInvalidator\InvalidatorRuntime;
use Ekesto\CacheInvalidator\Settings;

/**
 * Sanitizes and validates settings option payloads.
 */
final class SettingsSanitizer
{
    /**
     * Validate post type graph payload.
     *
     * @param mixed $postTypes Raw post_types payload.
     * @return array<int,string>
     */
    public function validatePostTypes($postTypes): array
    {
        if (!is_array($postTypes)) {
            return [
                __('Post type configuration is required.', 'cache-autopilot'),
            ];
        }

        $adjacency = [];
        foreach ($postTypes as $postType => $postTypeConfig) {
            $postType = sanitize_key((string) $postType);
            if ($postType === '' || !is_array($postTypeConfig)) {
                continue;
            }

            $relationships = $postTypeConfig['relationships'] ?? [];
            if (!is_array($relationships)) {
                $relationships = [];
            }

            $adjacency[$postType] = array_values(array_unique(array_filter(array_map(
                static fn($relationship): string => sanitize_key((string) $relationship),
                $relationships
            ), static fn(string $relationship): bool => $relationship !== '' && $relationship !== $postType)));
        }

        $cycle = RelationshipGraphResolver::detectCycle($adjacency);
        if (!empty($cycle['has_cycle'])) {
            $cyclePath = is_array($cycle['cycle_path'] ?? null) ? $cycle['cycle_path'] : [];
            $pathText = implode(' -> ', array_map('strval', $cyclePath));

            return [
                sprintf(
                    /* translators: %s: relationship cycle path. */
                    __('Relationship cycle detected: %s', 'cache-autopilot'),
                    $pathText
                ),
            ];
        }

        return [];
    }

    /**
     * Sanitizes the option on save.
     *
     * @param mixed $value Raw option value.
     * @return array<string,mixed>
     */
    public function sanitizeOption($value): array
    {
        if (!is_array($value)) {
            return $this->defaultOption();
        }

        $out = $this->sanitizeOptionStructure($value);
        $cycle = RelationshipGraphResolver::detectCycle($this->buildAdjacency($out['post_types']));
        if (!empty($cycle['has_cycle'])) {
            $cyclePath = is_array($cycle['cycle_path'] ?? null) ? $cycle['cycle_path'] : [];
            $pathText = implode(' -> ', array_map('strval', $cyclePath));

            add_settings_error(
                Settings::OPTION_NAME,
                'relationship_cycle',
                sprintf(
                    /* translators: %s: relationship cycle path. */
                    __('Settings not saved. Relationship cycle detected: %s', 'cache-autopilot'),
                    $pathText
                ),
                'error'
            );

            $current = get_option(Settings::OPTION_NAME, []);
            if (!is_array($current)) {
                return $this->defaultOption();
            }

            $fallback = $this->sanitizeOptionStructure($current);
            $fallbackCycle = RelationshipGraphResolver::detectCycle($this->buildAdjacency($fallback['post_types']));

            if (!empty($fallbackCycle['has_cycle'])) {
                return $this->defaultOption();
            }

            return $fallback;
        }

        return $out;
    }

    /**
     * Preserve stored premium-only settings for Lite builds.
     *
     * In Pro, the payload is returned unchanged. In Lite, only values that already
     * exist in stored settings are copied through for premium-only keys; incoming
     * values for absent stored keys are removed so Lite cannot invent them.
     *
     * @param array<string,mixed>      $candidate Candidate settings about to be validated/saved.
     * @param array<string,mixed>|null $stored    Stored settings to preserve from; falls back to current option.
     * @return array<string,mixed>
     */
    public function preserveStoredLitePremiumSettings(array $candidate, ?array $stored = null): array
    {
        if (FeatureFlags::isPremium()) {
            return $candidate;
        }

        if ($stored === null) {
            $stored = $this->getStoredOption();
        }

        if (!is_array($stored)) {
            $stored = [];
        }

        if (isset($stored['timed_invalidation']) && is_array($stored['timed_invalidation'])) {
            $candidate['timed_invalidation'] = $stored['timed_invalidation'];
        } else {
            unset($candidate['timed_invalidation']);
        }

        $storedGlobal = is_array($stored['global_invalidation'] ?? null)
            ? $stored['global_invalidation']
            : [];
        $global = is_array($candidate['global_invalidation'] ?? null)
            ? $candidate['global_invalidation']
            : [];

        foreach ([
            'multilingual_fanout_post_triggers_enabled',
            'multilingual_fanout_non_post_triggers_enabled',
        ] as $multilingualKey) {
            if (array_key_exists($multilingualKey, $storedGlobal)) {
                $global[$multilingualKey] = $storedGlobal[$multilingualKey];
            } else {
                unset($global[$multilingualKey]);
            }
        }

        $candidate['global_invalidation'] = $global;

        $storedPostTypes = is_array($stored['post_types'] ?? null)
            ? $stored['post_types']
            : [];
        $postTypes = is_array($candidate['post_types'] ?? null)
            ? $candidate['post_types']
            : [];

        foreach ($postTypes as $slug => $config) {
            if (!is_array($config)) {
                continue;
            }

            $storedConfig = is_array($storedPostTypes[$slug] ?? null)
                ? $storedPostTypes[$slug]
                : [];

            foreach (['relationships', 'expand_post_types'] as $proKey) {
                if (array_key_exists($proKey, $storedConfig)) {
                    $config[$proKey] = $storedConfig[$proKey];
                } else {
                    unset($config[$proKey]);
                }
            }

            $postTypes[$slug] = $config;
        }

        $candidate['post_types'] = $postTypes;

        return $candidate;
    }

    /**
     * @return array<string,mixed>
     */
    private function defaultOption(): array
    {
        return [
            'post_types' => [],
            'global_invalidation' => Settings::DEFAULT_GLOBAL_INVALIDATION,
            'timed_invalidation' => Settings::DEFAULT_TIMED_INVALIDATION,
            'invalidation_behavior' => [
                'async_enabled' => false,
                'async_delay_seconds' => Config::DEFAULT_ASYNC_DELAY_SECONDS,
            ],
            'include_non_public_post_types' => false,
        ];
    }

    /**
     * @param mixed $postTypes
     * @return array<string,array<string,mixed>>
     */
    private function sanitizePostTypes($postTypes): array
    {
        if (!is_array($postTypes)) {
            return [];
        }

        $out = [];

        foreach ($postTypes as $postType => $postTypeConfig) {
            $postType = sanitize_key((string) $postType);
            if ($postType === '' || !is_array($postTypeConfig)) {
                continue;
            }

            $normalizedConfig = [];
            foreach ($postTypeConfig as $configKey => $configValue) {
                $normalizedConfig[sanitize_key((string) $configKey)] = $configValue;
            }

            $targetMode = $this->sanitizePostTypeTargetMode($normalizedConfig['target_mode'] ?? null);

            $targetPageIds = $normalizedConfig['target_page_ids'] ?? [];
            if (!is_array($targetPageIds)) {
                $targetPageIds = [];
            }
            $targetPageIds = array_values(array_unique(array_filter(array_map('absint', $targetPageIds), static fn(int $id): bool => $id > 0)));

            $relationships = $normalizedConfig['relationships'] ?? [];
            if (!is_array($relationships)) {
                $relationships = [];
            }

            $relationships = array_values(array_unique(array_filter(array_map(
                static fn($relationship): string => sanitize_key((string) $relationship),
                $relationships
            ))));

            $expandPostTypes = $normalizedConfig['expand_post_types'] ?? [];
            if (!is_array($expandPostTypes)) {
                $expandPostTypes = [];
            }

            $expandPostTypes = array_values(array_unique(array_filter(array_map(
                static fn($expandPostType): string => sanitize_key((string) $expandPostType),
                $expandPostTypes
            ))));
            sort($expandPostTypes, SORT_STRING);

            $propagationIn = $normalizedConfig['propagation'] ?? [];
            if (!is_array($propagationIn)) {
                $propagationIn = [];
            }

            $postTypeOut = [
                'archives_enabled' => $this->strictBoolFromMap($normalizedConfig, 'archives_enabled', false),
                'enabled' => $this->strictBoolFromMap($normalizedConfig, 'enabled', false),
                'tags_enabled' => $this->strictBoolFromMap($normalizedConfig, 'tags_enabled', false),
                'target_mode' => $targetMode,
                'target_page_ids' => $targetPageIds,
                'expand_post_types' => $expandPostTypes,
                'propagation' => $this->sanitizePropagation($propagationIn),
                'relationships' => $relationships,
                'taxonomy_archives_enabled' => $this->strictBoolFromMap($normalizedConfig, 'taxonomy_archives_enabled', false),
            ];

            ksort($postTypeOut, SORT_STRING);
            $out[$postType] = $postTypeOut;
        }

        ksort($out, SORT_STRING);

        return $out;
    }

    /**
     * Build a sanitized option payload without cycle fallback behavior.
     *
     * @param array<string,mixed> $value
     * @return array<string,mixed>
     */
    private function sanitizeOptionStructure(array $value): array
    {
        $out = $this->defaultOption();
        $out['post_types'] = $this->sanitizePostTypes($value['post_types'] ?? null);
        $out['global_invalidation'] = $this->sanitizeGlobalInvalidation($value['global_invalidation'] ?? null);
        $out['timed_invalidation'] = FeatureFlags::isPremium()
            ? $this->sanitizeTimedInvalidation(
                $value['timed_invalidation'] ?? null,
                $this->resolveEnabledPostTypeKeys($out['post_types'])
            )
            : $this->storedTimedInvalidation();
        $out['invalidation_behavior'] = $this->sanitizeInvalidationBehavior($value['invalidation_behavior'] ?? null);
        $out['include_non_public_post_types'] = $this->sanitizeBoolean(
            $value['include_non_public_post_types'] ?? false,
            false
        );

        if (!FeatureFlags::isPremium()) {
            $out = $this->preserveStoredLitePremiumSettings($out);
        }

        return $out;
    }

    /**
     * Build host -> relationships adjacency from sanitized post type config.
     *
     * @param array<string,array<string,mixed>> $postTypes
     * @return array<string,string[]>
     */
    private function buildAdjacency(array $postTypes): array
    {
        $adjacency = [];
        foreach ($postTypes as $postType => $postTypeConfig) {
            $postType = sanitize_key((string) $postType);
            if ($postType === '') {
                continue;
            }

            $relationships = is_array($postTypeConfig['relationships'] ?? null)
                ? array_values(array_map('strval', $postTypeConfig['relationships']))
                : [];

            // Allow explicit self-relationships in config while keeping cycle checks for multi-node loops.
            $adjacency[$postType] = array_values(array_filter(array_map(
                static fn($relationship): string => sanitize_key((string) $relationship),
                $relationships
            ), static fn(string $relationship): bool => $relationship !== '' && $relationship !== $postType));
        }

        return $adjacency;
    }

    /**
     * @param mixed $globalInvalidation
     * @return array<string,bool>
     */
    private function sanitizeGlobalInvalidation($globalInvalidation): array
    {
        $defaults = Settings::DEFAULT_GLOBAL_INVALIDATION;
        if (!is_array($globalInvalidation)) {
            return $defaults;
        }

        $out = [];
        foreach ($defaults as $key => $defaultValue) {
            $out[$key] = array_key_exists($key, $globalInvalidation)
                ? (bool) $globalInvalidation[$key]
                : (bool) $defaultValue;
        }

        return $out;
    }

    /**
     * Sanitize global invalidation behavior settings.
     *
     * @param mixed $value Raw invalidation_behavior payload.
     * @return array<string,mixed>
     */
    private function sanitizeInvalidationBehavior($value): array
    {
        if (!is_array($value)) {
            return [
                'async_enabled' => false,
            ];
        }

        $out = [];
        $out['async_enabled'] = array_key_exists('async_enabled', $value)
            ? (bool) $value['async_enabled']
            : false;

        if (array_key_exists('async_delay_seconds', $value)) {
            $delay = (int) $value['async_delay_seconds'];
            $out['async_delay_seconds'] = max(1, min(300, $delay));
        }

        return $out;
    }

    /**
     * Return the stored timed_invalidation value verbatim for Lite builds.
     *
     * Timed invalidation is premium-only: in Lite its UI is hidden and its
     * scheduler is stripped, so the stored value (last written by a Pro build,
     * already sanitized) must survive every Lite save unchanged. Re-sanitizing
     * it here would prune configured_post_types entries against the incoming
     * Lite post-type selection. Incoming payloads are never used in Lite; a
     * missing or malformed stored value falls back to the default shape.
     *
     * @return array<string,mixed>
     */
    private function storedTimedInvalidation(): array
    {
        $stored = $this->getStoredOption();
        $storedTimed = is_array($stored) ? ($stored['timed_invalidation'] ?? null) : null;

        return is_array($storedTimed) ? $storedTimed : Settings::DEFAULT_TIMED_INVALIDATION;
    }

    /**
     * @return array<string,mixed>
     */
    private function getStoredOption(): array
    {
        $stored = get_option(Settings::OPTION_NAME, []);

        return is_array($stored) ? $stored : [];
    }

    /**
     * Sanitize timed invalidation settings.
     *
     * @param mixed $value Raw timed invalidation value.
     * @return array<string,mixed>
     */
    private function sanitizeTimedInvalidation($value, array $allowedConfiguredPostTypes = []): array
    {
        $defaults = Settings::DEFAULT_TIMED_INVALIDATION;

        if (!is_array($value)) {
            return $defaults;
        }

        $enabled = isset($value['enabled']) ? (bool) $value['enabled'] : (bool) $defaults['enabled'];
        $rulesIn = $value['rules'] ?? [];
        $rulesOut = [];
        $seenIds = [];

        if (is_array($rulesIn)) {
            foreach ($rulesIn as $rule) {
                if (!is_array($rule)) {
                    continue;
                }

                $sanitizedRule = $this->sanitizeTimedRule($rule, $allowedConfiguredPostTypes);
                if ($sanitizedRule === null) {
                    continue;
                }

                $ruleId = (string) $sanitizedRule['id'];
                if (isset($seenIds[$ruleId])) {
                    continue;
                }

                $seenIds[$ruleId] = true;
                $rulesOut[] = $sanitizedRule;
            }
        }

        return [
            'enabled' => $enabled,
            'rules' => $rulesOut,
        ];
    }

    /**
     * Sanitize a single timed invalidation rule.
     *
     * @param array<string,mixed> $rule Raw rule array.
     * @return array<string,mixed>|null
     */
    private function sanitizeTimedRule(array $rule, array $allowedConfiguredPostTypes = []): ?array
    {
        $normalizedRule = [];
        foreach ($rule as $ruleKey => $ruleValue) {
            $normalizedRule[sanitize_key((string) $ruleKey)] = $ruleValue;
        }

        $ruleId = sanitize_key((string) ($normalizedRule['id'] ?? ''));
        if ($ruleId === '') {
            return null;
        }

        $time = $this->sanitizeTimeString((string) ($normalizedRule['time'] ?? ''));
        if ($time === null) {
            return null;
        }

        $pageIds = $normalizedRule['page_ids'] ?? [];
        if (!is_array($pageIds)) {
            $pageIds = [];
        }

        $pageIds = array_values(array_unique(array_filter(array_map('absint', $pageIds), static fn(int $id): bool => $id > 0)));
        $targetMode = $this->sanitizeTimedTargetMode($normalizedRule['target_mode'] ?? null);
        $additionalPostTypes = $normalizedRule['additional_post_types'] ?? [];
        $configuredPostTypes = $normalizedRule['configured_post_types'] ?? [];
        if (!is_array($additionalPostTypes)) {
            $additionalPostTypes = [];
        }
        if (!is_array($configuredPostTypes)) {
            $configuredPostTypes = [];
        }

        $additionalPostTypes = array_values(array_unique(array_filter(array_map(
            static fn($postType): string => sanitize_key((string) $postType),
            $additionalPostTypes
        ))));
        sort($additionalPostTypes, SORT_STRING);
        $configuredPostTypes = array_values(array_filter(
            $this->normalizeStringList($configuredPostTypes),
            static fn(string $postType): bool => in_array($postType, $allowedConfiguredPostTypes, true)
        ));
        sort($configuredPostTypes, SORT_STRING);

        return [
            'additional_post_types' => $additionalPostTypes,
            'configured_post_types' => $configuredPostTypes,
            'enabled' => array_key_exists('enabled', $normalizedRule) ? (bool) $normalizedRule['enabled'] : true,
            'id' => $ruleId,
            'page_ids' => $pageIds,
            'time' => $time,
            'target_mode' => $targetMode,
        ];
    }

    /**
     * Resolve one strict boolean from a sanitized key map.
     *
     * Missing or non-boolean values fall back to the provided default.
     *
     * @param array<string,mixed> $map
     * @param string              $key
     * @param bool                $default
     * @return bool
     */
    private function strictBoolFromMap(array $map, string $key, bool $default): bool
    {
        $key = sanitize_key($key);
        if ($key === '' || !array_key_exists($key, $map)) {
            return $default;
        }

        return is_bool($map[$key]) ? $map[$key] : $default;
    }

    /**
     * Normalize post-type target mode.
     *
     * @param mixed $mode Raw mode value.
     * @return string selected|all|full_flush
     */
    private function sanitizePostTypeTargetMode($mode): string
    {
        $mode = (string) $mode;
        if ($mode === 'all' || $mode === 'full_flush') {
            return $mode;
        }

        return 'selected';
    }

    /**
     * Normalize timed-rule target mode.
     *
     * @param mixed $mode Raw mode value.
     * @return string selected|all|configured_post_types|full_flush
     */
    private function sanitizeTimedTargetMode($mode): string
    {
        $mode = (string) $mode;
        if ($mode === 'all' || $mode === 'full_flush' || $mode === 'configured_post_types') {
            return $mode;
        }

        return 'selected';
    }

    /**
     * Normalize a list of scalar string values.
     *
     * @param mixed $values Raw values.
     * @return string[]
     */
    private function normalizeStringList($values): array
    {
        if (!is_array($values)) {
            return [];
        }

        $values = array_values(array_unique(array_filter(array_map(
            static fn($value): string => sanitize_key((string) $value),
            $values
        ))));
        sort($values, SORT_STRING);

        return $values;
    }

    /**
     * Resolve enabled post type keys from sanitized post type config.
     *
     * @param array<string,array<string,mixed>> $postTypes Sanitized post type config.
     * @return string[]
     */
    private function resolveEnabledPostTypeKeys(array $postTypes): array
    {
        $enabledPostTypes = [];

        foreach ($postTypes as $postType => $postTypeConfig) {
            $postType = sanitize_key((string) $postType);
            if ($postType === '') {
                continue;
            }

            if (!is_array($postTypeConfig) || empty($postTypeConfig['enabled'])) {
                continue;
            }

            $enabledPostTypes[] = $postType;
        }

        sort($enabledPostTypes, SORT_STRING);

        return array_values(array_unique($enabledPostTypes));
    }

    /**
     * Validate and normalize an HH:MM 24-hour time string.
     *
     * @param string $time Raw time string.
     * @return string|null
     */
    private function sanitizeTimeString(string $time): ?string
    {
        $time = trim($time);
        if ($time === '') {
            return null;
        }

        if (!preg_match('/^(\d{2}):(\d{2})$/', $time, $matches)) {
            return null;
        }

        $hour = (int) $matches[1];
        $minute = (int) $matches[2];

        if ($hour < 0 || $hour > 23 || $minute < 0 || $minute > 59) {
            return null;
        }

        return sprintf('%02d:%02d', $hour, $minute);
    }

    /**
     * Sanitize a boolean-like value.
     *
     * @param mixed $value Raw value.
     * @param bool  $default Default fallback when the value cannot be interpreted.
     * @return bool
     */
    private function sanitizeBoolean($value, bool $default = false): bool
    {
        $normalized = filter_var($value, FILTER_VALIDATE_BOOLEAN, FILTER_NULL_ON_FAILURE);
        if ($normalized === null) {
            return $default;
        }

        return $normalized;
    }

    /**
     * Sanitize a propagation settings array.
     *
     * @param array<string,mixed> $raw Raw propagation data.
     * @return array<string,mixed> Sanitized propagation settings.
     */
    private function sanitizePropagation(array $raw): array
    {
        $out = [];

        if (isset($raw['relationships_enabled'])) {
            $out['relationships_enabled'] = (bool) $raw['relationships_enabled'];
        }
        if (isset($raw['taxonomies_enabled'])) {
            $out['taxonomies_enabled'] = (bool) $raw['taxonomies_enabled'];
        }

        return $out;
    }
}
