<?php

declare(strict_types=1);

namespace ProRank\SEO\Core\Content;

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

/**
 * Detects posts/pages whose stored content (Gutenberg blocks + Elementor data)
 * lacks an H1 heading.
 *
 * H1 absence is a Google "missing or empty H1" audit signal that hurts on-page
 * SEO. This auditor walks both content sources and reports which posts could
 * use a source-level H1 upgrade.
 */
class H1Auditor {

    /** Post statuses to consider. */
    private const STATUSES = ['publish', 'private', 'pending', 'future'];

    /**
     * Post types that are NOT standalone content pages — they're reusable
     * template parts, popups, email templates, or other infrastructure where
     * H1 belongs to the parent that embeds them. Skipped from H1 audits.
     */
    private const EXCLUDED_POST_TYPES = [
        'elementor_library',  // Elementor saved templates (header/footer/popup/section)
        'e-landing-page',     // (kept VIEWABLE but they're independent pages — left in to fix; comment for clarity)
        'mailpoet_page',      // MailPoet captive pages
        'wp_template',        // FSE templates
        'wp_template_part',   // FSE template parts
        'wp_block',           // Reusable blocks
        'attachment',         // Media
        'revision',           // Revisions
        'nav_menu_item',      // Menu items
    ];

    public function scan(): array {
        global $wpdb;

        $post_types = get_post_types([
            'public' => true,
        ], 'names');

        if (empty($post_types)) {
            $post_types = ['post', 'page'];
        }

        // Filter out template/library/infrastructure types via constant + filter hook.
        $excluded = apply_filters('prorank_h1_audit_excluded_post_types', self::EXCLUDED_POST_TYPES);
        $post_types = array_diff($post_types, $excluded);

        $type_placeholders = implode(',', array_fill(0, count($post_types), '%s'));
        $status_placeholders = implode(',', array_fill(0, count(self::STATUSES), '%s'));

        $params = array_merge(array_values($post_types), array_values(self::STATUSES));

        $sql = $wpdb->prepare(
            "SELECT ID, post_title, post_type, post_status, post_content
             FROM {$wpdb->posts}
             WHERE post_type IN ({$type_placeholders})
               AND post_status IN ({$status_placeholders})",
            $params
        );

        $rows = $wpdb->get_results($sql, ARRAY_A) ?: [];

        $missing = [];
        $by_type = [];

        foreach ($rows as $row) {
            $post_id = (int) $row['ID'];
            $diagnosis = $this->diagnose_post($post_id, (string) $row['post_content']);

            if ($diagnosis['has_h1']) {
                continue;
            }

            // Confidence model: ONLY Elementor stored data is authoritative —
            // Elementor renders exactly its tree, so a stored-content H1 miss
            // is a real public miss. Classic/Gutenberg posts are routinely
            // given an <h1>{the_title}</h1> by the THEME template (e.g. Neve),
            // so stored-content absence does NOT imply the live page is
            // missing an H1. Those are "low" and MUST pass a rendered-HTML
            // check before being treated as a real missing-H1. (Previously
            // classic_* were wrongly "high", which over-reported missing H1.)
            $needs_rendered_verification = $diagnosis['source'] !== 'elementor';
            $confidence = $needs_rendered_verification ? 'low' : 'high';

            $missing[] = [
                'id' => $post_id,
                'title' => (string) $row['post_title'],
                'type' => (string) $row['post_type'],
                'status' => (string) $row['post_status'],
                'source' => $diagnosis['source'],
                'first_heading' => $diagnosis['first_heading'],
                'fixable' => $diagnosis['fixable'],
                'reason' => $diagnosis['reason'],
                'confidence' => $confidence,
                'needs_rendered_verification' => $needs_rendered_verification,
                'bulk_excluded' => $this->is_bulk_excluded($post_id),
                'edit_link' => admin_url('post.php?post=' . $post_id . '&action=edit'),
                'permalink' => (string) get_permalink($post_id),
            ];

            $by_type[$row['post_type']] = ($by_type[$row['post_type']] ?? 0) + 1;
        }

        usort($missing, static function ($a, $b) {
            // High-confidence first, then fixable, then by title.
            $a_conf = $a['confidence'] === 'high' ? 0 : 1;
            $b_conf = $b['confidence'] === 'high' ? 0 : 1;
            if ($a_conf !== $b_conf) {
                return $a_conf - $b_conf;
            }
            if ($a['fixable'] !== $b['fixable']) {
                return $a['fixable'] ? -1 : 1;
            }
            return strcmp((string) $a['title'], (string) $b['title']);
        });

        $high = array_values(array_filter($missing, static fn($m) => $m['confidence'] === 'high'));
        $low = array_values(array_filter($missing, static fn($m) => $m['confidence'] === 'low'));

        return [
            'scanned_at' => current_time('mysql'),
            'total_scanned' => count($rows),
            // stored_missing = pages whose STORED content has no H1. This is
            // NOT the customer-facing "missing H1" number — most are rendered
            // by the theme. Use verify() for confirmed rendered misses.
            'stored_missing' => count($missing),
            'missing_count' => count($missing),
            'high_confidence_count' => count($high),
            'low_confidence_count' => count($low),
            'needs_verification_count' => count($low),
            'by_post_type' => $by_type,
            'missing' => $missing,
        ];
    }

