<?php
if (!defined('ABSPATH')) exit;

class RANKFORGE_Meta_Generator {
    private static $instance = null;
    private $canonical_buffer_url = '';
    public static function instance() { if (!self::$instance) self::$instance = new self(); return self::$instance; }

    private function __construct() {
        add_action('wp', [$this, 'disable_default_canonical_when_custom'], 20);
        add_action('wp_head', [$this, 'start_custom_canonical_buffer'], -1000);
        add_action('wp_head', [$this, 'output_custom_canonical'], 0);
        add_action('wp_head', [$this, 'output_meta_tags'], 1);
        add_action('wp_head', [$this, 'finish_custom_canonical_buffer'], PHP_INT_MAX);

        // take over the HTML <title> so configured templates / per-post
        // overrides actually become the page title, not just a <meta name="title">.
        add_filter('pre_get_document_title', [$this, 'filter_document_title'], PHP_INT_MAX);
        add_filter('wpseo_title', [$this, 'filter_yoast_title'], PHP_INT_MAX);
        add_filter('wpseo_canonical', [$this, 'filter_yoast_canonical'], 20);
    }

    public static function get_custom_canonical_url($post_id) {
        $post_id = (int) $post_id;
        if (!$post_id) return '';

        $raw = trim((string) get_post_meta($post_id, '_rankforge_canonical_url', true));
        if ($raw === '') return '';

        $url = esc_url_raw($raw, ['http', 'https']);
        if ($url === '') return '';

        $parts = wp_parse_url($url);
        if (empty($parts['scheme']) || empty($parts['host'])) return '';
        if (!in_array(strtolower($parts['scheme']), ['http', 'https'], true)) return '';

        return $url;
    }

    public function disable_default_canonical_when_custom() {
        if (is_admin() || is_feed() || !is_singular()) return;
        if (!self::get_custom_canonical_url(get_queried_object_id())) return;

        remove_action('wp_head', 'rel_canonical');
    }

    public function output_custom_canonical() {
        if (is_admin() || is_feed() || !is_singular() || is_attachment()) return;

        $canonical = self::get_custom_canonical_url(get_queried_object_id());
        if ($canonical === '') return;

        remove_action('wp_head', 'rel_canonical');
        echo '<link rel="canonical" href="' . esc_url($canonical) . '">' . "\n";
    }

    public function start_custom_canonical_buffer() {
        if (is_admin() || is_feed() || !is_singular() || is_attachment()) return;

        $canonical = self::get_custom_canonical_url(get_queried_object_id());
        if ($canonical === '') return;

        $this->canonical_buffer_url = $canonical;
        ob_start();
    }

    public function finish_custom_canonical_buffer() {
        if ($this->canonical_buffer_url === '') return;

        $html = ob_get_clean();
        $canonical = $this->canonical_buffer_url;
        $this->canonical_buffer_url = '';

        $html = preg_replace("/<link\\b(?=[^>]*\\brel=([\"'])canonical\\1)[^>]*>\\s*/i", '', $html);
        // $html is not plugin-built markup: it's WordPress core's own
        // wp_head buffer (title/meta/link/script/style from core, the theme,
        // and every other active plugin), captured via ob_start/ob_get_clean
        // only so this stray <link rel="canonical"> can be stripped and
        // replaced. wp_kses_post/esc_html would strip <title>, <meta>,
        // <script>, <style> and most <link> attributes, corrupting the
        // document head this same request already queued for output.
        echo '<link rel="canonical" href="' . esc_url($canonical) . '">' . "\n" . $html; // phpcs:ignore WordPress.Security.EscapeOutput.OutputNotEscaped -- buffered core wp_head output, see comment above
    }

    public function filter_yoast_title($title) {
        return $this->filter_document_title($title);
    }

    public function filter_yoast_canonical($canonical) {
        if (is_admin() || is_feed() || !is_singular()) return $canonical;
        return self::get_custom_canonical_url(get_queried_object_id()) !== '' ? '' : $canonical;
    }

