<?php
declare(strict_types=1);

namespace Ekesto\CacheInvalidator\Resolvers;

use Ekesto\CacheInvalidator\InvalidatorRuntime;
use Ekesto\CacheInvalidator\Selectors\SelectorResolver;
use Ekesto\CacheInvalidator\Support\Debug;
use Ekesto\CacheInvalidator\Taxonomy\TaxonomyPolicy;

/**
 * Resolves archive URLs affected by content and term changes.
 */
final class ArchiveResolver
{
    /**
     * Resolve archive URLs affected by a single post change.
     *
     * Includes:
     * - post type archive (when available)
     * - assigned taxonomy term archives and their ancestors (publicly queryable taxonomies only)
     *
     * @param \WP_Post $post Changed post.
     * @param bool     $includeTaxonomyArchives Whether taxonomy archives should be included.
     * @return string[] Absolute URLs.
     */
    public static function resolveForPost(\WP_Post $post, bool $includeTaxonomyArchives = true): array
    {
        return self::resolveForPostOutcome($post, $includeTaxonomyArchives)['urls'];
    }

    /**
     * Resolve archive outcomes affected by a single post change.
     *
     * @param \WP_Post $post Changed post.
     * @param bool     $includeTaxonomyArchives Whether taxonomy archives should be included.
     * @return array{urls:string[],has_selectors:bool,intent:string,targeted_empty:bool}
     */
    public static function resolveForPostOutcome(\WP_Post $post, bool $includeTaxonomyArchives = true): array
    {
        $context = [
            'source' => 'post',
            'post_id' => (int) $post->ID,
            'post_type' => (string) $post->post_type,
            'include_taxonomy_archives' => $includeTaxonomyArchives,
        ];

        $urls = self::resolvePostTypeArchiveUrls([(string) $post->post_type]);
        if ($includeTaxonomyArchives) {
            $urls = array_merge($urls, self::resolveAssignedTaxonomyArchiveUrls((int) $post->ID, (string) $post->post_type));
        }
        $urls = array_values(array_unique(array_filter($urls)));

        $selectorResult = self::resolveArchiveSelectorIntent($context);
        $hasSelectors = !empty($selectorResult['has_selectors']);
        $intent = (string) ($selectorResult['intent'] ?? SelectorResolver::INTENT_TARGETED);

        if ($hasSelectors) {
            if ($intent === SelectorResolver::INTENT_GLOBAL) {
                $urls = [];
            } else {
                $selectorPostIds = is_array($selectorResult['post_ids'] ?? null) ? $selectorResult['post_ids'] : [];
                $selectorPostTypes = is_array($selectorResult['post_types'] ?? null) ? $selectorResult['post_types'] : [];
                $urls = self::resolveSelectorTargetUrls($selectorPostIds, $selectorPostTypes);
            }
        }

        $urls = self::applyArchiveUrlsFilter($urls, $context);
        $urls = self::applyPostArchiveUrlCap($urls, $context);
        $targetedEmpty = $hasSelectors
            && $intent === SelectorResolver::INTENT_TARGETED
            && empty($urls);

        Debug::log(sprintf(
            'archive resolve(post): post_id=%d post_type=%s include_tax=%s has_selectors=%s intent=%s urls=%d',
            (int) $post->ID,
            (string) $post->post_type,
            $includeTaxonomyArchives ? 'yes' : 'no',
            $hasSelectors ? 'yes' : 'no',
            $intent,
            count($urls)
        ));

        return [
            'urls' => $urls,
            'has_selectors' => $hasSelectors,
            'intent' => $intent,
            'targeted_empty' => $targetedEmpty,
        ];
    }

    /**
     * Resolve archive URL for a single taxonomy term.
     *
     * @param string $taxonomy Taxonomy slug.
     * @param int    $termId Term ID.
     * @return string[] Absolute URL list (0 or 1 entries).
     */
    public static function resolveForTerm(string $taxonomy, int $termId): array
    {
        return self::resolveForTermOutcome($taxonomy, $termId)['urls'];
    }

