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

class SEOFORGE_Core {
    private static $instance = null;

    /** @var array|null Snapshot of $submenu['seoforge'] taken after our pages
 * register, restored late if the Freemius SDK menu integration wipes it. */
    private $submenu_snapshot = null;

    public static function instance() {
        if (self::$instance === null) self::$instance = new self();
        return self::$instance;
    }

    private function __construct() {
        add_action('admin_menu', [$this, 'admin_menu']);
        // Late hook to reorder sidebar AFTER Freemius adds License /
        // Contact Us / Account / Affiliation. Freemius hooks run with
        // an internal priority that's still ahead of PHP_INT_MAX, but
        // it lives in the SDK so we can't pin it. Use the largest
        // possible int and a fallback hook on admin_head to catch any
        // late-added items.
        add_action('admin_menu', [$this, 'reorder_submenu'], PHP_INT_MAX);
        // SF: the Freemius SDK menu integration (slug => 'seoforge', loaded by
        // seoforge-pro) wipes $submenu['seoforge'] at its own high admin_menu
        // priority. On multi-Forge-plugin sites this left every SEO Forge
        // sub-page (Settings / Analytics / Content / Tools hubs) returning
        // "Sorry, you are not allowed to access this page" — get_admin_page_parent
        // could no longer resolve the parent once the submenu array was gone.
        // The pages are still in $_registered_pages, so we snapshot the submenu
        // right after our own pages register (core/migration/license all hook
        // admin_menu at <= 99) and restore it late if it was wiped.
        add_action('admin_menu', [$this, 'snapshot_submenu'], 100);
        add_action('admin_menu', [$this, 'restore_submenu'], PHP_INT_MAX - 1);
        add_action('admin_head', [$this, 'reorder_submenu'], 1);
        // CSS-hide the Performance / Content sub-pages from the sidebar.
        // They're registered with a real parent_slug so
        // get_admin_page_parent can resolve them to seoforge — that's
        // what keeps the sidebar open + parent highlighted on every
        // sub-page. parent_file filter approach was a dead end:
        // get_admin_page_parent runs *after* the filter and unconditionally
        // overwrites the global with its own scan of $submenu, which
        // doesn't include parent_slug=null pages.
        add_action('admin_head', [$this, 'hide_grouped_sidebar_items']);
        add_filter('submenu_file', [$this, 'highlight_group_parent']);
        add_action('admin_enqueue_scripts', [$this, 'enqueue_assets']);
        add_action('enqueue_block_editor_assets', [$this, 'enqueue_gutenberg']);
        add_filter('admin_body_class', [$this, 'add_body_class']);
        // branded header (logo + brand gradient) at the top of every
        // SEO Forge admin screen.
        add_action('in_admin_header', [$this, 'render_branded_header']);
        add_action('wp_ajax_seoforge_scan_broken_links', [$this, 'ajax_start_scan']);
        add_action('seoforge_cron_scan_links', [$this, 'cron_scan_broken_links']);

        // clear any stale broken-links cache that contains doubled paths
        // (results stored before the URL-resolution bug was fixed).
        $this->maybe_clear_doubled_url_cache();
    }

    /**
 * Delete the broken-links transient if it contains any URLs with a doubled home path.
 * Runs once on each admin page load until the cache is clean or re-scanned.
 */
    private function maybe_clear_doubled_url_cache() {
        $home_path = wp_parse_url(home_url(), PHP_URL_PATH);
        if (!$home_path || $home_path === '/') {
            return; // no sub-directory install, doubling cannot occur
        }
        $cached = get_transient('seoforge_broken_links_cache');
        if (!is_array($cached) || empty($cached)) {
            return;
        }
        $doubled = $home_path . $home_path; // e.g. '/subdir/subdir' for subdirectory installs
        foreach ($cached as $entry) {
            if (!empty($entry['url']) && strpos($entry['url'], $doubled) !== false) {
                delete_transient('seoforge_broken_links_cache');
                return;
            }
        }
    }

    private static function can_scan_broken_links() {
        return class_exists('SEOFORGE_License') && SEOFORGE_License::can('internal_links');
    }

    private static function broken_links_pro_error() {
        return new WP_Error(
            'pro_required',
            __('Broken-link scanning requires SEO Forge Pro.', 'seoforge'),
            ['status' => 403]
        );
    }

    /**
 * AJAX: Start background broken links scan via Forge Queue
 */
    public function ajax_start_scan() {
        check_ajax_referer('seoforge_nonce', 'nonce');
        if (!current_user_can('manage_options')) wp_send_json_error();
        if (!self::can_scan_broken_links()) {
            wp_send_json_error(['message' => __('Broken-link scanning requires SEO Forge Pro.', 'seoforge')], 403);
            return;
        }

        // Mark scan as in progress
        update_option('seoforge_broken_links_scanning', true);
        delete_transient('seoforge_broken_links_cache');

        // Gather post IDs and dispatch in batches of 10
        $post_ids = get_posts([
            'post_type'      => 'any',
            'post_status'    => 'publish',
            'posts_per_page' => 200,
            'fields'         => 'ids',
        ]);

        $batches = array_chunk($post_ids, 10);
        $total_batches = count($batches);
        update_option('seoforge_broken_links_batch_total', $total_batches);
        update_option('seoforge_broken_links_batch_done', 0);

        foreach ($batches as $batch) {
            Forge_Queue::dispatch('scan_links_batch', [
                'post_ids'      => $batch,
                'total_batches' => $total_batches,
            ], 'seoforge');
        }

        wp_send_json_success(['message' => __('Scan started in background. You can leave this page.', 'seoforge')]);
    }

    /**
 * WP Cron fallback: Scan all posts for broken links (dispatches via queue)
 */
    public function cron_scan_broken_links() {
        if (!self::can_scan_broken_links()) {
            return false;
        }
        // Delegate to the same queue-based flow
        $_POST['nonce'] = wp_create_nonce('seoforge_nonce');
        $this->ajax_start_scan_internal();
    }

    /**
 * Internal scan dispatcher (no AJAX checks, used by cron fallback)
 */
    private function ajax_start_scan_internal() {
        if (!self::can_scan_broken_links()) {
            return false;
        }
        update_option('seoforge_broken_links_scanning', true);
        delete_transient('seoforge_broken_links_cache');

        $post_ids = get_posts([
            'post_type'      => 'any',
            'post_status'    => 'publish',
            'posts_per_page' => 200,
            'fields'         => 'ids',
        ]);

        $batches = array_chunk($post_ids, 10);
        $total_batches = count($batches);
        update_option('seoforge_broken_links_batch_total', $total_batches);
        update_option('seoforge_broken_links_batch_done', 0);

        foreach ($batches as $batch) {
            Forge_Queue::dispatch('scan_links_batch', [
                'post_ids'      => $batch,
                'total_batches' => $total_batches,
            ], 'seoforge');
        }
    }

    /**
 * Boolean SSRF gate kept for back-compat. Internally delegates to
 * resolve_safe_public_url and discards the resolved IP — the gate
 * is now closed (returns false on DNS-lookup failure instead of
 * fail-open). New callers should prefer fetch_safe_remote_head so
 * the gate and the request use the same resolved IP and DNS
 * rebinding can't reopen the window between check and use.
 */
    public static function is_safe_public_url($url) {
        return self::resolve_safe_public_url($url) !== false;
    }

    /**
 * Resolve $url's host to a single IP and verify that IP is on the
 * public Internet. Returns the resolved IPv4 address on success,
 * false on any failure (no host, DNS lookup failed, IP in private/
 * reserved range, or IPv6 — which gethostbyname can't represent).
 *
 * The resolved IP is the value the caller must pin the actual HTTP
 * request to so a re-resolve in the request stack can't be steered
 * to a different (potentially internal) address.
 */
    public static function resolve_safe_public_url($url) {
        $host = wp_parse_url($url, PHP_URL_HOST);
        if (!$host) return false;

        // Already an IP literal — validate directly.
        if (filter_var($host, FILTER_VALIDATE_IP)) {
            return self::is_public_ip($host) ? $host : false;
        }

        // gethostbyname returns the host string unchanged on failure —
        // that used to mean "fail open" here. Treat it as fail-closed.
        $ip = gethostbyname($host);
        if ($ip === $host || !filter_var($ip, FILTER_VALIDATE_IP)) {
            return false;
        }

        return self::is_public_ip($ip) ? $ip : false;
    }

    /**
 * Bare wrapper around the FILTER_FLAG_NO_PRIV_RANGE +
 * FILTER_FLAG_NO_RES_RANGE check — public IP iff both flags pass.
 * Extracted so we can call it on a raw IP without re-parsing a URL.
 */
    public static function is_public_ip($ip) {
        return filter_var(
            $ip,
            FILTER_VALIDATE_IP,
            FILTER_FLAG_NO_PRIV_RANGE | FILTER_FLAG_NO_RES_RANGE
        ) !== false;
    }

    /**
 * SSRF-hardened HEAD request used by the broken-link scanner. We pin
 * the connection to the IP we just resolved, then send the original
 * Host header so the remote vhost still routes correctly. Closes the
 * DNS rebinding window between the gate and the actual request.
 *
 * sslverify is intentionally false: pinning to an IP with a Host
 * header from the original URL would otherwise fail SNI on every
 * https target (the certificate's CN/SAN won't match an IP literal).
 * This is acceptable here because we only use this helper for the
 * internal broken-link scanner — we surface a status code, never
 * return body content to the user, so a MITM can at worst lie about
 * whether a link is alive. We do NOT use this helper for any
 * security-sensitive fetch (license validation, OAuth, etc.).
 *
 * @param string $url The original URL the user's content references.
 * @param array $extra wp_remote_head args merged on top of defaults.
 * @return array|WP_Error wp_remote_head return value (or WP_Error
 * if the host fails the SSRF gate).
 */
    public static function fetch_safe_remote_head($url, $extra = []) {
        $ip = self::resolve_safe_public_url($url);
        if ($ip === false) {
            return new WP_Error(
                'seoforge_ssrf_blocked',
                __('Refused to fetch a non-public host.', 'seoforge')
            );
        }

        $parts = wp_parse_url($url);
        if (!$parts || empty($parts['host']) || empty($parts['scheme'])) {
            return new WP_Error('seoforge_ssrf_invalid_url', __('Malformed URL.', 'seoforge'));
        }

        // Rebuild the URL targeting the resolved IP. Path + query +
        // fragment carry over verbatim. Port stays explicit only if the
        // original URL specified one.
        $port_segment = !empty($parts['port']) ? ':' . (int) $parts['port'] : '';
        $path_segment = $parts['path'] ?? '/';
        $query_segment = isset($parts['query']) ? '?' . $parts['query'] : '';
        $pinned_url = sprintf(
            '%s://%s%s%s%s',
            $parts['scheme'],
            $ip,
            $port_segment,
            $path_segment,
            $query_segment
        );

        $args = array_merge([
            'timeout'     => 2,
            'redirection' => 0, // any redirect would dodge the IP pin
            'sslverify'   => false, // see docblock — IP-pinned requests cannot pass SNI
            'headers'     => [],
        ], $extra);

        // Force the Host header to the original hostname so the remote
        // vhost serves the right document. Don't let $extra override it.
        $args['headers']['Host'] = $parts['host'];

        return wp_remote_head($pinned_url, $args);
    }

    /**
 * Forge Queue handler: process a batch of post IDs for broken links
 *
 * @param array $payload {post_ids: int, total_batches: int}
 * @return true|WP_Error
 */
    public static function process_scan_batch($payload) {
        if (!self::can_scan_broken_links()) {
            return self::broken_links_pro_error();
        }

        $post_ids      = $payload['post_ids'] ?? [];
        $total_batches = $payload['total_batches'] ?? 1;
        $url_map       = []; // url => [ ['id' => post_id, 'title' => title], ... ]

        foreach ($post_ids as $post_id) {
            $post = get_post($post_id);
            if (!$post || empty($post->post_content)) continue;

            preg_match_all('/<a\s+[^>]*href=["\']([^"\']+)["\'][^>]*>/i', $post->post_content, $matches);
            if (empty($matches[1])) continue;

            foreach (array_unique($matches[1]) as $url) {
                if (strpos($url, '#') === 0 || strpos($url, 'mailto:') === 0 || strpos($url, 'tel:') === 0) continue;
                if (strpos($url, 'http') !== 0) {
                    // Resolve relative URL to absolute, avoiding path doubling on subdirectory installs.
                    $home_path = wp_parse_url(home_url(), PHP_URL_PATH);
                    $url = '/' . ltrim($url, '/');
                    if ($home_path && $home_path !== '/' && strpos($url, $home_path) === 0) {
                        $url = home_url(substr($url, strlen($home_path)));
                    } else {
                        $url = home_url($url);
                    }
                }
                if (!isset($url_map[$url])) $url_map[$url] = [];
                $url_map[$url][] = ['id' => $post->ID, 'title' => $post->post_title];
            }
        }

        // Check each URL in this batch. fetch_safe_remote_head pins
        // the connection to the resolved IP so DNS rebinding can't
        // steer us at an internal host between the SSRF gate and the
        // request.
        $broken = [];
        foreach ($url_map as $url => $post_entries) {
            $response = self::fetch_safe_remote_head($url);
            if (is_wp_error($response) && $response->get_error_code() === 'seoforge_ssrf_blocked') {
                continue; // gate blocked — treat as not-checked
            }
            $code = is_wp_error($response) ? 0 : wp_remote_retrieve_response_code($response);

            if ($code === 0 || $code >= 400) {
                $seen = [];
                foreach ($post_entries as $entry) {
                    if (isset($seen[$entry['id']])) continue;
                    $seen[$entry['id']] = true;
                    $broken[] = ['post' => $entry['title'], 'post_id' => $entry['id'], 'url' => $url, 'status' => $code ?: 'Error'];
                }
            }
        }

        // Merge batch results into the transient
        $existing = get_transient('seoforge_broken_links_cache');
        if (!is_array($existing)) $existing = [];
        $merged = array_merge($existing, $broken);
        set_transient('seoforge_broken_links_cache', $merged, DAY_IN_SECONDS);

        // Track batch completion
        $done = (int) get_option('seoforge_broken_links_batch_done', 0) + 1;
        update_option('seoforge_broken_links_batch_done', $done);

        // If all batches are done, finalise
        if ($done >= $total_batches) {
            update_option('seoforge_broken_links_last_scan', time());
            update_option('seoforge_broken_links_total_checked', count(
                get_transient('seoforge_broken_links_cache') ?: []
            ));
            delete_option('seoforge_broken_links_scanning');
            delete_option('seoforge_broken_links_batch_total');
            delete_option('seoforge_broken_links_batch_done');
        }

        return true;
    }

    /**
 * Render the branded SEO Forge header strip on every plugin admin
 * screen. Uses the real Forge brand mark from assets/logo.svg.
 */
    public function render_branded_header() {
        $screen = get_current_screen();
        if (!$screen || strpos($screen->id, 'seoforge') === false) return;

        $version = defined('SEOFORGE_VERSION') ? SEOFORGE_VERSION : '';
        $logo    = SEOFORGE_URL . 'assets/logo.svg';
        ?>
        <div class="sf-brand-header">
            <div class="sf-brand-header__inner">
                <img class="sf-brand-header__logo" src="<?php echo esc_url($logo); ?>" alt="SEO Forge" width="40" height="40">
                <div class="sf-brand-header__text">
                    <div class="sf-brand-header__title">SEO Forge</div>
                    <div class="sf-brand-header__tagline"><?php esc_html_e('SEO that works out of the box.', 'seoforge'); ?></div>
                </div>
                <?php if ($version): ?>
                <div class="sf-brand-header__version">v<?php echo esc_html($version); ?></div>
                <?php endif; ?>
                <?php if (class_exists('Forge_Credits_Widget')) { Forge_Credits_Widget::render_chip_in_header(); } ?>
            </div>
        </div>
        <?php
    }

    public function add_body_class($classes) {
        $screen = get_current_screen();
        if ($screen && (strpos($screen->id, 'seoforge') !== false)) {
            $classes .= ' forge-admin sf-admin';
        }
        if ($screen && strpos($screen->id, 'forge-suite') !== false) {
            $classes .= ' forge-admin';
        }
        return $classes;
    }

    public function admin_menu() {
        // Forge Suite: unified dashboard
        if (class_exists('Forge_Suite_Dashboard')) {
            Forge_Suite_Dashboard::maybe_register_menu();
        }

        add_menu_page(__('SEO Forge', 'seoforge'), __('SEO Forge', 'seoforge'), 'manage_options', 'seoforge', [SEOFORGE_Dashboard::instance(), 'render'], 'dashicons-chart-line', 29);
        // PRO pages — show locked page on free plan. Callbacks for Pro-only
        // implementation classes (SEOFORGE_Redirects, SEOFORGE_Analytics, ...)
        // are passed as closures so the underlying instance static call
        // doesn't fire during admin_menu when Pro is not installed.
        // Gate per-capability, NOT per-plan: a feature that ships in the Free
        // plugin (listed in SEOFORGE_License::FREE_FEATURES — e.g. redirects,
        // local_seo) must open even when the site has no Pro add-on. Asking
        // can($feature) instead of is_free makes every FREE_FEATURES entry
        // unlock its menu page automatically, while genuine Pro features
        // (gsc, site_audit, content_brief, …) still fall through to the
        // upgrade screen on a Free-only install.
        $pro_gate = function ($feature, $callback) {
            return function () use ($feature, $callback) {
                if (SEOFORGE_License::can($feature)) {
                    call_user_func($callback);
                } else {
                    SEOFORGE_Upgrade_Prompt::instance()->locked_page($feature);
                }
            };
        };

        // sidebar restructure (2026-05-05). Replaces the previous
        // 4 visible parents (Dashboard / Performance / Content /
        // Redirects) with the hub model the tester requested:
        
        // 1 Dashboard — standalone
        // 2 Analytics — hub (sub-tabs: Performance · Analytics & Traffic · Indexing)
        // 3 Content — hub (sub-tabs: Optimizer · Content Brief · Content Decay)
        // 4 Tools — hub (sub-tabs: Redirects · Broken Links · Local SEO)
        // 5 Migration — standalone (registered in class-migration.php)
        // 6 Settings — standalone
        // 7 License — Freemius
        // 8 Contact Us — Freemius
        
        // Hub parents are NEW slugs (`seoforge-analytics-hub`,
        // `seoforge-content-hub`, `seoforge-tools-hub`) that route to
        // the first sub-tab via render_hub. Existing slugs
        // (`seoforge-rankings`, `seoforge-audit`, `seoforge-decay`,
        // `seoforge-analytics`, `seoforge-brief`, `seoforge-broken`,
        // `seoforge-local`, `seoforge-redirects`) stay registered so
        // deep links keep working — they're hidden from the sidebar
        // by `hide_grouped_sidebar_items` and reached only through
        // the in-page hub tab strip.
        // the Analytics and Content hubs are 100% Pro features, so
        // flag them in the sidebar with a PRO chip for Free users (no chip once
        // the gating capability is available). Tools is mixed (Redirects/Local
        // SEO are Free) so it stays unbadged. The chip is appended to the
        // MENU title only — the page title stays clean.
        $pro_chip = ' <span style="display:inline-block;background:#fff4e5;color:#b45309;border:1px solid #fcd9a8;border-radius:3px;font-size:9px;font-weight:700;letter-spacing:.4px;line-height:1.5;padding:0 4px;vertical-align:middle;text-transform:uppercase;">PRO</span>';
        $analytics_menu = __('Analytics', 'seoforge') . (SEOFORGE_License::can('gsc')        ? '' : $pro_chip);
        $content_menu   = __('Content', 'seoforge')   . (SEOFORGE_License::can('site_audit') ? '' : $pro_chip);
        add_submenu_page('seoforge', __('Dashboard', 'seoforge'),  __('Dashboard', 'seoforge'),  'manage_options', 'seoforge',                  [SEOFORGE_Dashboard::instance(), 'render'], 1);
        add_submenu_page('seoforge', __('Analytics', 'seoforge'),  $analytics_menu,              'manage_options', 'seoforge-analytics-hub',    $pro_gate('gsc',         [$this, 'render_hub_analytics']), 2);
        add_submenu_page('seoforge', __('Content', 'seoforge'),    $content_menu,                'manage_options', 'seoforge-content-hub',      $pro_gate('site_audit',  [$this, 'render_hub_content']),   3);
        add_submenu_page('seoforge', __('Tools', 'seoforge'),      __('Tools', 'seoforge'),      'manage_options', 'seoforge-tools-hub',        $pro_gate('redirects',   [$this, 'render_hub_tools']),     4);
        add_submenu_page('seoforge', __('Settings', 'seoforge'),   __('Settings', 'seoforge'),   'manage_options', 'seoforge-settings',         [SEOFORGE_Admin::instance(), 'render_settings'], 99);

        // Sub-tab pages — registered with a real parent_slug so
        // get_admin_page_parent finds them in $submenu['seoforge'] and
        // the SEO Forge top-level stays highlighted on every page.
        // Visibility is hidden via `hide_grouped_sidebar_items` below.
        add_submenu_page('seoforge', __('Performance', 'seoforge'),         __('Performance', 'seoforge'),         'manage_options', 'seoforge-rankings',  $pro_gate('gsc',            [$this, 'render_rankings_page']));
        add_submenu_page('seoforge', __('Optimizer', 'seoforge'),           __('Optimizer', 'seoforge'),           'manage_options', 'seoforge-audit',     $pro_gate('site_audit',     [$this, 'render_audit_page']));
        add_submenu_page('seoforge', __('Redirects', 'seoforge'),           __('Redirects', 'seoforge'),           'manage_options', 'seoforge-redirects', $pro_gate('redirects',      function () { if (class_exists('SEOFORGE_Redirects')) SEOFORGE_Redirects::instance()->render_page(); }));
        add_submenu_page('seoforge', __('Content Decay', 'seoforge'),       __('Content Decay', 'seoforge'),       'manage_options', 'seoforge-decay',     $pro_gate('gsc',            [$this, 'render_decay_page']));
        add_submenu_page('seoforge', __('Analytics & Traffic', 'seoforge'), __('Analytics & Traffic', 'seoforge'), 'manage_options', 'seoforge-analytics', $pro_gate('gsc',            function () { if (class_exists('SEOFORGE_Analytics')) SEOFORGE_Analytics::instance()->render_page(); }));
        add_submenu_page('seoforge', __('Content Brief', 'seoforge'),       __('Content Brief', 'seoforge'),       'manage_options', 'seoforge-brief',     $pro_gate('content_brief',  [$this, 'render_brief_page']));
        add_submenu_page('seoforge', __('Broken Links', 'seoforge'),        __('Broken Links', 'seoforge'),        'manage_options', 'seoforge-broken',    $pro_gate('internal_links', [$this, 'render_broken_links_page']));
        add_submenu_page('seoforge', __('Local SEO', 'seoforge'),           __('Local SEO', 'seoforge'),           'manage_options', 'seoforge-local',     $pro_gate('local_seo',      [$this, 'render_local_page']));

        // Indexing (Google Indexing API) is Pro. The real page is registered by
        // SEOFORGE_Indexing (in seoforge-pro) at admin_menu priority 20. On a
        // Free-only site that class is absent, so the Indexing tab in the
        // Analytics hub would 404 with WP's "Sorry, you are not allowed to
        // access this page." Register a gated placeholder here ONLY when Pro
        // isn't loaded, so Free shows the upgrade screen like every other Pro
        // page instead of a hard WP error. (2026-05-29.)
        if (!class_exists('SEOFORGE_Indexing')) {
            add_submenu_page('seoforge', __('Indexing', 'seoforge'), __('Indexing', 'seoforge'), 'manage_options', 'seoforge-indexing', $pro_gate('gsc', function () {}));
        }
    }