    /**
 * Resolve the social image URL from strongest post-specific source to
 * broad fallback. WooCommerce products without an image still get the
 * storefront placeholder so social previews are never image-empty.
 */
    public static function resolve_social_image_url($post_id, $size = 'full') {
        $post_id = (int) $post_id;
        if (!$post_id) return '';

        $image = '';
        $og_id = (int) get_post_meta($post_id, '_rankforge_og_image_id', true);
        if ($og_id) {
            $image = wp_get_attachment_image_url($og_id, $size);
        }

        if (!$image) {
            $stored = (string) get_post_meta($post_id, '_rankforge_og_image', true);
            if ($stored !== '') $image = $stored;
        }

        if (!$image) {
            $image = get_the_post_thumbnail_url($post_id, $size);
        }

        if (!$image && get_post_type($post_id) === 'product' && function_exists('wc_get_product')) {
            $product = wc_get_product($post_id);
            if ($product) {
                $product_image_id = (int) $product->get_image_id();
                if ($product_image_id) {
                    $image = wp_get_attachment_image_url($product_image_id, $size);
                }
                if (!$image && function_exists('wc_placeholder_img_src')) {
                    $image = wc_placeholder_img_src($size);
                }
            }
        }

        if (!$image) {
            $image = get_option('rankforge_default_og_image', '');
        }

        return is_string($image) ? $image : '';
    }

    /**
 * Replace the <title> tag with the resolved meta title on singular views.
 * Returns the original value untouched for non-singular/admin/feed requests.
 */
    public function filter_document_title($title) {
        if (is_admin() || is_feed()) return $title;
        if (!is_singular()) return $title;

        $resolved = RANKFORGE_Meta_Templates::instance()->get_resolved_meta(get_queried_object_id(), 'title');
        return !empty($resolved) ? $resolved : $title;
    }

    public function output_meta_tags() {
        if (is_admin() || is_feed()) return;

        // pages like Home (blog index), post-type archives and the
        // 404/search views used to bail out early because they aren't
        // is_singular. Build sensible defaults for those contexts too.
        $ctx = $this->resolve_meta_context();
        if (!$ctx) return;

        // A/B testing (and any future variant logic) overrides the description
        // through one source value, so exactly one <meta name="description">
        // plus a matching og/twitter description is emitted — no duplicates.
        $ctx['desc'] = (string) apply_filters('rankforge_meta_description', $ctx['desc']);

        $site_name = get_bloginfo('name');

        if ($ctx['title']) echo '<meta name="title" content="' . esc_attr($ctx['title']) . '">' . "\n";
        if ($ctx['desc'])  echo '<meta name="description" content="' . esc_attr($ctx['desc']) . '">' . "\n";

        // per-post OG / Twitter overrides. Fall through from Twitter
        // → OG → SEO if each override is empty.
        $og_title = $og_desc = $tw_title = $tw_desc = '';
        if (is_singular()) {
            $pid = get_queried_object_id();
            $og_title = (string) get_post_meta($pid, '_rankforge_og_title', true);
            $og_desc  = (string) get_post_meta($pid, '_rankforge_og_description', true);
            $tw_title = (string) get_post_meta($pid, '_rankforge_twitter_title', true);
            $tw_desc  = (string) get_post_meta($pid, '_rankforge_twitter_description', true);
        }
        $og_title_eff = $og_title ?: $ctx['title'];
        $og_desc_eff  = $og_desc  ?: $ctx['desc'];
        $tw_title_eff = $tw_title ?: $og_title_eff;
        $tw_desc_eff  = $tw_desc  ?: $og_desc_eff;

        // Open Graph
        echo '<meta property="og:type" content="' . esc_attr($ctx['og_type']) . '">' . "\n";
        if ($og_title_eff) echo '<meta property="og:title" content="' . esc_attr($og_title_eff) . '">' . "\n";
        if ($og_desc_eff)  echo '<meta property="og:description" content="' . esc_attr($og_desc_eff) . '">' . "\n";
        if ($ctx['url'])   echo '<meta property="og:url" content="' . esc_url($ctx['url']) . '">' . "\n";
        echo '<meta property="og:site_name" content="' . esc_attr($site_name) . '">' . "\n";
        if ($ctx['image']) echo '<meta property="og:image" content="' . esc_url($ctx['image']) . '">' . "\n";

        // WooCommerce product OG tags so Facebook / Pinterest render a
        // product card (price + availability) rather than a plain link preview.
        if (($ctx['og_type'] ?? '') === 'product' && !empty($ctx['product'])) {
            $pog = $ctx['product'];
            if (isset($pog['price']) && $pog['price'] !== '' && $pog['price'] !== null) {
                echo '<meta property="product:price:amount" content="' . esc_attr($pog['price']) . '">' . "\n";
                if (!empty($pog['currency'])) {
                    echo '<meta property="product:price:currency" content="' . esc_attr($pog['currency']) . '">' . "\n";
                }
            }
            if (!empty($pog['availability'])) {
                echo '<meta property="product:availability" content="' . esc_attr($pog['availability']) . '">' . "\n";
            }
        }

        // Twitter Card
        echo '<meta name="twitter:card" content="' . ($ctx['image'] ? 'summary_large_image' : 'summary') . '">' . "\n";
        if ($tw_title_eff) echo '<meta name="twitter:title" content="' . esc_attr($tw_title_eff) . '">' . "\n";
        if ($tw_desc_eff)  echo '<meta name="twitter:description" content="' . esc_attr($tw_desc_eff) . '">' . "\n";
        if ($ctx['image']) echo '<meta name="twitter:image" content="' . esc_url($ctx['image']) . '">' . "\n";
    }