    /**
     * Resolve archive outcomes for a single taxonomy term.
     * Retain the native term URL separately from selector and filter targets.
     *
     * @param string $taxonomy Taxonomy slug.
     * @param int    $termId Term ID.
     * @return array{urls:string[],has_selectors:bool,intent:string,targeted_empty:bool,term_url:string}
     */
    public static function resolveForTermOutcome(string $taxonomy, int $termId): array
    {
        $taxonomy = sanitize_key($taxonomy);
        $termId = absint($termId);
        if ($taxonomy === '' || $termId <= 0) {
            return [
                'urls' => [],
                'term_url' => '',
                'has_selectors' => false,
                'intent' => SelectorResolver::INTENT_TARGETED,
                'targeted_empty' => false,
            ];
        }
        if ($taxonomy === 'post_tag' && !self::hasEnabledTagTermArchiveHost()) {
            return [
                'urls' => [],
                'term_url' => '',
                'has_selectors' => false,
                'intent' => SelectorResolver::INTENT_TARGETED,
                'targeted_empty' => false,
            ];
        }

        $taxObj = get_taxonomy($taxonomy);
        if (!$taxObj || empty($taxObj->publicly_queryable)) {
            return [
                'urls' => [],
                'term_url' => '',
                'has_selectors' => false,
                'intent' => SelectorResolver::INTENT_TARGETED,
                'targeted_empty' => false,
            ];
        }

        $url = get_term_link($termId, $taxonomy);
        if (is_wp_error($url) || !is_string($url) || $url === '') {
            return [
                'urls' => [],
                'term_url' => '',
                'has_selectors' => false,
                'intent' => SelectorResolver::INTENT_TARGETED,
                'targeted_empty' => false,
            ];
        }

        $context = [
            'source' => 'term',
            'taxonomy' => $taxonomy,
            'term_id' => $termId,
            'include_taxonomy_archives' => true,
        ];

        $urls = [$url];
        $urls = array_values(array_unique(array_filter($urls)));

        $selectorResult = self::resolveArchiveSelectorIntent($context);
        $hasSelectors = !empty($selectorResult['has_selectors']);
        $intent = (string) ($selectorResult['intent'] ?? SelectorResolver::INTENT_TARGETED);

        if ($hasSelectors) {
            if ($intent === SelectorResolver::INTENT_GLOBAL) {
                $urls = [];
            } else {
                $selectorPostIds = is_array($selectorResult['post_ids'] ?? null) ? $selectorResult['post_ids'] : [];
                $selectorPostTypes = is_array($selectorResult['post_types'] ?? null) ? $selectorResult['post_types'] : [];
                $urls = self::resolveSelectorTargetUrls($selectorPostIds, $selectorPostTypes);
            }
        }

        $urls = self::applyArchiveUrlsFilter($urls, $context);
        $targetedEmpty = $hasSelectors
            && $intent === SelectorResolver::INTENT_TARGETED
            && empty($urls);

        Debug::log(sprintf(
            'archive resolve(term): taxonomy=%s term_id=%d has_selectors=%s intent=%s urls=%d',
            $taxonomy,
            $termId,
            $hasSelectors ? 'yes' : 'no',
            $intent,
            count($urls)
        ));

        return [
            'urls' => $urls,
            'term_url' => !$hasSelectors && in_array($url, $urls, true) ? $url : '',
            'has_selectors' => $hasSelectors,
            'intent' => $intent,
            'targeted_empty' => $targetedEmpty,
        ];
    }

    /**
     * Returns whether at least one configured host allows post_tag term archive invalidation.
     *
     * @return bool
     */
    private static function hasEnabledTagTermArchiveHost(): bool
    {
        if (!taxonomy_exists('post_tag')) {
            return false;
        }

        $settingsRepository = InvalidatorRuntime::settingsRepository();

        foreach ($settingsRepository->getConfiguredPostTypes() as $configuredPostType) {
            $configuredPostType = sanitize_key((string) $configuredPostType);
            if ($configuredPostType === '') {
                continue;
            }
            if (!is_object_in_taxonomy($configuredPostType, 'post_tag')) {
                continue;
            }

            $postTypeConfig = $settingsRepository->getPostTypeConfig($configuredPostType);
            if ($postTypeConfig === null || !($postTypeConfig['enabled'] ?? true)) {
                continue;
            }

            $propagation = $postTypeConfig['propagation'] ?? [];
            if (!($propagation['taxonomies_enabled'] ?? true)) {
                continue;
            }
            if (!TaxonomyPolicy::isTaxonomyEnabledForPostType($configuredPostType, 'post_tag')) {
                continue;
            }
            if (empty($postTypeConfig['archives_enabled']) || empty($postTypeConfig['taxonomy_archives_enabled'])) {
                continue;
            }

            return true;
        }

        return false;
    }

    /**
     * Resolve post type archives for a list of post types.
     *
     * @param string[] $postTypes Post type slugs.
     * @return string[] Absolute URLs.
     */
    public static function resolvePostTypeArchiveUrls(array $postTypes): array
    {
        $urls = [];

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

            $ptObj = get_post_type_object($postType);
            if (!$ptObj || empty($ptObj->publicly_queryable) || empty($ptObj->has_archive)) {
                continue;
            }

            $url = get_post_type_archive_link($postType);
            if (!is_string($url) || $url === '') {
                continue;
            }

            $urls[] = $url;
        }

