<?php

declare(strict_types=1);

namespace ProRank\SEO\Core\Content;

if ( ! defined( 'ABSPATH' ) ) {
    exit;
}

/**
 * Read-only detector + explainer for the "Title too long" SEO issue.
 *
 * It NEVER changes a title. It reports, per post, the rendered <title>, its
 * length, where the title comes from (explicit _prorank_seo_title override /
 * a ProRank title template / the WP-fallback construction), whether the site
 * name suffix is what pushed it over the limit, and whether the base post
 * title is itself too long.
 *
 * Ground truth is the LIVE rendered <title> fetched with a cache-safe request
 * (no cache-busting header, TLS on, short timeout, capped body) — the same
 * approach the H1 auditor uses. The stored-side diagnosis only explains WHY.
 */
class TitleAuditor {

    private const STATUSES = ['publish', 'private', 'pending', 'future'];

    /** Reusable template/library/infrastructure types — never standalone titles. */
    private const EXCLUDED_POST_TYPES = [
        'elementor_library', 'mailpoet_page', 'wp_template', 'wp_template_part',
        'wp_block', 'attachment', 'revision', 'nav_menu_item',
    ];

    /**
     * P0 is scoped to post/page-like content ONLY. Commerce post types use
     * title templates with product-specific tokens ({price}, {sku},
     * {product_cat}, …) that the deterministic resolver does not model — if
     * scanned, their projected length would be under-counted and real
     * too-long product titles could be missed before live verification ever
     * runs. They are explicitly out of P0 scope (filterable for later phases).
     */
    private const P0_COMMERCE_POST_TYPES = [
        'product', 'product_variation', 'shop_order', 'shop_order_refund',
        'shop_coupon', 'shop_subscription',
    ];

    /** Fallback character limit if the Titles/Meta module is unavailable. */
    private const DEFAULT_LIMIT = 60;

    /** Cached Titles/Meta module instance (or false once resolved as absent). */
    private $titles_meta = null;

    private function titles_meta() {
        if ($this->titles_meta !== null) {
            return $this->titles_meta ?: null;
        }
        $this->titles_meta = false;
        try {
            if (class_exists('\\ProRank\\SEO\\Plugin')) {
                $plugin = \ProRank\SEO\Plugin::get_instance();
                if ($plugin && method_exists($plugin, 'modules')) {
                    $m = $plugin->modules()->get_module('titles-meta');
                    if ($m) {
                        $this->titles_meta = $m;
                    }
                }
            }
        } catch (\Throwable $e) {
            $this->titles_meta = false;
        }
        return $this->titles_meta ?: null;
    }

    private function setting(string $key, $default = null) {
        $tm = $this->titles_meta();
        if ($tm && is_callable([$tm, 'get_setting'])) {
            try {
                return $tm->get_setting($key, $default);
            } catch (\Throwable $e) {
                // fall through
            }
        }
        return $default;
    }