    /**
 * gather Open Graph product data from WooCommerce — price,
 * currency and availability for the product:* OG tags. Returns an empty
 * array when WooCommerce is unavailable or the product can't be loaded.
 */
    private static function get_product_og_data($post_id) {
        if (!function_exists('wc_get_product')) return [];
        $product = wc_get_product($post_id);
        if (!$product) return [];
        return [
            'price'        => $product->get_price(),
            'currency'     => function_exists('get_woocommerce_currency') ? get_woocommerce_currency() : get_option('woocommerce_currency', 'USD'),
            'availability' => $product->is_in_stock() ? 'instock' : 'outofstock',
        ];
    }

    /**
 * Resolve title / description / image / og:type for whatever view is
 * currently being rendered. Returns null for views we intentionally skip
 * (admin, feeds, attachments).
 */
    private function resolve_meta_context() {
        $templates     = RANKFORGE_Meta_Templates::instance();
        $default_image = get_option('rankforge_default_og_image', '');

        // Singular (posts, pages, CPTs)
        if (is_singular()) {
            if (is_attachment()) return null;
            $post_id = get_queried_object_id();
            // WooCommerce products advertise og:type=product (+ price
            // tags in output_meta_tags) so Facebook / Pinterest render product
            // cards instead of a generic article preview.
            $is_product = (get_post_type($post_id) === 'product') && function_exists('wc_get_product');
            $ctx = [
                'title'   => $templates->get_resolved_meta($post_id, 'title'),
                'desc'    => $templates->get_resolved_meta($post_id, 'description'),
                'image'   => self::resolve_social_image_url($post_id, 'full') ?: $default_image,
                'url'     => get_permalink($post_id),
                'og_type' => $is_product ? 'product' : (is_page() ? 'website' : 'article'),
            ];
            if ($is_product) {
                $ctx['product'] = self::get_product_og_data($post_id);
            }
            return $ctx;
        }

        // Home / front page — either blog index (show_on_front=posts) or
        // the "posts page" when a static front page is set.
        if (is_home() || is_front_page()) {
            $title = get_bloginfo('name');
            $tag   = get_bloginfo('description');
            /* translators: %s: site name */
            $desc  = $tag ?: sprintf(__('Latest posts from %s', 'rankforge'), $title);
            return [
                'title'   => $title,
                'desc'    => $desc,
                'image'   => $default_image,
                'url'     => home_url('/'),
                'og_type' => 'website',
            ];
        }

        // Term archives (category/tag/custom tax)
        if (is_category() || is_tag() || is_tax()) {
            $term = get_queried_object();
            if (!$term) return null;
            $override_title = get_term_meta($term->term_id, '_rankforge_meta_title', true);
            $override_desc  = get_term_meta($term->term_id, '_rankforge_meta_description', true);
            return [
                'title'   => $override_title ?: $term->name,
                'desc'    => $override_desc ?: ($term->description ? wp_strip_all_tags($term->description) : ''),
                'image'   => $default_image,
                'url'     => get_term_link($term),
                'og_type' => 'website',
            ];
        }

        // Post-type archive
        if (is_post_type_archive()) {
            $pt = get_queried_object();
            if (!$pt) return null;
            return [
                'title'   => post_type_archive_title('', false),
                /* translators: %s: post type label */
                'desc'    => !empty($pt->description) ? $pt->description : sprintf(__('Archive of %s', 'rankforge'), $pt->labels->name ?? ''),
                'image'   => $default_image,
                'url'     => get_post_type_archive_link($pt->name),
                'og_type' => 'website',
            ];
        }

        // Author / date archives
        if (is_author() || is_date()) {
            return [
                'title'   => wp_get_document_title(),
                /* translators: %s: site name */
                'desc'    => sprintf(__('Posts on %s', 'rankforge'), get_bloginfo('name')),
                'image'   => $default_image,
                'url'     => '',
                'og_type' => 'website',
            ];
        }

        // Search / 404 — emit minimal OG so social previews don't look broken.
        if (is_search() || is_404()) {
            return [
                'title'   => wp_get_document_title(),
                'desc'    => get_bloginfo('description'),
                'image'   => $default_image,
                'url'     => '',
                'og_type' => 'website',
            ];
        }

        return null;
    }

