<?php
declare(strict_types=1);

namespace Ekesto\CacheInvalidator\Settings;

use Ekesto\CacheInvalidator\Core\Config;
use Ekesto\CacheInvalidator\Settings;

/**
 * Repository for settings option storage, defaults, and runtime getters.
 */
final class SettingsRepository
{
    private SettingsSanitizer $sanitizer;

    /** @var int[]|null */
    private ?array $publishedPageIdsCache = null;

    /** @var array<string,mixed>|null */
    private ?array $invalidationBehaviorSnapshot = null;

    /**
     * @param SettingsSanitizer $sanitizer Sanitizer used for option writes.
     */
    public function __construct(SettingsSanitizer $sanitizer)
    {
        $this->sanitizer = $sanitizer;
    }

    /**
     * Registers the option and sanitization.
     *
     * @return void
     */
    public function registerOption(): void
    {
        register_setting(
            Settings::SETTINGS_GROUP,
            Settings::OPTION_NAME,
            [
                'type' => 'array',
                'sanitize_callback' => [$this->sanitizer, 'sanitizeOption'],
                'default' => [
                    '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,
                ],
            ]
        );
    }

    /**
     * Returns full option array.
     *
     * @return array<string,mixed>
     */
    public function getOption(): array
    {
        $opt = get_option(Settings::OPTION_NAME, []);
        return is_array($opt) ? $opt : [];
    }

    /**
     * Returns whether non-public post types should be shown in the UI.
     *
     * @return bool
     */
    public function getIncludeNonPublicPostTypes(): bool
    {
        $opt = $this->getOption();
        $normalized = filter_var(
            $opt['include_non_public_post_types'] ?? false,
            FILTER_VALIDATE_BOOLEAN,
            FILTER_NULL_ON_FAILURE
        );

        return $normalized === null ? false : $normalized;
    }

    /**
     * Sanitizes and stores the option.
     *
     * @param mixed $value Raw option value.
     * @return array<string,mixed> Sanitized value.
     */
    public function updateOption($value): array
    {
        $sanitized = $this->sanitizer->sanitizeOption($value);
        if (is_array($sanitized['invalidation_behavior'] ?? null)) {
            $this->invalidationBehaviorSnapshot = $this->normalizeInvalidationBehavior($sanitized['invalidation_behavior']);
        }
        update_option(Settings::OPTION_NAME, $sanitized);
        return $sanitized;
    }

    /**
     * Returns configured post type slugs.
     *
     * @return string[]
     */
    public function getConfiguredPostTypes(): array
    {
        $postTypes = $this->getPostTypesRaw();
        $configured = array_keys($postTypes);
        sort($configured, SORT_STRING);
        return $configured;
    }

    /**
     * Returns normalized config for a post type.
     *
     * @param string $postType Post type slug.
     * @return array<string,mixed>|null
     */
    public function getPostTypeConfig(string $postType): ?array
    {
        $postType = sanitize_key($postType);
        if ($postType === '') {
            return null;
        }

        $postTypes = $this->getPostTypesRaw();
        $raw = $postTypes[$postType] ?? null;
        if (!is_array($raw)) {
            return null;
        }

        $normalizedRaw = [];
        foreach ($raw as $rawKey => $rawValue) {
            $normalizedRaw[sanitize_key((string) $rawKey)] = $rawValue;
        }

        $targetMode = $this->normalizePostTypeTargetMode($normalizedRaw['target_mode'] ?? 'selected');
        $targetPageIds = $this->normalizeIds($normalizedRaw['target_page_ids'] ?? []);
        $relationships = $this->normalizeRelationships($normalizedRaw['relationships'] ?? []);
        $expandPostTypes = $this->normalizeRelationships($normalizedRaw['expand_post_types'] ?? []);
        $propagation = $this->normalizePropagation($normalizedRaw['propagation'] ?? []);

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

        ksort($config, SORT_STRING);

        return $config;
    }

