<?php
declare(strict_types=1);

namespace Ekesto\CacheInvalidator\Settings;

use Ekesto\CacheInvalidator\Integrations\Caching\AdapterManager;
use Ekesto\CacheInvalidator\Core\Config;
use Ekesto\CacheInvalidator\Integrations\Multilingual\LanguageResolverFactory;

/**
 * Builds settings payloads used by REST and localized admin boot data.
 */
final class SettingsPayloadBuilder
{
    private const UNCONFIGURED_NOTICE_DISMISSED_META_KEY = '_ekesto_eci_unconfigured_notice_dismissed_post_types';
    private const UI_EXCLUDED_POST_TYPES = [
        // Already excluded but keep for clarity at UI level.
        'page',
        'attachment',

        // Core structural/editor.
        'wp_block',
        'wp_template',
        'wp_template_part',
        'wp_navigation',
        'wp_global_styles',

        // Core internals.
        'nav_menu_item',
        'revision',
        'custom_css',
        'customize_changeset',
        'oembed_cache',
        'user_request',

        // ACF internals.
        'acf-field',
        'acf-field-group',
        'acf-post-type',
        'acf-taxonomy',

        // Elementor structural.
        'elementor_library',
        'elementor_component',
        'elementor_snippet',
        'elementor_font',
        'elementor_icons',
        'e-floating-buttons'
    ];

    private SettingsRepository $repository;
    /** @var array<string,string>|null */
    private ?array $unconfiguredPublicPostTypesCache = null;
    /** @var string[]|null */
    private ?array $dismissedUnconfiguredPostTypesCache = null;

    /**
     * @param SettingsRepository $repository Repository for settings reads/default helpers.
     */
    public function __construct(SettingsRepository $repository)
    {
        $this->repository = $repository;
    }

    /**
     * Build the settings payload for both localized boot data and REST responses.
     *
     * @return array<string,mixed>
     */
    public function buildSettingsPayload(): array
    {
        $includeNonPublicPostTypes = $this->repository->getIncludeNonPublicPostTypes();
        $defaultDelay = Config::DEFAULT_ASYNC_DELAY_SECONDS;
        $siteTimezone = wp_timezone_string();
        if ($siteTimezone === '') {
            $siteTimezone = 'UTC';
        }

        $postTypesOut = [];
        $postTypeLabels = [];

        foreach ($this->repository->getConfiguredPostTypes() as $postType) {
            $postTypeConfig = $this->repository->getPostTypeConfig($postType);
            if ($postTypeConfig === null) {
                continue;
            }

            $postTypesOut[$postType] = $postTypeConfig;
            $postTypeLabels[$postType] = $this->getPostTypeLabel(
                get_post_type_object($postType),
                $postType
            );
        }

        uksort(
            $postTypesOut,
            static function (string $leftSlug, string $rightSlug) use ($postTypeLabels): int {
                return strcasecmp(
                    (string) ($postTypeLabels[$leftSlug] ?? $leftSlug),
                    (string) ($postTypeLabels[$rightSlug] ?? $rightSlug)
                );
            }
        );

        $cronDisabled = defined('DISABLE_WP_CRON') && DISABLE_WP_CRON;
        $selectablePages = $this->getSelectableTargetPages();
        return [
            'include_non_public_post_types' => $includeNonPublicPostTypes,
            'post_types' => $postTypesOut,
            'available_pages' => $this->getSelectableTargetPagesRest($selectablePages),
            'available_pages_meta' => $this->getSelectableTargetPagesMeta($selectablePages),
            'adapter_info' => [
                'has_adapter' => AdapterManager::hasAvailableAdapters(),
                'label' => AdapterManager::getActiveAdapterLabel(),
            ],
            'post_type_catalog' => $this->getPostTypeCatalog(),
            'available_post_types' => $this->getAvailablePostTypesForUi(),
            'public_post_types' => $this->getAllPublicPostTypes(),
            'all_post_types' => $this->getConfiguredPostTypesForUi(),
            'async_delay_default_seconds' => $defaultDelay,
            'cron_disabled' => $cronDisabled,
            'invalidation_behavior' => $this->repository->getInvalidationBehavior(),
            'global_invalidation' => $this->repository->getGlobalInvalidation(),
            'timed_invalidation' => $this->repository->getTimedInvalidation(),
            'theme_context' => $this->buildThemeContext(),
            'site_timezone' => $siteTimezone,
        ];
    }

