<?php

namespace WPSN\SEO;

use WPSN\Core\Cache;
use WPSN\Core\Logger;
use WPSN\Core\Settings;
use WPSN\Domain\SeoDocumentContext;
use WPSN\Domain\SeoMeta;

defined('ABSPATH') || exit;

final class SeoMetaRepository
{
    private Settings $settings;
    private TemplateEngine $templates;
    private Cache $cache;
    private Logger $logger;

    public function __construct(Settings $settings, TemplateEngine $templates, Cache $cache, Logger $logger)
    {
        $this->settings  = $settings;
        $this->templates = $templates;
        $this->cache     = $cache;
        $this->logger    = $logger;
    }

    public function forContext(SeoDocumentContext $ctx): SeoMeta
    {
        \WPSN\Core\PerfProbe::start('meta_repo');
        $cacheKey = 'meta_' . md5($ctx->type . '|' . (string) $ctx->objectId . '|' . (string) $ctx->objectSubType . '|' . $ctx->url . '|' . (string) $ctx->paged);
        $cached = $this->cache->get($cacheKey);
        if ($cached instanceof SeoMeta) {
            \WPSN\Core\PerfProbe::stop('meta_repo');
            return $cached;
        }

        $title       = $this->resolveTitle($ctx);
        $description = $this->resolveDescription($ctx);

        $canonical   = $this->resolveCanonical($ctx);
        $robots      = $this->resolveRobots($ctx);

        // Social (OpenGraph / Twitter fallback)
        $ogTitle = $this->resolveOgTitle($ctx, $title);
        $ogDescription = $this->resolveOgDescription($ctx, $description);
        $ogImage = $this->resolveOgImage($ctx);

        // Social (Twitter)
        $twitterTitle = $this->resolveTwitterTitle($ctx, $ogTitle);
        $twitterDescription = $this->resolveTwitterDescription($ctx, $ogDescription);
        $twitterImage = $this->resolveTwitterImage($ctx, $ogImage);

        $meta = new SeoMeta($title, $description, $canonical, $robots, $ogTitle, $ogDescription, $ogImage, $twitterTitle, $twitterDescription, $twitterImage);

        /**
         * Filter the final computed SeoMeta.
         *
         * @since 0.1.0
         */
        $meta = apply_filters('wpsn/seo_meta', $meta, $ctx);

        $this->cache->set($cacheKey, $meta, 60);

        \WPSN\Core\PerfProbe::stop('meta_repo');
        return $meta;
    }

    /**
     * Same as forContext(), but applies the provided meta overrides (e.g. unsaved Gutenberg edits).
     * This method bypasses cache to ensure fresh preview.
     *
     * @param array<string, mixed> $overrides
     */
    public function forContextWithOverrides(SeoDocumentContext $ctx, array $overrides): SeoMeta
    {
        \WPSN\Core\PerfProbe::start('meta_repo_preview');
        $title       = $this->resolveTitle($ctx, $overrides);
        $description = $this->resolveDescription($ctx, $overrides);
        $canonical   = $this->resolveCanonical($ctx, $overrides);
        $robots      = $this->resolveRobots($ctx, $overrides);

        $ogTitle = $this->resolveOgTitle($ctx, $title, $overrides);
        $ogDescription = $this->resolveOgDescription($ctx, $description, $overrides);
        $ogImage = $this->resolveOgImage($ctx, $overrides);

        $twitterTitle = $this->resolveTwitterTitle($ctx, $ogTitle, $overrides);
        $twitterDescription = $this->resolveTwitterDescription($ctx, $ogDescription, $overrides);
        $twitterImage = $this->resolveTwitterImage($ctx, $ogImage, $overrides);

        $meta = new SeoMeta($title, $description, $canonical, $robots, $ogTitle, $ogDescription, $ogImage, $twitterTitle, $twitterDescription, $twitterImage);

        /**
         * @since 0.3.0
         */
        $meta = apply_filters('wpsn/seo_meta_preview', $meta, $ctx, $overrides);
        \WPSN\Core\PerfProbe::stop('meta_repo_preview');
        return $meta;
    }