    /**
     * Deterministic, POST-AWARE template resolver.
     *
     * TitlesMetaModule::process_template() resolves {title} etc. against
     * is_singular()/get_queried_object(), so during a REST/CLI scan loop it
     * resolves against the wrong (or no) queried object. This resolver takes
     * the explicit target post and expands the tokens HeadOutput's templates
     * actually use, in both {token} and %token% / %%token%% forms. Unknown
     * tokens are stripped (treated as empty) so length is not inflated by
     * literal token text.
     */
    private function resolve_template(string $template, \WP_Post $post): string {
        $sep = trim((string) $this->setting('title_separator', '-'));
        if ($sep === '') {
            $sep = '-';
        }
        $title = $this->clean((string) get_the_title($post));
        $sitename = trim(wp_strip_all_tags((string) get_bloginfo('name')));
        $tagline = trim(wp_strip_all_tags((string) get_bloginfo('description')));

        $excerpt = '';
        if ($post->post_excerpt !== '') {
            $excerpt = $this->clean((string) $post->post_excerpt);
        } elseif ($post->post_content !== '') {
            $excerpt = $this->clean(wp_trim_words(wp_strip_all_tags((string) $post->post_content), 30, ''));
        }

        $author = '';
        if ($post->post_author) {
            $author = (string) get_the_author_meta('display_name', (int) $post->post_author);
        }

        $category = '';
        $terms = get_the_terms($post->ID, 'category');
        if (is_array($terms) && !empty($terms) && isset($terms[0]->name)) {
            $category = (string) $terms[0]->name;
        }

        $map = [
            'title' => $title,
            'sep' => $sep,
            'separator' => $sep,
            'sitename' => $sitename,
            'site_name' => $sitename,
            'tagline' => $tagline,
            'site_tagline' => $tagline,
            'excerpt' => $excerpt,
            'author' => $author,
            'name' => $author,
            'category' => $category,
            'primary_category' => $category,
            'date' => (string) get_the_date('', $post),
            'modified' => (string) get_the_modified_date('', $post),
            'id' => (string) $post->ID,
            'currentyear' => gmdate('Y'),
            'currentmonth' => gmdate('F'),
            'currentday' => gmdate('j'),
            'page' => '',
            'page_number' => '',
            'pagenumber' => '',
        ];

        $out = $template;
        foreach ($map as $token => $value) {
            $out = str_ireplace(
                ['{' . $token . '}', '%%' . $token . '%%', '%' . $token . '%'],
                $value,
                $out
            );
        }
        // Strip any remaining unmodelled tokens so they don't inflate length.
        $out = preg_replace('/\{[a-z0-9_]+\}|%%?[a-z0-9_]+%%?/i', '', $out);
        return $this->clean((string) $out);
    }

    public function get_limit(): int {
        $limit = (int) $this->setting('title_length_warning', self::DEFAULT_LIMIT);
        return $limit > 0 ? $limit : self::DEFAULT_LIMIT;
    }

    private function len(string $s): int {
        return function_exists('mb_strlen') ? mb_strlen($s) : strlen($s);
    }

    private function clean(string $s): string {
        return trim((string) preg_replace('/\s+/', ' ', wp_strip_all_tags($s)));
    }

    /**
     * Mirror of HeadOutput::maybe_shorten_title_for_length() — identical
     * sitename-omission regex so the projected title matches what the front
     * end would actually output. Kept in sync deliberately.
     */
    private function shorten(string $title): string {
        $title = $this->clean($title);
        if ($title === '' || $this->len($title) <= $this->get_limit()) {
            return $title;
        }
        $site_name = trim(wp_strip_all_tags((string) get_bloginfo('name')));
        if ($site_name === '') {
            return $title;
        }
        $separators = array_unique(array_filter([
            (string) $this->setting('title_separator', '-'), '-', '|', '–', '—', ':',
        ]));
        foreach ($separators as $sep) {
            $sep = trim($sep);
            if ($sep === '') {
                continue;
            }
            $candidate = preg_replace(
                '/\s+' . preg_quote($sep, '/') . '\s+' . preg_quote($site_name, '/') . '\s*$/iu',
                '',
                $title
            );
            if (is_string($candidate) && $candidate !== $title && trim($candidate) !== '') {
                return trim($candidate);
            }
        }
        return $title;
    }