    /**
     * Returns configured post type labels for settings payloads.
     *
     * @return array<string,string> [slug => label]
     */
    public function getConfiguredPostTypesForUi(): array
    {
        $out = [];
        $configuredPostTypes = $this->repository->getConfiguredPostTypes();

        foreach ($configuredPostTypes as $postType) {
            $postType = sanitize_key((string) $postType);
            if ($postType === '') {
                continue;
            }

            $postTypeObject = get_post_type_object($postType);
            $label = $this->getPostTypeLabel($postTypeObject, $postType);
            $out[$postType] = (string) $label;
        }

        ksort($out, SORT_STRING);

        return $out;
    }

    /**
     * Returns all post types with show_ui for the settings catalog.
     *
     * @return array<string,array<string,mixed>> [slug => metadata]
     */
    public function getPostTypeCatalog(): array
    {
        $out = [];
        $postTypes = get_post_types([], 'objects');

        foreach ($postTypes as $postType => $postTypeObject) {
            if (in_array($postType, Config::EXCLUDED_POST_TYPES, true)) {
                continue;
            }

            if (in_array($postType, self::UI_EXCLUDED_POST_TYPES, true)) {
                continue;
            }

            if (!$postTypeObject instanceof \WP_Post_Type || empty($postTypeObject->show_ui)) {
                continue;
            }

            $postType = sanitize_key((string) $postType);
            if ($postType === '') {
                continue;
            }

            $out[$postType] = [
                'slug' => $postType,
                'label' => $this->getPostTypeLabel($postTypeObject, $postType),
                'public' => (bool) $postTypeObject->public,
                'publicly_queryable' => (bool) $postTypeObject->publicly_queryable,
                'show_ui' => (bool) $postTypeObject->show_ui,
                'has_archive' => (bool) $postTypeObject->has_archive,
            ];
        }

        uasort(
            $out,
            static function (array $left, array $right): int {
                $labelCompare = strcasecmp(
                    (string) ($left['label'] ?? ''),
                    (string) ($right['label'] ?? '')
                );

                if ($labelCompare !== 0) {
                    return $labelCompare;
                }

                return strcmp(
                    (string) ($left['slug'] ?? ''),
                    (string) ($right['slug'] ?? '')
                );
            }
        );

        return $out;
    }

    /**
     * Returns labels for visible post types in the current UI toggle state.
     *
     * @return array<string,string> [slug => label]
     */
    public function getAvailablePostTypesForUi(): array
    {
        return $this->catalogToLabelMap($this->getPostTypeCatalog());
    }

    /**
     * Returns all public post types with show_ui for Post Type Tabs management.
     *
     * @return array<string,string> [slug => label]
     */
    public function getAllPublicPostTypes(): array
    {
        $catalog = array_filter(
            $this->getPostTypeCatalog(),
            static fn(array $postType): bool => (bool) ($postType['public'] ?? false)
        );

        return $this->catalogToLabelMap($catalog);
    }

    /**
     * Returns unconfigured eligible post types for passive settings detection.
     *
     * Eligible post types follow the Post Type Tabs model:
     * show_ui = true, excluding page and attachment.
     *
     * @return array<string,string> [slug => label]
     */
    public function getUnconfiguredEligiblePostTypes(): array
    {
        if (is_array($this->unconfiguredPublicPostTypesCache)) {
            return $this->unconfiguredPublicPostTypesCache;
        }

        $out = [];
        $catalog = $this->getPostTypeCatalog();
        $eligiblePostTypes = array_keys($catalog);

        $configuredPostTypes = array_values(array_unique(array_filter(array_map(
            static fn($postType): string => sanitize_key((string) $postType),
            $this->repository->getConfiguredPostTypes()
        ))));

        $filtered = [];
        foreach (array_values(array_diff($eligiblePostTypes, $configuredPostTypes)) as $postType) {
            $postType = sanitize_key((string) $postType);
            if ($postType === '') {
                continue;
            }

            $filtered[$postType] = $catalog[$postType] ?? [
                'slug' => $postType,
                'label' => $postType,
            ];
        }

        uasort(
            $filtered,
            static function (array $left, array $right): int {
                $labelCompare = strcasecmp(
                    (string) ($left['label'] ?? ''),
                    (string) ($right['label'] ?? '')
                );

                if ($labelCompare !== 0) {
                    return $labelCompare;
                }

                return strcmp(
                    (string) ($left['slug'] ?? ''),
                    (string) ($right['slug'] ?? '')
                );
            }
        );

        foreach ($filtered as $postType => $postTypeData) {
            $out[$postType] = (string) ($postTypeData['label'] ?? $postType);
        }

        $this->unconfiguredPublicPostTypesCache = $out;

        return $this->unconfiguredPublicPostTypesCache;
    }