    /**
     * @param array<string, mixed>|null $overrides
     */
    private function resolveTitle(SeoDocumentContext $ctx, ?array $overrides = null): string
    {
        $override = $this->getOverride($ctx, MetaKeys::TITLE, $overrides);
        if ($override !== '') {
            return $override;
        }

        $tplKey = 'templates.title';

        // Author archive templates.
        if ($ctx->type === SeoDocumentContext::TYPE_ARCHIVE && $ctx->objectSubType === 'author') {
            $tplKey = 'templates.author_title';
        }

        $tpl = (string) $this->settings->get($tplKey, '%title% %sep% %sitename%');
        if ($tplKey !== 'templates.title' && trim($tpl) === '') {
            $tpl = (string) $this->settings->get('templates.title', '%title% %sep% %sitename%');
        }

        $title = $this->templates->render($tpl, $ctx);

        // If paged and template doesn't include %page%, append it.
        if ($ctx->isPaged() && strpos($tpl, '%page%') === false) {
            $page = $this->templates->render('%page%', $ctx);
            if ($page !== '') {
                $sep = $this->settings->separator();
                $title = trim($title . ' ' . $sep . ' ' . $page);
            }
        }

        /**
         * @since 0.1.0
         */
        return (string) apply_filters('wpsn/meta/title', $title, $ctx);
    }

    /**
     * @param array<string, mixed>|null $overrides
     */
    private function resolveDescription(SeoDocumentContext $ctx, ?array $overrides = null): string
    {
        $override = $this->getOverride($ctx, MetaKeys::DESCRIPTION, $overrides);
        if ($override !== '') {
            return $override;
        }

        $tplKey = 'templates.description';

        // Author archive templates.
        if ($ctx->type === SeoDocumentContext::TYPE_ARCHIVE && $ctx->objectSubType === 'author') {
            $tplKey = 'templates.author_description';
        }

        $tpl = (string) $this->settings->get($tplKey, '%excerpt%');
        if ($tplKey !== 'templates.description' && trim($tpl) === '') {
            $tpl = (string) $this->settings->get('templates.description', '%excerpt%');
        }

        $desc = $this->templates->render($tpl, $ctx);

        /**
         * @since 0.1.0
         */
        $desc = (string) apply_filters('wpsn/meta/description', $desc, $ctx);

        return $desc;
    }

    /**
     * @param array<string, mixed>|null $overrides
     */
    private function resolveOgTitle(SeoDocumentContext $ctx, string $fallbackSeoTitle, ?array $overrides = null): string
    {
        $override = $this->getOverride($ctx, MetaKeys::OG_TITLE, $overrides);
        if ($override !== '') {
            $override = trim($override);
            /** @since 0.12.0 */
            return (string) apply_filters('wpsn/meta/og_title', $override, $ctx);
        }

        $title = trim($fallbackSeoTitle);
        /** @since 0.12.0 */
        return (string) apply_filters('wpsn/meta/og_title', $title, $ctx);
    }

    /**
     * @param array<string, mixed>|null $overrides
     */
    private function resolveOgDescription(SeoDocumentContext $ctx, string $fallbackSeoDescription, ?array $overrides = null): string
    {
        $override = $this->getOverride($ctx, MetaKeys::OG_DESCRIPTION, $overrides);
        if ($override !== '') {
            $override = trim($override);
            /** @since 0.12.0 */
            return (string) apply_filters('wpsn/meta/og_description', $override, $ctx);
        }

        $desc = trim($fallbackSeoDescription);
        /** @since 0.12.0 */
        return (string) apply_filters('wpsn/meta/og_description', $desc, $ctx);
    }