    /**
 * hub-parent pages dispatch to the first sub-tab's
 * renderer so the user lands on a real page (not an empty hub)
 * when they click "Analytics" / "Content" / "Tools" in the WP
 * sidebar. The tab strip across the top still shows ONLY this
 * hub's siblings (rendered by render_*_page via the existing
 * `SEOFORGE_Admin_Tabs::render` call).
 */
    public function render_hub_analytics() {
        // Performance is the canonical landing for the Analytics hub.
        $this->render_rankings_page();
    }
    public function render_hub_content() {
        // Optimizer (formerly the standalone "Content" page / audit).
        $this->render_audit_page();
    }
    public function render_hub_tools() {
        // Redirects is the canonical landing for the Tools hub. On Free-only
        // sites the Pro class isn't loaded — fall through to upgrade prompt.
        if (class_exists('SEOFORGE_Redirects')) {
            SEOFORGE_Redirects::instance()->render_page();
        } else {
            SEOFORGE_Upgrade_Prompt::instance()->locked_page('redirects');
        }
    }

    /**
 * Hide the Performance / Content sub-pages from the WP sidebar via
 * CSS. They stay registered in $submenu so get_admin_page_parent
 * resolves their parent to "seoforge" and the entire SEO Forge
 * submenu stays expanded with the parent highlighted across every
 * page in the two groups. Indexing's own registration uses the same
 * pattern.
 */
    public function hide_grouped_sidebar_items() {
        // hide every sub-tab slug from the sidebar. They stay
        // registered (so deep links + parent highlighting work) and are
        // reached only through the hub's top-tab strip.
        $hidden = [
            // Sub-tabs (each routed through one of the 3 hubs).
            'seoforge-rankings',  'seoforge-analytics',                       // Analytics hub
            'seoforge-audit',     'seoforge-brief',     'seoforge-decay',     // Content hub
            'seoforge-redirects', 'seoforge-broken',    'seoforge-local',     // Tools hub
            // Indexing is an Analytics tab; its own submenu entry is hidden
            // and reached through the Analytics tab strip.
            'seoforge-indexing',
        ];
        $selectors = [];
        foreach ($hidden as $slug) {
            // Hide the wrapping <li> via :has — supported by all
            // current evergreen browsers + Safari 15.4+. WP admin runs
            // on the user's browser, so this is safe.
            $selectors[] = '#toplevel_page_seoforge .wp-submenu li:has(> a[href$="page=' . $slug . '"])';
        }
        echo '<style id="seoforge-hide-grouped-submenus">' .
             implode(',', $selectors) . // phpcs:ignore WordPress.Security.EscapeOutput.OutputNotEscaped -- trusted pre-built CSS selectors from registered menu slugs
             '{display:none !important;}</style>';
    }

    /**
 * Final pass over the sidebar after Freemius has added its own
 * pages. Brings them into canonical order:
 *
 * 1 Dashboard · 2 Performance · 3 Content · 4 Redirects ·
 * 5 Migration · 6 Contact Us · 7 License · 8 Settings (always last)
 *
 * Slug-based mapping covers items we register; Freemius items are
 * matched by visible label (License / Contact Us / Pricing / Account /
 * Affiliation) since their slugs are version-dependent.
 */
    /**
 * Snapshot $submenu['seoforge'] after every SEO Forge page has registered
 * (core admin_menu @10, migration @20, license @99) but before the
 * Freemius SDK menu hook runs at its much higher internal priority. Used by
 * restore_submenu to undo Freemius wiping the array.
 */
    public function snapshot_submenu() {
        global $submenu;
        if (!empty($submenu['seoforge'])) {
            $this->submenu_snapshot = $submenu['seoforge'];
        }
    }

    /**
 * Restore the submenu if a later admin_menu hook (the Freemius SDK menu
 * integration) emptied $submenu['seoforge']. Without this, every SEO Forge
 * sub-page 403s on multi-Forge-plugin sites because get_admin_page_parent
 * can't resolve the parent. The pages themselves are still registered in
 * $_registered_pages, so restoring the array is sufficient. Runs just
 * before reorder_submenu (PHP_INT_MAX) so the restored items get sorted.
 */
    public function restore_submenu() {
        global $submenu;
        if (empty($submenu['seoforge']) && !empty($this->submenu_snapshot)) {
            $submenu['seoforge'] = $this->submenu_snapshot;
        }
    }

    public function reorder_submenu() {
        global $submenu;
        if (empty($submenu['seoforge'])) return;

        // canonical sidebar order:
        // 10 Dashboard · 20 Analytics · 30 Content · 40 Tools ·
        // 50 Migration · 60 Contact Us · 70 License · 80 Settings
        
        // Sub-tab slugs (rankings/audit/decay/analytics/brief/broken/
        // local/redirects) get pos=999 so they sort last among visible
        // items — but they're already hidden via CSS in
        // hide_grouped_sidebar_items, so the sort just keeps the
        // submenu array tidy.
        $sorted = [];
        foreach ($submenu['seoforge'] as $idx => $item) {
            $slug  = isset($item[2]) ? (string) $item[2] : '';
            $title = isset($item[0]) ? strtolower(wp_strip_all_tags((string) $item[0])) : '';
            $pos   = 500 + $idx; // unknowns float to the bottom in registration order.

            if ($slug === 'seoforge')                                                               $pos = 10;
            elseif ($slug === 'seoforge-analytics-hub')                                             $pos = 20;
            elseif ($slug === 'seoforge-content-hub')                                               $pos = 30;
            elseif ($slug === 'seoforge-tools-hub')                                                 $pos = 40;
            elseif (strpos($slug, 'migration') !== false || strpos($title, 'migrat') !== false)     $pos = 50;
            elseif (strpos($title, 'contact') !== false || strpos($title, 'support') !== false)    $pos = 60;
            elseif (strpos($title, 'license') !== false  || strpos($title, 'account') !== false
                || strpos($title, 'affiliation') !== false || strpos($title, 'pricing') !== false
                || strpos($title, 'upgrade') !== false   || strpos($title, 'add-ons') !== false)   $pos = 70;
            elseif ($slug === 'seoforge-settings')                                                  $pos = 80;
            // Sub-tabs (still registered for deep links; hidden via CSS).
            elseif (in_array($slug, ['seoforge-rankings','seoforge-audit','seoforge-redirects',
                                      'seoforge-decay','seoforge-analytics','seoforge-brief',
                                      'seoforge-broken','seoforge-local','seoforge-indexing'], true)) $pos = 900;

            $sorted[] = ['pos' => $pos, 'idx' => $idx, 'item' => $item];
        }
        usort($sorted, function ($a, $b) {
            if ($a['pos'] !== $b['pos']) return $a['pos'] - $b['pos'];
            return $a['idx'] - $b['idx'];
        });
        $submenu['seoforge'] = array_map(function ($s) { return $s['item']; }, $sorted);
    }

    /**
 * Keep the consolidated sidebar item highlighted when the user is on
 * a hidden sub-page reached through the in-page tabs. Without this
 * filter, navigating from "Performance" → Content Decay tab would
 * un-highlight everything in the SEO Forge sidebar block.
 */
    public function highlight_group_parent($submenu_file) {
        // phpcs:ignore WordPress.Security.NonceVerification.Recommended -- Read-only admin UI state, no data mutation.
        $current = isset($_GET['page']) ? sanitize_key(wp_unslash($_GET['page'])) : '';

        // highlight the right hub parent in the sidebar when
        // the user is on a sub-tab page. The hub parent slug is the
        // canonical "current" item for every sub-tab in that hub.
        if (class_exists('SEOFORGE_Admin_Tabs')) {
            $hubs = SEOFORGE_Admin_Tabs::hubs();
            foreach ($hubs as $hub) {
                foreach ($hub['tabs'] as $tab) {
                    if ($tab['slug'] === $current) {
                        return $hub['parent'];
                    }
                }
            }
        }

        return $submenu_file;
    }