    /**
     * Backward-compatible alias for legacy callers.
     *
     * @return array<string,string> [slug => label]
     */
    public function getUnconfiguredPublicPostTypes(): array
    {
        return $this->getUnconfiguredEligiblePostTypes();
    }

    /**
     * Whether the unconfigured post types notice should be shown for current user.
     *
     * The notice stays hidden for identical-or-smaller sets after dismissal and reappears
     * only when at least one newly discovered unconfigured post type is present.
     *
     * @return bool
     */
    public function shouldShowUnconfiguredPostTypesNotice(): bool
    {
        $unconfiguredPostTypes = $this->normalizePostTypeSlugs(array_keys($this->getUnconfiguredEligiblePostTypes()));
        if (empty($unconfiguredPostTypes)) {
            return false;
        }

        $dismissedPostTypes = $this->getDismissedUnconfiguredPostTypes();
        if (empty($dismissedPostTypes)) {
            return true;
        }

        return !empty(array_diff($unconfiguredPostTypes, $dismissedPostTypes));
    }

    /**
     * Dismisses the current unconfigured post types notice for current user.
     *
     * @return bool True when persistence succeeded.
     */
    public function dismissUnconfiguredPostTypesNotice(): bool
    {
        $userId = get_current_user_id();
        if ($userId <= 0) {
            return false;
        }

        $currentPostTypes = $this->normalizePostTypeSlugs(array_keys($this->getUnconfiguredEligiblePostTypes()));
        $this->dismissedUnconfiguredPostTypesCache = $currentPostTypes;

        if (empty($currentPostTypes)) {
            return delete_user_meta($userId, self::UNCONFIGURED_NOTICE_DISMISSED_META_KEY);
        }

        return update_user_meta(
            $userId,
            self::UNCONFIGURED_NOTICE_DISMISSED_META_KEY,
            wp_json_encode($currentPostTypes)
        ) !== false;
    }

    /**
     * Get dismissed unconfigured post types for current user.
     *
     * @return string[]
     */
    private function getDismissedUnconfiguredPostTypes(): array
    {
        if (is_array($this->dismissedUnconfiguredPostTypesCache)) {
            return $this->dismissedUnconfiguredPostTypesCache;
        }

        $userId = get_current_user_id();
        if ($userId <= 0) {
            $this->dismissedUnconfiguredPostTypesCache = [];
            return $this->dismissedUnconfiguredPostTypesCache;
        }

        $raw = get_user_meta($userId, self::UNCONFIGURED_NOTICE_DISMISSED_META_KEY, true);
        if (!is_string($raw) || $raw === '') {
            $this->dismissedUnconfiguredPostTypesCache = [];
            return $this->dismissedUnconfiguredPostTypesCache;
        }

        $decoded = json_decode($raw, true);
        if (!is_array($decoded)) {
            $this->dismissedUnconfiguredPostTypesCache = [];
            return $this->dismissedUnconfiguredPostTypesCache;
        }

        $this->dismissedUnconfiguredPostTypesCache = $this->normalizePostTypeSlugs($decoded);
        return $this->dismissedUnconfiguredPostTypesCache;
    }

    /**
     * Normalize an array of post type slugs.
     *
     * @param mixed[] $postTypes
     * @return string[]
     */
    private function normalizePostTypeSlugs(array $postTypes): array
    {
        $postTypes = array_values(array_unique(array_filter(array_map(
            static fn($postType): string => sanitize_key((string) $postType),
            $postTypes
        ))));
        sort($postTypes, SORT_STRING);

        return $postTypes;
    }

    /**
     * Convert a post type catalog to a slug => label map.
     *
     * @param array<string,array<string,mixed>> $catalog
     * @return array<string,string>
     */
    private function catalogToLabelMap(array $catalog): array
    {
        $out = [];

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

            $out[$slug] = (string) ($postType['label'] ?? $slug);
        }

        ksort($out, SORT_STRING);