    /**
     * @param array<string, mixed>|null $overrides
     */
    private function resolveOgImage(SeoDocumentContext $ctx, ?array $overrides = null): string
    {
        // 1) Per-object override
        $override = $this->getOverride($ctx, MetaKeys::OG_IMAGE, $overrides);
        if ($override !== '') {
            $u = $this->sanitizeImageUrl($override);
            if ($u !== '') {
                /** @since 0.12.0 */
                return (string) apply_filters('wpsn/meta/og_image', $u, $ctx);
            }
        }

        $url = '';

        // 2) Featured image (singular)
        if ($ctx->isSingular() && $ctx->objectId > 0) {
            $thumbId = (int) get_post_thumbnail_id($ctx->objectId);
            if ($thumbId > 0) {
                $img = wp_get_attachment_image_url($thumbId, 'full');
                if (is_string($img) && $img !== '') {
                    $url = $img;
                }
            }
        }

        
        // 2a) Author image (singular fallback - optional)
        if ($url === '' && $ctx->isSingular() && $ctx->objectId > 0 && (bool) $this->settings->get('social.fallback_author_image', false)) {
            $post = get_post((int) $ctx->objectId);
            if ($post instanceof \WP_Post) {
                $authorId = (int) $post->post_author;
                if ($authorId > 0) {
                    $u = (string) get_user_meta($authorId, UserMetaKeys::AUTHOR_IMAGE_URL, true);
                    $u = $this->sanitizeImageUrl($u);
                    if ($u !== '') {
                        $url = $u;
                    }
                }
            }
        }

// 2b) Author image (author archive)
        if ($url === '' && $ctx->type === SeoDocumentContext::TYPE_ARCHIVE && $ctx->objectSubType === 'author' && $ctx->objectId !== null && $ctx->objectId > 0) {
            $u = (string) get_user_meta((int) $ctx->objectId, UserMetaKeys::AUTHOR_IMAGE_URL, true);
            $u = $this->sanitizeImageUrl($u);
            if ($u !== '') {
                $url = $u;
            }
        }

        // 3) Default site OG image (settings)
        if ($url === '') {
            $default = (string) $this->settings->get('social.default_image_url', '');
            $default = $this->sanitizeImageUrl($default);
            if ($default !== '') {
                $url = $default;
            }
        }

        // 4) Site icon
        if ($url === '' && function_exists('get_site_icon_url')) {
            $icon = get_site_icon_url(512);
            if (is_string($icon) && $icon !== '') {
                $url = $icon;
            }
        }

        $url = $this->sanitizeImageUrl($url);

        /** @since 0.12.0 */
        return (string) apply_filters('wpsn/meta/og_image', $url, $ctx);
    }

    /**
     * @param array<string, mixed>|null $overrides
     */
    private function resolveTwitterTitle(SeoDocumentContext $ctx, string $fallbackOgTitle, ?array $overrides = null): string
    {
        $override = $this->getOverride($ctx, MetaKeys::TWITTER_TITLE, $overrides);
        if ($override !== '') {
            $override = trim($override);
            /** @since 0.12.1 */
            return (string) apply_filters('wpsn/meta/twitter_title', $override, $ctx);
        }

        $title = trim($fallbackOgTitle);
        /** @since 0.12.1 */
        return (string) apply_filters('wpsn/meta/twitter_title', $title, $ctx);
    }

    /**
     * @param array<string, mixed>|null $overrides
     */
    private function resolveTwitterDescription(SeoDocumentContext $ctx, string $fallbackOgDescription, ?array $overrides = null): string
    {
        $override = $this->getOverride($ctx, MetaKeys::TWITTER_DESCRIPTION, $overrides);
        if ($override !== '') {
            $override = trim($override);
            /** @since 0.12.1 */
            return (string) apply_filters('wpsn/meta/twitter_description', $override, $ctx);
        }

        $desc = trim($fallbackOgDescription);
        /** @since 0.12.1 */
        return (string) apply_filters('wpsn/meta/twitter_description', $desc, $ctx);
    }

    /**
     * @param array<string, mixed>|null $overrides
     */
    private function resolveTwitterImage(SeoDocumentContext $ctx, string $fallbackOgImage, ?array $overrides = null): string
    {
        $override = $this->getOverride($ctx, MetaKeys::TWITTER_IMAGE, $overrides);
        if ($override !== '') {
            $u = $this->sanitizeImageUrl($override);
            if ($u !== '') {
                /** @since 0.12.1 */
                return (string) apply_filters('wpsn/meta/twitter_image', $u, $ctx);
            }
        }

        $url = $this->sanitizeImageUrl($fallbackOgImage);
        /** @since 0.12.1 */
        return (string) apply_filters('wpsn/meta/twitter_image', $url, $ctx);
    }

    /**
     * @param array<string, mixed>|null $overrides
     */
    private function getOverride(SeoDocumentContext $ctx, string $metaKey, ?array $overrides = null): string
    {
        $value = '';

        // External overrides take precedence (even if empty) to allow "clearing" values in previews.
        if (is_array($overrides) && array_key_exists($metaKey, $overrides)) {
            $v = $overrides[$metaKey];
            $value = is_scalar($v) ? (string) $v : '';
            return trim($value);
        }

        if ($ctx->isSingular()) {
            $value = (string) get_post_meta($ctx->objectId, $metaKey, true);
        } elseif ($ctx->isTerm()) {
            $value = (string) get_term_meta($ctx->objectId, $metaKey, true);
        }

        $value = trim($value);

        return $value;
    }