    /**
     * Faithful re-creation of HeadOutput's SINGULAR title precedence
     * (P0 scope is posts/pages only — never home/archives/products).
     *
     * @return array{
     *   post_id:int, title:string, type:string,
     *   base_title:string, base_len:int,
     *   source:string, effective_title:string, effective_len:int,
     *   projected_title:string, projected_len:int,
     *   limit:int, sitename_suffix:string,
     *   manual_override:bool, sitename_caused:bool, base_title_too_long:bool,
     *   status:string
     * }
     */
    public function diagnose_post(int $post_id): array {
        $post = get_post($post_id);
        $limit = $this->get_limit();
        if (!$post) {
            return [
                'post_id' => $post_id, 'title' => '', 'type' => '',
                'base_title' => '', 'base_len' => 0,
                'source' => 'unknown', 'effective_title' => '', 'effective_len' => 0,
                'projected_title' => '', 'projected_len' => 0, 'limit' => $limit,
                'sitename_suffix' => '', 'manual_override' => false,
                'sitename_caused' => false, 'mechanically_ok_after_sitename_omit' => false,
                'base_title_too_long' => false,
                'status' => 'post_not_found',
            ];
        }

        $base_title = $this->clean((string) get_the_title($post_id));
        $base_len = $this->len($base_title);
        $sep = (string) $this->setting('title_separator', '-');
        $site_name = trim(wp_strip_all_tags((string) get_bloginfo('name')));
        $sitename_suffix = ($sep !== '' && $site_name !== '') ? ' ' . trim($sep) . ' ' . $site_name : '';

        $manual = (string) get_post_meta($post_id, '_prorank_seo_title', true);
        $is_page = $post->post_type === 'page';

        // Faithful mirror of HeadOutput::get_seo_title() SINGULAR precedence:
        //   1. explicit _prorank_seo_title  (DynamicVariables, post-aware)
        //   2. per-post-type single_title   (post_type_{type} setting)
        //   3. global post/page template
        //   4. WP fallback: base {sep} {sitename}
        if ($manual !== '') {
            $source = 'manual_override';
            $effective = $this->resolve_dynamic($manual, $post);
        } else {
            $pt_settings = $this->setting('post_type_' . $post->post_type, []);
            $single_title = is_array($pt_settings) ? (string) ($pt_settings['single_title'] ?? '') : '';

            if ($single_title !== '') {
                $source = 'post_type_template';
                $effective = $this->resolve_template($single_title, $post);
            } else {
                $tpl_key = $is_page ? 'page_title_template' : 'post_title_template';
                $template = (string) $this->setting($tpl_key, '');
                if ($template !== '' && $this->titles_meta()) {
                    $source = 'global_template';
                    $effective = $this->resolve_template($template, $post);
                } else {
                    $source = 'wp_fallback';
                    $effective = $base_title . $sitename_suffix;
                }
            }
        }

        $effective = $this->clean($effective);
        $projected = $this->shorten($effective);

        $effective_len = $this->len($effective);
        $projected_len = $this->len($projected);

        // sitename_caused: the full title is over the limit but the projected
        // (suffix-stripped) title fits. HeadOutput already performs this
        // omission at RENDER time, so such a page is NOT a problem and NOT a
        // repair candidate — it is recorded as a separate explanatory fact
        // (mechanically_ok_after_sitename_omit), never as `needs_rewrite`.
        $sitename_caused = $effective_len > $limit && $projected_len <= $limit;
        $base_title_too_long = $base_len > $limit;

        if ($projected_len <= $limit) {
            // Renders within the limit (possibly thanks to existing render-
            // time sitename omission). Not a candidate.
            $status = 'ok';
        } else {
            // Still over the limit even after sitename omission — only a
            // deliberate shorter SEO title can fix this.
            $status = 'needs_rewrite';
        }
        $mechanically_ok_after_sitename_omit = $status === 'ok' && $sitename_caused;

        return [
            'post_id' => $post_id,
            'title' => (string) $post->post_title,
            'type' => $post->post_type,
            'base_title' => $base_title,
            'base_len' => $base_len,
            'source' => $source,
            'effective_title' => $effective,
            'effective_len' => $effective_len,
            'projected_title' => $projected,
            'projected_len' => $projected_len,
            'limit' => $limit,
            'sitename_suffix' => $sitename_suffix,
            'manual_override' => $manual !== '',
            'sitename_caused' => $sitename_caused,
            'mechanically_ok_after_sitename_omit' => $mechanically_ok_after_sitename_omit,
            'base_title_too_long' => $base_title_too_long,
            'status' => $status,
        ];
    }