    /**
 * Normalise AI- or content-derived meta text: decode HTML entities (so a
 * literal "&nbsp;" can never reach the field), strip tags, collapse all
 * whitespace (incl. non-breaking space + newlines) to single spaces, drop
 * surrounding quotes the model sometimes adds, and remove any trailing
 * ellipsis. We deliberately do NOT truncate — length is enforced upstream
 * (the Worker re-prompts until the title/description fits), so cutting here
 * would only re-introduce the "…" the tester reported. Shared by the AI
 * path (RANKFORGE_AI_Client) and the non-AI fallbacks below.
 */
    public static function sanitize_meta_text($text) {
        $text = html_entity_decode((string) $text, ENT_QUOTES | ENT_HTML5, 'UTF-8');
        $text = wp_strip_all_tags($text);
        $text = preg_replace('/[\s\x{00A0}]+/u', ' ', $text);
        $text = trim($text, " \t\n\r\0\x0B\"'«»“”‘’");
        $text = preg_replace('/\s*(?:\.{3}|…)\s*$/u', '', (string) $text);
        return trim((string) $text);
    }

    private static function ucfirst_mb($s) {
        $s = (string) $s;
        if ($s === '') return $s;
        return mb_strtoupper(mb_substr($s, 0, 1)) . mb_substr($s, 1);
    }

    /**
 * Trim text to at most $max_chars on a word boundary (never mid-word,
 * never with an ellipsis). Used by the keyword-weave helpers so the woven
 * title/description still fits its SEO length budget.
 */
    private static function trim_to_words($text, $max_chars) {
        $text = trim((string) $text);
        $max_chars = (int) $max_chars;
        if ($max_chars <= 0) return '';
        if (mb_strlen($text) <= $max_chars) return $text;
        $cut = mb_substr($text, 0, $max_chars);
        if (preg_match('/^(.*)\s\S*$/us', $cut, $m) && trim($m[1]) !== '') {
            $cut = $m[1];
        }
        return rtrim($cut, " \t\n\r\0\x0B-–—:|,;");
    }

    /**
 * guarantee the focus keyword appears in a generated SEO title.
 *
 * The combined "keyword + title + description" generation is a paid action,
 * but when the Worker can't fit the keyword into a 50–60 char title it
 * returns success:false and the plugin falls back to a keyword-less title
 * ("Post Title | Site"). The analyzer then immediately flags "Focus keyword
 * not in title" and offers to fix it for more credits — exactly the
 * "charging to fix our own output" loop the tester reported. So when the
 * keyword is missing, weave it in deterministically: lead with the keyword
 * (also satisfies "keyword near the start") and append as much of the
 * original title as fits the 60-char budget on a word boundary. A title
 * that already contains the keyword (the AI-success case) is returned
 * unchanged. Uses the SAME presence test as the scorer so the guarantee and
 * the analyzer never disagree.
 */
    public static function ensure_keyword_in_title($title, $keyword) {
        $title   = (string) $title;
        $keyword = trim((string) $keyword);
        if ($keyword === '' || $title === '') return $title;
        if (!class_exists('RANKFORGE_Scorer')) {
            require_once __DIR__ . '/class-seo-scorer.php';
        }
        if (RANKFORGE_Scorer::keyword_present($keyword, $title)) return $title;

        $kw   = self::ucfirst_mb($keyword);
        $sep  = ' – ';
        $room = 60 - mb_strlen($kw . $sep);
        $tail = $room > 0 ? self::trim_to_words($title, $room) : '';
        $out  = $tail !== '' ? ($kw . $sep . $tail) : $kw;
        return self::sanitize_meta_text($out);
    }