        return $out;
    }

    /**
     * Resolve a post type label and append the non-public suffix when needed.
     *
     * @param \WP_Post_Type|null $postTypeObject Post type object.
     * @param string             $slug Post type slug.
     * @return string
     */
    private function getPostTypeLabel($postTypeObject, string $slug): string
    {
        if (!$postTypeObject instanceof \WP_Post_Type) {
            return $slug;
        }

        $label = (string) ($postTypeObject->labels->singular_name ?? $postTypeObject->label ?? $slug);
        if (empty($postTypeObject->public)) {
            $label .= ' (non-public)';
        }

        return $label;
    }

    /**
     * Get pages formatted for REST API.
     *
     * @param array<int,\WP_Post> $pages
     * @return array<int,array<string,mixed>>
     */
    private function getSelectableTargetPagesRest(array $pages): array
    {
        $out = [];
        $frontPageId = (int) get_option('page_on_front');
        $hasStaticFrontPage = get_option('show_on_front') === 'page' && $frontPageId > 0;

        foreach ($pages as $page) {
            if (!$page instanceof \WP_Post) {
                continue;
            }

            $out[] = [
                'id' => (int) $page->ID,
                'title' => wp_specialchars_decode(get_the_title($page), ENT_QUOTES),
                'status' => (string) $page->post_status,
                'uri' => get_page_uri($page),
                'is_front_page' => $hasStaticFrontPage && (int) $page->ID === $frontPageId,
            ];
        }

        return $out;
    }

    /**
     * Build lightweight metadata for capped page selector lists.
     *
     * @param array<int,\WP_Post> $pages Loaded page rows.
     * @return array<string,mixed>
     */
    private function getSelectableTargetPagesMeta(array $pages): array
    {
        $shown = count($pages);
        $limit = Config::TARGET_PAGE_SELECTION_LIMIT;
        $total = max($shown, $this->countSelectableTargetPages());

        return [
            'limit' => $limit,
            'shown' => $shown,
            'total' => $total,
            'is_truncated' => $total > $shown,
        ];
    }

    /**
     * Count selectable pages (same statuses and language scope used by selector UI).
     *
     * @return int
     */
    private function countSelectableTargetPages(): int
    {
        $resolver = LanguageResolverFactory::create();

        $args = [
            'post_type' => 'page',
            'post_status' => ['publish', 'private', 'draft', 'pending'],
            'posts_per_page' => 1,
            'fields' => 'ids',
            'no_found_rows' => false,
            'update_post_meta_cache' => false,
            'update_post_term_cache' => false,
        ];

        if ($resolver !== null) {
            $args['suppress_filters'] = false;
            $resolver->switchLanguage($resolver->getDefaultLanguage());
        }

        try {
            $query = new \WP_Query($args);
        } finally {
            if ($resolver !== null) {
                $resolver->switchLanguage(null);
            }
        }

        return (int) $query->found_posts;
    }

    /**
     * Returns all pages that can be selected as purge targets.
     *
     * @return array<int,\WP_Post>
     */
    private function getSelectableTargetPages(): array
    {
        $args = [
            'post_type' => 'page',
            'post_status' => ['publish', 'private', 'draft', 'pending'],
            'posts_per_page' => Config::TARGET_PAGE_SELECTION_LIMIT,
            'orderby' => 'title',
            'order' => 'ASC',
            'no_found_rows' => true,
            'update_post_meta_cache' => false,
            'update_post_term_cache' => false,
        ];

        $resolver = LanguageResolverFactory::create();

        if ($resolver !== null) {
            $args['suppress_filters'] = false;
            $resolver->switchLanguage($resolver->getDefaultLanguage());
        }

        try {
            $pages = get_posts($args);
        } finally {
            if ($resolver !== null) {
                $resolver->switchLanguage(null);
            }
        }

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

    /**
     * Build advisory theme context metadata for the settings UI.
     *
     * @return array<string,mixed>
     */
    private function buildThemeContext(): array
    {
        $isBlockTheme = function_exists('wp_is_block_theme') ? (bool) wp_is_block_theme() : false;

        return [
            'type' => $isBlockTheme ? 'block' : 'classic',
            'is_block_theme' => $isBlockTheme,
            'feature_tags' => [
                'menus_enabled' => 'universal',
                'customizer_enabled' => 'universal',
                'widgets_enabled' => 'mostly_classic',
                'navigation_enabled' => 'mostly_block',
                'synced_patterns_enabled' => 'universal',
                'pattern_global_fallback_enabled' => 'universal',
                'reading_settings_enabled' => 'universal',
                'permalink_settings_enabled' => 'universal',
                'comments_enabled' => 'universal',
                'switch_theme_enabled' => 'universal',
                'core_update' => 'universal',
                'plugin_update' => 'universal',
                'theme_update' => 'universal',
                'forms_enabled' => 'universal',
                'multilingual_fanout_post_triggers_enabled' => 'universal',
                'multilingual_fanout_non_post_triggers_enabled' => 'universal',
                'widget_global_fallback_enabled' => 'mostly_classic',
                'forms_global_fallback_enabled' => 'universal',
            ],
        ];
    }
}