        return array_values(array_unique(array_filter($urls)));
    }

    /**
     * Resolve archive URLs for assigned taxonomy terms and their ancestors.
     *
     * Only taxonomies with publicly_queryable=true are included.
     *
     * @param int    $postId Post ID.
     * @param string $postType Post type slug.
     * @return string[] Absolute URLs.
     */
    private static function resolveAssignedTaxonomyArchiveUrls(int $postId, string $postType): array
    {
        $postId = absint($postId);
        $postType = sanitize_key($postType);
        if ($postId <= 0 || $postType === '') {
            return [];
        }

        $urls = [];
        $taxonomies = get_object_taxonomies($postType, 'objects');
        $taxonomies = TaxonomyPolicy::filterAllowedTaxonomies($postType, is_array($taxonomies) ? $taxonomies : []);

        foreach ($taxonomies as $taxonomy => $taxObj) {
            if (!is_object($taxObj) || empty($taxObj->publicly_queryable)) {
                continue;
            }

            $terms = get_the_terms($postId, (string) $taxonomy);
            if (!is_array($terms) || empty($terms)) {
                continue;
            }

            $archiveTerms = [];
            foreach ($terms as $term) {
                if (!$term instanceof \WP_Term) {
                    continue;
                }

                $archiveTerms[(int) $term->term_id] = $term;
                if (!empty($taxObj->hierarchical)) {
                    foreach (get_ancestors((int) $term->term_id, (string) $taxonomy, 'taxonomy') as $ancestorId) {
                        $ancestorId = absint($ancestorId);
                        if ($ancestorId > 0 && !isset($archiveTerms[$ancestorId])) {
                            $archiveTerms[$ancestorId] = $ancestorId;
                        }
                    }
                }
            }

            foreach ($archiveTerms as $term) {
                $url = get_term_link($term, (string) $taxonomy);
                if (is_wp_error($url) || !is_string($url) || $url === '') {
                    continue;
                }

                $urls[] = $url;
            }
        }

        return array_values(array_unique(array_filter($urls)));
    }

    /**
     * Apply the archive URL extension filter.
     *
     * @param string[]            $urls Archive URLs.
     * @param array<string,mixed> $context Resolver context.
     * @return string[]
     */
    private static function applyArchiveUrlsFilter(array $urls, array $context): array
    {
        /**
         * Filter archive URLs resolved for a post/term change.
         *
         * @param string[]            $urls Archive URLs.
         * @param array<string,mixed> $context Resolver context.
         */
        $urls = (array) apply_filters('ekesto_ci_archive_urls', $urls, $context);

        return array_values(array_unique(array_filter(array_map(
            static fn($url): string => trim((string) $url),
            $urls
        ))));
    }

    /**
     * Apply a deterministic per-post archive URL safety cap.
     *
     * @param string[]            $urls Archive URLs.
     * @param array<string,mixed> $context Resolver context.
     * @return string[]
     */
    private static function applyPostArchiveUrlCap(array $urls, array $context): array
    {
        $max = (int) apply_filters('ekesto_ci_max_archive_urls_per_post', 50, $context);
        if ($max <= 0) {
            return [];
        }

        $count = count($urls);
        if ($count <= $max) {
            return $urls;
        }

        // Ensure stable truncation results for identical inputs.
        $sorted = $urls;
        sort($sorted, SORT_STRING);
        $capped = array_slice($sorted, 0, $max);

        Debug::log(sprintf(
            'archive URL cap applied: post_id=%d post_type=%s urls=%d capped=%d',
            (int) ($context['post_id'] ?? 0),
            sanitize_key((string) ($context['post_type'] ?? '')),
            $count,
            $max
        ));

        return $capped;
    }

    /**
     * Resolve selector intent from archive selector filter payload.
     *
     * @param array<string,mixed> $context Resolver context.
     * @return array{intent:string,post_ids:int[],post_types:string[],has_selectors:bool}
     */
    private static function resolveArchiveSelectorIntent(array $context): array
    {
        /**
         * Filter archive targets via selectors.
         *
         * @param array<int,array<string,mixed>> $selectors Selector entries.
         * @param array<string,mixed>            $context Resolver context.
         */
        $selectors = apply_filters('ekesto_ci_archive_selectors', [], $context);
        if (!is_array($selectors)) {
            $selectors = [];
        }

        return SelectorResolver::resolve(array_values($selectors));
    }

    /**
     * Resolve target URLs for selector-targeted archive outcomes.
     *
     * @param int[]    $postIds Selector post IDs.
     * @param string[] $postTypes Selector post types.
     * @return string[]
     */
    private static function resolveSelectorTargetUrls(array $postIds, array $postTypes): array
    {
        $urls = array_merge(
            self::resolvePermalinksForPostIds($postIds),
            self::resolvePostTypeArchiveUrls($postTypes)
        );

        return array_values(array_unique(array_filter($urls)));
    }

    /**
     * Resolve permalinks for post IDs.
     *
     * @param int[] $postIds Post IDs.
     * @return string[]
     */
    private static function resolvePermalinksForPostIds(array $postIds): array
    {
        $urls = [];
        $postIds = array_values(array_unique(array_filter(array_map('absint', $postIds), static fn(int $id): bool => $id > 0)));

        foreach ($postIds as $postId) {
            $url = (string) get_permalink($postId);
            if ($url !== '') {
                $urls[] = $url;
            }
        }

        return array_values(array_unique(array_filter($urls)));
    }

}