    private function resolve_dynamic(string $string, \WP_Post $post): string {
        try {
            if (class_exists('\\ProRank\\SEO\\Core\\DynamicVariables')) {
                $dv = new \ProRank\SEO\Core\DynamicVariables();
                if (is_callable([$dv, 'parse'])) {
                    return (string) $dv->parse($string, $post, 'post');
                }
            }
        } catch (\Throwable $e) {
            // fall through
        }
        return $string;
    }

    /**
     * Cache-safe live <title> fetch. Mirrors H1Auditor::verify_rendered():
     * no cache-busting header, TLS verification ON, 5s timeout, capped body.
     * `ok` is false when the request failed or returned a non-success status
     * — callers MUST treat that as inconclusive.
     *
     * @return array{rendered_title:string, rendered_len:int, http_status:int, ok:bool}
     */
    public function verify_rendered(int $post_id): array {
        $url = get_permalink($post_id);
        if (!$url) {
            return ['rendered_title' => '', 'rendered_len' => 0, 'http_status' => 0, 'ok' => false];
        }
        $res = wp_remote_get($url, [
            'timeout' => 5,
            'redirection' => 3,
            'limit_response_size' => 512 * 1024,
            'headers' => ['User-Agent' => 'ProRank-SEO/TitleAuditor'],
        ]);
        if (is_wp_error($res)) {
            return ['rendered_title' => '', 'rendered_len' => 0, 'http_status' => 0, 'ok' => false];
        }
        $status = (int) wp_remote_retrieve_response_code($res);
        $body = (string) wp_remote_retrieve_body($res);
        $ok = $status >= 200 && $status < 400 && $body !== '';
        $rendered = '';
        if ($ok && preg_match('/<title\b[^>]*>(.*?)<\/title>/is', $body, $m)) {
            $rendered = $this->clean(html_entity_decode($m[1], ENT_QUOTES | ENT_HTML5, 'UTF-8'));
        }
        return [
            'rendered_title' => $rendered,
            'rendered_len' => $this->len($rendered),
            'http_status' => $status,
            'ok' => $ok && $rendered !== '',
        ];
    }

    /**
     * Stored-side scan: every singular post whose PROJECTED title would
     * exceed the limit (or whose base title is itself too long). This is the
     * candidate set; verify() confirms it against live HTML.
     */
    public function scan(): array {
        global $wpdb;

        $post_types = get_post_types(['public' => true], 'names');
        if (empty($post_types)) {
            $post_types = ['post', 'page'];
        }
        $excluded = apply_filters(
            'prorank_title_audit_excluded_post_types',
            array_merge(self::EXCLUDED_POST_TYPES, self::P0_COMMERCE_POST_TYPES)
        );
        $post_types = array_values(array_diff($post_types, $excluded));
        if (empty($post_types)) {
            return $this->empty_scan();
        }

        $tp = implode(',', array_fill(0, count($post_types), '%s'));
        $sp = implode(',', array_fill(0, count(self::STATUSES), '%s'));
        $params = array_merge($post_types, self::STATUSES);
        $sql = $wpdb->prepare(
            "SELECT ID FROM {$wpdb->posts} WHERE post_type IN ({$tp}) AND post_status IN ({$sp})",
            $params
        );
        $ids = $wpdb->get_col($sql) ?: [];

        $long = [];
        // Pages whose stored title is over the limit ONLY because of the site
        // name suffix, which HeadOutput already strips at render time. These
        // are NOT repair candidates — counted purely for explanation.
        $mechanically_ok = 0;
        foreach ($ids as $id) {
            $d = $this->diagnose_post((int) $id);
            if ($d['status'] === 'post_not_found') {
                continue;
            }
            if (!empty($d['mechanically_ok_after_sitename_omit'])) {
                $mechanically_ok++;
            }
            if ($d['status'] !== 'needs_rewrite') {
                continue;
            }
            $long[] = [
                'id' => $d['post_id'],
                'title' => $d['title'],
                'type' => $d['type'],
                'source' => $d['source'],
                'effective_len' => $d['effective_len'],
                'projected_len' => $d['projected_len'],
                'limit' => $d['limit'],
                'status' => $d['status'],
                'manual_override' => $d['manual_override'],
                'base_title_too_long' => $d['base_title_too_long'],
                'edit_link' => admin_url('post.php?post=' . $d['post_id'] . '&action=edit'),
                'permalink' => (string) get_permalink($d['post_id']),
            ];
        }

        usort($long, static function ($a, $b) {
            return $b['effective_len'] - $a['effective_len'];
        });

        return [
            'scanned_at' => current_time('mysql'),
            'total_scanned' => count($ids),
            'limit' => $this->get_limit(),
            'stored_too_long' => count($long),
            'needs_rewrite_count' => count($long),
            'mechanically_ok_after_sitename_omit_count' => $mechanically_ok,
            'items' => $long,
        ];
    }