    /**
     * Returns whether a post type is enabled in config.
     *
     * @param string $postType
     * @return bool
     */
    public function isPostTypeEnabled(string $postType): bool
    {
        $config = $this->getPostTypeConfig($postType);
        if ($config === null) {
            return false;
        }

        return (bool) ($config['enabled'] ?? true);
    }

    /**
     * Returns configured target page IDs for a post type.
     *
     * @param string $postType
     * @return int[]
     */
    public function getPostTypeTargetPageIds(string $postType): array
    {
        $config = $this->getPostTypeConfig($postType);
        if ($config === null) {
            return [];
        }

        return is_array($config['target_page_ids'] ?? null)
            ? $config['target_page_ids']
            : [];
    }

    /**
     * Returns configured target mode for a post type.
     *
     * @param string $postType
     * @return string selected|all|full_flush
     */
    public function getPostTypeTargetMode(string $postType): string
    {
        $config = $this->getPostTypeConfig($postType);
        if ($config === null) {
            return 'selected';
        }

        return $this->normalizePostTypeTargetMode($config['target_mode'] ?? 'selected');
    }

    /**
     * Returns effective target page IDs for a post type.
     *
     * @param string $postType
     * @return int[]
     */
    public function getEffectivePostTypeTargetPageIds(string $postType): array
    {
        if (in_array($this->getPostTypeTargetMode($postType), ['all', 'full_flush'], true)) {
            return $this->getAllPublishedPageIds();
        }

        return $this->getPostTypeTargetPageIds($postType);
    }

    /**
     * Returns relationships configured for a post type.
     *
     * @param string $postType
     * @return string[]
     */
    public function getPostTypeRelationships(string $postType): array
    {
        $config = $this->getPostTypeConfig($postType);
        if ($config === null) {
            return [];
        }

        return is_array($config['relationships'] ?? null)
            ? $config['relationships']
            : [];
    }

    /**
     * Returns propagation settings for a post type.
     *
     * @param string $postType
     * @return array<string,mixed>
     */
    public function getPostTypePropagation(string $postType): array
    {
        $config = $this->getPostTypeConfig($postType);
        if ($config === null) {
            return $this->normalizePropagation([]);
        }

        return is_array($config['propagation'] ?? null)
            ? $config['propagation']
            : $this->normalizePropagation([]);
    }

    /**
     * Returns global invalidation behavior settings.
     *
     * This is the single source of truth for async scheduling decisions.
     *
     * @return array{async_enabled:bool,async_delay_seconds?:int}
     */
    public function getInvalidationBehavior(): array
    {
        if (is_array($this->invalidationBehaviorSnapshot)) {
            return $this->invalidationBehaviorSnapshot;
        }

        $opt = $this->getOption();
        $raw = $opt['invalidation_behavior'] ?? null;
        if (is_array($raw)) {
            $this->invalidationBehaviorSnapshot = $this->normalizeInvalidationBehavior($raw);
            return $this->invalidationBehaviorSnapshot;
        }

        // Runtime-safe migration for legacy installs that have not saved settings yet.
        $this->invalidationBehaviorSnapshot = $this->deriveLegacyInvalidationBehavior($opt);

        return $this->invalidationBehaviorSnapshot;
    }

    /**
     * Returns global invalidation settings with defaults.
     *
     * @return array<string,bool>
     */
    public function getGlobalInvalidation(): array
    {
        $opt = $this->getOption();
        $raw = $opt['global_invalidation'] ?? [];
        if (!is_array($raw)) {
            $raw = [];
        }

        $defaults = Settings::DEFAULT_GLOBAL_INVALIDATION;
        $out = [];

        foreach ($defaults as $key => $defaultValue) {
            $out[$key] = (bool) ($raw[$key] ?? $defaultValue);
        }

        return $out;
    }