    /**
     * @param array<string, mixed>|null $overrides
     */
    private function resolveCanonical(SeoDocumentContext $ctx, ?array $overrides = null): string
    {
        // Do not output canonical for 404.
        if ($ctx->type === SeoDocumentContext::TYPE_404) {
            return '';
        }

        $override = $this->getOverride($ctx, MetaKeys::CANONICAL, $overrides);
        if ($override !== '') {
            $url = $this->sanitizeCanonicalUrl($override);
            if ($url !== '') {
                /**
                 * @since 0.2.0
                 */
                return (string) apply_filters('wpsn/meta/canonical', $url, $ctx);
            }
        }

        $url = '';

        // Prefer WP canonical logic when available.
        if (function_exists('wp_get_canonical_url')) {
            $maybe = wp_get_canonical_url();
            if (is_string($maybe) && $maybe !== '') {
                $url = $maybe;
            }
        }

        // Fallback to context URL.
        if ($url === '') {
            $url = $ctx->url;
        }

        // Ensure paginated canonical points to the correct page.
        if ($ctx->isPaged() && function_exists('get_pagenum_link')) {
            $pagedUrl = get_pagenum_link($ctx->paged);
            if (is_string($pagedUrl) && $pagedUrl !== '') {
                $url = $pagedUrl;
            }
        }

        $url = $this->sanitizeCanonicalUrl($url);
        if ($url !== '') {
            $url = $this->normalizeCanonicalUrl($url, $ctx);
            $url = $this->sanitizeCanonicalUrl($url);
        }

        /**
         * @since 0.2.0
         */
        return (string) apply_filters('wpsn/meta/canonical', $url, $ctx);
    }

    /**
     * @param array<string, mixed>|null $overrides
     * @return array<string, string|bool>
     */
    private function resolveRobots(SeoDocumentContext $ctx, ?array $overrides = null): array
    {
        $robots = [
            'noindex'           => false,
            'nofollow'          => false,
            'max-image-preview' => (string) $this->settings->get('robots.advanced.max-image-preview', 'large'),
            'max-snippet'       => (string) $this->settings->get('robots.advanced.max-snippet', '-1'),
            'max-video-preview' => (string) $this->settings->get('robots.advanced.max-video-preview', '-1'),
            'noarchive'         => (bool) $this->settings->get('robots.advanced.noarchive', false),
            'nosnippet'         => (bool) $this->settings->get('robots.advanced.nosnippet', false),
        ];

        // Context defaults.
        $typeKey = $ctx->type;
        $index = (bool) $this->settings->get('robots.contexts.' . $typeKey . '.index', true);
        $follow = (bool) $this->settings->get('robots.contexts.' . $typeKey . '.follow', true);

        // Common safe defaults: search + 404 noindex.
        if ($ctx->type === SeoDocumentContext::TYPE_SEARCH || $ctx->type === SeoDocumentContext::TYPE_404) {
            $index = false;
        }

        // Optional: noindex paginated archives/terms/search.
        $noindexPaged = (bool) $this->settings->get('robots.noindex_paged', false);
        if ($noindexPaged && $ctx->isPaged() && !$ctx->isSingular()) {
            $index = false;
        }

        $robots['noindex'] = !$index;
        $robots['nofollow'] = !$follow;

        // Per-object overrides (tri-state).
        $indexOverride = $this->getOverride($ctx, MetaKeys::ROBOTS_INDEX, $overrides);
        if ($indexOverride === 'noindex') {
            $robots['noindex'] = true;
        } elseif ($indexOverride === 'index') {
            $robots['noindex'] = false;
        }

        $followOverride = $this->getOverride($ctx, MetaKeys::ROBOTS_FOLLOW, $overrides);
        if ($followOverride === 'nofollow') {
            $robots['nofollow'] = true;
        } elseif ($followOverride === 'follow') {
            $robots['nofollow'] = false;
        }

        /**
         * Filter computed robots directives.
         *
         * @since 0.2.0
         *
         * @param array<string, string|bool> $robots
         */
        $robots = apply_filters('wpsn/meta/robots', $robots, $ctx);

        return $robots;
    }