    private function empty_scan(): array {
        return [
            'scanned_at' => current_time('mysql'),
            'total_scanned' => 0,
            'limit' => $this->get_limit(),
            'stored_too_long' => 0,
            'needs_rewrite_count' => 0,
            'mechanically_ok_after_sitename_omit_count' => 0,
            'items' => [],
        ];
    }

    /**
     * Bounded, cache-safe live-verification pass over the stored-too-long set.
     * Buckets mirror the H1 auditor's verified shape the reviewer approved.
     */
    public function verify(int $limit = 50): array {
        $limit = max(1, min(500, $limit));
        $scan = $this->scan();

        $buckets = [
            'stored_too_long' => $scan['stored_too_long'],
            'mechanically_ok_after_sitename_omit' => $scan['mechanically_ok_after_sitename_omit_count'],
            'examined' => 0,
            'too_long_confirmed' => 0,
            'ok_rendered' => 0,
            'inconclusive' => 0,
            'needs_rewrite' => 0,
            'unverified_remaining' => 0,
            'complete' => false,
        ];
        $items = [];

        foreach ($scan['items'] as $cand) {
            if ($buckets['examined'] >= $limit) {
                break;
            }
            $buckets['examined']++;
            $r = $this->verify_rendered((int) $cand['id']);
            if (empty($r['ok'])) {
                $cls = 'inconclusive';
            } elseif ($r['rendered_len'] > $cand['limit']) {
                $cls = 'too_long_confirmed';
            } else {
                $cls = 'ok_rendered';
            }
            $buckets[$cls]++;
            if ($cls === 'too_long_confirmed') {
                // All candidates are needs_rewrite by construction (scan only
                // collects status === 'needs_rewrite').
                $buckets['needs_rewrite']++;
            }
            $items[] = array_merge($cand, [
                'verification' => $cls,
                'rendered_title' => $r['rendered_title'],
                'rendered_len' => $r['rendered_len'],
                'http_status' => $r['http_status'],
            ]);
        }

        $buckets['unverified_remaining'] = max(0, (int) $buckets['stored_too_long'] - (int) $buckets['examined']);
        $buckets['complete'] = $buckets['unverified_remaining'] === 0;

        usort($items, static function ($a, $b) {
            $rank = ['too_long_confirmed' => 0, 'inconclusive' => 1, 'ok_rendered' => 2];
            $ra = $rank[$a['verification']] ?? 3;
            $rb = $rank[$b['verification']] ?? 3;
            if ($ra !== $rb) {
                return $ra - $rb;
            }
            return ($b['rendered_len'] ?? 0) - ($a['rendered_len'] ?? 0);
        });

        return [
            'scanned_at' => $scan['scanned_at'],
            'total_scanned' => $scan['total_scanned'],
            'limit' => $scan['limit'],
            'buckets' => $buckets,
            'items' => $items,
        ];
    }
}