    /**
     * Returns timed invalidation settings with defaults.
     *
     * @return array<string,mixed>
     */
    public function getTimedInvalidation(): array
    {
        $opt = $this->getOption();
        $raw = $opt['timed_invalidation'] ?? [];
        if (!is_array($raw)) {
            $raw = [];
        }

        $enabled = (bool) ($raw['enabled'] ?? Settings::DEFAULT_TIMED_INVALIDATION['enabled']);
        $rulesIn = $raw['rules'] ?? [];
        $rulesOut = [];

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

                $normalizedRule = [];
                foreach ($rule as $ruleKey => $ruleValue) {
                    $normalizedRule[sanitize_key((string) $ruleKey)] = $ruleValue;
                }

                $ruleId = sanitize_key((string) ($normalizedRule['id'] ?? ''));
                $time = (string) ($normalizedRule['time'] ?? '');
                $targetMode = $this->normalizeTimedTargetMode($normalizedRule['target_mode'] ?? 'selected');
                $pageIds = is_array($normalizedRule['page_ids'] ?? null) ? $normalizedRule['page_ids'] : [];
                $additionalPostTypes = is_array($normalizedRule['additional_post_types'] ?? null)
                    ? $normalizedRule['additional_post_types']
                    : [];
                $configuredPostTypes = is_array($normalizedRule['configured_post_types'] ?? null)
                    ? $normalizedRule['configured_post_types']
                    : [];

                if ($ruleId === '' || $time === '') {
                    continue;
                }

                $pageIds = array_values(array_unique(array_map('absint', $pageIds)));
                $pageIds = array_values(array_filter($pageIds, static fn(int $id): bool => $id > 0));
                $additionalPostTypes = $this->normalizeRelationships($additionalPostTypes);
                $configuredPostTypes = $this->normalizeRelationships($configuredPostTypes);

                $rulesOut[] = [
                    '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,
                ];
            }
        }

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

    /**
     * Returns whether invalidation is globally enabled.
     *
     * @return bool
     */
    public function isInvalidationEnabled(): bool
    {
        $global = $this->getGlobalInvalidation();
        return (bool) ($global['plugin_enabled'] ?? true);
    }

    /**
     * Whether async scheduling is available at runtime.
     * Returns false when DISABLE_WP_CRON is true, regardless of saved setting.
     *
     * @return bool
     */
    public function isAsyncAvailable(): bool
    {
        return !(defined('DISABLE_WP_CRON') && DISABLE_WP_CRON);
    }

    /**
     * Returns whether async scheduling is globally enabled by user settings.
     *
     * @return bool
     */
    public function isAsyncEnabled(): bool
    {
        $behavior = $this->getInvalidationBehavior();
        return (bool) ($behavior['async_enabled'] ?? false);
    }

    /**
     * Returns the global async delay (seconds).
     *
     * Delay is preserved regardless of async_enabled state.
     *
     * @return int
     */
    public function getAsyncDelaySeconds(): int
    {
        $behavior = $this->getInvalidationBehavior();
        return max(
            1,
            min(300, (int) ($behavior['async_delay_seconds'] ?? Config::DEFAULT_ASYNC_DELAY_SECONDS))
        );
    }

    /**
     * Single authority for async scheduling decisions.
     *
     * @return array{should_schedule:bool,async_mode:string,execution_mode:string,reason:string,delay_seconds:int}
     */
    public function getAsyncSchedulingDecision(): array
    {
        $asyncEnabled = $this->isAsyncEnabled();
        $asyncAvailable = $this->isAsyncAvailable();

        $reason = 'async_enabled';
        $shouldSchedule = true;

        if (!$asyncEnabled) {
            $reason = 'async_disabled';
            $shouldSchedule = false;
        } elseif (!$asyncAvailable) {
            $reason = 'cron_unavailable';
            $shouldSchedule = false;
        }

        return [
            'should_schedule' => $shouldSchedule,
            'async_mode' => $asyncEnabled ? 'on' : 'off',
            'execution_mode' => $shouldSchedule ? 'async' : 'sync',
            'reason' => $reason,
            'delay_seconds' => $this->getAsyncDelaySeconds(),
        ];
    }

    /**
     * Whether timed invalidation is available at runtime.
     * Timed invalidation can run with either WP-Cron traffic spawning
     * or a real server cron invoking wp-cron.php.
     *
     * @return bool
     */
    public function isTimedInvalidationAvailable(): bool
    {
        return true;
    }

    /**
     * Resolve all published page IDs in default language context.
     *
     * @return int[]
     */
    public function getAllPublishedPageIds(): array
    {
        if (is_array($this->publishedPageIdsCache)) {
            return $this->publishedPageIdsCache;
        }

        $ids = get_posts([
            'post_type' => 'page',
            'post_status' => 'publish',
            'posts_per_page' => -1,
            'fields' => 'ids',
            'no_found_rows' => true,
            'update_post_meta_cache' => false,
            'update_post_term_cache' => false,
        ]);

        if (!is_array($ids)) {
            return [];
        }

        $ids = array_values(array_unique(array_map('absint', $ids)));
        $this->publishedPageIdsCache = array_values(array_filter($ids, static fn(int $id): bool => $id > 0));
        return $this->publishedPageIdsCache;
    }

    /**
     * @return array<string,array<string,mixed>>
     */
    private function getPostTypesRaw(): array
    {
        $opt = $this->getOption();
        $postTypes = $opt['post_types'] ?? [];
        if (!is_array($postTypes)) {
            return [];
        }

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

        return $out;
    }

    /**
     * @param mixed $mode
     * @return string
     */
    private function normalizePostTypeTargetMode($mode): string
    {
        $mode = (string) $mode;
        return in_array($mode, ['all', 'full_flush'], true) ? $mode : 'selected';
    }

    /**
     * @param mixed $mode
     * @return string
     */
    private function normalizeTimedTargetMode($mode): string
    {
        $mode = (string) $mode;
        return in_array($mode, ['all', 'configured_post_types', 'full_flush'], true) ? $mode : 'selected';
    }

    /**
     * @param mixed $ids
     * @return int[]
     */
    private function normalizeIds($ids): array
    {
        if (!is_array($ids)) {
            return [];
        }

        $ids = array_values(array_unique(array_map('absint', $ids)));
        return array_values(array_filter($ids, static fn(int $id): bool => $id > 0));
    }

    /**
     * @param mixed $relationships
     * @return string[]
     */
    private function normalizeRelationships($relationships): array
    {
        if (!is_array($relationships)) {
            return [];
        }

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

        return $relationships;
    }

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

        return [
            'relationships_enabled' => (bool) ($raw['relationships_enabled'] ?? true),
            'taxonomies_enabled' => (bool) ($raw['taxonomies_enabled'] ?? true),
        ];
    }

    /**
     * @param mixed $raw
     * @return array{async_enabled:bool,async_delay_seconds?:int}
     */
    private function normalizeInvalidationBehavior($raw): array
    {
        if (!is_array($raw)) {
            $raw = [];
        }

        $out = [
            'async_enabled' => (bool) ($raw['async_enabled'] ?? false),
        ];

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

        return $out;
    }

    /**
     * Derive invalidation_behavior from legacy per-post-type async keys.
     *
     * @param array<string,mixed> $option
     * @return array{async_enabled:bool,async_delay_seconds:int}
     */
    private function deriveLegacyInvalidationBehavior(array $option): array
    {
        $postTypes = $option['post_types'] ?? [];
        if (!is_array($postTypes)) {
            $postTypes = [];
        }

        $hasAsyncEnabled = false;
        $asyncDelays = [];

        foreach ($postTypes as $postTypeConfig) {
            if (!is_array($postTypeConfig)) {
                continue;
            }

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

            if (!($propagation['async_deep_invalidation_enabled'] ?? false)) {
                continue;
            }

            $hasAsyncEnabled = true;
            $asyncDelays[] = max(
                1,
                min(300, (int) ($propagation['async_delay_seconds'] ?? Config::DEFAULT_ASYNC_DELAY_SECONDS))
            );
        }

        $derivedDelay = !empty($asyncDelays)
            ? min($asyncDelays)
            : Config::DEFAULT_ASYNC_DELAY_SECONDS;

        return [
            'async_enabled' => $hasAsyncEnabled,
            'async_delay_seconds' => $derivedDelay,
        ];
    }

    /**
     * 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;
    }
}