    /**
     * Pages that should never be mass-repaired by default: WooCommerce
     * Cart/Checkout/My-Account utility pages and noindex-flagged content.
     * Reported (not hidden) but excluded from bulk unless explicitly included.
     */
    private function is_bulk_excluded(int $post_id): bool {
        if (function_exists('wc_get_page_id')) {
            foreach (['cart', 'checkout', 'myaccount'] as $wc_key) {
                $pid = (int) wc_get_page_id($wc_key);
                if ($pid > 0 && $pid === $post_id) {
                    return true;
                }
            }
        }

        // Common noindex signals across our plugin + Yoast + AIOSEO.
        $noindex_meta = [
            '_prorank_robots_noindex' => '1',
            '_yoast_wpseo_meta-robots-noindex' => '1',
            '_aioseo_robots_noindex' => '1',
        ];
        foreach ($noindex_meta as $key => $truthy) {
            $val = get_post_meta($post_id, $key, true);
            if ((string) $val === $truthy || $val === 1 || $val === true) {
                return true;
            }
        }

        return false;
    }

    /**
     * Bounded, cache-safe rendered-verification pass over the stored-missing
     * list. This is what produces the customer-facing buckets — only
     * `rendered_missing_confirmed` is a real, public missing-H1.
     *
     * @return array{
     *   scanned_at:string, total_scanned:int,
     *   buckets:array{stored_missing:int, examined:int,
     *     rendered_missing_confirmed:int, theme_renders_h1:int,
     *     inconclusive:int, bulk_excluded:int},
     *   items:array<int,array>
     * }
     */
    public function verify(int $limit = 50, bool $high_confidence_only = false): array {
        $limit = max(1, min(500, $limit));
        $scan = $this->scan();

        $buckets = [
            'stored_missing' => $scan['stored_missing'],
            'examined' => 0,
            'rendered_missing_confirmed' => 0,
            'theme_renders_h1' => 0,
            'inconclusive' => 0,
            'bulk_excluded' => 0,
            // Set after the loop: stored misses NOT rendered-checked this pass
            // (limit < stored_missing). While > 0 the confirmed count is a
            // lower bound, NOT the full-site number.
            'unverified_remaining' => 0,
            'complete' => false,
        ];
        $items = [];

        foreach ($scan['missing'] as $m) {
            if ($buckets['examined'] >= $limit) {
                break;
            }
            if ($high_confidence_only && ($m['confidence'] ?? 'low') !== 'high') {
                continue;
            }

            $buckets['examined']++;
            if (!empty($m['bulk_excluded'])) {
                $buckets['bulk_excluded']++;
            }

            $r = $this->verify_rendered((int) $m['id']);
            if (empty($r['ok'])) {
                $cls = 'inconclusive';
            } elseif (!empty($r['has_rendered_h1'])) {
                $cls = 'theme_renders_h1';
            } else {
                $cls = 'rendered_missing_confirmed';
            }
            $buckets[$cls]++;

            $items[] = array_merge($m, [
                'verification' => $cls,
                'http_status' => $r['http_status'] ?? 0,
            ]);
        }

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

        // Surface confirmed-missing first so the UI can headline them.
        usort($items, static function ($a, $b) {
            $rank = ['rendered_missing_confirmed' => 0, 'inconclusive' => 1, 'theme_renders_h1' => 2];
            $ra = $rank[$a['verification']] ?? 3;
            $rb = $rank[$b['verification']] ?? 3;
            if ($ra !== $rb) {
                return $ra - $rb;
            }
            return strcmp((string) $a['title'], (string) $b['title']);
        });

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

    /**
     * Verify a single post's missing-H1 claim by fetching its rendered HTML.
     * Used to upgrade a "low confidence" finding (Gutenberg post, theme may
     * inject H1) into a confirmed missing-H1 result.
     *
     * Cache-safe by design: it does NOT send a cache-busting header and keeps
     * TLS verification on, so bulk preview/repair cannot recreate the
     * "uncached slow page" problem. `ok` is false when the request failed or
     * returned a non-success status — callers must treat that as inconclusive
     * and refuse to commit unless the operator explicitly skips the check.
     *
     * @return array{has_rendered_h1: bool, http_status: int, ok: bool}
     */
    public function verify_rendered(int $post_id): array {
        $url = get_permalink($post_id);
        if (!$url) {
            return ['has_rendered_h1' => false, 'http_status' => 0, 'ok' => false];
        }

        $response = wp_remote_get($url, [
            'timeout' => 5,
            'redirection' => 3,
            // Keep TLS verification ON and do NOT bust the page cache.
            'limit_response_size' => 512 * 1024,
            'headers' => [
                'User-Agent' => 'ProRank-SEO/H1Auditor',
            ],
        ]);
        if (is_wp_error($response)) {
            return ['has_rendered_h1' => false, 'http_status' => 0, 'ok' => false];
        }

        $status = (int) wp_remote_retrieve_response_code($response);
        $body = (string) wp_remote_retrieve_body($response);
        $count = preg_match_all('/<h1\b/i', $body);
        $ok = $status >= 200 && $status < 400 && $body !== '';

        return [
            'has_rendered_h1' => $ok && $count !== false && $count > 0,
            'http_status' => $status,
            'ok' => $ok,
        ];
    }

    /**
     * Diagnose a single post for H1 presence.
     *
     * @return array{
     *     has_h1: bool,
     *     source: string,
     *     first_heading: ?array,
     *     fixable: bool,
     *     reason: string
     * }
     */
    public function diagnose_post(int $post_id, ?string $content = null): array {
        if ($content === null) {
            $post = get_post($post_id);
            if (!$post) {
                return [
                    'has_h1' => false,
                    'source' => 'unknown',
                    'first_heading' => null,
                    'fixable' => false,
                    'reason' => 'post_not_found',
                ];
            }
            $content = (string) $post->post_content;
        }

        $post_title = trim(wp_strip_all_tags((string) get_the_title($post_id)));

        // Elementor data is authoritative when present and non-empty. Some
        // migrated Elementor posts have edit_mode=builder but no Elementor
        // JSON; those still render post_content and should fall through.
        $elementor_data = get_post_meta($post_id, '_elementor_data', true);
        $elementor_edit_mode = get_post_meta($post_id, '_elementor_edit_mode', true);

        if (is_string($elementor_data) && $elementor_data !== '' && $elementor_edit_mode === 'builder') {
            $tree = json_decode($elementor_data, true);
            if (is_array($tree) && !empty($tree)) {
                $first_heading = $this->find_first_heading_in_elementor_tree($tree);
                $h1_widget = $this->find_first_elementor_h1_in_tree($tree);
                // Scope: the Ahrefs issue is "H1 missing or empty". If ANY H1
                // widget exists the page is NOT missing an H1 — whether that
                // H1 matches the post title is a separate, out-of-scope
                // concern and must never be pulled into missing-H1 bulk
                // repair. Such pages are reported has_h1 = true (reason
                // 'has_h1_not_title' for labelling) and excluded from scan().
                $has_h1 = $h1_widget !== null;

                if ($h1_widget !== null && !$this->is_elementor_title_candidate($h1_widget, $post_title)) {
                    return [
                        'has_h1' => true,
                        'source' => 'elementor',
                        'first_heading' => $h1_widget,
                        'fixable' => false,
                        'reason' => 'has_h1_not_title',
                    ];
                }

                if (!$has_h1 && $first_heading === null && $post_title !== '') {
                    $first_heading = [
                        'widget_id' => '',
                        'path' => [],
                        'header_size' => '',
                        'title' => $post_title,
                        'strategy' => 'insert_title_widget',
                        'widget_type' => 'heading',
                    ];
                }

                if (
                    !$has_h1
                    && $first_heading !== null
                    && ($first_heading['strategy'] ?? '') === 'upgrade_widget'
                    && !$this->is_elementor_title_candidate($first_heading, $post_title)
                    && $post_title !== ''
                ) {
                    $first_heading = [
                        'widget_id' => '',
                        'path' => [],
                        'header_size' => '',
                        'title' => $post_title,
                        'strategy' => 'insert_title_widget',
                        'widget_type' => 'heading',
                    ];
                }

                $fixable = !$has_h1 && $first_heading !== null;
                $reason = $has_h1 ? 'has_h1' : ($first_heading ? (string) ($first_heading['strategy'] ?? 'fixable') : 'no_heading_widget');

                return [
                    'has_h1' => $has_h1,
                    'source' => 'elementor',
                    'first_heading' => $first_heading,
                    'fixable' => $fixable,
                    'reason' => $reason,
                ];
            }
        }

        // Fall back to Gutenberg / classic content.
        $blocks = function_exists('parse_blocks') ? parse_blocks($content) : [];

        $has_h1 = false;
        $first_heading = null;

        foreach ($this->iterate_heading_blocks($blocks) as $heading_info) {
            $level = (int) $heading_info['level'];
            if ($level === 1) {
                $has_h1 = true;
                if ($first_heading === null) {
                    $first_heading = $heading_info;
                }
                break;
            }
            if ($first_heading === null) {
                $first_heading = $heading_info;
            }
        }

        // Classic HTML fallback — raw <h1> in non-block content.
        if (!$has_h1 && stripos($content, '<h1') !== false) {
            $has_h1 = true;
        }

        if ($has_h1) {
            return [
                'has_h1' => true,
                'source' => 'gutenberg',
                'first_heading' => $first_heading,
                'fixable' => false,
                'reason' => 'has_h1',
            ];
        }

        // Classic HTML fallback. If the page has H2/H3 content but no H1, the
        // safest source-level repair is to prepend the post title as a visible
        // H1 rather than promote a question/section heading.
        if (preg_match('/<h([2-6])\b[^>]*>(.*?)<\/h\1>/is', $content, $matches)) {
            $matched_text = trim(wp_strip_all_tags((string) ($matches[2] ?? '')));
            return [
                'has_h1' => false,
                'source' => 'classic_html',
                'first_heading' => [
                    'strategy' => 'prepend_title',
                    'level' => 1,
                    'matched_level' => 'h' . (int) ($matches[1] ?? 2),
                    'matched_text' => $matched_text,
                    'title' => $post_title,
                ],
                'fixable' => $post_title !== '',
                'reason' => $post_title !== '' ? 'prepend_title' : 'no_title',
            ];
        }

        if ($first_heading === null && $post_title !== '') {
            return [
                'has_h1' => false,
                'source' => 'classic_content',
                'first_heading' => [
                    'strategy' => 'prepend_title',
                    'level' => 1,
                    'title' => $post_title,
                ],
                'fixable' => true,
                'reason' => 'prepend_title',
            ];
        }

        $fixable = !$has_h1 && $first_heading !== null;
        $reason = $has_h1 ? 'has_h1' : ($first_heading ? 'fixable' : 'no_heading_block');

        return [
            'has_h1' => $has_h1,
            'source' => 'gutenberg',
            'first_heading' => $first_heading,
            'fixable' => $fixable,
            'reason' => $reason,
        ];
    }

    /**
     * Walk Elementor tree depth-first and return the first heading widget.
     *
     * @param array $tree
     * @param array $path Reference path to the widget (e.g. [section_index, column_index, widget_index]).
     */
    public function find_first_heading_in_elementor_tree(array $tree, array $path = []): ?array {
        $title_widget = $this->find_first_elementor_widget_by_type($tree, ['theme-post-title', 'post-title', 'page-title', 'archive-title'], $path);
        if ($title_widget !== null) {
            return $title_widget;
        }

        foreach ($tree as $i => $node) {
            $current_path = array_merge($path, [$i]);
            if (!is_array($node)) {
                continue;
            }

            if (
                ($node['elType'] ?? '') === 'widget'
                && ($node['widgetType'] ?? '') === 'heading'
            ) {
                $settings = is_array($node['settings'] ?? null) ? $node['settings'] : [];
                $header_size = isset($settings['header_size']) ? strtolower((string) $settings['header_size']) : 'h2';
                $title = isset($settings['title']) ? (string) $settings['title'] : '';
                return [
                    'widget_id' => (string) ($node['id'] ?? ''),
                    'path' => $current_path,
                    'header_size' => $header_size,
                    'title' => $title,
                    'strategy' => 'upgrade_widget',
                    'widget_type' => 'heading',
                    'dynamic' => is_array($settings['__dynamic__'] ?? null) ? $settings['__dynamic__'] : [],
                ];
            }

            if (!empty($node['elements']) && is_array($node['elements'])) {
                $found = $this->find_first_heading_in_elementor_tree($node['elements'], $current_path);
                if ($found !== null) {
                    return $found;
                }
            }
        }

        return null;
    }

    /**
     * Find the first Elementor widget whose widgetType matches one of $types.
     *
     * @param array<int|string,mixed> $tree
     * @param array<int,string>       $types
     * @param array<int,int|string>   $path
     */
    private function find_first_elementor_widget_by_type(array $tree, array $types, array $path = []): ?array {
        foreach ($tree as $i => $node) {
            $current_path = array_merge($path, [$i]);
            if (!is_array($node)) {
                continue;
            }

            if (($node['elType'] ?? '') === 'widget') {
                $widget_type = (string) ($node['widgetType'] ?? '');
                if (in_array($widget_type, $types, true)) {
                    $settings = is_array($node['settings'] ?? null) ? $node['settings'] : [];
                    $header_size = isset($settings['header_size']) ? strtolower((string) $settings['header_size']) : 'h2';
                    $title = isset($settings['title']) ? (string) $settings['title'] : '';
                    return [
                        'widget_id' => (string) ($node['id'] ?? ''),
                        'path' => $current_path,
                        'header_size' => $header_size,
                        'title' => $title,
                        'strategy' => 'upgrade_widget',
                        'widget_type' => $widget_type,
                        'dynamic' => is_array($settings['__dynamic__'] ?? null) ? $settings['__dynamic__'] : [],
                    ];
                }
            }

            if (!empty($node['elements']) && is_array($node['elements'])) {
                $found = $this->find_first_elementor_widget_by_type($node['elements'], $types, $current_path);
                if ($found !== null) {
                    return $found;
                }
            }
        }

        return null;
    }

    public function elementor_tree_has_h1(array $tree): bool {
        return $this->find_first_elementor_h1_in_tree($tree) !== null;
    }

    private function find_first_elementor_h1_in_tree(array $tree, array $path = []): ?array {
        foreach ($tree as $i => $node) {
            $current_path = array_merge($path, [$i]);
            if (!is_array($node)) {
                continue;
            }

            if (
                ($node['elType'] ?? '') === 'widget'
                && in_array((string) ($node['widgetType'] ?? ''), ['heading', 'theme-post-title', 'post-title', 'page-title', 'archive-title'], true)
            ) {
                $settings = is_array($node['settings'] ?? null) ? $node['settings'] : [];
                $header_size = isset($settings['header_size']) ? strtolower((string) $settings['header_size']) : 'h2';
                if ($header_size === 'h1') {
                    return [
                        'widget_id' => (string) ($node['id'] ?? ''),
                        'path' => $current_path,
                        'header_size' => $header_size,
                        'title' => isset($settings['title']) ? (string) $settings['title'] : '',
                        'strategy' => 'upgrade_widget',
                        'widget_type' => (string) ($node['widgetType'] ?? ''),
                        'dynamic' => is_array($settings['__dynamic__'] ?? null) ? $settings['__dynamic__'] : [],
                    ];
                }
            }

            if (!empty($node['elements']) && is_array($node['elements'])) {
                $found = $this->find_first_elementor_h1_in_tree($node['elements'], $current_path);
                if ($found !== null) {
                    return $found;
                }
            }
        }

        return null;
    }

    private function is_elementor_title_candidate(array $heading, string $post_title): bool {
        $dynamic = $heading['dynamic'] ?? null;
        if (is_array($dynamic)) {
            $dynamic_json = wp_json_encode($dynamic);
            if (is_string($dynamic_json) && preg_match('/\\b(post-title|page-title|archive-title)\\b/i', $dynamic_json)) {
                return true;
            }
        }

        $title = trim(wp_strip_all_tags((string) ($heading['title'] ?? '')));
        $normalized_title = $this->normalize_heading_text($title);
        $normalized_post_title = $this->normalize_heading_text($post_title);

        if ($normalized_title === '' || $normalized_title === 'add your heading text here') {
            return true;
        }

        return $normalized_title !== '' && $normalized_title === $normalized_post_title;
    }

    private function normalize_heading_text(string $text): string {
        $text = strtolower(trim(wp_strip_all_tags($text)));
        $text = html_entity_decode($text, ENT_QUOTES | ENT_HTML5, get_bloginfo('charset') ?: 'UTF-8');
        $text = preg_replace('/[^a-z0-9]+/', ' ', $text);
        return trim((string) $text);
    }

    /**
     * Generator over heading blocks (depth-first into innerBlocks).
     *
     * @param array $blocks
     * @return \Generator
     */
    private function iterate_heading_blocks(array $blocks) {
        foreach ($blocks as $index => $block) {
            if (!is_array($block)) {
                continue;
            }

            $name = (string) ($block['blockName'] ?? '');
            if ($name === 'core/heading') {
                $attrs = is_array($block['attrs'] ?? null) ? $block['attrs'] : [];
                $level = isset($attrs['level']) ? (int) $attrs['level'] : 2;
                yield [
                    'level' => $level,
                    'index' => $index,
                    'text' => isset($block['innerHTML']) ? wp_strip_all_tags((string) $block['innerHTML']) : '',
                ];
            }

            if (!empty($block['innerBlocks']) && is_array($block['innerBlocks'])) {
                yield from $this->iterate_heading_blocks($block['innerBlocks']);
            }
        }
    }
}