    /**
 * Rank Tracking page
 */
    public function render_rankings_page() {
        $gsc = SEOFORGE_GSC::instance();
        $connected = $gsc->is_connected();
        $rankings = get_option('seoforge_gsc_rankings_cache', []);
        $last_sync = get_option('seoforge_gsc_last_sync', 0);
        // Big Four + Striking Distance + Smart Insights — cached on Rankings page load.
        $totals        = $connected ? $gsc->get_site_totals(28, true) : null;
        $striking      = $connected ? $gsc->get_striking_distance_keywords(28, 20) : [];
        $cannibalized  = $connected ? $gsc->detect_cannibalization(28, 10) : [];
        $ctr_anomalies = $connected ? $gsc->detect_ctr_anomalies(28, 10) : [];
        $winners_losers= $connected ? $gsc->get_winners_losers(7, 5) : ['winners' => [], 'losers' => []];
        // Intent classification opt-in — AI-backed, cached 6h. Toggle via `?intent=1` so
        // the first Rankings visit after connect doesn't burn credits unexpectedly.
        // phpcs:ignore WordPress.Security.NonceVerification.Recommended -- Read-only admin UI state, no data mutation.
        $intent_on     = !empty($_GET['intent']);
        $by_intent     = ($connected && $intent_on) ? $gsc->classify_queries_by_intent(28, 30) : [];
        ?>
        <div class="wrap sf-admin">
            <?php SEOFORGE_Admin_Tabs::render('seoforge-rankings'); ?>
            <?php SEOFORGE_License::instance()->render_upgrade_banner(); ?>
            <h1><?php esc_html_e('Keyword Rank Tracking', 'seoforge'); ?><?php Forge_Docs_Link::render('seoforge', 'google-search-console-integration-pro', __('Rankings via GSC', 'seoforge'), __('How rank data is pulled from Google Search Console and updated daily.', 'seoforge')); ?></h1>

            <?php if (!$connected): ?>
            <div style="background:#fff;border:1px solid #e2e8f0;border-radius:10px;padding:32px;text-align:center;margin:20px 0;max-width:600px;">
                <span class="dashicons dashicons-chart-line" style="font-size:48px;width:48px;height:48px;color:#e2e8f0;margin-bottom:12px;display:block;"></span>
                <h2 style="margin:0 0 8px;"><?php esc_html_e('Connect Google Search Console', 'seoforge'); ?></h2>
                <p style="color:#64748b;margin:0 0 16px;"><?php esc_html_e('Track your keyword rankings, clicks, and positions — free via Google Search Console API.', 'seoforge'); ?></p>
                <p style="color:#94a3b8;font-size:14px;"><?php esc_html_e('Connect Google Search Console on the Integrations tab to get started.', 'seoforge'); ?></p>
                <?php // deep-link straight to the Integrations tab (the #sf-tab-<key> hash is read on load by the Settings tab JS) — that's where the Connect Google button lives, not General. ?>
                <a href="<?php echo esc_url(admin_url('admin.php?page=seoforge-settings#sf-tab-integrations')); ?>" class="button button-primary"><?php esc_html_e('Go to Settings', 'seoforge'); ?></a>
            </div>
            <?php else: ?>

            <div style="display:flex;gap:12px;align-items:center;margin:16px 0;flex-wrap:wrap;">
                <span style="font-size:14px;color:#059669;">&#10003;
                    <?php
                    $gsc_email = SEOFORGE_GSC::get_connected_email();
                    if ($gsc_email !== '') {
                        printf(
                            /* translators: %s = Google account email used to connect */
                            esc_html__('Connected as %s', 'seoforge'),
                            esc_html($gsc_email)
                        );
                    } else {
                        esc_html_e('Connected to Google Search Console', 'seoforge');
                    }
                    ?>
                </span>
                <?php if ($last_sync): ?>
                <?php /* translators: %s: last sync date/time */ ?>
                <span style="font-size:14px;color:#94a3b8;"><?php printf(esc_html__('Last synced: %s', 'seoforge'), esc_html( date_i18n('M j, H:i', $last_sync) )); ?></span>
                <?php endif; ?>
                <button type="button" class="button" id="sf-gsc-refresh" style="font-size:14px;padding:4px 12px;">
                    <span class="dashicons dashicons-update" style="font-size:14px;width:14px;height:14px;vertical-align:text-bottom;"></span>
                    <?php esc_html_e('Refresh', 'seoforge'); ?>
                </button>
            </div>

            <?php if ($totals && isset($totals['current'])):
                $cur = $totals['current'];
                $prv = $totals['previous'] ?? null;
                $d   = $totals['delta'] ?? null;
                $arrow = function($val, $inverse = false) {
                    if ($val === null) return ['', '#94a3b8'];
                    $good = $inverse ? $val < 0 : $val > 0;
                    if ($val == 0) return ['→', '#94a3b8'];
                    return [($good ? '↑' : '↓'), ($good ? '#059669' : '#dc2626')];
                };
            ?>
            <div class="sf-gsc-kpi-grid" aria-label="Big Four metrics vs. previous 28 days">
                <?php
                $cards = [
                    ['label' => __('Total clicks',      'seoforge'), 'value' => number_format_i18n((int)$cur['clicks']),       'delta' => $d['clicks']      ?? null, 'suffix' => '%',  'inverse' => false],
                    ['label' => __('Total impressions', 'seoforge'), 'value' => number_format_i18n((int)$cur['impressions']),  'delta' => $d['impressions'] ?? null, 'suffix' => '%',  'inverse' => false],
                    ['label' => __('Average CTR',       'seoforge'), 'value' => ((float)$cur['ctr']) . '%',                     'delta' => $d['ctr']         ?? null, 'suffix' => '%',  'inverse' => false],
                    // Position: lower is better. delta here is positions gained (prev - cur).
                    ['label' => __('Average position',  'seoforge'), 'value' => number_format((float)$cur['position'], 1),     'delta' => $d['position']    ?? null, 'suffix' => '',   'inverse' => false],
                ];
                foreach ($cards as $i => $card):
                    list($arrow_char, $arrow_color) = $arrow($card['delta'], $card['inverse']);
                ?>
                <div class="sf-gsc-kpi">
                    <div class="sf-gsc-kpi-label"><?php echo esc_html($card['label']); ?></div>
                    <div class="sf-gsc-kpi-value"><?php echo esc_html($card['value']); ?></div>
                    <?php if ($card['delta'] !== null): ?>
                    <div class="sf-gsc-kpi-delta" style="color:<?php echo esc_attr($arrow_color); ?>;">
                        <?php echo esc_html($arrow_char . ' ' . ($card['delta'] >= 0 ? '+' : '') . $card['delta'] . $card['suffix']); ?>
                        <span class="sf-gsc-kpi-vs"><?php esc_html_e('vs. prev 28d', 'seoforge'); ?></span>
                    </div>
                    <?php else: ?>
                    <div class="sf-gsc-kpi-delta" style="color:#94a3b8;"><?php esc_html_e('No prior data', 'seoforge'); ?></div>
                    <?php endif; ?>
                </div>
                <?php endforeach; ?>
            </div>
            <?php endif; ?>

            <?php if (!empty($striking)): ?>
            <div class="sf-gsc-card sf-gsc-striking">
                <div class="sf-gsc-card-head">
                    <h2 style="margin:0;font-size:16px;"><span class="dashicons dashicons-performance" style="color:#FF6B35;"></span> <?php esc_html_e('Striking distance keywords', 'seoforge'); ?></h2>
                    <span class="sf-gsc-card-hint"><?php esc_html_e('Positions 11–15. Small on-page tweaks can push these into the top 10.', 'seoforge'); ?></span>
                </div>
                <table class="widefat striped sf-gsc-table">
                    <thead>
                        <tr>
                            <th><?php esc_html_e('Keyword', 'seoforge'); ?></th>
                            <th><?php esc_html_e('Landing page', 'seoforge'); ?></th>
                            <th style="width:80px;text-align:center;"><?php esc_html_e('Position', 'seoforge'); ?></th>
                            <th style="width:100px;text-align:center;"><?php esc_html_e('Impressions', 'seoforge'); ?></th>
                            <th style="width:80px;text-align:center;"><?php esc_html_e('Clicks', 'seoforge'); ?></th>
                            <th style="width:70px;text-align:center;"><?php esc_html_e('CTR', 'seoforge'); ?></th>
                        </tr>
                    </thead>
                    <tbody>
                    <?php foreach ($striking as $row): $short = preg_replace('#^https?://[^/]+#', '', $row['page'] ?: ''); ?>
                        <tr>
                            <td><strong><?php echo esc_html($row['keyword']); ?></strong></td>
                            <td><a href="<?php echo esc_url($row['page']); ?>" target="_blank" style="font-size:13px;color:#334155;"><?php echo esc_html($short ?: $row['page']); ?></a></td>
                            <td style="text-align:center;"><span style="font-weight:700;color:#d97706;"><?php echo esc_html( $row['position'] ); ?></span></td>
                            <td style="text-align:center;"><?php echo esc_html( number_format_i18n($row['impressions']) ); ?></td>
                            <td style="text-align:center;"><?php echo esc_html( $row['clicks'] ); ?></td>
                            <td style="text-align:center;"><?php echo esc_html( $row['ctr'] ); ?>%</td>
                        </tr>
                    <?php endforeach; ?>
                    </tbody>
                </table>
            </div>
            <?php endif; ?>

            <?php if (!empty($cannibalized)): ?>
            <div class="sf-gsc-card">
                <div class="sf-gsc-card-head">
                    <h2 style="margin:0;font-size:16px;"><span class="dashicons dashicons-groups" style="color:#DC2626;"></span> <?php esc_html_e('Keyword cannibalization', 'seoforge'); ?></h2>
                    <span class="sf-gsc-card-hint"><?php esc_html_e('Queries where two or more of your pages compete against each other.', 'seoforge'); ?></span>
                </div>
                <?php foreach ($cannibalized as $c): ?>
                <div style="padding:10px 0;border-bottom:1px solid #f1f5f9;">
                    <div style="display:flex;justify-content:space-between;align-items:baseline;gap:10px;flex-wrap:wrap;">
                        <strong style="font-size:14px;"><?php echo esc_html($c['query']); ?></strong>
                        <?php /* translators: 1: number of pages, 2: impression count, 3: click count */ ?>
                        <span style="font-size:12px;color:#64748b;"><?php printf(esc_html__('%1$d pages · %2$s impressions · %3$s clicks', 'seoforge'), count($c['pages']), esc_html( number_format_i18n($c['total_impressions']) ), esc_html( number_format_i18n($c['total_clicks']) )); ?></span>
                    </div>
                    <ul style="margin:6px 0 0 20px;padding:0;font-size:13px;color:#334155;">
                        <?php foreach ($c['pages'] as $p): $short = preg_replace('#^https?://[^/]+#', '', $p['page']); ?>
                        <li style="padding:2px 0;">
                            <a href="<?php echo esc_url($p['page']); ?>" target="_blank" style="color:#334155;text-decoration:none;"><?php echo esc_html($short); ?></a>
                            — <span style="color:#94a3b8;">pos <?php echo esc_html( $p['position'] ); ?> · <?php echo esc_html( number_format_i18n($p['impressions']) ); ?> imp · <?php echo esc_html( $p['clicks'] ); ?> clicks</span>
                        </li>
                        <?php endforeach; ?>
                    </ul>
                </div>
                <?php endforeach; ?>
            </div>
            <?php endif; ?>

            <?php if (!empty($ctr_anomalies)): ?>
            <div class="sf-gsc-card">
                <div class="sf-gsc-card-head">
                    <h2 style="margin:0;font-size:16px;"><span class="dashicons dashicons-visibility" style="color:#D97706;"></span> <?php esc_html_e('CTR anomalies (Top-3 with low CTR)', 'seoforge'); ?></h2>
                    <span class="sf-gsc-card-hint"><?php esc_html_e('Great ranking, weak snippet. Rewrite titles / descriptions on these pages.', 'seoforge'); ?></span>
                </div>
                <table class="widefat striped sf-gsc-table">
                    <thead>
                        <tr>
                            <th><?php esc_html_e('Keyword', 'seoforge'); ?></th>
                            <th><?php esc_html_e('Landing page', 'seoforge'); ?></th>
                            <th style="width:70px;text-align:center;"><?php esc_html_e('Position', 'seoforge'); ?></th>
                            <th style="width:80px;text-align:center;"><?php esc_html_e('CTR', 'seoforge'); ?></th>
                            <th style="width:100px;text-align:center;"><?php esc_html_e('Benchmark', 'seoforge'); ?></th>
                            <th style="width:100px;text-align:center;"><?php esc_html_e('Impressions', 'seoforge'); ?></th>
                        </tr>
                    </thead>
                    <tbody>
                    <?php foreach ($ctr_anomalies as $row): $short = preg_replace('#^https?://[^/]+#', '', $row['page']); ?>
                        <tr>
                            <td><strong><?php echo esc_html($row['query']); ?></strong></td>
                            <td><a href="<?php echo esc_url($row['page']); ?>" target="_blank" style="font-size:13px;color:#334155;"><?php echo esc_html($short); ?></a></td>
                            <td style="text-align:center;color:#059669;font-weight:700;"><?php echo esc_html( $row['position'] ); ?></td>
                            <td style="text-align:center;color:#dc2626;font-weight:700;"><?php echo esc_html( $row['ctr'] ); ?>%</td>
                            <td style="text-align:center;color:#94a3b8;"><?php echo esc_html( $row['benchmark'] ); ?>%<br><small>(-<?php echo esc_html( $row['gap_percent'] ); ?>%)</small></td>
                            <td style="text-align:center;"><?php echo esc_html( number_format_i18n($row['impressions']) ); ?></td>
                        </tr>
                    <?php endforeach; ?>
                    </tbody>
                </table>
            </div>
            <?php endif; ?>

            <?php if (!empty($winners_losers['winners']) || !empty($winners_losers['losers'])): ?>
            <div class="sf-gsc-wl-grid">
                <div class="sf-gsc-card">
                    <div class="sf-gsc-card-head">
                        <h2 style="margin:0;font-size:16px;color:#059669;"><span class="dashicons dashicons-chart-line" style="color:#059669;"></span> <?php esc_html_e('Winners (last 7 days vs prior 7)', 'seoforge'); ?></h2>
                    </div>
                    <?php if (empty($winners_losers['winners'])): ?>
                    <p style="color:#94a3b8;font-size:13px;margin:8px 0 0;"><?php esc_html_e('No notable gainers this week.', 'seoforge'); ?></p>
                    <?php else: foreach ($winners_losers['winners'] as $w): ?>
                    <div style="padding:8px 0;border-bottom:1px solid #f1f5f9;font-size:13px;">
                        <?php if ($w['post_id']): ?>
                        <a href="<?php echo esc_url(get_edit_post_link($w['post_id'])); ?>" style="color:#0f172a;font-weight:500;text-decoration:none;"><?php echo esc_html($w['title']); ?></a>
                        <?php else: ?>
                        <span style="color:#0f172a;font-weight:500;"><?php echo esc_html($w['title']); ?></span>
                        <?php endif; ?>
                        <div style="color:#059669;margin-top:2px;">
                            +<?php echo esc_html( number_format_i18n($w['click_change']) ); ?> clicks
                            <?php if ($w['click_pct'] !== null): ?>(<?php echo esc_html( ($w['click_pct'] >= 0 ? '+' : '') . $w['click_pct'] ); ?>%)<?php else: ?>(<?php esc_html_e('new', 'seoforge'); ?>)<?php endif; ?>
                            <span style="color:#94a3b8;"> · pos <?php echo esc_html( $w['position_now'] ); ?></span>
                        </div>
                    </div>
                    <?php endforeach; endif; ?>
                </div>
                <div class="sf-gsc-card">
                    <div class="sf-gsc-card-head">
                        <h2 style="margin:0;font-size:16px;color:#dc2626;"><span class="dashicons dashicons-chart-bar" style="color:#dc2626;transform:scaleY(-1);"></span> <?php esc_html_e('Losers (last 7 days vs prior 7)', 'seoforge'); ?></h2>
                    </div>
                    <?php if (empty($winners_losers['losers'])): ?>
                    <p style="color:#94a3b8;font-size:13px;margin:8px 0 0;"><?php esc_html_e('No notable drops this week.', 'seoforge'); ?></p>
                    <?php else: foreach ($winners_losers['losers'] as $l): ?>
                    <div style="padding:8px 0;border-bottom:1px solid #f1f5f9;font-size:13px;">
                        <?php if ($l['post_id']): ?>
                        <a href="<?php echo esc_url(get_edit_post_link($l['post_id'])); ?>" style="color:#0f172a;font-weight:500;text-decoration:none;"><?php echo esc_html($l['title']); ?></a>
                        <?php else: ?>
                        <span style="color:#0f172a;font-weight:500;"><?php echo esc_html($l['title']); ?></span>
                        <?php endif; ?>
                        <div style="color:#dc2626;margin-top:2px;">
                            <?php echo esc_html( number_format_i18n($l['click_change']) ); ?> clicks
                            <?php if ($l['click_pct'] !== null): ?>(<?php echo esc_html( $l['click_pct'] ); ?>%)<?php endif; ?>
                            <span style="color:#94a3b8;"> · pos <?php echo esc_html( $l['position_now'] ); ?></span>
                        </div>
                    </div>
                    <?php endforeach; endif; ?>
                </div>
            </div>
            <?php endif; ?>

            <?php
            // Queries by intent — opt-in AI classification. Show the button if not loaded,
            // the grouped table once we have results.
            ?>
            <div class="sf-gsc-card">
                <div class="sf-gsc-card-head">
                    <h2 style="margin:0;font-size:16px;display:inline-flex;align-items:center;gap:6px;">
                        <span class="dashicons dashicons-category" style="color:#FF6B35;"></span>
                        <?php esc_html_e('Queries by intent', 'seoforge'); ?>
                        <?php if (class_exists('Forge_Docs_Link')) Forge_Docs_Link::render(
                            'seoforge',
                            'google-search-console-integration-pro',
                            __('Queries by intent', 'seoforge'),
                            __('AI groups your top 30 GSC queries into informational / commercial / transactional / navigational so you can see where in the funnel your traffic lives. 1 credit per batch, cached 6h.', 'seoforge')
                        ); ?>
                    </h2>
                    <span class="sf-gsc-card-hint"><?php esc_html_e('AI-classified into informational / commercial / transactional / navigational.', 'seoforge'); ?></span>
                </div>
                <?php if (!$intent_on): ?>
                    <p style="color:#64748b;font-size:13px;margin:0 0 10px;"><?php esc_html_e('Classification uses a single batched AI call (1 credit) covering your top 30 queries over the last 28 days. Results are cached for 6 hours.', 'seoforge'); ?></p>
                    <a href="<?php echo esc_url(add_query_arg('intent', '1')); ?>" class="button button-primary" data-forge-ai-action="custom"><?php esc_html_e('Classify my top queries', 'seoforge'); ?></a>
                <?php elseif (empty($by_intent)): ?>
                    <p style="color:#94a3b8;font-size:13px;margin:0;"><?php esc_html_e('No query data to classify yet. Come back after GSC has collected a few days of traffic.', 'seoforge'); ?></p>
                <?php else:
                    $groups = ['informational' => [], 'commercial' => [], 'transactional' => [], 'navigational' => []];
                    foreach ($by_intent as $q) {
                        $i = $q['intent'] ?? 'informational';
                        if (!isset($groups[$i])) $groups[$i] = [];
                        $groups[$i][] = $q;
                    }
                    $colors = [
                        'informational' => '#2563eb',
                        'commercial'    => '#D97706',
                        'transactional' => '#059669',
                        'navigational'  => '#7c3aed',
                    ];
                ?>
                <div class="sf-gsc-intent-grid">
                    <?php foreach ($groups as $intent => $rows): if (empty($rows)) continue; ?>
                    <div>
                        <div style="font-size:12px;font-weight:700;text-transform:uppercase;letter-spacing:0.4px;color:<?php echo esc_attr($colors[$intent]); ?>;margin-bottom:6px;"><?php echo esc_html($intent); ?> (<?php echo count($rows); ?>)</div>
                        <?php foreach ($rows as $q): ?>
                        <div style="padding:4px 0;font-size:13px;border-bottom:1px solid #f1f5f9;">
                            <span><?php echo esc_html($q['query']); ?></span>
                            <span style="color:#94a3b8;font-size:12px;"> · <?php echo esc_html( $q['clicks'] ); ?>clk · pos <?php echo esc_html( $q['position'] ); ?></span>
                        </div>
                        <?php endforeach; ?>
                    </div>
                    <?php endforeach; ?>
                </div>
                <p style="margin-top:10px;font-size:12px;color:#94a3b8;"><?php esc_html_e('Re-classify will re-run the AI call after cache expires (6h). Intent rarely changes, so this is usually enough.', 'seoforge'); ?></p>
                <?php endif; ?>
            </div>

            <?php if (!empty($rankings)): ?>
            <h2 style="margin-top:28px;font-size:16px;"><?php esc_html_e('Top tracked keywords', 'seoforge'); ?></h2>
            <table class="widefat striped">
                <thead>
                    <tr>
                        <th><?php esc_html_e('Keyword', 'seoforge'); ?></th>
                        <th style="width:80px;text-align:center;"><?php esc_html_e('Position', 'seoforge'); ?></th>
                        <th style="width:80px;text-align:center;"><?php esc_html_e('Clicks', 'seoforge'); ?></th>
                        <th style="width:100px;text-align:center;"><?php esc_html_e('Impressions', 'seoforge'); ?></th>
                        <th style="width:70px;text-align:center;"><?php esc_html_e('CTR', 'seoforge'); ?></th>
                    </tr>
                </thead>
                <tbody>
                <?php foreach ($rankings as $r): ?>
                    <tr>
                        <td><strong><?php echo esc_html($r['keyword']); ?></strong></td>
                        <td style="text-align:center;">
                            <span style="font-weight:700;color:<?php echo esc_attr( $r['position'] <= 3 ? '#059669' : ($r['position'] <= 10 ? '#d97706' : '#dc2626') ); ?>;">
                                <?php echo esc_html( $r['position'] ); ?>
                            </span>
                        </td>
                        <td style="text-align:center;"><?php echo esc_html( $r['clicks'] ); ?></td>
                        <td style="text-align:center;"><?php echo esc_html( number_format($r['impressions']) ); ?></td>
                        <td style="text-align:center;"><?php echo esc_html( $r['ctr'] ); ?>%</td>
                    </tr>
                <?php endforeach; ?>
                </tbody>
            </table>
            <?php else: ?>
                <?php
                // SF-empty-state-1: differentiate "no sync yet" from "synced but
                // GSC returned no rows". Without this, an empty $rankings
                // looked identical in both cases and the user couldn't tell
                // whether to wait or to click Refresh.
                $never_synced = empty($last_sync);
                ?>
                <div style="background:#fff;border:1px solid #e2e8f0;border-radius:10px;padding:28px;text-align:center;margin:20px 0;max-width:640px;">
                    <span class="dashicons dashicons-chart-line" style="font-size:36px;width:36px;height:36px;color:#cbd5e1;margin-bottom:8px;display:block;"></span>
                    <?php if ($never_synced): ?>
                        <h2 style="margin:6px 0 8px;color:#0f172a;"><?php esc_html_e('No data yet — first sync hasn\'t run', 'seoforge'); ?></h2>
                        <p style="color:#64748b;margin:0 0 6px;"><?php esc_html_e('Click Refresh above to pull rankings from Google Search Console for the first time. After that the daily cron keeps it fresh.', 'seoforge'); ?></p>
                        <p style="color:#94a3b8;font-size:13px;margin:0;"><?php esc_html_e('Note: GSC may need 24–72 hours after Connect to start populating Search Analytics, so an initial sync can legitimately return zero rows.', 'seoforge'); ?></p>
                    <?php else: ?>
                        <h2 style="margin:6px 0 8px;color:#0f172a;"><?php esc_html_e('Sync ran, but Google returned no keywords', 'seoforge'); ?></h2>
                        <p style="color:#64748b;margin:0 0 6px;"><?php
                            printf(
                                /* translators: %s = date and time of last sync */
                                esc_html__('Last sync: %s. GSC needs accumulated impressions before keywords appear — give it a few days, or check that the right property is selected in Settings.', 'seoforge'),
                                esc_html(date_i18n('M j, H:i', (int) $last_sync))
                            );
                        ?></p>
                    <?php endif; ?>
                </div>
            <?php endif; ?>

            <script>
            jQuery('#sf-gsc-refresh').on('click', function(){
                var $b = jQuery(this);
                $b.prop('disabled',true).find('.dashicons').css('animation','spin 1s linear infinite');
                jQuery.post(seoforgeAdmin.ajaxUrl, {action:'seoforge_gsc_fetch',nonce:seoforgeAdmin.nonce}, function(r){
                    location.reload();
                });
            });
            </script>
            <?php endif; ?>
        </div>
        <style>
        .sf-gsc-kpi-grid{display:grid;grid-template-columns:repeat(4,1fr);gap:14px;margin:8px 0 24px;}
        @media(max-width:780px){.sf-gsc-kpi-grid{grid-template-columns:repeat(2,1fr);}}
        .sf-gsc-kpi{background:#fff;border:1px solid #e2e8f0;border-radius:12px;padding:14px 16px;box-shadow:0 1px 3px rgba(0,0,0,0.04);}
        .sf-gsc-kpi-label{font-size:12px;font-weight:700;text-transform:uppercase;letter-spacing:0.4px;color:#64748b;margin-bottom:6px;}
        .sf-gsc-kpi-value{font-size:26px;font-weight:800;color:#0f172a;line-height:1;}
        .sf-gsc-kpi-delta{font-size:13px;font-weight:600;margin-top:6px;display:flex;align-items:center;gap:6px;}
        .sf-gsc-kpi-vs{color:#94a3b8;font-weight:500;font-size:12px;}
        .sf-gsc-card{background:#fff;border:1px solid #e2e8f0;border-radius:12px;padding:16px 20px;margin-bottom:18px;box-shadow:0 1px 3px rgba(0,0,0,0.04);}
        .sf-gsc-card-head{display:flex;flex-wrap:wrap;align-items:baseline;justify-content:space-between;gap:8px;margin-bottom:10px;}
        .sf-gsc-card-hint{font-size:13px;color:#94a3b8;}
        .sf-gsc-table{border:0;}
        .sf-gsc-table td a{text-decoration:none;}
        .sf-gsc-table td a:hover{color:#FF6B35;text-decoration:underline;}
        .sf-gsc-wl-grid{display:grid;grid-template-columns:1fr 1fr;gap:14px;margin-bottom:18px;}
        .sf-gsc-wl-grid > .sf-gsc-card{margin-bottom:0;}
        .sf-gsc-intent-grid{display:grid;grid-template-columns:repeat(auto-fit, minmax(220px, 1fr));gap:16px;}
        @media(max-width:780px){.sf-gsc-wl-grid{grid-template-columns:1fr;}}
        </style>
        <?php
    }

    /**
 * Content Decay page
 */
    public function render_decay_page() {
        $gsc = SEOFORGE_GSC::instance();
        $connected = $gsc->is_connected();
        $decay = get_option('seoforge_gsc_decay_cache', []);
        // SF-empty-state-2: distinguish "decay analysis hasn't run yet" from
        // "analysis ran and found nothing". Without seoforge_gsc_last_sync
        // both states render identically and the user is left guessing
        // whether the green "all good" message is real or fake.
        $last_sync = (int) get_option('seoforge_gsc_last_sync', 0);
        ?>
        <div class="wrap">
            <?php SEOFORGE_Admin_Tabs::render('seoforge-decay'); ?>
            <?php SEOFORGE_License::instance()->render_upgrade_banner(); ?>
            <h1><?php esc_html_e('Content Decay Detection', 'seoforge'); ?><?php Forge_Docs_Link::render('seoforge', 'content-decay-detection-pro', __('Content decay', 'seoforge'), __('Posts with declining impressions/clicks — what to refresh first.', 'seoforge')); ?></h1>

            <?php if (!$connected): ?>
            <div style="background:#fff;border:1px solid #e2e8f0;border-radius:10px;padding:32px;text-align:center;margin:20px 0;max-width:600px;">
                <h2 style="margin:0 0 8px;"><?php esc_html_e('Connect Google Search Console', 'seoforge'); ?></h2>
                <p style="color:#64748b;"><?php esc_html_e('Content decay detection compares last 28 days vs previous 28 days to find pages losing traffic.', 'seoforge'); ?></p>
                <?php // deep-link straight to the Integrations tab where Connect Google lives. ?>
                <a href="<?php echo esc_url(admin_url('admin.php?page=seoforge-settings#sf-tab-integrations')); ?>" class="button button-primary"><?php esc_html_e('Go to Settings', 'seoforge'); ?></a>
            </div>
            <?php elseif (empty($decay) && empty($last_sync)): ?>
                <div style="background:#fff;border:1px solid #e2e8f0;border-radius:10px;padding:28px;text-align:center;margin:20px 0;max-width:640px;">
                    <span class="dashicons dashicons-clock" style="font-size:36px;width:36px;height:36px;color:#cbd5e1;margin-bottom:8px;display:block;"></span>
                    <h2 style="margin:6px 0 8px;color:#0f172a;"><?php esc_html_e('No data yet — analysis hasn\'t run', 'seoforge'); ?></h2>
                    <p style="color:#64748b;margin:0 0 12px;"><?php esc_html_e('Content decay compares the last 28 days vs the previous 28 days. The first comparison runs after the GSC sync — open Rankings and click Refresh, or wait for the daily cron.', 'seoforge'); ?></p>
                    <a href="<?php echo esc_url(admin_url('admin.php?page=seoforge-rankings')); ?>" class="button button-primary"><?php esc_html_e('Open Rankings to sync now', 'seoforge'); ?></a>
                </div>
            <?php elseif (empty($decay)): ?>
                <div style="background:#f0fdf4;border:1px solid #bbf7d0;border-radius:10px;padding:20px;margin:20px 0;max-width:600px;">
                    <p style="color:#059669;font-weight:600;margin:0 0 4px;">&#10003; <?php esc_html_e('No content decay detected', 'seoforge'); ?></p>
                    <p style="color:#065f46;font-size:13px;margin:0;"><?php
                        printf(
                            /* translators: %s = date and time of last sync */
                            esc_html__('Last sync: %s. None of your pages dropped significantly in clicks or position vs the previous 28-day window.', 'seoforge'),
                            esc_html(date_i18n('M j, H:i', $last_sync))
                        );
                    ?></p>
                </div>
            <?php else: ?>

            <p style="color:#64748b;font-size:14px;margin:16px 0;"><?php esc_html_e('Pages with significant drop in clicks or positions compared to the previous 28-day period.', 'seoforge'); ?></p>

            <table class="widefat striped">
                <thead>
                    <tr>
                        <th><?php esc_html_e('Page', 'seoforge'); ?></th>
                        <th style="width:100px;text-align:center;"><?php esc_html_e('Clicks Change', 'seoforge'); ?></th>
                        <th style="width:100px;text-align:center;"><?php esc_html_e('Position Change', 'seoforge'); ?></th>
                        <th style="width:80px;"></th>
                    </tr>
                </thead>
                <tbody>
                <?php foreach ($decay as $d): ?>
                    <tr>
                        <td>
                            <?php if ($d['post_id']): ?>
                                <a href="<?php echo esc_url( get_edit_post_link($d['post_id']) ); ?>"><?php echo esc_html($d['title']); ?></a>
                            <?php else: ?>
                                <span><?php echo esc_html($d['title']); ?></span>
                            <?php endif; ?>
                            <div style="font-size:14px;color:#94a3b8;"><?php echo esc_html($d['url']); ?></div>
                        </td>
                        <td style="text-align:center;">
                            <span style="color:#dc2626;font-weight:700;"><?php echo esc_html( $d['click_change'] ); ?>%</span>
                            <div style="font-size:14px;color:#94a3b8;"><?php echo esc_html( $d['clicks_prev'] ); ?> → <?php echo esc_html( $d['clicks_now'] ); ?></div>
                        </td>
                        <td style="text-align:center;">
                            <span style="color:<?php echo esc_attr( $d['position_change'] > 0 ? '#dc2626' : '#059669' ); ?>;font-weight:700;">
                                <?php echo $d['position_change'] > 0 ? '+' : ''; ?><?php echo esc_html( $d['position_change'] ); ?>
                            </span>
                            <div style="font-size:14px;color:#94a3b8;"><?php echo esc_html( $d['position_prev'] ); ?> → <?php echo esc_html( $d['position_now'] ); ?></div>
                        </td>
                        <td>
                            <?php if ($d['post_id']): ?>
                            <a href="<?php echo esc_url( get_edit_post_link($d['post_id']) ); ?>" class="button button-small"><?php esc_html_e('Fix', 'seoforge'); ?></a>
                            <?php endif; ?>
                        </td>
                    </tr>
                <?php endforeach; ?>
                </tbody>
            </table>
            <?php endif; ?>
        </div>
        <?php
    }

    /**
 * Local SEO page
 */
    public function render_local_page() {
        if (isset($_POST['seoforge_save_local']) && wp_verify_nonce(sanitize_text_field(wp_unslash($_POST['_local_nonce'] ?? '')), 'seoforge_save_local')) {
            $settings = [
                'enabled'       => !empty($_POST['local_enabled']),
                'business_name' => sanitize_text_field(wp_unslash($_POST['business_name'] ?? '')),
                'business_type' => sanitize_text_field(wp_unslash($_POST['business_type'] ?? 'LocalBusiness')),
                'phone'         => sanitize_text_field(wp_unslash($_POST['phone'] ?? '')),
                'email'         => sanitize_email(wp_unslash($_POST['email'] ?? '')),
                'street'        => sanitize_text_field(wp_unslash($_POST['street'] ?? '')),
                'city'          => sanitize_text_field(wp_unslash($_POST['city'] ?? '')),
                'state'         => sanitize_text_field(wp_unslash($_POST['state'] ?? '')),
                'zip'           => sanitize_text_field(wp_unslash($_POST['zip'] ?? '')),
                'country'       => sanitize_text_field(wp_unslash($_POST['country'] ?? '')),
                'lat'           => sanitize_text_field(wp_unslash($_POST['lat'] ?? '')),
                'lng'           => sanitize_text_field(wp_unslash($_POST['lng'] ?? '')),
                'opening_hours' => sanitize_textarea_field(wp_unslash($_POST['opening_hours'] ?? '')),
                'price_range'   => sanitize_text_field(wp_unslash($_POST['price_range'] ?? '')),
                'logo_url'      => esc_url_raw(wp_unslash($_POST['logo_url'] ?? '')),
                'image_url'     => esc_url_raw(wp_unslash($_POST['image_url'] ?? '')),
                'social_urls'   => sanitize_textarea_field(wp_unslash($_POST['social_urls'] ?? '')),
            ];
            update_option('seoforge_local_seo', $settings);
            echo '<div class="notice notice-success"><p>' . esc_html__('Local SEO settings saved.', 'seoforge') . '</p></div>';
        }

        $s = SEOFORGE_Local_Seo::get_settings();
        $types = SEOFORGE_Local_Seo::get_business_types();
        ?>
        <div class="wrap" style="max-width:860px;">
            <?php SEOFORGE_Admin_Tabs::render('seoforge-local'); ?>
            <?php SEOFORGE_License::instance()->render_upgrade_banner(); ?>
            <h1><?php esc_html_e('Local SEO', 'seoforge'); ?><?php Forge_Docs_Link::render('seoforge', 'local-seo-for-physical-businesses-pro', __('Local business schema', 'seoforge'), __('Business NAP + hours injected as LocalBusiness JSON-LD for Google.', 'seoforge')); ?></h1>

            <form method="post">
                <?php wp_nonce_field('seoforge_save_local', '_local_nonce'); ?>

                <?php
                // Local SEO moved to Free (2026-05-29 rebalance) so this
                // page now renders without the Pro add-on. SEOFORGE_AI_Client
                // lives in seoforge-pro — calling it unguarded fataled the whole
                // page ("critical error") on Free-only sites. Guard with
                // class_exists like every other AI entry point; the AI Auto-Fill
                // block simply stays hidden in Free (AI is Pro).
                ?>
                <?php if (class_exists('SEOFORGE_AI_Client') && SEOFORGE_AI_Client::instance()->is_configured()): ?>
                <div style="margin-top:16px;background:linear-gradient(135deg,#FF6B35,#E05520);border-radius:10px;padding:16px 20px;">
                    <div style="display:flex;align-items:center;justify-content:space-between;margin-bottom:12px;">
                        <div style="color:#fff;">
                            <strong style="font-size:14px;"><?php esc_html_e('AI Auto-Fill', 'seoforge'); ?></strong>
                            <div style="font-size:14px;opacity:0.8;margin-top:2px;"><?php esc_html_e('Tell us the basics, AI fills the rest', 'seoforge'); ?></div>
                        </div>
                    </div>
                    <div style="display:grid;grid-template-columns:1fr 1fr;gap:8px;">
                        <input type="text" id="sf-ai-biz-name" placeholder="<?php esc_attr_e('Business name *', 'seoforge'); ?>" style="padding:9px 12px;border:none;border-radius:6px;font-size:14px;" value="<?php echo esc_attr(get_bloginfo('name')); ?>">
                        <input type="text" id="sf-ai-biz-city" placeholder="<?php esc_attr_e('City *', 'seoforge'); ?>" style="padding:9px 12px;border:none;border-radius:6px;font-size:14px;">
                        <input type="text" id="sf-ai-biz-type" placeholder="<?php esc_attr_e('What do you do? (e.g. dental clinic, restaurant)', 'seoforge'); ?>" style="padding:9px 12px;border:none;border-radius:6px;font-size:14px;grid-column:span 2;">
                    </div>
                    <div style="margin-top:10px;text-align:right;">
                        <button type="button" id="sf-local-ai-fill" style="background:#fff;color:#FF6B35;border:none;border-radius:8px;padding:10px 20px;font-size:14px;font-weight:700;cursor:pointer;">
                            <span class="dashicons dashicons-superhero-alt" style="font-size:14px;width:14px;height:14px;vertical-align:text-bottom;margin-right:4px;"></span>
                            <?php esc_html_e('Generate All Fields', 'seoforge'); ?>
                        </button>
                    </div>
                </div>
                <script>
 // sfa wraps sfModal.alert with a native fallback.
                function sfLocalAlert(msg, opts){ return (window.sfModal && window.sfModal.alert) ? window.sfModal.alert(msg, opts) : (alert(msg), Promise.resolve(true)); }
                jQuery('#sf-local-ai-fill').on('click', function(){
                    var name = jQuery('#sf-ai-biz-name').val().trim();
                    var city = jQuery('#sf-ai-biz-city').val().trim();
                    var type = jQuery('#sf-ai-biz-type').val().trim();

                    if(!name || !city) {
                        sfLocalAlert('<?php echo esc_js(__('Please enter business name and city.', 'seoforge')); ?>', { title: '<?php echo esc_js(__('Missing info', 'seoforge')); ?>', variant: 'warning' });
                        return;
                    }

                    var $btn = jQuery(this);
                    var originalHtml = '<span class="dashicons dashicons-superhero-alt" style="font-size:14px;width:14px;height:14px;vertical-align:text-bottom;margin-right:4px;"></span> Generate All Fields';
                    var resetBtn = function(){ $btn.prop('disabled',false).html(originalHtml); };
                    $btn.prop('disabled',true).html('<span class="dashicons dashicons-update" style="animation:spin 1s linear infinite;font-size:14px;width:14px;height:14px;vertical-align:text-bottom;margin-right:4px;"></span> Generating...');
                    jQuery.ajax({
                        url: seoforgeAdmin.restUrl + '/local/generate',
                        method: 'POST',
                        contentType: 'application/json',
                        data: JSON.stringify({business_name: name, city: city, business_desc: type}),
                        headers: {'X-WP-Nonce': seoforgeAdmin.restNonce},
                        timeout: 90000,
                        success: function(r) {
                            resetBtn();
                            if (r && !r.error) {
                                var map = {
                                    'business_name':'[name="business_name"]', 'phone':'[name="phone"]',
                                    'email':'[name="email"]', 'street':'[name="street"]',
                                    'city':'[name="city"]', 'state':'[name="state"]',
                                    'zip':'[name="zip"]', 'country':'[name="country"]',
                                    'price_range':'[name="price_range"]',
                                    'opening_hours':'[name="opening_hours"]',
                                    'social_urls':'[name="social_urls"]'
                                };
                                for(var k in map) { if(r[k]) jQuery(map[k]).val(r[k]); }
                                if(r.business_type) jQuery('[name="business_type"]').val(r.business_type);
                            } else {
                                sfLocalAlert(r && r.error ? r.error : 'AI generation failed.', { title: 'AI generation failed', variant: 'danger' });
                            }
                        },
 // without error/timeout handlers the button was stuck on
                        // "Generating..." when the backend crashed or timed out.
                        error: function(xhr, status){
                            resetBtn();
                            var msg = 'AI generation failed.';
                            if (status === 'timeout') msg = 'AI request timed out. Please try again.';
                            else if (xhr && xhr.responseJSON && xhr.responseJSON.message) msg = xhr.responseJSON.message;
                            else if (xhr && xhr.status) msg += ' (HTTP ' + xhr.status + ')';
                            sfLocalAlert(msg, { title: 'AI generation failed', variant: 'danger' });
                        }
                    });
                });
                </script>
                <?php endif; ?>

                <div style="display:flex;flex-direction:column;gap:20px;margin-top:16px;">

                <!-- Enable -->
                <div style="background:#fff;border:1px solid #e2e8f0;border-radius:10px;padding:20px;box-shadow:0 1px 3px rgba(0,0,0,0.04);">
                    <label style="display:flex;align-items:center;gap:10px;cursor:pointer;">
                        <input type="checkbox" name="local_enabled" value="1" <?php checked($s['enabled']); ?>>
                        <span><strong><?php esc_html_e('Enable Local Business Schema', 'seoforge'); ?></strong>
                        <br><span style="font-size:14px;color:#64748b;"><?php esc_html_e('Outputs LocalBusiness JSON-LD on your homepage and pages', 'seoforge'); ?></span></span>
                    </label>
                </div>

                <!-- Business Info -->
                <div style="background:#fff;border:1px solid #e2e8f0;border-radius:10px;padding:20px;box-shadow:0 1px 3px rgba(0,0,0,0.04);">
                    <h2 style="margin:0 0 16px;font-size:16px;"><?php esc_html_e('Business Information', 'seoforge'); ?></h2>
                    <div style="display:grid;grid-template-columns:1fr 1fr;gap:14px;">
                        <div>
                            <label style="font-size:14px;font-weight:600;color:#64748b;text-transform:uppercase;display:block;margin-bottom:4px;"><?php esc_html_e('Business Name', 'seoforge'); ?></label>
                            <input type="text" name="business_name" value="<?php echo esc_attr($s['business_name']); ?>" class="widefat">
                        </div>
                        <div>
                            <label style="font-size:14px;font-weight:600;color:#64748b;text-transform:uppercase;display:block;margin-bottom:4px;"><?php esc_html_e('Business Type', 'seoforge'); ?></label>
                            <select name="business_type" class="widefat">
                                <?php foreach ($types as $val => $label): ?>
                                <option value="<?php echo esc_attr($val); ?>" <?php selected($s['business_type'], $val); ?>><?php echo esc_html($label); ?></option>
                                <?php endforeach; ?>
                            </select>
                        </div>
                        <div>
                            <label style="font-size:14px;font-weight:600;color:#64748b;text-transform:uppercase;display:block;margin-bottom:4px;"><?php esc_html_e('Phone', 'seoforge'); ?></label>
                            <input type="text" name="phone" value="<?php echo esc_attr($s['phone']); ?>" class="widefat" placeholder="+1 (555) 123-4567">
                        </div>
                        <div>
                            <label style="font-size:14px;font-weight:600;color:#64748b;text-transform:uppercase;display:block;margin-bottom:4px;"><?php esc_html_e('Email', 'seoforge'); ?></label>
                            <input type="email" name="email" value="<?php echo esc_attr($s['email']); ?>" class="widefat">
                        </div>
                    </div>
                </div>

                <!-- Address -->
                <div style="background:#fff;border:1px solid #e2e8f0;border-radius:10px;padding:20px;box-shadow:0 1px 3px rgba(0,0,0,0.04);">
                    <h2 style="margin:0 0 16px;font-size:16px;"><?php esc_html_e('Address', 'seoforge'); ?></h2>
                    <div style="display:grid;grid-template-columns:1fr 1fr;gap:14px;">
                        <div style="grid-column:span 2;">
                            <label style="font-size:14px;font-weight:600;color:#64748b;text-transform:uppercase;display:block;margin-bottom:4px;"><?php esc_html_e('Street Address', 'seoforge'); ?></label>
                            <input type="text" name="street" value="<?php echo esc_attr($s['street']); ?>" class="widefat">
                        </div>
                        <div>
                            <label style="font-size:14px;font-weight:600;color:#64748b;text-transform:uppercase;display:block;margin-bottom:4px;"><?php esc_html_e('City', 'seoforge'); ?></label>
                            <input type="text" name="city" value="<?php echo esc_attr($s['city']); ?>" class="widefat">
                        </div>
                        <div>
                            <label style="font-size:14px;font-weight:600;color:#64748b;text-transform:uppercase;display:block;margin-bottom:4px;"><?php esc_html_e('State / Region', 'seoforge'); ?></label>
                            <input type="text" name="state" value="<?php echo esc_attr($s['state']); ?>" class="widefat">
                        </div>
                        <div>
                            <label style="font-size:14px;font-weight:600;color:#64748b;text-transform:uppercase;display:block;margin-bottom:4px;"><?php esc_html_e('ZIP / Postal Code', 'seoforge'); ?></label>
                            <input type="text" name="zip" value="<?php echo esc_attr($s['zip']); ?>" class="widefat">
                        </div>
                        <div>
                            <label style="font-size:14px;font-weight:600;color:#64748b;text-transform:uppercase;display:block;margin-bottom:4px;"><?php esc_html_e('Country', 'seoforge'); ?></label>
                            <input type="text" name="country" value="<?php echo esc_attr($s['country']); ?>" class="widefat" placeholder="US">
                        </div>
                        <div>
                            <label style="font-size:14px;font-weight:600;color:#64748b;text-transform:uppercase;display:block;margin-bottom:4px;"><?php esc_html_e('Latitude', 'seoforge'); ?></label>
                            <input type="text" name="lat" value="<?php echo esc_attr($s['lat']); ?>" class="widefat" placeholder="40.7128">
                        </div>
                        <div>
                            <label style="font-size:14px;font-weight:600;color:#64748b;text-transform:uppercase;display:block;margin-bottom:4px;"><?php esc_html_e('Longitude', 'seoforge'); ?></label>
                            <input type="text" name="lng" value="<?php echo esc_attr($s['lng']); ?>" class="widefat" placeholder="-74.0060">
                        </div>
                    </div>
                </div>

                <!-- Additional -->
                <div style="background:#fff;border:1px solid #e2e8f0;border-radius:10px;padding:20px;box-shadow:0 1px 3px rgba(0,0,0,0.04);">
                    <h2 style="margin:0 0 16px;font-size:16px;"><?php esc_html_e('Additional', 'seoforge'); ?></h2>
                    <div style="display:grid;grid-template-columns:1fr 1fr;gap:14px;">
                        <div>
                            <label style="font-size:14px;font-weight:600;color:#64748b;text-transform:uppercase;display:block;margin-bottom:4px;"><?php esc_html_e('Price Range', 'seoforge'); ?></label>
                            <input type="text" name="price_range" value="<?php echo esc_attr($s['price_range']); ?>" class="widefat" placeholder="$$">
                        </div>
                        <div>
                            <label style="font-size:14px;font-weight:600;color:#64748b;text-transform:uppercase;display:block;margin-bottom:4px;"><?php esc_html_e('Logo URL', 'seoforge'); ?></label>
                            <input type="url" name="logo_url" value="<?php echo esc_attr($s['logo_url']); ?>" class="widefat">
                        </div>
                        <div style="grid-column:span 2;">
                            <label style="font-size:14px;font-weight:600;color:#64748b;text-transform:uppercase;display:block;margin-bottom:4px;"><?php esc_html_e('Opening Hours', 'seoforge'); ?></label>
                            <textarea name="opening_hours" rows="3" class="widefat" placeholder="Mo-Fr 09:00-17:00&#10;Sa 10:00-14:00"><?php echo esc_textarea($s['opening_hours']); ?></textarea>
                            <span style="font-size:14px;color:#94a3b8;">One per line. Format: Mo-Fr 09:00-17:00</span>
                        </div>
                        <div style="grid-column:span 2;">
                            <label style="font-size:14px;font-weight:600;color:#64748b;text-transform:uppercase;display:block;margin-bottom:4px;"><?php esc_html_e('Social Profile URLs', 'seoforge'); ?></label>
                            <textarea name="social_urls" rows="3" class="widefat" placeholder="https://facebook.com/yourbiz&#10;https://twitter.com/yourbiz"><?php echo esc_textarea($s['social_urls']); ?></textarea>
                            <span style="font-size:14px;color:#94a3b8;">One URL per line</span>
                        </div>
                    </div>
                </div>

                </div>

                <div style="margin-top:16px;">
                    <button type="submit" name="seoforge_save_local" style="background:#FF6B35;color:#fff;border:none;border-radius:10px;padding:12px 32px;font-size:14px;font-weight:600;cursor:pointer;box-shadow:0 2px 8px rgba(255,107,53,0.2);"><?php esc_html_e('Save Local SEO', 'seoforge'); ?></button>
                </div>
            </form>
        </div>
        <?php
    }

    /**
 * Site Audit page
 */
    public function render_audit_page() {
        // defence-in-depth — menu pro_gate already blocks Free users,
        // but also guard the render path so direct-URL or route misuse
        // doesn't leak Pro content.
        if (SEOFORGE_License::instance()->is_free()) {
            SEOFORGE_Upgrade_Prompt::instance()->locked_page('site_audit');
            return;
        }
        $audit = SEOFORGE_Site_Audit::instance();
        $cached = $audit->get_cached_audit();
        $ai_configured = SEOFORGE_AI_Client::instance()->is_configured();
        ?>
        <div class="wrap sf-admin" style="max-width:1000px;">
            <?php SEOFORGE_Admin_Tabs::render('seoforge-audit'); ?>
            <?php SEOFORGE_License::instance()->render_upgrade_banner(); ?>
            <div style="display:flex;align-items:center;gap:10px;margin-bottom:20px;padding-bottom:14px;border-bottom:2px solid #FF6B35;">
                <span class="dashicons dashicons-search" style="color:#FF6B35;font-size:24px;width:24px;height:24px;"></span>
                <h1 style="margin:0;font-size:22px;font-weight:700;"><?php esc_html_e('Site Audit', 'seoforge'); ?><?php Forge_Docs_Link::render('seoforge', 'ai-site-audit-pro', __('Running the Site Audit', 'seoforge'), __('What the audit checks, how it scores, how to re-run.', 'seoforge')); ?></h1>
            </div>

            <!-- compact hero — run audit is ghost-style when we already
 have a result (the user's not here to run it, they're here to
 fix things). The three severity counters become clickable
 anchor-buttons that scroll to the issue list and activate the
 matching filter. -->
            <div class="sf-audit-hero<?php echo $cached ? '' : ' sf-audit-hero--empty'; ?>" id="sf-audit-hero">
                <div class="sf-audit-hero-meta">
                    <button type="button"
                            id="sf-run-audit"
                            class="<?php echo $cached ? 'button sf-audit-run-ghost' : 'button button-primary'; ?>"
                            data-forge-ai-action="audit_summary">
                        <span class="dashicons dashicons-superhero-alt" style="font-size:16px;width:16px;height:16px;line-height:16px;"></span>
                        <?php echo $cached ? esc_html__('Re-run Audit', 'seoforge') : esc_html__('Run Audit', 'seoforge'); ?>
                    </button>
                    <span id="sf-audit-date" class="sf-audit-date">
                        <?php if ($cached): ?>
                            <?php /* translators: %s: date/time of the last audit */ ?>
                            <?php printf(esc_html__('Last audit: %s', 'seoforge'), esc_html(date_i18n('j M Y, H:i', $cached['timestamp']))); ?>
                        <?php else: ?>
                            <?php esc_html_e('No audit has been run yet.', 'seoforge'); ?>
                        <?php endif; ?>
                    </span>
                </div>
            </div>

            <!-- Loading state -->
            <div id="sf-audit-loading" style="display:none;text-align:center;padding:60px 20px;">
                <div class="sf-audit-spinner"></div>
                <p style="color:#64748b;font-size:14px;margin-top:16px;"><?php esc_html_e('Analyzing all site pages...', 'seoforge'); ?></p>
                <p style="color:#94a3b8;font-size:14px;"><?php esc_html_e('This may take 10–20 seconds per batch.', 'seoforge'); ?></p>
            </div>

            <!-- Results container -->
            <div id="sf-audit-results" style="<?php echo $cached ? '' : 'display:none;'; ?>">
                <?php if ($cached) $this->render_audit_results($cached); ?>
            </div>

            <!-- story 4: sticky bulk-fix bar for selected posts -->
            <div id="sf-audit-bulk-bar" class="sf-audit-bulk-bar" hidden role="region" aria-live="polite">
                <span class="sf-audit-bulk-bar-count" id="sf-audit-bulk-count">0</span>
                <span class="sf-audit-bulk-bar-label"><?php esc_html_e('pages selected', 'seoforge'); ?></span>
                <button type="button" id="sf-audit-bulk-clear" class="button button-link"><?php esc_html_e('Clear', 'seoforge'); ?></button>
                <button type="button" id="sf-audit-bulk-fix" class="button button-primary" data-forge-ai-action="fix_issue">
                    <span class="dashicons dashicons-superhero" style="font-size:14px;width:14px;height:14px;line-height:14px;margin-right:4px;"></span>
                    <?php esc_html_e('Fix selected with AI', 'seoforge'); ?>
                </button>
            </div>
        </div>

        <style>
        .sf-audit-spinner{width:40px;height:40px;border:4px solid #e2e8f0;border-top-color:#FF6B35;border-radius:50%;animation:sf-spin 0.8s linear infinite;margin:0 auto;}
        @keyframes sf-spin{to{transform:rotate(360deg);}}
        /* compact hero strip (health + counters + meta, one row) */
        .sf-audit-hero{display:flex;align-items:center;gap:14px;margin-bottom:14px;}
        .sf-audit-hero-meta{display:flex;align-items:center;gap:14px;}
        .sf-audit-date{font-size:14px;color:#94a3b8;}
        .sf-audit-run-ghost{background:#fff !important;color:#FF6B35 !important;border:1px solid #FFBA96 !important;box-shadow:none !important;}
        .sf-audit-run-ghost:hover{background:#FFF7ED !important;border-color:#FF6B35 !important;}
        .sf-audit-summary{display:flex;align-items:center;gap:16px;background:#fff;border:1px solid #e2e8f0;border-radius:14px;padding:16px 20px;margin-bottom:20px;box-shadow:0 1px 3px rgba(0,0,0,0.06);flex-wrap:wrap;}
        .sf-audit-summary-score{width:56px;height:56px;border-radius:50%;display:flex;align-items:center;justify-content:center;font-size:22px;font-weight:800;color:#fff;flex-shrink:0;}
        .sf-audit-summary-text{flex:1;min-width:200px;}
        .sf-audit-summary-title{font-size:15px;font-weight:700;color:#1e293b;margin-bottom:2px;}
        .sf-audit-summary-sub{font-size:13px;color:#64748b;line-height:1.4;}
        .sf-audit-summary-counters{display:flex;gap:8px;margin-left:auto;}
        .sf-audit-summary-counter{background:#fff;border:1px solid #e2e8f0;border-radius:10px;padding:10px 14px;display:flex;flex-direction:column;align-items:center;justify-content:center;min-width:80px;cursor:pointer;transition:all 0.15s;font-family:inherit;}
        .sf-audit-summary-counter:hover{transform:translateY(-1px);box-shadow:0 3px 10px rgba(0,0,0,0.06);}
        .sf-audit-summary-counter:focus{outline:2px solid rgba(255,107,53,0.4);outline-offset:2px;}
        .sf-audit-summary-num{font-size:22px;font-weight:800;line-height:1;}
        .sf-audit-summary-label{font-size:11px;font-weight:700;text-transform:uppercase;letter-spacing:0.4px;margin-top:4px;}
        .sf-audit-summary-counter--critical{border-top:3px solid #DC2626;}
        .sf-audit-summary-counter--critical .sf-audit-summary-num,
        .sf-audit-summary-counter--critical .sf-audit-summary-label{color:#DC2626;}
        .sf-audit-summary-counter--warning{border-top:3px solid #D97706;}
        .sf-audit-summary-counter--warning .sf-audit-summary-num,
        .sf-audit-summary-counter--warning .sf-audit-summary-label{color:#D97706;}
        .sf-audit-summary-counter--info{border-top:3px solid #FF6B35;}
        .sf-audit-summary-counter--info .sf-audit-summary-num,
        .sf-audit-summary-counter--info .sf-audit-summary-label{color:#FF6B35;}
        /* Priority Action Plan — clickable buttons */
        .sf-audit-priorities{background:#fff;border:1px solid #e2e8f0;border-radius:12px;padding:20px 24px;margin-bottom:24px;box-shadow:0 1px 3px rgba(0,0,0,0.06);}
        .sf-audit-priorities h3{margin:0 0 12px;font-size:15px;font-weight:700;color:#1e293b;}
        .sf-audit-priority{display:flex;align-items:flex-start;gap:10px;padding:10px 0;border:none;border-bottom:1px solid #f1f5f9;background:none;text-align:left;width:100%;cursor:pointer;font-family:inherit;transition:background 0.15s;}
        .sf-audit-priority:hover{background:#FFF7ED;border-radius:8px;}
        .sf-audit-priority:last-child{border-bottom:none;}
        .sf-audit-priority-num{width:24px;height:24px;border-radius:50%;background:#FF6B35;color:#fff;font-size:14px;font-weight:700;display:flex;align-items:center;justify-content:center;flex-shrink:0;margin-top:1px;}
        .sf-audit-priority-text{font-size:14px;color:#1e293b;flex:1;}
        .sf-audit-priority-impact{font-size:14px;font-weight:600;text-transform:uppercase;padding:2px 8px;border-radius:4px;flex-shrink:0;}
        .sf-audit-priority-impact--high{background:#fef2f2;color:#dc2626;}
        .sf-audit-priority-impact--medium{background:#fffbeb;color:#d97706;}
        .sf-audit-priority-impact--low{background:#FFF7ED;color:#FF6B35;}
        /* Issue card */
        .sf-audit-issue{background:#fff;border:1px solid #e2e8f0;border-radius:10px;padding:16px 20px;margin-bottom:12px;box-shadow:0 1px 2px rgba(0,0,0,0.04);transition:border-color 0.2s, box-shadow 0.2s;}
        .sf-audit-issue--highlight{border-color:#FF6B35;box-shadow:0 0 0 3px rgba(255,107,53,0.18);}
        .sf-audit-issue-header{display:flex;align-items:center;gap:8px;margin-bottom:8px;flex-wrap:wrap;}
        .sf-audit-issue-badge{font-size:14px;font-weight:700;text-transform:uppercase;padding:2px 8px;border-radius:4px;letter-spacing:0.3px;}
        .sf-audit-issue-badge--critical{background:#fef2f2;color:#dc2626;}
        .sf-audit-issue-badge--warning{background:#fffbeb;color:#d97706;}
        .sf-audit-issue-badge--info{background:#FFF7ED;color:#FF6B35;}
        .sf-audit-issue-title{font-size:14px;font-weight:600;color:#1e293b;}
        .sf-audit-issue-desc{font-size:14px;color:#64748b;margin-bottom:8px;}
        .sf-audit-issue-rec{font-size:14px;color:#059669;background:#f0fdf4;border-radius:6px;padding:8px 12px;margin-bottom:8px;}
        .sf-audit-issue-rec strong{font-weight:600;}
        .sf-audit-issue-count{font-size:13px;background:#ef4444;color:#fff;padding:1px 7px;border-radius:10px;font-weight:600;margin-left:6px;}
        .sf-audit-bulk-fix{font-size:13px;color:#fff;background:linear-gradient(135deg,#FF6B35,#E05520);border:none;border-radius:6px;padding:5px 12px;font-weight:700;display:inline-flex;align-items:center;gap:4px;margin-left:auto;cursor:pointer;}
        .sf-audit-qw{display:flex;flex-wrap:wrap;gap:8px;margin-top:8px;}
        .sf-audit-qw-item{font-size:14px;background:#f0fdf4;color:#059669;border-radius:6px;padding:6px 12px;font-weight:500;}
        /* Accordion + table */
        .sf-audit-acc-toggle{display:inline-flex;align-items:center;gap:6px;background:none;border:0;color:#475569;font-size:13px;font-weight:600;padding:4px 6px;border-radius:6px;cursor:pointer;font-family:inherit;}
        .sf-audit-acc-toggle:hover{background:#f1f5f9;color:#0f172a;}
        .sf-audit-acc-toggle .dashicons{transition:transform 0.18s;font-size:16px;width:16px;height:16px;}
        .sf-audit-acc-toggle.is-open .dashicons{transform:rotate(90deg);}
        .sf-audit-acc{margin-top:8px;border:1px solid #e2e8f0;border-radius:8px;overflow:hidden;}
        .sf-audit-posts-table{width:100%;border-collapse:collapse;background:#fff;}
        .sf-audit-posts-table thead th{background:#f8fafc;font-size:12px;font-weight:600;text-transform:uppercase;color:#64748b;letter-spacing:0.4px;padding:8px 12px;text-align:left;}
        .sf-audit-posts-table td{padding:8px 12px;border-top:1px solid #f1f5f9;vertical-align:middle;}
        .sf-audit-posts-table tbody tr:hover{background:#fff7ed;}
        .sf-audit-col-check{width:36px;text-align:center;}
        .sf-audit-col-actions{width:200px;text-align:right;}
        .sf-audit-row-link{font-size:14px;color:#0f172a;text-decoration:none;font-weight:500;}
        .sf-audit-row-link:hover{color:#FF6B35;text-decoration:underline;}
        .sf-audit-row-edit,.sf-audit-row-fix{opacity:0;transition:opacity 0.12s;background:none;border:0;color:#64748b;cursor:pointer;padding:4px 8px;border-radius:6px;font-family:inherit;font-size:13px;display:inline-flex;align-items:center;gap:4px;}
        .sf-audit-row-edit:hover,.sf-audit-row-fix:hover{background:#fff;color:#FF6B35;}
        .sf-audit-row-fix{color:#E05520;font-weight:600;}
        .sf-audit-posts-table tbody tr:hover .sf-audit-row-edit,
        .sf-audit-posts-table tbody tr:hover .sf-audit-row-fix,
        .sf-audit-posts-table tbody tr:focus-within .sf-audit-row-edit,
        .sf-audit-posts-table tbody tr:focus-within .sf-audit-row-fix{opacity:1;}
        .sf-audit-row-fix .dashicons{font-size:14px;width:14px;height:14px;}
        .sf-audit-row-fix.loading{opacity:1 !important;cursor:wait;}
        /* "Fix manually" link — shown for issue types that are
 intentionally not AI-fixable. Unlike the hover-only Edit/Fix
 actions this stays visible at rest (muted) so the missing
 "Fix with AI" button reads as a deliberate choice, not a bug. */
        .sf-audit-row-manual{opacity:0.75;transition:opacity 0.12s,color 0.12s;text-decoration:none;color:#64748b;padding:4px 8px;border-radius:6px;font-family:inherit;font-size:13px;display:inline-flex;align-items:center;gap:4px;}
        .sf-audit-row-manual:hover,.sf-audit-row-manual:focus{opacity:1;background:#fff;color:#FF6B35;}
        .sf-audit-row-manual .dashicons{font-size:14px;width:14px;height:14px;}
        /* Sticky bulk-fix bar (story 4) */
        .sf-audit-bulk-bar{position:fixed;bottom:20px;left:50%;transform:translateX(-50%);background:#0f172a;color:#fff;border-radius:14px;padding:12px 20px;display:inline-flex;align-items:center;gap:14px;box-shadow:0 12px 32px rgba(0,0,0,0.25);z-index:1500;font-size:14px;}
        .sf-audit-bulk-bar-count{background:#FF6B35;color:#fff;border-radius:999px;font-weight:800;padding:2px 9px;font-size:13px;}
        .sf-audit-bulk-bar-label{color:#cbd5e1;}
        /* Clear button on the dark sticky bulk-fix bar. The `.button` base
 class wants a white-ish background, which made the light-grey
 text + underline almost invisible on the dark bar (the button
 looked like a white pill); on hover the text turned full white,
 disappearing entirely. Repaint as a transparent outlined pill
 with readable text in both states, no underline. */
        #sf-audit-bulk-clear {
            color: #cbd5e1 !important;
            background: transparent !important;
            border: 1px solid rgba(255,255,255,0.18) !important;
            border-radius: 8px !important;
            padding: 6px 14px !important;
            text-decoration: none !important;
            box-shadow: none !important;
            font-weight: 600;
        }
        #sf-audit-bulk-clear:hover,
        #sf-audit-bulk-clear:focus {
            color: #fff !important;
            background: rgba(255,255,255,0.1) !important;
            border-color: rgba(255,255,255,0.35) !important;
            text-decoration: none !important;
        }
        #sf-audit-bulk-fix{background:#FF6B35 !important;color:#fff !important;border:none !important;padding:6px 18px !important;border-radius:10px !important;font-weight:700 !important;box-shadow:0 4px 10px rgba(255,107,53,0.4);}
        #sf-audit-bulk-fix:hover{background:#E05520 !important;}
        /* follow-up: filter bar + pagination for audit issues */
        .sf-audit-issues-bar{display:flex;align-items:center;justify-content:space-between;flex-wrap:wrap;gap:12px;margin:24px 0 12px;}
        .sf-audit-issues-title{font-size:15px;font-weight:700;margin:0;}
        .sf-audit-filters{display:flex;gap:4px;background:#f1f5f9;padding:3px;border-radius:8px;}
        .sf-audit-filter{background:transparent;border:none;color:#475569;font-size:14px;font-weight:600;padding:6px 12px;border-radius:6px;cursor:pointer;transition:all 0.12s;}
        .sf-audit-filter:hover{color:#1e293b;background:rgba(255,255,255,0.6);}
        .sf-audit-filter.is-active{background:#fff;color:#E05520;box-shadow:0 1px 3px rgba(0,0,0,0.08);}
        .sf-audit-pagination{display:flex;flex-wrap:wrap;align-items:center;gap:4px;justify-content:center;margin:18px 0 8px;}
        .sf-audit-page{background:#fff;border:1px solid #e2e8f0;color:#475569;padding:6px 11px;border-radius:6px;font-size:14px;font-weight:600;cursor:pointer;transition:all 0.12s;min-width:32px;}
        .sf-audit-page:hover:not(:disabled){border-color:#FF6B35;color:#FF6B35;}
        .sf-audit-page.is-active{background:#FF6B35;color:#fff;border-color:#FF6B35;cursor:default;}
        .sf-audit-page:disabled{opacity:0.4;cursor:not-allowed;}
        .sf-audit-page--nav{padding:6px 14px;}
        .sf-audit-page-gap{color:#94a3b8;padding:0 4px;}
        .sf-audit-perpage{margin-left:auto;font-size:14px;color:#64748b;display:inline-flex;align-items:center;gap:6px;}
        .sf-audit-perpage select{padding:4px 8px;border:1px solid #e2e8f0;border-radius:6px;font-size:14px;}
        @media(max-width:782px){.sf-audit-cards{grid-template-columns:1fr;}.sf-audit-perpage{margin-left:0;width:100%;}}
        </style>

        <script>
        jQuery(function($){
            var $btn = $('#sf-run-audit'),
                $loading = $('#sf-audit-loading'),
                $results = $('#sf-audit-results'),
                $date = $('#sf-audit-date');

            // i18n strings passed from PHP
            var sfAuditI18n = {
                critical:        '<?php echo esc_js(__('Critical', 'seoforge')); ?>',
                warnings:        '<?php echo esc_js(__('Warnings', 'seoforge')); ?>',
                info:            '<?php echo esc_js(__('Info', 'seoforge')); ?>',
                health:          '<?php echo esc_js(__('SEO Health', 'seoforge')); ?>',
                priorityPlan:    '<?php echo esc_js(__('AI: Priority Action Plan', 'seoforge')); ?>',
                quickWins:       '<?php echo esc_js(__('Quick Wins', 'seoforge')); ?>',
                allIssues:       '<?php echo esc_js(__('All issues found', 'seoforge')); ?>',
                recommendation:  '<?php echo esc_js(__('Recommendation', 'seoforge')); ?>',
                fixWithAI:       '<?php echo esc_js(__('Fix with AI', 'seoforge')); ?>',
                fixAllAi:        '<?php /* translators: %d: number of posts to fix */ echo esc_js(__('Fix all %d with AI', 'seoforge')); ?>',
                fixInPlace:      '<?php echo esc_js(__('Apply AI fix in place (uses credits)', 'seoforge')); ?>',
                showPages:       '<?php /* translators: %d: number of pages */ echo esc_js(__('Show %d pages', 'seoforge')); ?>',
                hidePages:       '<?php echo esc_js(__('Hide pages', 'seoforge')); ?>',
                selectAll:       '<?php echo esc_js(__('Select all pages', 'seoforge')); ?>',
                pageCol:         '<?php echo esc_js(__('Page', 'seoforge')); ?>',
                edit:            '<?php echo esc_js(__('Edit', 'seoforge')); ?>',
                more:            '<?php echo esc_js(__('more', 'seoforge')); ?>',
                noIssuesTitle:   '<?php echo esc_js(__('Great! No issues found.', 'seoforge')); ?>',
                noIssuesDesc:    '<?php echo esc_js(__('Your site is well optimised.', 'seoforge')); ?>',
                lastAudit:       '<?php echo esc_js(__('Last audit:', 'seoforge')); ?>',
                auditError:      '<?php echo esc_js(__('Audit error:', 'seoforge')); ?>',
                connectionError: '<?php echo esc_js(__('Connection error. Please try again.', 'seoforge')); ?>',
                analyzing:       '<?php /* translators: 1: current item number, 2: total item count */ echo esc_js(__('Analyzing… %1$d / %2$d', 'seoforge')); ?>',
                editBaseUrl:     '<?php echo esc_js(admin_url('post.php?action=edit&post=')); ?>',
                filterAll:       '<?php echo esc_js(__('All', 'seoforge')); ?>',
                prevPage:        '<?php echo esc_js(__('Previous', 'seoforge')); ?>',
                nextPage:        '<?php echo esc_js(__('Next', 'seoforge')); ?>',
                pageOf:          '<?php /* translators: 1: current page number, 2: total number of pages */ echo esc_js(__('Page %1$d of %2$d', 'seoforge')); ?>',
                perPage:         '<?php echo esc_js(__('Per page', 'seoforge')); ?>',
 // "Fix manually" affordance for issue types that are
                // intentionally not AI-fixable. `manualHints` is keyed by
                // issue type code; the catch-all lives under '' .
                fixManually:     '<?php echo esc_js(__('Fix manually', 'seoforge')); ?>',
                manualHints: {
                    '':                  '<?php echo esc_js(SEOFORGE_Site_Audit::manual_fix_hint('')); ?>',
                    'missing_keyword':   '<?php echo esc_js(SEOFORGE_Site_Audit::manual_fix_hint('missing_keyword')); ?>',
                    'long_title':        '<?php echo esc_js(SEOFORGE_Site_Audit::manual_fix_hint('long_title')); ?>',
                    'short_description': '<?php echo esc_js(SEOFORGE_Site_Audit::manual_fix_hint('short_description')); ?>',
                    'long_description':  '<?php echo esc_js(SEOFORGE_Site_Audit::manual_fix_hint('long_description')); ?>',
                    'missing_h1':        '<?php echo esc_js(SEOFORGE_Site_Audit::manual_fix_hint('missing_h1')); ?>',
                    'duplicate_titles':  '<?php echo esc_js(SEOFORGE_Site_Audit::manual_fix_hint('duplicate_titles')); ?>'
                }
            };

 // slug helper for generating data-issue-key attributes
            function slugify(s) {
                return String(s || '').toLowerCase().replace(/[^a-z0-9]+/g, '-').replace(/(^-|-$)/g, '').slice(0, 80) || 'x';
            }

            $btn.on('click', function(){
                $btn.prop('disabled', true).css('opacity', '0.6');
                $loading.show();
                $results.hide();

                runAuditChunked(0, [], {}, null);
            });

            /**
 * Chunked audit runner — calls the AJAX endpoint repeatedly until complete.
 */
            function runAuditChunked(offset, issuesSoFar, statsSoFar, titleMapRaw) {
                var payload = {
                    action:     'seoforge_run_audit',
                    nonce:      seoforgeAdmin.nonce,
                    offset:     offset,
                    batch_size: 10
                };
                if (titleMapRaw) payload.title_map_raw = titleMapRaw;
                if (issuesSoFar.length) payload.issues_raw = JSON.stringify(issuesSoFar);
                if (Object.keys(statsSoFar).length) payload.stats_raw = JSON.stringify(statsSoFar);

                $.ajax({
                    url:      seoforgeAdmin.ajaxUrl,
                    type:     'POST',
                    data:     payload,
                    dataType: 'json'
                }).done(function(resp){
                    if (!resp || !resp.success) {
                        // Stop recursion — show error or upgrade prompt in the UI
                        $loading.hide();
                        $btn.prop('disabled', false).css('opacity', '1');
                        var msg = (resp && resp.data && resp.data.message) ? resp.data.message : sfAuditI18n.connectionError;
                        if (resp && resp.data && resp.data.upgrade) {
                            $results.html('<div class="notice notice-warning inline"><p>' + msg + '</p></div>').show();
                        } else {
                            $results.html('<div class="notice notice-error inline"><p>' + msg + '</p></div>').show();
                        }
                        return; // DO NOT recurse
                    }
                    var data = resp.data;

                    if (!data.complete) {
                        // Update progress text
                        var pct = data.total > 0 ? Math.round(data.processed / data.total * 100) : 0;
                        $loading.find('p:first').text(
                            sfAuditI18n.analyzing
                                .replace('%d', data.processed)
                                .replace('%d', data.total)
                            + ' (' + pct + '%)'
                        );
                        // Continue with next batch (recurse only on success)
                        runAuditChunked(
                            data.processed,
                            data.results_so_far || [],
                            data.stats || {},
                            data.title_map_raw || null
                        );
                    } else {
                        $loading.hide();
                        $btn.prop('disabled', false).css('opacity', '1');
                        renderAuditResults(data);
                        $results.show();
                    }
                }).fail(function(){
                    // Network/HTTP error — stop, do not recurse
                    $loading.hide();
                    $btn.prop('disabled', false).css('opacity', '1');
                    $results.html('<div class="notice notice-error inline"><p>' + sfAuditI18n.connectionError + '</p></div>').show();
                });
            }

            function renderAuditResults(data) {
                var h = '';

                /* compact hero: health + counters in one strip */
                var c = data.counts || {};
                var aiSum = data.ai_summary || {};
                var score = (typeof aiSum.health_score === 'number') ? aiSum.health_score : null;
                var bg = score === null ? '#94a3b8' : (score >= 70 ? '#059669' : (score >= 40 ? '#D97706' : '#DC2626'));
                h += '<div class="sf-audit-summary">';
                if (score !== null) {
                    h += '<div class="sf-audit-summary-score" style="background:'+bg+';">'+score+'</div>';
                }
                h += '<div class="sf-audit-summary-text">';
                h += '<div class="sf-audit-summary-title">'+sfAuditI18n.health+'</div>';
                h += '<div class="sf-audit-summary-sub">'+esc(aiSum.summary || '')+'</div>';
                h += '</div>';
                h += '<div class="sf-audit-summary-counters" role="group">';
                h += '<button type="button" class="sf-audit-summary-counter sf-audit-summary-counter--critical" data-sev="critical"><span class="sf-audit-summary-num">'+(c.critical||0)+'</span><span class="sf-audit-summary-label">'+sfAuditI18n.critical+'</span></button>';
                h += '<button type="button" class="sf-audit-summary-counter sf-audit-summary-counter--warning" data-sev="warning"><span class="sf-audit-summary-num">'+(c.warning||0)+'</span><span class="sf-audit-summary-label">'+sfAuditI18n.warnings+'</span></button>';
                h += '<button type="button" class="sf-audit-summary-counter sf-audit-summary-counter--info" data-sev="info"><span class="sf-audit-summary-num">'+(c.info||0)+'</span><span class="sf-audit-summary-label">'+sfAuditI18n.info+'</span></button>';
                h += '</div></div>';

                /* AI Priorities — clickable to scroll/highlight matching issue */
                if (data.ai_summary && data.ai_summary.top_priorities && data.ai_summary.top_priorities.length) {
                    h += '<div class="sf-audit-priorities"><h3><span class="dashicons dashicons-superhero-alt" style="color:#FF6B35;font-size:16px;vertical-align:text-bottom;margin-right:4px;"></span> '+sfAuditI18n.priorityPlan+'</h3>';
                    data.ai_summary.top_priorities.forEach(function(p, i){
                        var impCls = 'sf-audit-priority-impact--'+(p.impact||'medium');
                        h += '<button type="button" class="sf-audit-priority" data-priority-idx="'+i+'" data-priority-text="'+esc(p.action||'')+'">';
                        h += '<span class="sf-audit-priority-num">'+(i+1)+'</span>';
                        h += '<span class="sf-audit-priority-text">'+esc(p.action)+'</span>';
                        h += '<span class="sf-audit-priority-impact '+impCls+'">'+(p.impact||'')+'</span>';
                        h += '</button>';
                    });
                    /* Quick wins */
                    if (data.ai_summary.quick_wins && data.ai_summary.quick_wins.length) {
                        h += '<h3 style="margin-top:16px;font-size:14px;">&#9889; '+sfAuditI18n.quickWins+'</h3>';
                        h += '<div class="sf-audit-qw">';
                        data.ai_summary.quick_wins.forEach(function(w){
                            h += '<div class="sf-audit-qw-item">'+esc(w)+'</div>';
                        });
                        h += '</div>';
                    }
                    h += '</div>';
                }

                /* Issues list — wrapped so the filter bar + pagination JS picks it up */
                if (data.issues && data.issues.length) {
                    var counts = {critical:0, warning:0, info:0};
                    data.issues.forEach(function(i){ if (counts[i.severity] !== undefined) counts[i.severity]++; });
                    h += '<div id="sf-audit-issues-wrap" data-total="'+data.issues.length+'">';
                    h += '<div class="sf-audit-issues-bar">';
                    h += '<h3 class="sf-audit-issues-title">'+sfAuditI18n.allIssues+' (<span data-visible-count>'+data.issues.length+'</span>)</h3>';
                    h += '<div class="sf-audit-filters" role="group" aria-label="Severity filter">';
                    h += '<button type="button" class="sf-audit-filter is-active" data-sev="all">'+sfAuditI18n.filterAll+' ('+data.issues.length+')</button>';
                    h += '<button type="button" class="sf-audit-filter" data-sev="critical">'+sfAuditI18n.critical+' ('+counts.critical+')</button>';
                    h += '<button type="button" class="sf-audit-filter" data-sev="warning">'+sfAuditI18n.warnings+' ('+counts.warning+')</button>';
                    h += '<button type="button" class="sf-audit-filter" data-sev="info">'+sfAuditI18n.info+' ('+counts.info+')</button>';
                    h += '</div></div>';
                    h += '<div id="sf-audit-issues-list">';
                    data.issues.forEach(function(issue, issueIdx){
                        var postCount = (issue.posts && issue.posts.length) || 0;
                        var key = slugify(issue.title || ('issue-' + issueIdx));
                        var combinedIssue = (issue.title||'') + ': ' + (issue.recommendation||'');
                        h += '<div class="sf-audit-issue" data-sev="'+issue.severity+'" data-issue-key="'+esc(key)+'">';
                        h += '<div class="sf-audit-issue-header">';
                        h += '<span class="sf-audit-issue-badge sf-audit-issue-badge--'+issue.severity+'">'+issue.severity+'</span>';
                        h += '<span class="sf-audit-issue-title">'+esc(issue.title)+'</span>';
                        if (postCount > 0) {
                            h += '<span class="sf-audit-issue-count">×'+postCount+'</span>';
                        }
                        if (issue.fixable && postCount > 1) {
                            var bulkIds = issue.posts.map(function(p){ return p.id; }).join(',');
                            h += '<button type="button" class="sf-audit-bulk-fix" '
                              + 'data-ids="'+esc(bulkIds)+'" '
                              + 'data-issue-type="'+esc(issue.type || '')+'" '
                              + 'data-issue="'+esc(combinedIssue)+'">'
                              + '<span class="dashicons dashicons-superhero" style="font-size:14px;width:12px;height:12px;"></span>'
                              + (sfAuditI18n.fixAllAi ? sfAuditI18n.fixAllAi.replace('%d', postCount) : ('Fix all ' + postCount + ' with AI'))
                              + '</button>';
                        }
                        h += '</div>';
                        h += '<div class="sf-audit-issue-desc">'+esc(issue.description)+'</div>';
                        h += '<div class="sf-audit-issue-rec"><strong>'+sfAuditI18n.recommendation+':</strong> '+esc(issue.recommendation)+'</div>';
                        if (postCount) {
 // story 3: accordion — show-N-pages toggle, table inside.
                            var accId = 'sf-audit-acc-' + key;
                            var showLabel = (sfAuditI18n.showPages || 'Show %d pages').replace('%d', postCount);
                            var hideLabel = (sfAuditI18n.hidePages || 'Hide pages');
                            h += '<button type="button" class="sf-audit-acc-toggle" aria-expanded="false" aria-controls="'+accId+'">';
                            h += '<span class="dashicons dashicons-arrow-right" aria-hidden="true"></span>';
                            h += '<span class="sf-audit-acc-toggle-label" data-open="'+esc(hideLabel)+'" data-closed="'+esc(showLabel)+'">'+esc(showLabel)+'</span>';
                            h += '</button>';
                            h += '<div id="'+accId+'" class="sf-audit-acc" hidden>';
                            h += '<table class="sf-audit-posts-table" data-issue="'+esc(combinedIssue)+'" data-issue-type="'+esc(issue.type || '')+'">';
                            h += '<thead><tr>';
                            h += '<th class="sf-audit-col-check"><input type="checkbox" class="sf-audit-checkall" aria-label="'+esc(sfAuditI18n.selectAll || 'Select all')+'"></th>';
                            h += '<th class="sf-audit-col-title">'+esc(sfAuditI18n.pageCol || 'Page')+'</th>';
                            h += '<th class="sf-audit-col-actions"></th>';
                            h += '</tr></thead><tbody>';
                            issue.posts.forEach(function(p){
                                var editUrl = sfAuditI18n.editBaseUrl + p.id;
                                h += '<tr data-post-id="'+p.id+'">';
                                h += '<td class="sf-audit-col-check"><input type="checkbox" class="sf-audit-post-check" value="'+p.id+'" aria-label="'+esc(p.title||'')+'"></td>';
                                h += '<td class="sf-audit-col-title"><a href="'+editUrl+'" target="_blank" class="sf-audit-row-link">'+esc(p.title||'')+'</a></td>';
                                h += '<td class="sf-audit-col-actions">';
                                h += '<a href="'+editUrl+'" target="_blank" class="sf-audit-row-edit" title="'+esc(sfAuditI18n.edit || 'Edit')+'"><span class="dashicons dashicons-edit" aria-hidden="true"></span></a>';
                                if (issue.fixable) {
                                    h += '<button type="button" class="sf-audit-row-fix sf-fix-btn" '
                                      + 'data-post="'+p.id+'" '
                                      + 'data-issue="'+esc(combinedIssue)+'" '
                                      + 'data-issue-type="'+esc(issue.type || '')+'" '
                                      + 'title="'+esc(sfAuditI18n.fixWithAI)+'">'
                                      + '<span class="dashicons dashicons-superhero-alt" aria-hidden="true"></span>'
                                      + '<span class="sf-audit-row-fix-label">'+esc(sfAuditI18n.fixWithAI)+'</span>'
                                      + '</button>';
                                } else {
 // issue type intentionally not AI-fixable —
                                    // render a "Fix manually" link to the editor with a
                                    // type-specific tooltip so it looks deliberate.
                                    var manualHints = sfAuditI18n.manualHints || {};
                                    var manualHint = manualHints[issue.type || ''] || manualHints[''] || '';
                                    h += '<a href="'+editUrl+'" target="_blank" class="sf-audit-row-manual" '
                                      + 'title="'+esc(manualHint)+'">'
                                      + '<span class="dashicons dashicons-admin-tools" aria-hidden="true"></span>'
                                      + '<span class="sf-audit-row-manual-label">'+esc(sfAuditI18n.fixManually || 'Fix manually')+'</span>'
                                      + '</a>';
                                }
                                h += '</td></tr>';
                            });
                            h += '</tbody></table></div>';
                        }
                        h += '</div>';
                    });
                    h += '</div>';// /#sf-audit-issues-list
                    h += '<nav class="sf-audit-pagination" aria-label="Issues pagination"></nav>';
                    h += '</div>';// /#sf-audit-issues-wrap
                }

                if (!data.issues || data.issues.length === 0) {
                    h += '<div style="text-align:center;padding:40px;background:#f0fdf4;border-radius:12px;margin-top:16px;">';
                    h += '<span class="dashicons dashicons-yes-alt" style="font-size:48px;width:48px;height:48px;color:#059669;"></span>';
                    h += '<h3 style="color:#059669;margin:12px 0 4px;">'+sfAuditI18n.noIssuesTitle+'</h3>';
                    h += '<p style="color:#64748b;font-size:14px;">'+sfAuditI18n.noIssuesDesc+'</p></div>';
                }

                $results.html(h);
                initIssuesPager();
                /* Update date */
                if (data.date) {
                    $date.text(sfAuditI18n.lastAudit + ' ' + data.date);
                }
            }

 // follow-up: client-side filter + pagination so 1000+ issues
            // are not dumped as a single 50-screen scroll. Default 25 / page;
            // user can change via the page-size <select>. Filter buttons cap
            // by severity (Critical / Warnings / Info / All). Pagination
            // recalculates whenever the filter changes.
            function initIssuesPager(){
                var $wrap = jQuery('#sf-audit-issues-wrap');
                if (!$wrap.length) return;
                var $list  = $wrap.find('#sf-audit-issues-list');
                var $items = $list.find('.sf-audit-issue');
                var $pager = $wrap.find('.sf-audit-pagination');
                var $count = $wrap.find('[data-visible-count]');
                var state = { sev: 'all', page: 1, perPage: 25 };

                function render(){
                    var visible = $items.filter(function(){
                        return state.sev === 'all' || jQuery(this).data('sev') === state.sev;
                    });
                    var total   = visible.length;
                    var pages   = Math.max(1, Math.ceil(total / state.perPage));
                    if (state.page > pages) state.page = pages;
                    var start = (state.page - 1) * state.perPage;
                    var end   = start + state.perPage;
                    $items.hide();
                    visible.slice(start, end).show();
                    $count.text(total);
                    // Build pager
                    if (total <= state.perPage) {
                        $pager.empty();
                        return;
                    }
                    var html = '';
                    html += '<button type="button" class="sf-audit-page sf-audit-page--nav" data-act="prev" '+(state.page<=1?'disabled':'')+'>&lsaquo; '+sfAuditI18n.prevPage+'</button>';
                    // Numbered buttons (compact: first, last, current ±2)
                    var shown = {};
                    function add(p){ if(p<1||p>pages||shown[p]) return; shown[p]=1;
                        html += '<button type="button" class="sf-audit-page'+(p===state.page?' is-active':'')+'" data-page="'+p+'">'+p+'</button>';
                    }
                    add(1);
                    if (state.page > 4) html += '<span class="sf-audit-page-gap">&hellip;</span>';
                    for (var p = state.page - 2; p <= state.page + 2; p++) add(p);
                    if (state.page < pages - 3) html += '<span class="sf-audit-page-gap">&hellip;</span>';
                    add(pages);
                    html += '<button type="button" class="sf-audit-page sf-audit-page--nav" data-act="next" '+(state.page>=pages?'disabled':'')+'>'+sfAuditI18n.nextPage+' &rsaquo;</button>';
                    html += '<label class="sf-audit-perpage">'+sfAuditI18n.perPage+': <select>';
                    [10,25,50,100].forEach(function(n){
                        html += '<option value="'+n+'"'+(state.perPage===n?' selected':'')+'>'+n+'</option>';
                    });
                    html += '</select></label>';
                    $pager.html(html);
                }

                $wrap.on('click', '.sf-audit-filter', function(){
                    $wrap.find('.sf-audit-filter').removeClass('is-active');
                    jQuery(this).addClass('is-active');
                    state.sev  = jQuery(this).data('sev');
                    state.page = 1;
                    render();
                });
                $wrap.on('click', '.sf-audit-page[data-page]', function(){ state.page = parseInt(jQuery(this).data('page'),10); render(); jQuery('html,body').animate({scrollTop:$wrap.offset().top-60},200); });
                $wrap.on('click', '.sf-audit-page[data-act="prev"]', function(){ if(state.page>1){state.page--;render();jQuery('html,body').animate({scrollTop:$wrap.offset().top-60},200);} });
                $wrap.on('click', '.sf-audit-page[data-act="next"]', function(){ state.page++; render(); jQuery('html,body').animate({scrollTop:$wrap.offset().top-60},200); });
                $wrap.on('change', '.sf-audit-perpage select', function(){ state.perPage = parseInt(jQuery(this).val(),10); state.page = 1; render(); });
                render();
            }
            // Initialise on first paint (server-rendered cached audit).
            jQuery(initIssuesPager);

 // ─── Site Audit redesign interactivity ──────────────────
            function initSiteAuditRedesign() {
                var $wrap = jQuery('#sf-audit-issues-wrap');
                if (!$wrap.length) return;

                // Story 1: clickable counters — scroll to issue block + apply severity filter
                jQuery(document).off('click.sfAuditCounter').on('click.sfAuditCounter', '.sf-audit-summary-counter', function(){
                    var sev = jQuery(this).data('sev');
                    var $btn = jQuery('.sf-audit-filter[data-sev="'+sev+'"]');
                    if ($btn.length) $btn.trigger('click');
                    jQuery('html,body').animate({ scrollTop: jQuery('#sf-audit-issues-wrap').offset().top - 40 }, 280);
                });

                // Story 2: priority item click — find matching issue by substring and scroll/highlight
                jQuery(document).off('click.sfAuditPriority').on('click.sfAuditPriority', '.sf-audit-priority', function(){
                    var action = String(jQuery(this).data('priority-text') || '').toLowerCase();
                    if (!action) return;
                    var best = null, bestScore = 0;
                    jQuery('.sf-audit-issue').each(function(){
                        var title = (jQuery(this).find('.sf-audit-issue-title').text() || '').toLowerCase();
                        var score = 0;
                        action.split(/\s+/).forEach(function(word){
                            if (word.length > 3 && title.indexOf(word) !== -1) score++;
                        });
                        if (score > bestScore) { bestScore = score; best = this; }
                    });
                    if (!best) return;
                    var $target = jQuery(best);
                    // Expand accordion if collapsed
                    var $acc = $target.find('.sf-audit-acc-toggle');
                    if ($acc.length && $acc.attr('aria-expanded') === 'false') $acc.trigger('click');
                    // Flash highlight
                    $target.addClass('sf-audit-issue--highlight');
                    setTimeout(function(){ $target.removeClass('sf-audit-issue--highlight'); }, 1800);
                    jQuery('html,body').animate({ scrollTop: $target.offset().top - 40 }, 280);
                });

                // Story 3: accordion toggle
                jQuery(document).off('click.sfAuditAcc').on('click.sfAuditAcc', '.sf-audit-acc-toggle', function(){
                    var $btn = jQuery(this);
                    var accId = $btn.attr('aria-controls') || '';
 // v3: server-rendered IDs use sanitize_title
                    // which percent-encodes Cyrillic UTF-8 (e.g.
                    // "sf-audit-acc-%d0%be%d1%82..."). jQuery's selector
                    // engine throws "Syntax error, unrecognized expression"
                    // on `%` inside `#…`, which silently kills the
                    // delegated handler — clicking Show pages did
                    // nothing on prod. document.getElementById accepts
                    // any literal ID (it doesn't run CSS-selector
                    // tokenisation), so wrap that in jQuery to keep the
                    // rest of the .prop('hidden', …) flow intact.
                    var accNode = accId ? document.getElementById(accId) : null;
                    if (!accNode) return;
                    var $acc = jQuery(accNode);
                    var open = $btn.attr('aria-expanded') === 'true';
                    $btn.attr('aria-expanded', open ? 'false' : 'true').toggleClass('is-open', !open);
                    $acc.prop('hidden', open);
                    var $lbl = $btn.find('.sf-audit-acc-toggle-label');
                    $lbl.text(open ? $lbl.attr('data-closed') : $lbl.attr('data-open'));
                });

                // Story 4: select-all + sticky bulk-fix bar
                var $bar   = jQuery('#sf-audit-bulk-bar');
                var $barN  = jQuery('#sf-audit-bulk-count');
                function updateBulkBar(){
                    var $all = jQuery('.sf-audit-post-check:checked');
                    var n = $all.length;
                    if (n === 0) { $bar.prop('hidden', true); return; }
                    $barN.text(n);
                    $bar.prop('hidden', false);
                    // Aggregate selected post IDs + associated issue (per selected table)
                    var data = [];
                    jQuery('.sf-audit-posts-table').each(function(){
                        var $t = jQuery(this);
                        var checked = $t.find('.sf-audit-post-check:checked').map(function(){ return parseInt(this.value, 10); }).get();
                        if (!checked.length) return;
                        data.push({ issue: $t.data('issue') || '', code: $t.data('issue-type') || '', ids: checked });
                    });
                    jQuery('#sf-audit-bulk-fix').data('selection', data);
                }
                jQuery(document).off('change.sfAuditCheck').on('change.sfAuditCheck', '.sf-audit-post-check, .sf-audit-checkall', function(){
                    if (jQuery(this).hasClass('sf-audit-checkall')) {
                        var on = this.checked;
                        jQuery(this).closest('table').find('.sf-audit-post-check').prop('checked', on).trigger('change');
                        return;
                    }
                    var $t = jQuery(this).closest('table');
                    var total = $t.find('.sf-audit-post-check').length;
                    var sel   = $t.find('.sf-audit-post-check:checked').length;
                    $t.find('.sf-audit-checkall').prop('checked', sel > 0 && sel === total).prop('indeterminate', sel > 0 && sel < total);
                    updateBulkBar();
                });
                jQuery('#sf-audit-bulk-clear').off('click.sfAuditClear').on('click.sfAuditClear', function(){
                    jQuery('.sf-audit-post-check,.sf-audit-checkall').prop('checked', false).prop('indeterminate', false);
                    updateBulkBar();
                });
                jQuery('#sf-audit-bulk-fix').off('click.sfAuditBulkFix').on('click.sfAuditBulkFix', function(){
                    var data = jQuery(this).data('selection') || [];
                    if (!data.length) return;
 // story 5: route text-issue groups through the drawer's
                    // bulk-review mode (one accordion per page, single Apply All).
                    // Non-text issue groups keep the legacy chunked chain-of-fixes.
                    var textGroups = [], otherGroups = [];
                    data.forEach(function(g){
 // v2: match on canonical code first, fall back
                        // to language-neutral text match (RU+EN).
                        var textCodes = ['missing_meta_title','long_title','short_title','missing_meta_desc','long_description','short_description','missing_keyword'];
                        if (g.code && textCodes.indexOf(g.code) !== -1) { textGroups.push(g); return; }
                        var lower = String(g.issue || '').toLowerCase();
                        if (/focus keyword|ключев(ое|ых) слов|meta description|meta-description|мета.?описан|meta title|seo.?title|seo.?заголов|мета.?заголов|\btitle\b/.test(lower)) textGroups.push(g);
                        else otherGroups.push(g);
                    });
                    var opened = false;
                    if (textGroups.length && typeof window.sfDrawerOpenBulk === 'function') {
                        opened = window.sfDrawerOpenBulk(textGroups);
                    }
                    // Anything drawer-incompatible still runs chunked in place.
                    otherGroups.forEach(function(group){
                        var $temp = jQuery('<button>').attr({
                            'data-ids': group.ids.join(','),
                            'data-issue': group.issue,
                        }).addClass('sf-audit-bulk-fix').css({display:'none'});
                        jQuery('body').append($temp);
                        $temp.trigger('click');
                        setTimeout(function(){ $temp.remove(); }, 0);
                    });
                    // If nothing got routed — e.g. the selection was entirely empty
                    // after filtering — fall back to the old behaviour so the user
                    // doesn't get a silent no-op.
                    if (!opened && !otherGroups.length) {
                        data.forEach(function(group){
                            var $temp = jQuery('<button>').attr({
                                'data-ids': group.ids.join(','),
                                'data-issue': group.issue,
                            }).addClass('sf-audit-bulk-fix').css({display:'none'});
                            jQuery('body').append($temp);
                            $temp.trigger('click');
                            setTimeout(function(){ $temp.remove(); }, 0);
                        });
                    }
                });

                updateBulkBar();
            }
            jQuery(initSiteAuditRedesign);
            // Re-init after a fresh render
            jQuery(document).ajaxComplete(function(_e, _x, settings){
                if (settings && settings.data && /action=seoforge_run_audit/.test(String(settings.data))) {
                    setTimeout(initSiteAuditRedesign, 20);
                }
            });

            function esc(s) {
                if (!s) return '';
                var d = document.createElement('div');
                d.appendChild(document.createTextNode(s));
                return d.innerHTML;
            }
        });
        </script>
        <?php
    }

    /**
 * Render server-side cached audit results (used on initial page load).
 */
    private function render_audit_results($data) {
        $counts = $data['counts'] ?? [];
        $ai = $data['ai_summary'] ?? null;
        ?>

        <?php
            // compact hero strip combines health score + 3 severity
            // counters into one row. Counters are anchor-buttons — clicking
            // smooth-scrolls to the issue list and activates the matching filter.
            $score = isset($ai['health_score']) ? (int)$ai['health_score'] : null;
            $bg    = $score === null ? '#94a3b8' : ($score >= 70 ? '#059669' : ($score >= 40 ? '#D97706' : '#DC2626'));
        ?>
        <div class="sf-audit-summary">
            <?php if ($score !== null): ?>
            <div class="sf-audit-summary-score" style="background:<?php echo esc_attr($bg); ?>;"><?php echo (int) $score; ?></div>
            <?php endif; ?>
            <div class="sf-audit-summary-text">
                <div class="sf-audit-summary-title"><?php esc_html_e('SEO Health', 'seoforge'); ?></div>
                <div class="sf-audit-summary-sub"><?php echo esc_html($ai['summary'] ?? ''); ?></div>
            </div>
            <div class="sf-audit-summary-counters" role="group" aria-label="Severity counters">
                <button type="button" class="sf-audit-summary-counter sf-audit-summary-counter--critical" data-sev="critical">
                    <span class="sf-audit-summary-num"><?php echo (int)($counts['critical'] ?? 0); ?></span>
                    <span class="sf-audit-summary-label"><?php esc_html_e('Critical', 'seoforge'); ?></span>
                </button>
                <button type="button" class="sf-audit-summary-counter sf-audit-summary-counter--warning" data-sev="warning">
                    <span class="sf-audit-summary-num"><?php echo (int)($counts['warning'] ?? 0); ?></span>
                    <span class="sf-audit-summary-label"><?php esc_html_e('Warnings', 'seoforge'); ?></span>
                </button>
                <button type="button" class="sf-audit-summary-counter sf-audit-summary-counter--info" data-sev="info">
                    <span class="sf-audit-summary-num"><?php echo (int)($counts['info'] ?? 0); ?></span>
                    <span class="sf-audit-summary-label"><?php esc_html_e('Info', 'seoforge'); ?></span>
                </button>
            </div>
        </div>

        <?php if ($ai && !empty($ai['top_priorities'])): ?>
        <div class="sf-audit-priorities">
            <h3><span class="dashicons dashicons-superhero-alt" style="color:#FF6B35;font-size:16px;vertical-align:text-bottom;margin-right:4px;"></span> <?php esc_html_e('AI: Priority Action Plan', 'seoforge'); ?></h3>
            <?php foreach ($ai['top_priorities'] as $i => $p): ?>
            <button type="button"
                    class="sf-audit-priority"
                    data-priority-idx="<?php echo (int) $i; ?>"
                    data-priority-text="<?php echo esc_attr($p['action'] ?? ''); ?>"
                    <?php /* translators: %s: the SEO issue action text */ ?>
                    aria-label="<?php echo esc_attr(sprintf(__('Jump to the matching issue — %s', 'seoforge'), $p['action'] ?? '')); ?>">
                <span class="sf-audit-priority-num"><?php echo esc_html( $i + 1 ); ?></span>
                <span class="sf-audit-priority-text"><?php echo esc_html($p['action'] ?? ''); ?></span>
                <span class="sf-audit-priority-impact sf-audit-priority-impact--<?php echo esc_attr($p['impact'] ?? 'medium'); ?>"><?php echo esc_html($p['impact'] ?? ''); ?></span>
            </button>
            <?php endforeach; ?>

            <?php if (!empty($ai['quick_wins'])): ?>
            <h3 style="margin-top:16px;font-size:14px;">&#9889; <?php esc_html_e('Quick Wins', 'seoforge'); ?></h3>
            <div class="sf-audit-qw">
                <?php foreach ($ai['quick_wins'] as $w): ?>
                <div class="sf-audit-qw-item"><?php echo esc_html($w); ?></div>
                <?php endforeach; ?>
            </div>
            <?php endif; ?>
        </div>
        <?php endif; ?>

        <?php if (!empty($data['issues'])):
            $sev_counts = ['critical'=>0,'warning'=>0,'info'=>0];
            foreach ($data['issues'] as $i) { if (isset($sev_counts[$i['severity']])) $sev_counts[$i['severity']]++; }
        ?>
        <div id="sf-audit-issues-wrap" data-total="<?php echo esc_attr( count($data['issues']) ); ?>">
            <div class="sf-audit-issues-bar">
                <h3 class="sf-audit-issues-title"><?php printf(esc_html__('All issues found', 'seoforge')); ?> (<span data-visible-count><?php echo esc_html( count($data['issues']) ); ?></span>)</h3>
                <div class="sf-audit-filters" role="group" aria-label="Severity filter">
                    <button type="button" class="sf-audit-filter is-active" data-sev="all"><?php esc_html_e('All', 'seoforge'); ?> (<?php echo esc_html( count($data['issues']) ); ?>)</button>
                    <button type="button" class="sf-audit-filter" data-sev="critical"><?php esc_html_e('Critical', 'seoforge'); ?> (<?php echo esc_html( $sev_counts['critical'] ); ?>)</button>
                    <button type="button" class="sf-audit-filter" data-sev="warning"><?php esc_html_e('Warnings', 'seoforge'); ?> (<?php echo esc_html( $sev_counts['warning'] ); ?>)</button>
                    <button type="button" class="sf-audit-filter" data-sev="info"><?php esc_html_e('Info', 'seoforge'); ?> (<?php echo esc_html( $sev_counts['info'] ); ?>)</button>
                </div>
            </div>
            <div id="sf-audit-issues-list">
        <?php foreach ($data['issues'] as $issue):
            $post_count = count($issue['posts'] ?? []);
            $all_post_ids = array_map(function($p){ return (int)$p['id']; }, $issue['posts'] ?? []);
        ?>
        <?php
            $issue_key = sanitize_title($issue['title']) ?: md5($issue['title']);
            $combined_issue = ($issue['title'] ?? '') . ': ' . ($issue['recommendation'] ?? '');
        ?>
        <div class="sf-audit-issue" data-sev="<?php echo esc_attr($issue['severity']); ?>" data-issue-key="<?php echo esc_attr($issue_key); ?>">
            <div class="sf-audit-issue-header">
                <span class="sf-audit-issue-badge sf-audit-issue-badge--<?php echo esc_attr($issue['severity']); ?>"><?php echo esc_html($issue['severity']); ?></span>
                <span class="sf-audit-issue-title"><?php echo esc_html($issue['title']); ?></span>
                <?php if ($post_count > 0): ?>
                <?php /* translators: %d: number of posts affected by the issue */ ?>
                <span class="sf-audit-issue-count" title="<?php echo esc_attr(sprintf(_n('%d post affected', '%d posts affected', $post_count, 'seoforge'), $post_count)); ?>">×<?php echo esc_html( $post_count ); ?></span>
                <?php endif; ?>
                <?php if (!empty($issue['fixable']) && $post_count > 1): ?>
                <button type="button"
                        class="sf-audit-bulk-fix"
                        data-ids="<?php echo esc_attr(implode(',', $all_post_ids)); ?>"
                        data-issue="<?php echo esc_attr($combined_issue); ?>"
                        data-issue-type="<?php echo esc_attr($issue['type'] ?? ''); ?>"
                        title="<?php esc_attr_e('Apply AI fix to all affected posts', 'seoforge'); ?>">
                    <span class="dashicons dashicons-superhero" style="font-size:14px;width:12px;height:12px;"></span>
                    <?php /* translators: %d: number of posts to fix */ ?>
                    <?php printf(esc_html__('Fix all %d with AI', 'seoforge'), esc_html($post_count)); ?>
                </button>
                <?php endif; ?>
            </div>
            <div class="sf-audit-issue-desc"><?php echo esc_html($issue['description']); ?></div>
            <div class="sf-audit-issue-rec"><strong><?php esc_html_e('Recommendation', 'seoforge'); ?>:</strong> <?php echo esc_html($issue['recommendation']); ?></div>
            <?php if (!empty($issue['posts'])): ?>
            <?php
                // story 3: hide the post list behind an accordion — click
                // "Show N pages" to reveal the table. Table has checkboxes for
                // bulk-select and actions that appear on row hover.
                $acc_id = 'sf-audit-acc-' . $issue_key;
            ?>
            <button type="button" class="sf-audit-acc-toggle" aria-expanded="false" aria-controls="<?php echo esc_attr($acc_id); ?>">
                <span class="dashicons dashicons-arrow-right" aria-hidden="true"></span>
                <?php /* translators: %d: number of pages to show/hide */ ?>
                <span class="sf-audit-acc-toggle-label" data-open="<?php esc_attr_e('Hide pages', 'seoforge'); ?>" data-closed="<?php echo esc_attr(sprintf(_n('Show %d page', 'Show %d pages', $post_count, 'seoforge'), $post_count)); ?>"><?php echo esc_html(sprintf(_n('Show %d page', 'Show %d pages', $post_count, 'seoforge'), $post_count)); ?></span>
            </button>
            <div id="<?php echo esc_attr($acc_id); ?>" class="sf-audit-acc" hidden>
                <table class="sf-audit-posts-table" data-issue="<?php echo esc_attr($combined_issue); ?>" data-issue-type="<?php echo esc_attr($issue['type'] ?? ''); ?>">
                    <thead>
                        <tr>
                            <th class="sf-audit-col-check"><input type="checkbox" class="sf-audit-checkall" aria-label="<?php esc_attr_e('Select all pages', 'seoforge'); ?>"></th>
                            <th class="sf-audit-col-title"><?php esc_html_e('Page', 'seoforge'); ?></th>
                            <th class="sf-audit-col-actions"></th>
                        </tr>
                    </thead>
                    <tbody>
                    <?php foreach ($issue['posts'] as $p): ?>
                        <tr data-post-id="<?php echo (int) $p['id']; ?>">
                            <td class="sf-audit-col-check"><input type="checkbox" class="sf-audit-post-check" value="<?php echo (int) $p['id']; ?>" aria-label="<?php echo esc_attr($p['title']); ?>"></td>
                            <td class="sf-audit-col-title">
                                <a href="<?php echo esc_url( admin_url('post.php?action=edit&post=' . (int)$p['id']) ); ?>" target="_blank" class="sf-audit-row-link">
                                    <?php echo esc_html($p['title']); ?>
                                </a>
                            </td>
                            <td class="sf-audit-col-actions">
                                <a href="<?php echo esc_url( admin_url('post.php?action=edit&post=' . (int)$p['id']) ); ?>" target="_blank" class="sf-audit-row-edit" title="<?php esc_attr_e('Edit', 'seoforge'); ?>">
                                    <span class="dashicons dashicons-edit" aria-hidden="true"></span>
                                </a>
                                <?php if (!empty($issue['fixable'])): ?>
                                <button type="button"
                                        class="sf-audit-row-fix sf-fix-btn"
                                        data-post="<?php echo (int) $p['id']; ?>"
                                        data-issue="<?php echo esc_attr($combined_issue); ?>"
                                        data-issue-type="<?php echo esc_attr($issue['type'] ?? ''); ?>"
                                        title="<?php esc_attr_e('Fix with AI', 'seoforge'); ?>">
                                    <span class="dashicons dashicons-superhero-alt" aria-hidden="true"></span>
                                    <span class="sf-audit-row-fix-label"><?php esc_html_e('Fix with AI', 'seoforge'); ?></span>
                                </button>
                                <?php else:
                                    // this issue type is intentionally NOT AI-fixable
                                    // (e.g. "focus keyword not set" needs a human editorial
                                    // decision). Show a "Fix manually" link to the post editor
                                    // with a tooltip explaining what to do, so the absent AI
                                    // button reads as a deliberate choice, not a broken UI.
                                    $manual_hint = SEOFORGE_Site_Audit::manual_fix_hint($issue['type'] ?? '');
                                ?>
                                <a href="<?php echo esc_url( admin_url('post.php?action=edit&post=' . (int)$p['id']) ); ?>"
                                   target="_blank"
                                   class="sf-audit-row-manual"
                                   title="<?php echo esc_attr($manual_hint); ?>">
                                    <span class="dashicons dashicons-admin-tools" aria-hidden="true"></span>
                                    <span class="sf-audit-row-manual-label"><?php esc_html_e('Fix manually', 'seoforge'); ?></span>
                                </a>
                                <?php endif; ?>
                            </td>
                        </tr>
                    <?php endforeach; ?>
                    </tbody>
                </table>
            </div>
            <?php endif; ?>
        </div>
        <?php endforeach; ?>
            </div><!-- /#sf-audit-issues-list -->
            <nav class="sf-audit-pagination" aria-label="Issues pagination"></nav>
        </div><!-- /#sf-audit-issues-wrap -->
        <?php else: ?>
        <div style="text-align:center;padding:40px;background:#f0fdf4;border-radius:12px;margin-top:16px;">
            <span class="dashicons dashicons-yes-alt" style="font-size:48px;width:48px;height:48px;color:#059669;"></span>
            <h3 style="color:#059669;margin:12px 0 4px;"><?php esc_html_e('Great! No issues found.', 'seoforge'); ?></h3>
            <p style="color:#64748b;font-size:14px;"><?php esc_html_e('Your site is well optimised.', 'seoforge'); ?></p>
        </div>
        <?php endif; ?>
        <?php
    }

    public function enqueue_assets($hook) {
        $v = SEOFORGE_VERSION . '.' . filemtime(SEOFORGE_DIR . 'assets/js/admin.js');
        wp_enqueue_style('seoforge-admin', SEOFORGE_URL . 'assets/css/admin.css', [], $v);
        // media uploader for the per-post Social Media Image picker in
        // the SEO metabox.
        if (in_array($hook, ['post.php', 'post-new.php'], true)) {
            wp_enqueue_media();
        }
        // shared modal dialog utility (window.sfModal) — loaded as a
        // dependency so every admin.js handler + inline page script can use it.
        wp_enqueue_script('seoforge-modal', SEOFORGE_URL . 'assets/js/sf-modal.js', [], $v, true);
        wp_enqueue_script('seoforge-admin', SEOFORGE_URL . 'assets/js/admin.js', ['jquery', 'seoforge-modal'], $v, true);
        wp_localize_script('seoforge-admin', 'seoforgeAdmin', [
            'ajaxUrl' => admin_url('admin-ajax.php'),
            'restUrl' => rest_url('seoforge/v1'),
            'nonce' => wp_create_nonce('seoforge_nonce'),
            'restNonce' => wp_create_nonce('wp_rest'),
            // AI paywall — Free users see locked AI buttons that open an
            // upsell modal (admin.js) routed to this URL. No AI call is made.
            'upgradeUrl' => SEOFORGE_License::upgrade_url(),
            'aiLocked'   => !SEOFORGE_License::can('ai_features'),
            // Per-issue help (code → {url, summary, cta}) for the "?" tooltip
            // that renderIssuesList draws next to each issue.
            'issueHelp' => class_exists('SEOFORGE_Analyzer') ? SEOFORGE_Analyzer::issue_help_localized() : [],
            // User-visible strings that get rendered from JS into the DOM.
            // Centralized here so translators only see them in the .pot
            // file once. Reference from JS as `seoforgeAdmin.i18n.<key>`.
            'i18n' => [
                'analyzingContent' => __('Analyzing content…', 'seoforge'),
                'optimizationFailed' => __('Optimization failed', 'seoforge'),
                'example'          => __('Example', 'seoforge'),
                'hide'             => __('Hide', 'seoforge'),
                'apply'            => __('Apply', 'seoforge'),
                'applying'         => __('Applying…', 'seoforge'),
                'applied'          => __('Applied!', 'seoforge'),
                'close'            => __('Close', 'seoforge'),
                'cancel'           => __('Cancel', 'seoforge'),
                'regenerate'       => __('Regenerate', 'seoforge'),
                'applyAndSave'     => __('Apply & Save', 'seoforge'),
                'saved'            => __('Saved', 'seoforge'),
                'saveFailed'       => __('Save failed', 'seoforge'),
                'requestFailed'    => __('Request failed — please try again.', 'seoforge'),
                'fixing'           => __('Fixing…', 'seoforge'),
                'linking'          => __('Linking…', 'seoforge'),
                'autoLinkFailed'   => __('Auto-link failed', 'seoforge'),
                'aiSuggestion'     => __('AI suggestion', 'seoforge'),
                'currentLabel'     => __('Current', 'seoforge'),
                'noValueYet'       => __('No value set yet', 'seoforge'),
                'unsavedChanges'   => __('Unsaved changes', 'seoforge'),
                'keepEditing'      => __('Keep editing', 'seoforge'),
                'unsavedAiHint'    => __('You have unsaved changes in the AI suggestion. Discard them?', 'seoforge'),
                'analysingPage'    => __('Analysing page content…', 'seoforge'),
                'couldNotGenerate' => __('Could not generate suggestion.', 'seoforge'),
                'words'            => __('words', 'seoforge'),
                'keywordLabel'     => __('keyword', 'seoforge'),
                'seoStrength'      => __('SEO Content Strength', 'seoforge'),
                'genTitleFailed'   => __('AI title/description generation failed.', 'seoforge'),
                'genKeywordFailed' => __('AI keyword generation failed.', 'seoforge'),
                'genTitleEmpty'    => __('Could not generate title.', 'seoforge'),
                'genDescEmpty'     => __('Could not generate description.', 'seoforge'),
                'genKeywordEmpty'  => __('Could not generate keyword.', 'seoforge'),
                'insertLinksFailed' => __('Could not insert links.', 'seoforge'),
                'bulkFixFailed'    => __('Bulk fix failed', 'seoforge'),
                'bulkFixStopped'   => __('Bulk fix stopped', 'seoforge'),
                'noIssuesFound'    => __('No issues found!', 'seoforge'),
                'fix'              => __('Fix', 'seoforge'),
                'spendsAiCredits'  => __('Spends AI credits', 'seoforge'),
                // AI upsell modal copy.
                'paywallTitle'     => __('Unlock AI Powers', 'seoforge'),
                'paywallCta'       => __('Upgrade to Pro', 'seoforge'),
                'paywallLater'     => __('Maybe later', 'seoforge'),
                'paywallMeta'      => __('Let AI write your SEO title, meta description and focus keyword from the post content in one click — no more blank-page guesswork.', 'seoforge'),
                'paywallReadiness' => __('Get an AI content analysis that scores depth, readability and search intent, with concrete fixes before you hit publish.', 'seoforge'),
                'paywallSchema'    => __('Let AI detect the right Schema.org types for this post and auto-fill the structured-data fields for rich results in Google.', 'seoforge'),
                'paywallOptimize'  => __('Let AI rewrite and optimize your content for the focus keyword, tone and search intent.', 'seoforge'),
                'paywallAutolink'  => __('Let AI find and insert relevant internal links across your site automatically.', 'seoforge'),
            ],
        ]);
    }

    public function enqueue_gutenberg() {
        wp_enqueue_script('seoforge-gutenberg', SEOFORGE_URL . 'assets/js/gutenberg-sidebar.js', ['wp-plugins', 'wp-edit-post', 'wp-element', 'wp-components', 'wp-data'], SEOFORGE_VERSION, true);
    }

    public static function get_option($key, $default = '') {
        $options = get_option('seoforge_settings', []);
        return $options[$key] ?? $default;
    }

    /**
 * @deprecated 1.0.1 Use SEOFORGE_Schema_Manager::maybe_upgrade — this
 * shim stays as a no-op delegate so external callers keep working.
 */
    public static function activate() {
        if ( ! class_exists( 'SEOFORGE_Schema_Manager' ) ) {
            require_once SEOFORGE_DIR . 'includes/class-schema-manager.php';
        }
        SEOFORGE_Schema_Manager::maybe_upgrade();
    }

    /**
 * Content Brief Generator page (disabled-until-keyword + history).
 */
    public function render_brief_page() {
        $gen_label  = esc_js(__('Generate Brief', 'seoforge'));
        $hist_label = esc_js(__('Recent briefs', 'seoforge'));
        $empty_msg  = esc_js(__('No saved briefs yet. Your generated briefs will appear here so you can revisit them for free.', 'seoforge'));
        $view_label = esc_js(__('View', 'seoforge'));
        $del_label  = esc_js(__('Delete', 'seoforge'));
        $show_more  = esc_js(__('Show older briefs', 'seoforge'));
        $view_from_history_badge = esc_js(__('Opened from history — free to reopen anytime.', 'seoforge'));
        $dupe_open_label = esc_js(__('Open saved version', 'seoforge'));
        $req_failed  = esc_js(__('Request failed — check your connection and try again.', 'seoforge'));
        ?>
        <div class="wrap sf-admin" style="max-width:860px;">
            <?php SEOFORGE_Admin_Tabs::render('seoforge-brief'); ?>
            <?php SEOFORGE_License::instance()->render_upgrade_banner(); ?>
            <h1><?php esc_html_e('AI Content Brief Generator', 'seoforge'); ?><?php Forge_Docs_Link::render('seoforge', 'ai-content-brief-pro', __('AI content briefs', 'seoforge'), __('Generate outlines, questions, competitor angles for a topic.', 'seoforge')); ?></h1>
            <p style="color:#64748b;"><?php esc_html_e('Enter a target keyword — AI creates a comprehensive content brief with title suggestions, headings structure, questions to answer, and more.', 'seoforge'); ?></p>

            <div style="display:flex;gap:10px;margin:20px 0 6px;align-items:center;">
                <input type="text" id="sf-brief-keyword" class="regular-text" placeholder="<?php esc_attr_e('Enter target keyword...', 'seoforge'); ?>" style="flex:1;max-width:400px;">
                <button type="button" id="sf-generate-brief" class="button button-primary" data-forge-ai-action="generate_brief" disabled aria-disabled="true" title="<?php esc_attr_e('Enter a target keyword first', 'seoforge'); ?>"><?php esc_html_e('Generate Brief', 'seoforge'); ?></button>
            </div>
            <div id="sf-brief-dupe-hint" hidden style="margin:0 0 16px;font-size:14px;color:#b45309;background:#fff7ed;border:1px solid #fdba74;padding:8px 12px;border-radius:8px;max-width:560px;"></div>

            <div id="sf-brief-result" style="display:none;margin-top:20px;"></div>

            <!-- Recent briefs -->
            <section id="sf-brief-history" style="margin-top:32px;" aria-labelledby="sf-brief-history-heading">
                <h2 id="sf-brief-history-heading" style="margin:0 0 12px;font-size:18px;"><?php esc_html_e('Recent briefs', 'seoforge'); ?></h2>
                <div id="sf-brief-history-list"></div>
                <button type="button" id="sf-brief-history-more" class="button" hidden style="margin-top:12px;"><?php esc_html_e('Show older briefs', 'seoforge'); ?></button>
            </section>
        </div>

        <script>
        (function(){
            var $ = jQuery;
            var $input   = $('#sf-brief-keyword');
            var $btn     = $('#sf-generate-brief');
            var $result  = $('#sf-brief-result');
            var $list    = $('#sf-brief-history-list');
            var $more    = $('#sf-brief-history-more');
            var $dupe    = $('#sf-brief-dupe-hint');

            var PAGE = 10;
            var historyCache = [];   // summary list, newest first
            var visibleCount = PAGE;

 // disable Generate button until keyword non-empty.
            function syncBtn(){
                var has = $input.val().trim().length > 0;
                $btn.prop('disabled', !has).attr('aria-disabled', has ? 'false' : 'true');
            }
            window.sfBriefSync = syncBtn;
            $input.on('input change paste keyup', function(){ syncBtn(); refreshDupeHint(); });
            syncBtn();

            // ── escaping helper so AI values never touch raw HTML ────────
            function esc(s) {
                return String(s == null ? '' : s)
                    .replace(/&/g, '&amp;').replace(/</g, '&lt;').replace(/>/g, '&gt;')
                    .replace(/"/g, '&quot;').replace(/'/g, '&#39;');
            }
            function fmtDate(ts) {
                if (!ts) return '';
                var d = new Date(ts * 1000);
                try { return d.toLocaleString(); } catch (e) { return d.toString(); }
            }

            // ── Render brief body (reused for fresh + reopened briefs) ───
            function renderBrief(r, meta) {
                var html = '<div style="display:flex;flex-direction:column;gap:16px;">';
                if (meta && meta.fromHistory) {
                    html += '<div style="background:#ecfdf5;border:1px solid #a7f3d0;color:#065f46;border-radius:8px;padding:10px 12px;font-size:14px;display:flex;align-items:center;justify-content:space-between;gap:10px;">';
                    html += '<span><strong>' + esc(meta.keyword || '') + '</strong> · ' + esc(fmtDate(meta.created_at)) + ' · <?php echo $view_from_history_badge; // phpcs:ignore WordPress.Security.EscapeOutput.OutputNotEscaped -- already escaped via esc_js ?></span>';
                    html += '<button type="button" class="button" data-sf-brief-close>&times;</button>';
                    html += '</div>';
                }
                if (Array.isArray(r.title_suggestions) && r.title_suggestions.length) {
                    html += '<div class="sf-brief-card"><h3>Title Suggestions</h3>';
                    r.title_suggestions.forEach(function(t){
                        html += '<div class="sf-brief-title">' + esc(t) + '</div>';
                    });
                    html += '</div>';
                }
                html += '<div class="sf-brief-grid">';
                if (Array.isArray(r.headings) && r.headings.length) {
                    html += '<div class="sf-brief-card"><h3>Content Structure</h3>';
                    r.headings.forEach(function(h){
                        var sub = String(h).indexOf('H3') > -1;
                        html += '<div class="sf-brief-h' + (sub ? '3' : '2') + '">' + esc(h) + '</div>';
                    });
                    if (r.target_word_count) html += '<div class="sf-brief-target">Target: ~' + esc(r.target_word_count) + ' words</div>';
                    html += '</div>';
                }
                if (Array.isArray(r.questions_to_answer) && r.questions_to_answer.length) {
                    html += '<div class="sf-brief-card"><h3>Questions to Answer</h3>';
                    r.questions_to_answer.forEach(function(q){
                        html += '<div class="sf-brief-q">&#x2753; ' + esc(q) + '</div>';
                    });
                    html += '</div>';
                }
                html += '</div>';
                if (Array.isArray(r.key_points) && r.key_points.length) {
                    html += '<div class="sf-brief-card"><h3>Key Points to Cover</h3><div class="sf-brief-chips">';
                    r.key_points.forEach(function(p){
                        html += '<span class="sf-brief-chip">' + esc(p) + '</span>';
                    });
                    html += '</div></div>';
                }
                if (r.competitor_angle || r.tone || r.target_audience) {
                    html += '<div class="sf-brief-card sf-brief-meta-row">';
                    if (r.tone)             html += '<div><div class="sf-brief-cap">Tone</div><div>' + esc(r.tone) + '</div></div>';
                    if (r.target_audience)  html += '<div><div class="sf-brief-cap">Audience</div><div>' + esc(r.target_audience) + '</div></div>';
                    if (r.competitor_angle) html += '<div><div class="sf-brief-cap">Angle</div><div>' + esc(r.competitor_angle) + '</div></div>';
                    html += '</div>';
                }
                html += '</div>';
                $result.html(html).show();
 // v2: track which brief is currently shown so deleteBrief
                // can clear the viewer when this brief is the one being deleted.
                if (meta && meta.id) {
                    $result.data('briefId', String(meta.id));
                } else {
                    $result.removeData('briefId');
                }
                $result[0].scrollIntoView({ behavior: 'smooth', block: 'start' });
            }

            // Close "opened from history" preview
            $result.on('click', '[data-sf-brief-close]', function(){
                $result.hide().empty().removeData('briefId');
            });

            // ── History list ────────────────────────────────────────────
 // fix: suppress the "already exists" warning for the keyword
            // we JUST generated — the tester was confused seeing the hint pop up
            // right after they paid for a fresh brief. Stays suppressed until the
            // user changes the keyword (or opens one from history).
            var suppressDupeFor = null; // lower-cased keyword
            function refreshDupeHint() {
                var kw = ($input.val() || '').trim().toLowerCase();
                if (!kw) { $dupe.hide().empty(); return; }
                if (suppressDupeFor && kw === suppressDupeFor) { $dupe.hide().empty(); return; }
                var match = historyCache.find(function(b){ return String(b.keyword || '').toLowerCase() === kw; });
                if (!match) { $dupe.hide().empty(); return; }
                var link = '<a href="#" data-sf-open-brief="' + esc(match.id) + '" style="font-weight:600;color:#b45309;">' + '<?php echo $dupe_open_label; // phpcs:ignore WordPress.Security.EscapeOutput.OutputNotEscaped -- already escaped via esc_js ?>' + '</a>';
                $dupe.html('<?php echo esc_js(__('A brief with this keyword already exists. ', 'seoforge')); ?>' + link + ' <?php echo esc_js(__('(free to reopen)', 'seoforge')); ?>').show();
            }

            function renderHistoryList() {
                $list.empty();
                if (!historyCache.length) {
                    $list.html('<div style="padding:18px;text-align:center;color:#94a3b8;background:#f8fafc;border:1px dashed #cbd5e1;border-radius:10px;"><?php echo $empty_msg; // phpcs:ignore WordPress.Security.EscapeOutput.OutputNotEscaped -- already escaped via esc_js ?></div>');
                    $more.attr('hidden', true);
                    return;
                }
                var show = Math.min(visibleCount, historyCache.length);
                var table = $('<div class="sf-brief-history-table" role="list"></div>');
                for (var i = 0; i < show; i++) {
                    var b = historyCache[i];
                    var row = $('<div class="sf-brief-history-row" role="listitem"></div>');
                    row.append($('<div class="sf-brief-history-kw"></div>').text(b.keyword || ''));
                    row.append($('<div class="sf-brief-history-date"></div>').text(fmtDate(b.created_at)));
                    var actions = $('<div class="sf-brief-history-actions"></div>');
                    actions.append($('<button type="button" class="button button-small" data-sf-view-brief></button>').text('<?php echo $view_label; // phpcs:ignore WordPress.Security.EscapeOutput.OutputNotEscaped -- already escaped via esc_js ?>').attr('data-id', b.id));
                    actions.append($('<button type="button" class="button button-small button-link-delete" data-sf-delete-brief></button>').text('<?php echo $del_label; // phpcs:ignore WordPress.Security.EscapeOutput.OutputNotEscaped -- already escaped via esc_js ?>').attr('data-id', b.id));
                    row.append(actions);
                    table.append(row);
                }
                $list.append(table);
                if (historyCache.length > show) {
                    $more.removeAttr('hidden').text('<?php echo $show_more; // phpcs:ignore WordPress.Security.EscapeOutput.OutputNotEscaped -- already escaped via esc_js ?>' + ' (' + (historyCache.length - show) + ')');
                } else {
                    $more.attr('hidden', true);
                }
            }

            function loadHistory() {
                $.ajax({
                    url: seoforgeAdmin.restUrl + '/brief/history',
                    method: 'GET',
                    headers: {'X-WP-Nonce': seoforgeAdmin.restNonce},
                    success: function(r){
                        historyCache = (r && r.briefs) ? r.briefs : [];
                        visibleCount = PAGE;
                        renderHistoryList();
                        refreshDupeHint();
                    },
                });
            }

            $more.on('click', function(){
                visibleCount += PAGE;
                renderHistoryList();
            });

            // Delegate view / delete / dupe-link
            $(document).on('click', '[data-sf-view-brief]', function(e){ e.preventDefault(); openBrief(jQuery(this).attr('data-id')); });
            $(document).on('click', '[data-sf-open-brief]', function(e){ e.preventDefault(); openBrief(jQuery(this).attr('data-sf-open-brief')); });
            $(document).on('click', '[data-sf-delete-brief]', function(e){
                e.preventDefault();
                var id = jQuery(this).attr('data-id');
                var match = historyCache.find(function(b){ return b.id === id; });
                var msg  = '<?php echo esc_js(__('Delete this brief? The paid-for result will be lost.', 'seoforge')); ?>';
                if (match) msg = '<?php echo esc_js(__('Delete brief for', 'seoforge')); ?> "' + (match.keyword || '') + '"? ' + '<?php echo esc_js(__('The paid-for result will be lost.', 'seoforge')); ?>';
                var opts = { title: '<?php echo esc_js(__('Delete brief?', 'seoforge')); ?>', okText: '<?php echo esc_js(__('Delete', 'seoforge')); ?>', cancelText: '<?php echo esc_js(__('Keep', 'seoforge')); ?>', variant: 'danger' };
                var go = function(ok){ if (!ok) return; deleteBrief(id); };
                if (window.sfModal && window.sfModal.confirm) window.sfModal.confirm(msg, opts).then(go);
                else go(confirm(msg));
            });

            function openBrief(id) {
                $.ajax({
                    url: seoforgeAdmin.restUrl + '/brief/history/' + encodeURIComponent(id),
                    method: 'GET',
                    headers: {'X-WP-Nonce': seoforgeAdmin.restNonce},
                    success: function(r){
                        if (!r || !r.result) return;
                        renderBrief(r.result, { fromHistory: true, id: id, keyword: r.keyword, created_at: r.created_at });
                    },
                });
            }

            function deleteBrief(id) {
                $.ajax({
                    url: seoforgeAdmin.restUrl + '/brief/history/' + encodeURIComponent(id),
                    method: 'DELETE',
                    headers: {'X-WP-Nonce': seoforgeAdmin.restNonce},
                    success: function(){
 // v2: when the deleted brief is the one currently
                        // expanded above the history list, clear the viewer so
                        // the user doesn't keep staring at a brief that's gone.
                        if ($result.data('briefId') === id) {
                            $result.hide().empty().removeData('briefId');
                            // Free up the dupe-suppression so the duplicate hint
                            // can re-appear if the keyword is re-typed.
                            suppressDupeFor = '';
                        }
                        loadHistory();
                    },
                });
            }

            // ── Generate ────────────────────────────────────────────────
            $btn.on('click', function(){
                var kw = $input.val().trim();
                if (!kw) { $input.focus(); return; }
                $btn.prop('disabled', true).text('Generating...');
                $result.hide().empty();
                $.ajax({
                    url: seoforgeAdmin.restUrl + '/brief/generate',
                    method: 'POST',
                    contentType: 'application/json',
                    data: JSON.stringify({keyword: kw}),
                    headers: {'X-WP-Nonce': seoforgeAdmin.restNonce},
                    success: function(r){
                        $btn.text('<?php echo $gen_label; // phpcs:ignore WordPress.Security.EscapeOutput.OutputNotEscaped -- already escaped via esc_js ?>');
                        syncBtn();
                        if (r && r.error) {
                            var msg = r.message || r.error;
                            if (window.sfModal && window.sfModal.alert) window.sfModal.alert(msg, { title: '<?php echo esc_js(__('Brief generation failed', 'seoforge')); ?>', variant: 'danger' });
                            else alert(msg);
                            return;
                        }
                        suppressDupeFor = ($input.val() || '').trim().toLowerCase();
 // v2: server returns history_id with the freshly
                        // saved brief — pass it so the viewer is delete-aware.
                        renderBrief(r, { fromHistory: false, id: r.history_id || '' });
                        loadHistory();
                        $dupe.hide().empty();
                    },
                    error: function(){
                        $btn.text('<?php echo $gen_label; // phpcs:ignore WordPress.Security.EscapeOutput.OutputNotEscaped -- already escaped via esc_js ?>');
                        syncBtn();
                        var msg = '<?php echo $req_failed; // phpcs:ignore WordPress.Security.EscapeOutput.OutputNotEscaped -- already escaped via esc_js ?>';
                        if (window.sfModal && window.sfModal.alert) window.sfModal.alert(msg, { title: '<?php echo esc_js(__('Request failed', 'seoforge')); ?>', variant: 'danger' });
                        else alert(msg);
                    }
                });
            });

            // seoforgeAdmin is defined by wp_localize_script in the admin footer,
            // which runs AFTER this inline IIFE. Defer the initial fetch to
            // DOM-ready so the global is in scope by the time loadHistory runs.
            jQuery(function(){ loadHistory(); });
        })();
        </script>
        <?php
    }

    /**
 * Broken Links Checker page
 */
    public function render_broken_links_page() {
        $cached = get_transient('seoforge_broken_links_cache');
        $last_scan = get_option('seoforge_broken_links_last_scan', 0);
        $scanning = get_option('seoforge_broken_links_scanning', false);
        $total_checked = get_option('seoforge_broken_links_total_checked', 0);
        ?>
        <div class="wrap">
            <?php SEOFORGE_Admin_Tabs::render('seoforge-broken'); ?>
            <?php SEOFORGE_License::instance()->render_upgrade_banner(); ?>
            <h1><?php esc_html_e('Broken Links Checker', 'seoforge'); ?><?php Forge_Docs_Link::render('seoforge', 'broken-links-scanner-pro', __('Broken links scanner', 'seoforge'), __('Scans internal + external links. How scheduling + fixing work.', 'seoforge')); ?></h1>
            <p style="color:#64748b;"><?php esc_html_e('Scan your posts for broken links (404, timeout, connection errors). Runs in background — safe to leave the page.', 'seoforge'); ?></p>

            <div style="margin:20px 0;display:flex;gap:10px;align-items:center;flex-wrap:wrap;">
                <?php if ($scanning): ?>
                    <span style="display:inline-flex;align-items:center;gap:6px;background:#FFF7ED;border:1px solid #FED7AA;border-radius:8px;padding:8px 16px;font-size:14px;color:#FF6B35;font-weight:600;">
                        <span class="dashicons dashicons-update" style="animation:spin 1s linear infinite;font-size:14px;width:14px;height:14px;"></span>
                        <?php esc_html_e('Scanning in background... You can leave this page.', 'seoforge'); ?>
                    </span>
                <?php else: ?>
                    <button type="button" id="sf-scan-links" class="button button-primary"><?php esc_html_e('Scan Now', 'seoforge'); ?></button>
                <?php endif; ?>
                <?php if ($last_scan): ?>
                    <span style="font-size:14px;color:#94a3b8;">
                        <?php esc_html_e('Last scan:', 'seoforge'); ?> <span class="sf-local-time" data-ts="<?php echo esc_attr( $last_scan ); ?>"></span>
                        <?php if ($total_checked): echo esc_html( sprintf(' (%d links checked)', $total_checked) ); endif; ?>
                    </span>
                <?php endif; ?>
            </div>

            <?php if ($scanning): ?>
                <div style="background:#fffbeb;border:1px solid #fde68a;border-radius:8px;padding:12px 16px;font-size:14px;color:#92400e;max-width:500px;">
                    <?php esc_html_e('Scan is running in background. Refresh this page to see results when complete.', 'seoforge'); ?>
                </div>
            <?php elseif (is_array($cached)): ?>
                <?php if (empty($cached)): ?>
                    <div style="background:#f0fdf4;border:1px solid #bbf7d0;border-radius:10px;padding:16px;color:#059669;font-weight:600;">&#10003; <?php esc_html_e('No broken links found!', 'seoforge'); ?></div>
                <?php else: ?>
                    <div style="background:#fef2f2;border:1px solid #fecaca;border-radius:10px;padding:12px 16px;margin-bottom:16px;">
                        <?php /* translators: %d: number of broken links found */ ?>
                        <strong style="color:#dc2626;"><?php printf(esc_html__('%d broken links found', 'seoforge'), count($cached)); ?></strong>
                    </div>
                    <table class="widefat striped">
                        <thead><tr><th><?php esc_html_e('Post', 'seoforge'); ?></th><th><?php esc_html_e('Broken URL', 'seoforge'); ?></th><th style="width:80px;"><?php esc_html_e('Status', 'seoforge'); ?></th><th style="width:60px;"></th></tr></thead>
                        <tbody>
                        <?php foreach ($cached as $b): ?>
                            <tr>
                                <td>
                                    <?php if (!empty($b['post_id'])): ?>
                                        <a href="<?php echo esc_url( get_edit_post_link($b['post_id']) ); ?>"><strong><?php echo esc_html($b['post']); ?></strong></a>
                                    <?php else: ?>
                                        <strong><?php echo esc_html($b['post']); ?></strong>
                                    <?php endif; ?>
                                </td>
                                <td style="word-break:break-all;font-size:14px;"><?php echo esc_html($b['url']); ?></td>
                                <td><span style="color:#dc2626;font-weight:700;"><?php echo esc_html($b['status'] ?: 'Error'); ?></span></td>
                                <td>
                                    <?php if (!empty($b['post_id'])): ?>
                                        <a href="<?php echo esc_url( get_edit_post_link($b['post_id']) ); ?>" class="button button-small"><?php esc_html_e('Edit', 'seoforge'); ?></a>
                                    <?php endif; ?>
                                </td>
                            </tr>
                        <?php endforeach; ?>
                        </tbody>
                    </table>
                <?php endif; ?>
            <?php endif; ?>
        </div>

        <script>
        // Show timestamps in user's local timezone
        document.querySelectorAll('.sf-local-time').forEach(function(el){
            var ts = parseInt(el.dataset.ts);
            if(ts){ var d=new Date(ts*1000); el.textContent=d.toLocaleDateString('en-GB',{day:'2-digit',month:'short'})+', '+d.toLocaleTimeString([],{hour:'2-digit',minute:'2-digit',hour12:false}); }
        });

        jQuery('#sf-scan-links').on('click', function(){
            var $btn = jQuery(this);
            $btn.prop('disabled',true).html('<span class="dashicons dashicons-update" style="animation:spin 1s linear infinite;font-size:14px;width:14px;height:14px;vertical-align:text-bottom;margin-right:4px;"></span> Starting...');

            jQuery.post(seoforgeAdmin.ajaxUrl, {
                action: 'seoforge_scan_broken_links',
                nonce: seoforgeAdmin.nonce
            }, function(r){
                if(r.success) location.reload();
            });
        });
        </script>
        <?php
    }
}