    /**
 * same guarantee for the meta description. When the keyword is
 * absent, lead the snippet with it and keep the original description,
 * trimmed to the 160-char budget on a word boundary. No-op when the
 * description already contains the keyword.
 */
    public static function ensure_keyword_in_description($desc, $keyword) {
        $desc    = (string) $desc;
        $keyword = trim((string) $keyword);
        if ($keyword === '' || $desc === '') return $desc;
        if (!class_exists('RANKFORGE_Scorer')) {
            require_once __DIR__ . '/class-seo-scorer.php';
        }
        if (RANKFORGE_Scorer::keyword_present($keyword, $desc)) return $desc;

        $kw        = self::ucfirst_mb($keyword);
        $candidate = $kw . ': ' . $desc;
        if (mb_strlen($candidate) > 160) {
            $room      = 160 - mb_strlen($kw . ': ');
            $candidate = $kw . ': ' . self::trim_to_words($desc, max(0, $room));
        }
        return self::sanitize_meta_text($candidate);
    }

    /**
 * Auto-generate meta title from post title
 */
    public function generate_title($post_id) {
        // AI stub
        $ai = $this->generate_meta_ai($post_id, 'title');
        if ($ai) return $ai;

        $post = get_post($post_id);
        if (!$post) return '';

        $title = $post->post_title;
        $site = get_bloginfo('name');

        // No AI — use the post title (with the site name when it still fits).
        // Never truncate with an ellipsis; an over-length title is shown
        // honestly by the length meter rather than faked with "...".
        if (mb_strlen($title . ' | ' . $site) <= 60) {
            return self::sanitize_meta_text($title . ' | ' . $site);
        }
        return self::sanitize_meta_text($title);
    }

    /**
 * Auto-generate meta description from content
 */
    public function generate_description($post_id) {
        $ai = $this->generate_meta_ai($post_id, 'description');
        if ($ai) return $ai;

        $post = get_post($post_id);
        if (!$post) return '';

        // Use excerpt if available
        if (!empty($post->post_excerpt)) {
            return mb_substr(self::sanitize_meta_text($post->post_excerpt), 0, 160);
        }

        // Resolve content: page builders (Elementor/Beaver/Divi/Oxygen)
        // leave post_content empty and store their data in postmeta.
        // apply_filters('the_content', …) lets them render; fall back to
        // common builder metas if still empty, then title as last resort.
        $content = wp_strip_all_tags($post->post_content);
        if (trim($content) === '') {
            $content = wp_strip_all_tags((string) apply_filters('the_content', $post->post_content)); // phpcs:ignore WordPress.NamingConventions.PrefixAllGlobals -- intentional ACF/WPML/core compatibility
        }
        if (trim($content) === '') {
            foreach (['_elementor_data', '_fl_builder_data', 'et_pb_old_content', 'ct_builder_shortcodes'] as $mk) {
                $raw = get_post_meta($post_id, $mk, true);
                if (!empty($raw)) { $content = wp_strip_all_tags(is_array($raw) ? wp_json_encode($raw) : (string) $raw); break; }
            }
            // ACF Flexible / repeater sweep: collect any long postmeta
            // string values (editor / content blobs).
            if (trim($content) === '') {
                $chunks = [];
                $all = get_post_meta($post_id);
                foreach ($all as $k => $v) {
                    if (strpos($k, '_') === 0) continue;
                    $val = is_array($v) ? ($v[0] ?? '') : $v;
                    if (!is_string($val) || mb_strlen($val) < 40) continue;
                    if (preg_match('/^[aOs]:\d+:/', $val)) continue;
                    $chunks[] = wp_strip_all_tags($val);
                }
                if ($chunks) $content = implode("\n\n", array_slice($chunks, 0, 10));
            }
        }
        if (trim($content) === '') {
            $content = (string) $post->post_title;
        }

        // Normalise: decode entities (&nbsp; → space), strip tags, collapse
        // whitespace — so an empty first "<p>&nbsp;</p>" can't leak a literal
        // "&nbsp;" + newlines into the meta description.
        $content = self::sanitize_meta_text($content);

        // First sentences
        $sentences = preg_split('/[.!?]+/', $content, 4);
        $desc = '';
        foreach ($sentences as $s) {
            $s = trim($s);
            if (empty($s)) continue;
            if (mb_strlen($desc . $s . '. ') > 160) break;
            $desc .= $s . '. ';
        }

        return trim($desc) ?: mb_substr($content, 0, 160);
    }

    /**
 * AI stub for meta generation. RANKFORGE_AI_Client lives in the Pro add-on
 * (post-split) — on Free-only sites the class is absent and we skip the
 * AI path entirely, falling back to the deterministic title/excerpt
 * builder above.
 */
    private function generate_meta_ai($post_id, $type) {
        if (!class_exists('RANKFORGE_AI_Client')) return null;
        $ai = RANKFORGE_AI_Client::instance();
        if (!$ai->is_configured()) return null;
        return $ai->generate_meta($post_id, $type);
    }
}