    /**
     * Normalize canonical URLs:
     * - removes URL fragments (#...)
     * - strips known tracking query args (utm_*, gclid, fbclid, etc.)
     */
    private function normalizeCanonicalUrl(string $url, SeoDocumentContext $ctx): string
    {
        $url = trim($url);
        if ($url === '') {
            return '';
        }

        $parts = wp_parse_url($url);
        if (!is_array($parts)) {
            return $url;
        }

        // Canonical should never include fragments.
        if (isset($parts['fragment'])) {
            unset($parts['fragment']);
        }

        $query = [];
        if (isset($parts['query']) && is_string($parts['query']) && $parts['query'] !== '') {
            parse_str($parts['query'], $query);
        }

        if (is_array($query) && !empty($query)) {
            $strip = $this->defaultCanonicalStripArgs();

            /**
             * Filter query-string keys to strip from canonicals.
             *
             * @since 0.66.1
             *
             * @param array<int,string> $strip
             * @param SeoDocumentContext $ctx
             */
            $strip = apply_filters('wpsn/canonical/strip_query_args', $strip, $ctx);
            $strip = is_array($strip) ? $strip : $this->defaultCanonicalStripArgs();

            $stripMap = [];
            foreach ($strip as $k) {
                $k = is_string($k) ? strtolower(trim($k)) : '';
                if ($k !== '') {
                    $stripMap[$k] = true;
                }
            }

            $clean = [];
            foreach ($query as $k => $v) {
                $lk = strtolower((string) $k);

                // Always strip utm_* keys.
                if (strpos($lk, 'utm_') === 0) {
                    continue;
                }

                if (isset($stripMap[$lk])) {
                    continue;
                }

                $clean[$k] = $v;
            }

            $query = $clean;
        }

        // Rebuild.
        $scheme = isset($parts['scheme']) ? (string) $parts['scheme'] : '';
        $host = isset($parts['host']) ? (string) $parts['host'] : '';
        $port = isset($parts['port']) ? ':' . (string) ((int) $parts['port']) : '';
        $path = isset($parts['path']) ? (string) $parts['path'] : '';

        $user = isset($parts['user']) ? (string) $parts['user'] : '';
        $pass = isset($parts['pass']) ? (string) $parts['pass'] : '';
        $auth = '';
        if ($user !== '') {
            $auth = $user;
            if ($pass !== '') {
                $auth .= ':' . $pass;
            }
            $auth .= '@';
        }

        $base = $url;
        if ($scheme !== '' && $host !== '') {
            $base = $scheme . '://' . $auth . $host . $port . $path;
        }

        $qs = '';
        if (is_array($query) && !empty($query)) {
            $qs = http_build_query($query, '', '&', PHP_QUERY_RFC3986);
        }
        if ($qs !== '') {
            $base .= '?' . $qs;
        }

        $base = esc_url_raw($base);
        return $base !== '' ? $base : $url;
    }

    /**
     * @return array<int,string>
     */
    private function defaultCanonicalStripArgs(): array
    {
        // Keep conservative: only remove common tracking / click IDs.
        return [
            'utm_source',
            'utm_medium',
            'utm_campaign',
            'utm_term',
            'utm_content',
            'utm_id',

            'gclid',
            'dclid',
            'fbclid',
            'msclkid',
            'yclid',

            'mc_cid',
            'mc_eid',

            '_ga',
            '_gl',
            '_gac',
            '_gid',

            'igshid',
            'mkt_tok',

            'amp',

            // WordPress comment reply links should never affect canonical.
            'replytocom',
        ];
    }

    private function sanitizeCanonicalUrl(string $url): string
    {
        $url = trim($url);
        if ($url === '') {
            return '';
        }

        // Support site-relative paths.
        if (strpos($url, '/') === 0 && strpos($url, '//') !== 0) {
            $url = home_url($url);
        }

        $url = esc_url_raw($url);
        if ($url === '') {
            return '';
        }

        if (function_exists('wp_http_validate_url')) {
            $validated = wp_http_validate_url($url);
            if (!$validated) {
                return '';
            }
        }

        return $url;
    }

    private function sanitizeImageUrl(string $url): string
    {
        $url = trim($url);
        if ($url === '') {
            return '';
        }

        // Support site-relative paths.
        if (strpos($url, '/') === 0 && strpos($url, '//') !== 0) {
            $url = home_url($url);
        }

        $url = esc_url_raw($url);
        if ($url === '') {
            return '';
        }

        // For images we do not hard-fail on wp_http_validate_url in case of CDNs / custom schemes.
        return $url;
    }
}
