/* SEO Forge Admin JS */
(function($) {
    'use strict';

 // shim around window.sfModal so callers don't need to null-check
    // on every invocation. Fall back to native alert/confirm if the modal
    // utility somehow failed to load (e.g. asset pipeline glitch).
    function sfAlert(message, opts) {
        if (window.sfModal && window.sfModal.alert) return window.sfModal.alert(message, opts);
        window.alert(message);
        return Promise.resolve(true);
    }
    function sfConfirm(message, opts) {
        if (window.sfModal && window.sfModal.confirm) return window.sfModal.confirm(message, opts);
        return Promise.resolve(window.confirm(message));
    }

 // refresh the credits chip after any AI call. class-forge-api.php
    // stashes the fresh balance in the PHP transient via push_credits() on
    // every /ai/process response, so a subsequent refresh() is a cheap local
    // call — no second round-trip to the worker. Safe to call unconditionally.
    function sfCreditsRefresh() {
        if (window.ForgeCredits && typeof window.ForgeCredits.refresh === 'function') {
            try { window.ForgeCredits.refresh(); return; } catch (e) {}
        }
        try { document.dispatchEvent(new CustomEvent('forge:credits-refresh')); } catch (e) {}
    }

 // global ajaxSuccess hook — fires for every jQuery AJAX that
    // completes. We only trigger the chip refresh on seoforge REST paths
    // that actually spend credits so we don't thrash the widget on /score,
    // /dashboard, /schema/get calls. Read-only endpoints are explicitly
    // excluded below. This beats sprinkling sfCreditsRefresh() into 12+
    // success handlers (and keeps working for handlers we add later).
    var SF_CREDITED_PATH = /\/seoforge\/v1\/(meta\/(generate|preview|apply)|fix\/|links\/auto-insert|links\/suggest|brief\/generate|optimize(\/|$)|ai-readiness|schema\/detect|local\/generate|analysis\/analyze)/;
 // v2: Site Audit's summary endpoint is admin-ajax (not REST), so
    // the REST regex above misses it. Explicitly cover the credited admin-ajax
    // actions that actually spend credits.
    var SF_CREDITED_AJAX_ACTIONS = ['seoforge_run_audit'];
    $(document).on('ajaxSuccess', function(_event, _xhr, settings) {
        if (!settings) return;
        if (typeof settings.url === 'string' && SF_CREDITED_PATH.test(settings.url)) {
            sfCreditsRefresh();
            return;
        }
        // admin-ajax: settings.data may be a string or URLSearchParams; peek at `action=`.
        var dataStr = '';
        if (typeof settings.data === 'string') dataStr = settings.data;
        else if (settings.data && typeof settings.data.toString === 'function') dataStr = settings.data.toString();
        for (var i = 0; i < SF_CREDITED_AJAX_ACTIONS.length; i++) {
            var re = new RegExp('(^|&)action=' + SF_CREDITED_AJAX_ACTIONS[i] + '(&|$)');
            if (re.test(dataStr)) { sfCreditsRefresh(); return; }
        }
    });

 // ─── AI preview drawer ───────────────────────────────────────
    // Opens a right-side drawer instead of applying the AI fix in place for
    // title/description/keyword issues in Site Audit. Lets the user review,
    // edit, regenerate, or cancel before the value lands in the DB.
    var SF_DRAWER = null;

    var SF_TYPE_LIMITS = { title: 60, description: 160, keyword: 0, content: 0 };

 // v3: map issue.type codes to drawer types directly. Codes come
    // straight from class-site-audit.php::make_issue() and are never
    // localized, so they're the canonical source. Falls back to text
    // matching for legacy callers that don't carry a code. The drawer now
    // covers both `meta` types (title/description/keyword via meta endpoints)
    // AND content-rewrite types (thin_content, no_internal_links, etc. via
    // /fix/preview + /fix/apply) — previously the latter bypassed the
 // drawer and applied directly, which the QA tester hit on .
    var SF_ISSUE_TYPE_MAP = {
        missing_meta_title: 'title',
        long_title:         'title',
        short_title:        'title',
        duplicate_titles:   'title',
        missing_meta_desc:  'description',
        long_description:   'description',
        short_description:  'description',
        missing_keyword:    'keyword',
        thin_content:       'content',
        no_internal_links:  'content',
        missing_h1:         'content',
        missing_alt:        'content',
    };
    // Drawer types whose preview/apply uses the post-content rewrite flow
    // (POST /seoforge/v1/fix/preview + /apply) instead of the meta flow
    // (/meta/preview + /meta/apply). Title/description/keyword stay on
    // meta endpoints because they edit single short fields, not full body content.
    var SF_CONTENT_TYPES = { content: true };
    function sfTypeFromIssue(issue, code) {
        if (code && SF_ISSUE_TYPE_MAP[code]) return SF_ISSUE_TYPE_MAP[code];
        issue = String(issue || '').toLowerCase();
        if (/focus keyword|ключев(ое|ых) слов/.test(issue))                return 'keyword';
        if (/(meta description|meta-description|мета.?описан)/.test(issue)) return 'description';
        if (/(meta title|seo.?title|seo.?заголов|мета.?заголов|\btitle\b)/.test(issue)) return 'title';
        // Catch-all for content-rewrite issues: thin content, missing
        // headings, internal links, alt text — anything that operates on
        // the post body via $ai->fix_issue() rather than meta fields.
        if (/(thin content|тонк|мало контент|internal link|внутренн.*ссылк|missing h1|нет h1|missing alt|без alt|alt-?\s*текст)/.test(issue)) return 'content';
        return null;
    }

    function sfEl(tag, cls, text) {
        var n = document.createElement(tag);
        if (cls) n.className = cls;
        if (text != null) n.textContent = String(text);
        return n;
    }
    function sfClear(node) { while (node.firstChild) node.removeChild(node.firstChild); }

    // Credit "crystal" cost chip, built with DOM nodes (no innerHTML). Mirrors
    // the SVG used in renderIssuesList.
    function sfCreditChip(cost) {
        var chip = document.createElement('span');
        chip.className = 'sf-credits-badge';
        var NS = 'http://www.w3.org/2000/svg';
        var svg = document.createElementNS(NS, 'svg');
        svg.setAttribute('viewBox', '0 0 24 22');
        svg.setAttribute('fill', 'currentColor');
        svg.setAttribute('aria-hidden', 'true');
        var poly = document.createElementNS(NS, 'polygon');
        poly.setAttribute('points', '7,4 17,4 21,9 12,19 3,9');
        svg.appendChild(poly);
        chip.appendChild(svg);
        chip.appendChild(document.createTextNode(' ' + cost));
        return chip;
    }

    // Regenerate re-runs the AI request, so it costs credits just like the
    // original action — show the same cost chip (hidden for free actions like
    // the deterministic ToC, where cost is 0).
    function sfSetRegenCost(cost) {
        if (!SF_DRAWER) return;
        var r = SF_DRAWER.regen;
        sfClear(r);
        var ic = document.createElement('span');
        ic.className = 'dashicons dashicons-update';
        ic.setAttribute('aria-hidden', 'true');
        r.appendChild(ic);
        r.appendChild(document.createTextNode(' Regenerate'));
        if (cost && cost > 0) {
            r.appendChild(document.createTextNode(' '));
            r.appendChild(sfCreditChip(cost));
        }
    }

    // Pull the credit cost off the issue-row button that opened the drawer
    // (its `.sf-credits-badge` already carries the authoritative server cost).
    function sfCostFromButton($btn) {
        if (!$btn || !$btn.length) return 0;
        return parseInt(($btn.find('.sf-credits-badge').text() || '').replace(/\D/g, ''), 10) || 0;
    }

    // Per-issue "?" help tooltip (matches Forge_Docs_Link::tooltip markup so the
    // shared .forge-tip styles apply). Returns an escaped HTML string, or '' when
    // there's no help entry for this issue code. Keyed by the server issue code,
    // so it's language-neutral.
    function sfIssueHelpHtml(code, message) {
        var h = (window.seoforgeAdmin && seoforgeAdmin.issueHelp && seoforgeAdmin.issueHelp[code]) || null;
        if (!h || !h.summary) return '';
        var esc = function (x) { return $('<div>').text(x == null ? '' : String(x)).html(); };
        var escAttr = function (x) { return esc(x).replace(/"/g, '&quot;'); };
        var title = message || '';
        var aria = title + (h.summary ? ' — ' + h.summary : '');
        var hrefAttr = h.url ? (' href="' + escAttr(h.url) + '" target="_blank" rel="noopener"') : '';
        return '<a' + hrefAttr + ' class="forge-tip" aria-label="' + escAttr(aria) + '"'
            + ' data-tip-title="' + escAttr(title) + '" data-tip-summary="' + escAttr(h.summary) + '">'
            + '<span class="forge-tip__icon" aria-hidden="true">?</span>'
            + '<span class="forge-tip__panel" role="tooltip">'
                + '<strong class="forge-tip__title">' + esc(title) + '</strong>'
                + '<span class="forge-tip__summary">' + esc(h.summary) + '</span>'
                + '<span class="forge-tip__cta">' + esc(h.cta || 'Read the docs') + ' &rarr;</span>'
            + '</span>'
        + '</a>';
    }

    function sfDrawerEnsure() {
        if (SF_DRAWER) return SF_DRAWER;
        var overlay = sfEl('div', 'sf-drawer-overlay');
        var drawer = sfEl('div', 'sf-drawer');
        drawer.setAttribute('role', 'dialog');
        drawer.setAttribute('aria-modal', 'true');

        var head = sfEl('div', 'sf-drawer-head');
        var headMeta = sfEl('div', 'sf-drawer-head-meta');
        var titleEl = sfEl('h2', 'sf-drawer-head-title');
        var subEl = sfEl('div', 'sf-drawer-head-sub');
        headMeta.appendChild(titleEl);
        headMeta.appendChild(subEl);
        var closeBtn = sfEl('button', 'sf-drawer-close', '\u00D7');
        closeBtn.type = 'button';
        closeBtn.setAttribute('aria-label', 'Close');
        head.appendChild(headMeta);
        head.appendChild(closeBtn);

        var body = sfEl('div', 'sf-drawer-body');

        var foot = sfEl('div', 'sf-drawer-foot');
        var regen = sfEl('button', 'sf-drawer-regen');
        regen.type = 'button';
        var regenIcon = sfEl('span', 'dashicons dashicons-update');
        regenIcon.setAttribute('aria-hidden', 'true');
        regen.appendChild(regenIcon);
        regen.appendChild(document.createTextNode(' Regenerate'));
        var spacer = sfEl('div');
        spacer.style.flex = '1';
        var cancel = sfEl('button', 'sf-drawer-cancel', 'Cancel');
        cancel.type = 'button';
        var apply = sfEl('button', 'sf-drawer-apply', 'Apply & Save');
        apply.type = 'button';
        foot.appendChild(regen);
        foot.appendChild(spacer);
        foot.appendChild(cancel);
        foot.appendChild(apply);

        drawer.appendChild(head);
        drawer.appendChild(body);
        drawer.appendChild(foot);

        document.body.appendChild(overlay);
        document.body.appendChild(drawer);

        SF_DRAWER = {
            overlay: overlay, drawer: drawer,
            title: titleEl, sub: subEl, body: body,
            regen: regen, apply: apply, cancel: cancel, close: closeBtn,
            state: { postId: 0, type: '', issue: '', suggestion: '', dirty: false, row: null },
        };

        overlay.addEventListener('click', function(){ sfDrawerClose(false); });
        closeBtn.addEventListener('click', function(){ sfDrawerClose(false); });
        cancel.addEventListener('click', function(){ sfDrawerClose(false); });
        regen.addEventListener('click', function(){ sfDrawerRun(true); });
        apply.addEventListener('click', function(){
            if (SF_DRAWER && SF_DRAWER.state && SF_DRAWER.state.mode === 'bulk') sfDrawerApplyBulk();
            else sfDrawerApply();
        });
        document.addEventListener('keydown', function(e){
            if (e.key === 'Escape' && SF_DRAWER && SF_DRAWER.overlay.classList.contains('is-open')) {
                sfDrawerClose(false);
            }
        });
        return SF_DRAWER;
    }

    function sfDrawerOpen(ctx) {
        var d = sfDrawerEnsure();
        d.state = $.extend({ dirty: false }, ctx, { suggestion: '', mode: 'single' });
        var labels = {
            title:       { head: 'Generate SEO title',       sub: 'for "' + (ctx.postTitle || 'post #' + ctx.postId) + '"' },
            description: { head: 'Generate meta description',sub: 'for "' + (ctx.postTitle || 'post #' + ctx.postId) + '"' },
            keyword:     { head: 'Generate focus keyword',   sub: 'for "' + (ctx.postTitle || 'post #' + ctx.postId) + '"' },
            content:     { head: 'AI rewrite',                sub: 'for "' + (ctx.postTitle || 'post #' + ctx.postId) + '"' },
        };
        var contentLabels = {
            expand_content:     { head: 'Expand content with AI', sub: 'Review the longer version before it replaces your content' },
            insert_toc:         { head: 'Insert table of contents', sub: 'Built from your headings — review before saving' },
            add_external_links: { head: 'Add external links', sub: 'AI suggests authoritative links — verify the URLs before saving' },
            keyword_subheading: { head: 'Add keyword to a subheading', sub: 'AI rewrites one subheading to include your focus keyword — review before saving' }
        };
        if (contentLabels[ctx.code]) labels.content = contentLabels[ctx.code];
        d.title.textContent = (labels[ctx.type] || {}).head || 'AI suggestion';
        d.sub.textContent = (labels[ctx.type] || {}).sub || '';
        // Restore single-mode chrome in case the drawer was last used for bulk.
        d.regen.style.display = '';
        sfSetRegenCost(d.state.cost); // show the credit cost of re-running (0 = free, e.g. ToC)
        d.apply.textContent = 'Apply & Save';
        d.overlay.classList.add('is-open');
        d.drawer.classList.add('is-open');
        sfDrawerRun(false);
    }

    function sfDrawerClose(force) {
        if (!SF_DRAWER) return;
        var needsWarn = SF_DRAWER.state.dirty && !force;
        if (needsWarn) {
            sfConfirm('You have unsaved changes in the AI suggestion. Close anyway?', {
                title: 'Unsaved changes', okText: 'Close', cancelText: 'Keep editing', variant: 'danger'
            }).then(function(ok){ if (ok) sfDrawerClose(true); });
            return;
        }
        SF_DRAWER.overlay.classList.remove('is-open');
        SF_DRAWER.drawer.classList.remove('is-open');
        SF_DRAWER.state = { postId: 0, type: '', issue: '', suggestion: '', dirty: false, row: null };
    }

    function sfDrawerRun(isRegen) {
        var d = SF_DRAWER;
        sfClear(d.body);
        var loading = sfEl('div', 'sf-drawer-loading');
        loading.appendChild(sfEl('div', 'sf-drawer-skeleton'));
        loading.appendChild(sfEl('div', 'sf-drawer-skeleton'));
        loading.appendChild(sfEl('div', 'sf-drawer-skeleton sf-drawer-skeleton--short'));
        var label = sfEl('div', '', 'Analysing page content\u2026');
        label.style.marginTop = '16px';
        label.style.fontSize = '13px';
        loading.appendChild(label);
        d.body.appendChild(loading);
        d.apply.disabled = true;
        d.regen.disabled = true;
 // v3: route content-rewrite types through /fix/preview;
        // meta types continue using /meta/preview as before.
        var isContent = !!SF_CONTENT_TYPES[d.state.type];
        var url = seoforgeAdmin.restUrl + (isContent ? '/fix/preview' : '/meta/preview');
        var payload = isContent
            ? { post_id: d.state.postId, issue_message: d.state.issue || '', issue_type: d.state.code || '' }
            : { post_id: d.state.postId, type: d.state.type };
        $.ajax({
            url: url,
            method: 'POST',
            contentType: 'application/json',
            data: JSON.stringify(payload),
            headers: { 'X-WP-Nonce': seoforgeAdmin.restNonce },
        }).done(function(r){
            d.regen.disabled = false;
            if (isCreditsExhausted(r)) { showCreditsExhausted(r); sfDrawerClose(true); return; }
            if (!r || !r.success) {
                sfClear(d.body);
                var err = sfEl('div', '', (r && r.error) || 'Could not generate suggestion.');
                err.style.color = '#dc2626';
                err.style.padding = '20px 0';
                d.body.appendChild(err);
                return;
            }
            sfDrawerRenderReview(r);
        }).fail(function(){
            d.regen.disabled = false;
            sfClear(d.body);
            var err = sfEl('div', '', 'Request failed \u2014 please try again.');
            err.style.color = '#dc2626';
            err.style.padding = '20px 0';
            d.body.appendChild(err);
        });
    }

 // phase 2 — open the drawer in bulk-review mode. Called by
    // the Site Audit sticky bar's Fix selected handler. `groups` is an array
    // of { issue, ids } from the bulk bar; every id whose issue resolves to
    // title/description/keyword becomes one accordion row in the drawer.
    // Non-text issues are skipped (they keep the legacy chunked flow).
    function sfDrawerOpenBulk(groups) {
        var items = [];
        (groups || []).forEach(function(g){
            var type = sfTypeFromIssue(g.issue || '', g.code || '');
            if (!type) return;
 // v4 (2026-04-27): content types are now first-class
            // bulk-review citizens. Earlier we routed content groups to
            // single-fix-per-click "for UX reasons" but the spec explicitly
            // requires N accordions + Apply All for ALL types — tester
            // returned the ticket exactly because of this. The accordion
            // body shape (collapsed-by-default for items 2..N, scrollable
            // textarea inside) handles long content rewrites fine.
            (g.ids || []).forEach(function(id){
                items.push({
                    postId:   parseInt(id, 10),
                    type:     type,
                    code:     g.code || '',
                    issue:    g.issue || '',
                    current:  '',
                    suggestion: '',
                    dirty:    false,
                    loaded:   false,
                    applied:  false,
                    error:    '',
                });
            });
        });
        if (!items.length) return false;

        var d = sfDrawerEnsure();
        d.state = { mode: 'bulk', items: items, dirty: false };
        d.title.textContent = 'Review ' + items.length + ' AI fixes';
        d.sub.textContent = 'Edit each suggestion, then Apply All to save them together.';
        d.overlay.classList.add('is-open');
        d.drawer.classList.add('is-open');
        d.apply.textContent = 'Apply All (' + items.length + ')';
        d.regen.style.display = 'none';
        sfDrawerRenderBulk();
        sfDrawerRunBulkPreviews();
        return true;
    }

    function sfDrawerRenderBulk() {
        var d = SF_DRAWER;
        sfClear(d.body);
        d.state.items.forEach(function(item, idx){
            var card = sfEl('div', 'sf-drawer-bulk-item');
            card.setAttribute('data-idx', idx);
            var header = sfEl('div', 'sf-drawer-bulk-head');
            var toggleBtn = sfEl('button', 'sf-drawer-bulk-toggle');
            toggleBtn.type = 'button';
            toggleBtn.setAttribute('aria-expanded', idx === 0 ? 'true' : 'false');
            var arrow = sfEl('span', 'dashicons dashicons-arrow-right');
            arrow.setAttribute('aria-hidden', 'true');
            toggleBtn.appendChild(arrow);
            var label = sfEl('span', '', (idx + 1) + '. ' + (item.type || 'item') + ' · post #' + item.postId);
            toggleBtn.appendChild(label);
            header.appendChild(toggleBtn);
            if (item.applied) {
                var applied = sfEl('span', 'sf-drawer-bulk-status', 'Saved');
                applied.style.color = '#059669';
                header.appendChild(applied);
            } else if (item.error) {
                var errBadge = sfEl('span', 'sf-drawer-bulk-status', 'Error');
                errBadge.style.color = '#dc2626';
                header.appendChild(errBadge);
            }
            card.appendChild(header);

            var body = sfEl('div', 'sf-drawer-bulk-body');
            body.style.display = idx === 0 ? 'block' : 'none';
            if (!item.loaded) {
                var load = sfEl('div', 'sf-drawer-skeleton');
                body.appendChild(load);
                body.appendChild(sfEl('div', 'sf-drawer-skeleton sf-drawer-skeleton--short'));
            } else if (item.error) {
                var err = sfEl('div', '', item.error);
                err.style.color = '#b91c1c';
                body.appendChild(err);
            } else {
                var cur = sfEl('div', 'sf-drawer-current');
                if (item.current) cur.textContent = item.current;
                else { cur.classList.add('is-empty'); cur.textContent = 'No value set yet'; }
                var curWrap = sfEl('div', 'sf-drawer-section');
                curWrap.appendChild(sfEl('div', 'sf-drawer-section-label', 'Current'));
                curWrap.appendChild(cur);
                body.appendChild(curWrap);

                var aiWrap = sfEl('div', 'sf-drawer-section');
                aiWrap.appendChild(sfEl('div', 'sf-drawer-section-label', 'AI suggestion'));
                var ta = document.createElement('textarea');
                ta.className = 'sf-drawer-suggestion';
                ta.value = item.suggestion;
                var limit = SF_TYPE_LIMITS[item.type] || 0;
                var counter = sfEl('div', 'sf-drawer-counter');
                function syncCount() {
                    var len = (ta.value || '').length;
                    counter.textContent = limit ? (len + ' / ' + limit + ' characters') : (len + ' characters');
                    counter.classList.toggle('is-over', limit > 0 && len > limit);
                }
                ta.addEventListener('input', function(){
                    item.suggestion = ta.value;
                    item.dirty = true;
                    SF_DRAWER.state.dirty = true;
                    syncCount();
                });
                aiWrap.appendChild(ta);
                aiWrap.appendChild(counter);
                body.appendChild(aiWrap);
                setTimeout(syncCount, 0);
            }

            toggleBtn.addEventListener('click', function(){
                var open = body.style.display !== 'none';
                body.style.display = open ? 'none' : 'block';
                toggleBtn.setAttribute('aria-expanded', open ? 'false' : 'true');
                toggleBtn.classList.toggle('is-open', !open);
            });
            if (idx === 0) toggleBtn.classList.add('is-open');

            card.appendChild(body);
            d.body.appendChild(card);
        });
        // Disable Apply All until at least one item has loaded a non-empty suggestion.
        var any = d.state.items.some(function(i){ return i.loaded && !i.error && (i.suggestion || '').trim().length; });
        d.apply.disabled = !any;
    }

    function sfDrawerRunBulkPreviews() {
        var d = SF_DRAWER;
 // Fire one preview per item in parallel. v4 (2026-04-27):
        // route per-item by type — content rewrites use /fix/preview
        // (which calls $ai->fix_issue under the hood), meta types use
        // /meta/preview as before. Without this branch a bulk content
        // group would hit /meta/preview and 400 (type='content' isn't
        // in the meta-preview allowlist), surface as "Preview failed"
        // in every accordion.
        d.state.items.forEach(function(item, idx){
            var isContent = !!SF_CONTENT_TYPES[item.type];
            var url = seoforgeAdmin.restUrl + (isContent ? '/fix/preview' : '/meta/preview');
            var payload = isContent
                ? { post_id: item.postId, issue_type: item.code || '', issue: item.issue || '' }
                : { post_id: item.postId, type: item.type };
            $.ajax({
                url: url,
                method: 'POST',
                contentType: 'application/json',
                data: JSON.stringify(payload),
                headers: { 'X-WP-Nonce': seoforgeAdmin.restNonce },
            }).done(function(r){
                if (isCreditsExhausted(r)) { item.error = 'Credits exhausted.'; }
                else if (!r || !r.success) { item.error = (r && r.error) || 'Preview failed.'; }
                else { item.current = r.current || ''; item.suggestion = r.suggestion || ''; }
                item.loaded = true;
                sfDrawerRenderBulk();
            }).fail(function(){
                item.loaded = true;
                item.error = 'Request failed.';
                sfDrawerRenderBulk();
            });
        });
    }

    function sfDrawerApplyBulk() {
        var d = SF_DRAWER;
        d.apply.disabled = true;
        var pending = d.state.items.filter(function(i){ return i.loaded && !i.error && (i.suggestion || '').trim().length && !i.applied; });
        if (!pending.length) { sfDrawerClose(true); return; }

        var done = 0, failed = 0;
        pending.forEach(function(item){
 // v4 (2026-04-27): per-item routing — content rewrites
            // persist via /fix/apply (writes post_content), meta types via
            // /meta/apply (writes meta fields). Mirror of sfDrawerRunBulkPreviews.
            var isContent = !!SF_CONTENT_TYPES[item.type];
            var url = seoforgeAdmin.restUrl + (isContent ? '/fix/apply' : '/meta/apply');
            var payload = isContent
                ? { post_id: item.postId, issue_type: item.code || '', issue: item.issue || '', value: (item.suggestion || '').trim() }
                : { post_id: item.postId, type: item.type, value: (item.suggestion || '').trim() };
            $.ajax({
                url: url,
                method: 'POST',
                contentType: 'application/json',
                data: JSON.stringify(payload),
                headers: { 'X-WP-Nonce': seoforgeAdmin.restNonce },
            }).done(function(r){
                if (r && r.success) { item.applied = true; done++; }
                else { item.error = (r && r.error) || 'Save failed'; failed++; }
            }).fail(function(){ item.error = 'Request failed'; failed++; })
              .always(function(){
                if (done + failed >= pending.length) {
                    sfDrawerRenderBulk();
                    if (!failed) {
                        setTimeout(function(){ sfDrawerClose(true); location.reload(); }, 800);
                    } else {
                        sfAlert('Applied ' + done + ' of ' + pending.length + '. ' + failed + ' failed — see individual items for details.', { title: 'Partial success', variant: 'warning' });
                        d.apply.disabled = false;
                    }
                }
            });
        });
    }

    function sfDrawerRenderReview(r) {
        var d = SF_DRAWER;
        d.state.suggestion = r.suggestion || '';
        d.state.dirty = false;
        var limit = SF_TYPE_LIMITS[d.state.type] || 0;
        sfClear(d.body);

        // Current value
        var curSection = sfEl('div', 'sf-drawer-section');
        curSection.appendChild(sfEl('div', 'sf-drawer-section-label', 'Current value'));
        var cur = sfEl('div', 'sf-drawer-current');
        if (r.current) cur.textContent = r.current;
        else { cur.classList.add('is-empty'); cur.textContent = 'No value set yet'; }
        curSection.appendChild(cur);
        d.body.appendChild(curSection);

        // AI suggestion
        var aiSection = sfEl('div', 'sf-drawer-section');
        aiSection.appendChild(sfEl('div', 'sf-drawer-section-label', 'AI suggestion \u2014 edit to taste'));
        var ta = document.createElement('textarea');
        ta.className = 'sf-drawer-suggestion';
        ta.value = d.state.suggestion;
        var counter = sfEl('div', 'sf-drawer-counter');
        function syncCounter() {
            var len = (ta.value || '').length;
            counter.textContent = limit ? (len + ' / ' + limit + ' characters') : (len + ' characters');
            counter.classList.toggle('is-over', limit > 0 && len > limit);
        }
        ta.addEventListener('input', function(){
            d.state.suggestion = ta.value;
            d.state.dirty = true;
            syncCounter();
            d.apply.disabled = !(d.state.suggestion || '').trim().length;
        });
        aiSection.appendChild(ta);
        aiSection.appendChild(counter);
        d.body.appendChild(aiSection);

        syncCounter();
        d.apply.disabled = !(d.state.suggestion || '').trim().length;
        try { ta.focus(); ta.select(); } catch (e) {}
    }

    function sfDrawerApply() {
        var d = SF_DRAWER;
        var value = (d.state.suggestion || '').trim();
        if (!value) return;
        d.apply.disabled = true;
        d.regen.disabled = true;
 // v3: content-rewrite types persist via /fix/apply (writes
        // post_content), meta types persist via /meta/apply (writes meta).
        var isContent = !!SF_CONTENT_TYPES[d.state.type];
        var url = seoforgeAdmin.restUrl + (isContent ? '/fix/apply' : '/meta/apply');
        var payload = isContent
            ? { post_id: d.state.postId, value: value, issue_type: d.state.code || '' }
            : { post_id: d.state.postId, type: d.state.type, value: value };
        $.ajax({
            url: url,
            method: 'POST',
            contentType: 'application/json',
            data: JSON.stringify(payload),
            headers: { 'X-WP-Nonce': seoforgeAdmin.restNonce },
        }).done(function(r){
            d.apply.disabled = false;
            d.regen.disabled = false;
            if (!r || !r.success) {
                sfAlert((r && r.error) || 'Could not save.', { title: 'Save failed', variant: 'danger' });
                return;
            }
            var row = d.state.row;
            d.state.dirty = false;
            if (row && row.length) {
                row.find('.sf-audit-row-fix-label').text('Fixed');
                row.find('.sf-audit-row-fix').css({ opacity: 1 }).prop('disabled', true);
                row.css({ background: '#ecfdf5' });
                sfDrawerClose(true);
            } else {
                // Metabox context: the content was saved server-side. Reload so
                // the editor + score reflect it (and a later Update can't clobber
                // the expansion with the stale editor content).
                sfDrawerClose(true);
                setTimeout(function(){ location.reload(); }, 500);
            }
        }).fail(function(){
            d.apply.disabled = false;
            d.regen.disabled = false;
            sfAlert('Request failed \u2014 please try again.', { title: 'Save failed', variant: 'danger' });
        });
    }

    // Content-length "Expand with AI" \u2192 open the review drawer with a \u2265600-word
    // expansion preview. User reviews/edits before it touches the post.
    function sfOpenContentDrawer($btn, code) {
        var postTitle = ($('#title').val() || (window.SEOForgeLive && window.SEOForgeLive.post_title) || '');
        sfDrawerOpen({
            type: 'content',
            code: code,
            postId: parseInt($btn.data('post'), 10),
            issue: $btn.data('issue') || '',
            postTitle: postTitle,
            cost: sfCostFromButton($btn) // Regenerate shows this cost (ToC has none → 0)
        });
    }
    // Content-length "Expand with AI" → ≥600-word expansion preview.
    $(document).on('click', '.sf-expand-btn', function(e){ e.preventDefault(); e.stopPropagation(); sfOpenContentDrawer($(this), 'expand_content'); });
    // "Insert ToC" → deterministic table-of-contents preview (no AI).
    $(document).on('click', '.sf-toc-btn', function(e){ e.preventDefault(); e.stopPropagation(); sfOpenContentDrawer($(this), 'insert_toc'); });
    // "Suggest links" → AI external-link suggestions preview.
    $(document).on('click', '.sf-ext-btn', function(e){ e.preventDefault(); e.stopPropagation(); sfOpenContentDrawer($(this), 'add_external_links'); });
    // "Add to subheading" → AI rewrites ONE subheading to include the keyword.
    $(document).on('click', '.sf-subhead-btn', function(e){ e.preventDefault(); e.stopPropagation(); sfOpenContentDrawer($(this), 'keyword_subheading'); });
 // content-body rewrites (break up long paragraphs, readability)
    // open the same Before/After review drawer in the metabox — the
    // data-issue-type carries the scorer code so the server can route the
    // deterministic paragraph split vs the AI readability rewrite.
    $(document).on('click', '.sf-content-fix-btn', function(e){ e.preventDefault(); e.stopPropagation(); sfOpenContentDrawer($(this), $(this).data('issue-type') || ''); });

    // Capture-phase handler so we run before .sf-fix-btn's legacy logic.
 // v2: widen selector to any .sf-fix-btn inside the audit page
    // (the bulk-fix-per-issue variant `.sf-audit-bulk-fix` isn't a .sf-fix-btn
    // — it's handled separately below). Also prefer the explicit
    // `data-issue-type` attribute so routing is language-neutral.
    document.addEventListener('click', function(e){
        var btn = e.target.closest && e.target.closest('.sf-audit-row-fix, .sf-fix-btn');
        if (!btn) return;
        if (btn.classList.contains('sf-fix-all-btn')) return;
        // Only handle within Site Audit context (skip metabox on post edit).
        if (!btn.closest('#sf-audit-issues-wrap') && !btn.closest('.sf-audit-issue')) return;
        var issueText = btn.getAttribute('data-issue') || '';
        var issueCode = btn.getAttribute('data-issue-type') || '';
        var type = sfTypeFromIssue(issueText, issueCode);
        if (!type) return; // non-text fixes keep the legacy in-place flow
        e.preventDefault();
        e.stopImmediatePropagation();
        var $row = $(btn).closest('tr');
        var postId = parseInt(btn.getAttribute('data-post'), 10);
        var postTitle = $row.find('.sf-audit-row-link').text() || '';
        sfDrawerOpen({ postId: postId, type: type, code: issueCode, issue: issueText, row: $row, postTitle: postTitle, cost: sfCostFromButton($(btn)) });
    }, true);

 // v2 — per-issue bulk ("Fix all 5 with AI" next to issue header).
    // Previously routed directly to sfRunBulkFix (no preview). Now we open
    // the drawer in bulk-review mode for text issues, fall through to the
    // legacy flow for non-text.
    document.addEventListener('click', function(e){
        var btn = e.target.closest && e.target.closest('.sf-audit-bulk-fix');
        if (!btn) return;
        var issueText = btn.getAttribute('data-issue') || '';
        var issueCode = btn.getAttribute('data-issue-type') || '';
        var type = sfTypeFromIssue(issueText, issueCode);
        if (!type) return;
        var idsAttr = btn.getAttribute('data-ids') || '';
        var ids = idsAttr.split(',').map(function(s){ return parseInt(s, 10); }).filter(Boolean);
        if (!ids.length) return;
 // v4 (2026-04-27): all types — including content rewrites —
        // go through the same N-accordion bulk-review drawer. The "open
        // single-fix for first id only" content branch from v3 was
        // explicitly the regression the tester returned the ticket for
        
        // accordion body is collapsed-by-default for items 2..N and the
        // textarea inside scrolls vertically, so a 5000-char content
        // rewrite stays manageable in the side panel.
        e.preventDefault();
        e.stopImmediatePropagation();
        var opened = sfDrawerOpenBulk([{ issue: issueText, code: issueCode, ids: ids }]);
        if (!opened) return; // shouldn't happen — we already know type is truthy
    }, true);

 // Story 5 — when the Site Audit bulk bar's Fix selected is clicked
    // and the selected pages map to text fixes (title/description/keyword),
    // open the review drawer in bulk mode instead of firing the chunked
    // chain-of-fixes handler. Non-text mixes fall through to the legacy
    // behaviour (individual .sf-audit-bulk-fix triggers per group) which
    // SF_AUDIT_FIX_CLICKED lets happen.
    window.sfDrawerOpenBulk = sfDrawerOpenBulk;

    function showCreditsExhausted(data) {
        var resetDate = data.reset_date ? new Date(data.reset_date).toLocaleDateString() : '';
        var msg = 'Monthly AI credits exhausted.';
        if (resetDate) msg += ' Resets on ' + resetDate + '.';
        sfAlert(msg, { title: 'Out of credits', variant: 'warning' });
    }

    function isCreditsExhausted(r) {
        return r && r.error === 'credits_exhausted';
    }

 // Re-analyze button removed — score refreshes live via the
    // analyzer metabox, so the manual trigger is no longer needed.

 // ── per-post Social Media Image picker ──
    var sfOgFrame;
    $(document).on('click', '#sf-og-image-pick', function(e) {
        e.preventDefault();
        if (sfOgFrame) { sfOgFrame.open(); return; }
        if (typeof wp === 'undefined' || !wp.media) { sfAlert('Media uploader unavailable.', { title: 'Upload unavailable', variant: 'danger' }); return; }
        sfOgFrame = wp.media({
            title: 'Select Social Media Image',
            button: { text: 'Use this image' },
            library: { type: 'image' },
            multiple: false
        });
        sfOgFrame.on('select', function() {
            var att = sfOgFrame.state().get('selection').first().toJSON();
            var url = (att.sizes && att.sizes.large && att.sizes.large.url) ? att.sizes.large.url : att.url;
            $('#sf-og-image-id').val(att.id);
            $('#sf-og-image-url').val(url);
 // show preview at OG aspect ratio (1200×630) so the image isn't a thin strip
            var previewHtml = '<div style="width:100%;aspect-ratio:1200/630;border:1px solid #e2e8f0;border-radius:6px;overflow:hidden;background:#f8fafc url(' + url + ') center/contain no-repeat;"></div>'
                + '<div style="font-size:13px;color:#94a3b8;margin-top:4px;">Recommended size: 1200×630 px</div>';
            $('#sf-og-image-preview').html(previewHtml).show();
            // Also refresh the Facebook/Twitter card preview immediately
            $('.sf-field-group .sf-field-header:contains("Social Preview")').next().find('> div').first().attr('style', 'width:100%;aspect-ratio:1200/630;background:url(' + url + ') center/cover;');
            $('#sf-og-image-remove').show();
            $('#sf-og-image-pick').find('.dashicons').nextAll().remove().end().parent().contents().filter(function(){return this.nodeType===3;}).remove();
            $('#sf-og-image-pick').append(' Change image');
        });
        sfOgFrame.open();
    });
    $(document).on('click', '#sf-og-image-remove', function(e) {
        e.preventDefault();
        $('#sf-og-image-id').val('');
        $('#sf-og-image-url').val('');
        $('#sf-og-image-preview').empty().hide();
        $(this).hide();
    });

 // Live char count + length meter 
    // Thresholds (per Jira ticket):
    //   <50%   of ideal → red   ("too short")
    //   50-80% of ideal → yellow ("growing")
    //   80-100% of ideal → green ("optimal")
    //   >100% of ideal  → red   ("too long — Google truncates")
    function updateLengthMeter(field) {
        var $field = $(field);
        var meter = $field.siblings('.sf-length-meter').get(0);
        if (!meter) return;
        var ideal = parseInt(meter.getAttribute('data-ideal'), 10) || 60;
        var len = $field.val().length;
        var pct = Math.min(100, Math.round((len / ideal) * 100));
        var fill = meter.querySelector('.sf-length-meter__fill');
        var hint = meter.querySelector('.sf-length-meter__hint');

        var state, hintText;
        if (len > ideal) {
            state = 'long';
            hintText = hint && hint.getAttribute('data-long') || '';
            fill.style.width = '100%';
        } else if (pct >= 80) {
            state = 'ideal';
            hintText = hint && hint.getAttribute('data-ideal-label') || '';
            fill.style.width = pct + '%';
        } else if (pct >= 50) {
            state = 'ok';
            hintText = hint && hint.getAttribute('data-ok') || '';
            fill.style.width = pct + '%';
        } else {
            state = 'short';
            hintText = hint && hint.getAttribute('data-short') || '';
            fill.style.width = pct + '%';
        }
        meter.setAttribute('data-state', state);
        if (hint) hint.textContent = len === 0 ? '' : hintText;

        // Number counter stays inside the meter row — uses ideal as denominator.
        $field.siblings('.sf-length-meter').find('.seoforge-char-count').text(len + '/' + ideal);
    }

    $(document).on('input', 'input[name="seoforge_meta_title"]', function() {
        updateLengthMeter(this);
        $('.seoforge-serp-title').text($(this).val() || $('input#title').val());
        scheduleLiveScore();
    });
    $(document).on('input', 'textarea[name="seoforge_meta_description"]', function() {
        updateLengthMeter(this);
        $('.seoforge-serp-desc').text($(this).val());
        scheduleLiveScore();
    });
    // Initial paint (server-rendered value already present on first load).
    $(function(){
        $('input[name="seoforge_meta_title"], textarea[name="seoforge_meta_description"]').each(function(){
            updateLengthMeter(this);
        });
    });
    $(document).on('input', 'input[name="seoforge_focus_keyword"]', scheduleLiveScore);

 // ─────────── Live SEO score ───────────
    // The circle updates as the user edits Keyword / Title / Description /
    // post title / post body — no save, no reload. Mirrors the rule-based
    // scoring in SEOFORGE_Analyzer::analyze() and refreshes content-derived
    // metrics from the current editor state before each score calculation.
    // The score + Issues come from ONE rubric, in PHP. Editing any field just
    // asks the server to re-score the current draft (debounced); the response
    // updates both the score circle and the Issues list. No parallel JS rubric,
    // so the live and saved scores can never diverge.
    function scheduleLiveScore() { scheduleLiveIssues(); }

    function currentPostContentHtml() {
        if (window.wp && wp.data && typeof wp.data.select === 'function') {
            try {
                var editorStore = wp.data.select('core/editor');
                if (editorStore && typeof editorStore.getEditedPostContent === 'function') {
                    var blockContent = editorStore.getEditedPostContent();
                    if (typeof blockContent === 'string') return blockContent;
                }
            } catch (e) {}
        }

        if (window.tinymce && typeof window.tinymce.get === 'function') {
            var editor = window.tinymce.get('content');
            if (editor && typeof editor.getContent === 'function' && !editor.isHidden()) {
                return editor.getContent();
            }
        }

        return $('#content').val() || '';
    }


    function scheduleLiveContentScore() { scheduleLiveIssues(); }

    function bindLiveContentEditor() {
        $(document).on('input change keyup', '#content, #title', function() {
            if (this.id === 'title' && window.SEOForgeLive) {
                window.SEOForgeLive.post_title = $(this).val() || '';
            }
            scheduleLiveContentScore();
        });

        if (window.tinymce && typeof window.tinymce.get === 'function') {
            var editor = window.tinymce.get('content');
            if (editor && typeof editor.on === 'function') {
                editor.on('input change keyup paste SetContent', scheduleLiveContentScore);
            }
        }

        if (window.wp && wp.data && typeof wp.data.subscribe === 'function') {
            var lastContent = currentPostContentHtml();
            wp.data.subscribe(function() {
                var nextContent = currentPostContentHtml();
                if (nextContent === lastContent) return;
                lastContent = nextContent;
                scheduleLiveContentScore();
            });
        }
    }

    function runLiveScore() { scheduleLiveIssues(); }
    $(bindLiveContentEditor);

    // On load, once the editor has initialised, ask the server to score the
    // ACTUAL editor content. The metabox seed reads saved post_content, which
    // for block/builder posts can differ from what's rendered — this makes the
    // on-load circle + Issues match the live content (no "jump on first edit").
    $(function () { setTimeout(function () { if (window.SEOForgeLive && window.SEOForgeLive.post_id) refreshIssuesList(parseInt(window.SEOForgeLive.post_id, 10)); }, 1200); });

    // R3 (2026-04-28) F3: live Issues list. Debounced 600ms (longer than
    // score's 180ms because this hits the network); in-flight requests
    // are aborted on the next type so the UI never displays a stale
    // older-than-current snapshot. On AJAX failure the existing list
    // stays put — we never replace it with an empty/error state.
    var liveIssuesTimer = null;
    var liveIssuesXhr = null;
    function scheduleLiveIssues() {
        if (typeof seoforgeAdmin === 'undefined') return;
        // post_id comes from SEOForgeLive (always present when the metabox
        // rendered). Fall back to any data-post we can see on the page.
        var pid = 0;
        if (window.SEOForgeLive && window.SEOForgeLive.post_id) {
            pid = parseInt(window.SEOForgeLive.post_id, 10);
        }
        if (!pid) {
            var $any = $('.sf-fix-btn[data-post], .sf-ai-gen-btn[data-post], .sf-ai-gen-both[data-post]').first();
            if ($any.length) pid = parseInt($any.data('post'), 10);
        }
        if (!pid) return;
        clearTimeout(liveIssuesTimer);
        liveIssuesTimer = setTimeout(function(){ refreshIssuesList(pid); }, 400);
    }

    function refreshIssuesList(pid) {
        if (liveIssuesXhr && liveIssuesXhr.readyState !== 4) {
            try { liveIssuesXhr.abort(); } catch(e){}
        }
        var $wrap = $('.seoforge-issues').first().closest('div[style*="position:relative"]');
        var $details = $('.seoforge-issues').first();
        var $loading = $('#sf-issues-loading');
        if (!$loading.length && $details.length) {
            $loading = $('<span id="sf-issues-loading" aria-hidden="true" style="display:inline-block;width:10px;height:10px;border:2px solid rgba(0,0,0,0.15);border-top-color:#FF6B35;border-radius:50%;animation:sf-spin 0.7s linear infinite;margin-left:6px;vertical-align:middle;"></span>');
            $details.find('summary').first().append($loading);
        }
        if ($loading) $loading.show();

        liveIssuesXhr = $.ajax({
            url: seoforgeAdmin.ajaxUrl,
            method: 'POST',
            data: {
                action: 'seoforge_get_issues',
                nonce:  seoforgeAdmin.nonce,
                post_id: pid,
                meta_title: $('#sf-meta-title').val() || '',
                meta_desc:  $('#sf-meta-desc').val() || '',
                keyword:    $('#sf-keyword').val() || '',
                // Live editor content so the server scores unsaved edits.
                content_html: currentPostContentHtml()
            }
        }).done(function(resp){
            if ($loading) $loading.hide();
            if (!resp || !resp.success || !resp.data) {
                if (window.console && console.warn) console.warn('[SEO Forge] live issues: bad response', resp);
                return;
            }
            renderIssuesList(resp.data, pid);
        }).fail(function(xhr, status){
            if ($loading) $loading.hide();
            if (status === 'abort') return; // user typed again
            if (window.console && console.warn) console.warn('[SEO Forge] live issues failed:', status);
            // Keep existing list — better stale than empty.
        });
    }

    function renderIssuesList(data, pid) {
        var issues = data.issues || [];
        var count = data.count || 0;
        var fixable = data.fixable_count || 0;

        // Score circle — the server is authoritative (single PHP rubric).
        if (typeof data.score === 'number') {
            var s = data.score;
            var color = s >= 80 ? '#059669' : (s >= 50 ? '#D97706' : '#DC2626');
            $('#sf-score-value').text(s).css('color', color);
            $('#sf-score-circle').css('border-color', color);
            var $lbl = $('#sf-score-label');
            if ($lbl.length) $lbl.text($lbl.text().replace(/\d+\s*\/\s*100/, s + ' / 100'));
        }

        // Update SEO tab badge.
        var $tab = $('#sf-mb-tab-seo');
        var $badge = $('#sf-mb-issues-badge');
        if (count > 0) {
            if (!$badge.length && $tab.length) {
                $badge = $('<span class="sf-mb-tab__badge sf-mb-tab__badge--issues" id="sf-mb-issues-badge"></span>');
                $tab.append($badge);
            }
            $badge.text(count);
        } else if ($badge.length) {
            $badge.remove();
        }

        // Update issue summary count chip.
        var $details = $('.seoforge-issues').first();
        var $summary = $details.find('summary').first();
        var $summaryBadge = $summary.find('span').first();
        if ($summaryBadge.length) $summaryBadge.text(count);

        // Update Fix-all button visibility + label + the issue list it acts on.
        var $fixAll = $('.sf-fix-all-btn');
        if (fixable > 1) {
            var totalCost = 0;
            var fixableMsgs = [];
            issues.forEach(function(iss){ if (iss.fixable) { totalCost += (iss.cost || 2); fixableMsgs.push(iss.message); } });
            // Build the inner HTML: "Fix all N" + credit chip.
            var label = (window.SEOForgeI18n && window.SEOForgeI18n.fixAll || 'Fix all') + ' ' + fixable;
            var chip = '<span class="sf-credits-badge"><svg viewBox="0 0 24 22" fill="currentColor" aria-hidden="true"><polygon points="7,4 17,4 21,9 12,19 3,9"/></svg> ' + totalCost + '</span>';
            if ($fixAll.length) {
                // Keep data-issues in sync with the live list so Fix-all targets
                // exactly what's shown (not the stale server-rendered set).
                $fixAll.attr('data-issues', JSON.stringify(fixableMsgs)).html(label + ' ' + chip).show();
            }
        } else if ($fixAll.length) {
            $fixAll.hide();
        }

 // render the optional "Optimization ideas" list (numbers / power
        // words in the title) — split from problems server-side, never part of
        // the red Issues count. Rendered before the count===0 early-return so it
        // shows even when there are no real problems.
        (function renderIdeas() {
            var ideas = data.ideas || [];
            var $ideasD = $('.seoforge-ideas').first();
            if (!$ideasD.length) return;
            if (!ideas.length) { $ideasD.hide(); return; }
            $ideasD.show();
            var $ib = $ideasD.find('summary span').first();
            if ($ib.length) $ib.text(ideas.length);
            var $iul = $ideasD.find('ul').first();
            if (!$iul.length) return;
            var csvg = '<svg viewBox="0 0 24 22" fill="currentColor" aria-hidden="true"><polygon points="7,4 17,4 21,9 12,19 3,9"/></svg>';
            var applyLabel = (seoforgeAdmin.i18n && seoforgeAdmin.i18n.apply) || 'Apply';
            var ih = '';
            ideas.forEach(function (iss) {
                var m = $('<div>').text(iss.message).html();
                ih += '<li class="seoforge-issue seoforge-issue--info seoforge-issue--idea">';
                ih += '<span class="sf-issue-text">' + m + '</span>';
                ih += sfIssueHelpHtml(iss.code, iss.message);
                if (iss.fixable) {
                    var c = iss.cost || 2;
                    ih += '<button type="button" class="sf-fix-btn" data-post="' + pid + '" data-issue="' + m + '" title="Spends AI credits">' + applyLabel + ' <span class="sf-credits-badge">' + csvg + ' ' + c + '</span></button>';
                }
                ih += '</li>';
            });
            $iul.html(ih);
        })();

        // Replace the <ul> contents.
        if (count === 0) {
            // Hide the whole details element rather than show an empty list.
            $details.hide();
            return;
        }
        $details.show();

        var $ul = $details.find('ul').first();
        if (!$ul.length) return;

        // Re-render <li> items. Keep the exact same DOM shape the server
        // emits so existing event handlers (delegated on the document)
        // keep working without rebinding.
        var html = '';
        issues.forEach(function(iss){
            var sev = iss.severity || 'warning';
            var msg = $('<div>').text(iss.message).html(); // escape
            html += '<li class="seoforge-issue seoforge-issue--' + sev + '">';
            html += '<span class="sf-issue-text">' + msg + '</span>';
            html += sfIssueHelpHtml(iss.code, iss.message); // per-issue "?" help tooltip
            var creditSvg = '<svg viewBox="0 0 24 22" fill="currentColor" aria-hidden="true"><polygon points="7,4 17,4 21,9 12,19 3,9"/></svg>';
            if (iss.content_fix) {
 // content-body rewrites open the review drawer
                // (Before/After). Paragraph splitting is deterministic + free
                // (cost 0 → no credit chip); readability rewrites show the cost.
                var ccLabel = (seoforgeAdmin.i18n && seoforgeAdmin.i18n.fix) || 'Fix';
                var ccCost = iss.cost || 0;
                var ccChip = ccCost > 0 ? (' <span class="sf-credits-badge">' + creditSvg + ' ' + ccCost + '</span>') : '';
                html += '<button type="button" class="sf-content-fix-btn" data-post="' + pid + '" data-issue="' + $('<div>').text(iss.message).html() + '" data-issue-type="' + $('<div>').text(iss.content_code || '').html() + '" title="Preview the change before applying">' + ccLabel + ccChip + '</button>';
            } else if (iss.fixable) {
                var c = iss.cost || 2;
                var fixTitle = (seoforgeAdmin.i18n && seoforgeAdmin.i18n.spendsAiCredits) || 'Spends AI credits';
                var fixLabel = (seoforgeAdmin.i18n && seoforgeAdmin.i18n.fix) || 'Fix';
                html += '<button type="button" class="sf-fix-btn" data-post="' + pid + '" data-issue="' + $('<div>').text(iss.message).html() + '" title="' + fixTitle + '">' + fixLabel + ' <span class="sf-credits-badge">' + creditSvg + ' ' + c + '</span></button>';
            } else if (iss.expandable) {
                // Content length → "Expand" opens a review drawer (preview the
                // ≥600-word expansion before it touches the post).
                var ec = iss.expand_cost || 2;
                html += '<button type="button" class="sf-expand-btn" data-post="' + pid + '" data-issue="' + $('<div>').text(iss.message).html() + '" title="Preview an AI expansion before applying">Expand with AI <span class="sf-credits-badge">' + creditSvg + ' ' + ec + '</span></button>';
            } else if (iss.toc) {
                // No ToC → build one from headings (no AI), reviewed in the drawer.
                html += '<button type="button" class="sf-toc-btn" data-post="' + pid + '" data-issue="' + $('<div>').text(iss.message).html() + '" title="Build a table of contents from your headings">Insert ToC</button>';
            } else if (iss.external) {
                var xc = iss.external_cost || 2;
                html += '<button type="button" class="sf-ext-btn" data-post="' + pid + '" data-issue="' + $('<div>').text(iss.message).html() + '" title="Preview AI external-link suggestions before applying">Suggest links <span class="sf-credits-badge">' + creditSvg + ' ' + xc + '</span></button>';
            } else if (iss.subhead) {
                // Keyword not in subheadings → rewrite ONE subheading (reviewed in the drawer).
                var sc = iss.subhead_cost || 2;
                html += '<button type="button" class="sf-subhead-btn" data-post="' + pid + '" data-issue="' + $('<div>').text(iss.message).html() + '" title="Rewrite one subheading to include the focus keyword">Add to subheading <span class="sf-credits-badge">' + creditSvg + ' ' + sc + '</span></button>';
            }
            html += '</li>';
        });
        $ul.html(html);
    }

 // locked AI buttons (Free) open the upsell modal instead of calling
    // any AI endpoint. These elements deliberately lack the .sf-ai-gen-* / id
    // hooks, so the real generators below never bind to them — this handler is
    // the only thing that fires, guaranteeing zero calls to api.avakode.com.
    $(document).on('click', '[data-sf-paywall]', function(e) {
        e.preventDefault();
        e.stopImmediatePropagation();
        var i18n = (window.seoforgeAdmin && seoforgeAdmin.i18n) || {};
        var copy = {
            meta:      i18n.paywallMeta,
            readiness: i18n.paywallReadiness,
            schema:    i18n.paywallSchema,
            optimize:  i18n.paywallOptimize,
            autolink:  i18n.paywallAutolink
        };
        var feature = String($(this).data('sf-paywall') || 'meta');
        var msg = copy[feature] || i18n.paywallMeta || 'This is a Pro feature.';
        var upgradeUrl = (window.seoforgeAdmin && seoforgeAdmin.upgradeUrl) || '#';
        if (window.sfModal && window.sfModal.paywall) {
            window.sfModal.paywall(msg, {
                title:     i18n.paywallTitle || 'Unlock AI Powers',
                ctaText:   i18n.paywallCta   || 'Upgrade to Pro',
                ctaUrl:    upgradeUrl,
                laterText: i18n.paywallLater || 'Maybe later',
                closeLabel: i18n.close || 'Close'
            });
        } else {
            window.open(upgradeUrl, '_blank');
        }
    });

    // AI Generate single field (title or description)
    $(document).on('click', '.sf-ai-gen-btn', function(e) {
        e.preventDefault();
        e.stopPropagation();
        var $btn = $(this), type = $btn.data('type'), pid = $btn.data('post');
        
        $btn.addClass('loading').html('<span class="dashicons dashicons-update" style="animation:spin 1s linear infinite;font-size:14px;width:12px;height:12px;"></span>');
        $.ajax({
            url: seoforgeAdmin.restUrl + '/meta/generate',
            method: 'POST',
            contentType: 'application/json',
            // Per-field button only generates the field it targets — saves a credit.
            data: JSON.stringify({ post_id: pid, type: type }),
            headers: { 'X-WP-Nonce': seoforgeAdmin.restNonce },
            success: function(r) {
                
                $btn.removeClass('loading').html('<span class="dashicons dashicons-superhero-alt" style="font-size:14px;width:12px;height:12px;"></span> AI');
                if (isCreditsExhausted(r)) { showCreditsExhausted(r); return; }
                if (type === 'title' && r.title) {
                    $('#sf-meta-title').val(r.title).trigger('input');
                } else if (type === 'description' && r.description) {
                    $('#sf-meta-desc').val(r.description).trigger('input');
                } else if (type === 'keyword' && r.keyword) {
                    $('#sf-keyword').val(r.keyword).trigger('input');
                }
            },
            error: function(xhr) {
                console.error('[SEO Forge] AI error:', xhr.status, xhr.responseText);
                $btn.removeClass('loading').html('<span class="dashicons dashicons-superhero-alt" style="font-size:14px;width:12px;height:12px;"></span> AI');
                sfAlert('AI generation failed. Check API key in SEO Forge → Settings.', { title: 'AI request failed', variant: 'danger' });
            }
        });
    });

    // AI Generate keyword → then title + description (using that keyword).
    // 3-credit chained flow: first a keyword call so the model commits to one
    // focus keyword, then a meta call that must weave that keyword verbatim
    // into both title and description (enforced server-side in the prompt).
    $(document).on('click', '.sf-ai-gen-both', function(e) {
        e.preventDefault();
        e.stopPropagation();
        var $btn = $(this), pid = $btn.data('post');
        var origHtml = $btn.html();
        function restore(){ $btn.removeClass('loading').html(origHtml); }
        $btn.addClass('loading').html('<span class="dashicons dashicons-update" style="animation:spin 1s linear infinite;font-size:14px;width:14px;height:14px;"></span> Keyword…');

        $.ajax({
            url: seoforgeAdmin.restUrl + '/meta/generate',
            method: 'POST',
            contentType: 'application/json',
            data: JSON.stringify({ post_id: pid, type: 'keyword' }),
            headers: { 'X-WP-Nonce': seoforgeAdmin.restNonce },
            success: function(kwResp) {
                if (isCreditsExhausted(kwResp)) { restore(); showCreditsExhausted(kwResp); return; }
                if (kwResp && kwResp.keyword) {
                    $('#sf-keyword').val(kwResp.keyword).trigger('input');
                }
                // Even if the keyword call returned nothing, continue with
                // title+description — worst case the meta is generated
                // without a focus keyword (same as old behaviour).
                $btn.html('<span class="dashicons dashicons-update" style="animation:spin 1s linear infinite;font-size:14px;width:14px;height:14px;"></span> Title + Description…');
                $.ajax({
                    url: seoforgeAdmin.restUrl + '/meta/generate',
                    method: 'POST',
                    contentType: 'application/json',
                    data: JSON.stringify({ post_id: pid /* default type=both, server reads fresh _seoforge_focus_keyword */ }),
                    headers: { 'X-WP-Nonce': seoforgeAdmin.restNonce },
                    success: function(r) {
                        restore();
                        if (isCreditsExhausted(r)) { showCreditsExhausted(r); return; }
                        if (r.title)       $('#sf-meta-title').val(r.title).trigger('input');
                        if (r.description) $('#sf-meta-desc').val(r.description).trigger('input');
                    },
                    error: function() {
                        restore();
                        sfAlert('AI title/description generation failed.', { title: 'AI request failed', variant: 'danger' });
                    }
                });
            },
            error: function() {
                restore();
                sfAlert('AI keyword generation failed. Check API key in SEO Forge → Settings.', { title: 'AI request failed', variant: 'danger' });
            }
        });
    });

 // ── + Bulk Fix all N (group-level AI fix) ──
 // (2026-04-18): the previous implementation sent ALL ids in one
    // XHR. For 187 posts the server loop × AI latency easily exceeded the
    // Cloudflare / Apache timeout → 504. Now we slice client-side into
    // fixed-size chunks and POST them sequentially, updating the button
    // with per-batch progress and aggregating fixed_ids across all batches.
    var BULK_CHUNK = 5; // posts per server-round-trip; safe under 60s PHP cap
    $(document).on('click', '.sf-audit-bulk-fix', function(e) {
        e.preventDefault();
        var $btn = $(this);
        var allIds = ($btn.data('ids') + '').split(',').map(function(s){ return parseInt(s, 10); }).filter(Boolean);
        var issue = $btn.data('issue');
        if (!allIds.length) return;
        sfConfirm(
            'Apply AI fix to ' + allIds.length + ' posts? This spends credits per post. Processing in batches of ' + BULK_CHUNK + ' to avoid timeouts.',
            { title: 'Apply AI fix to ' + allIds.length + ' posts?', okText: 'Fix ' + allIds.length, cancelText: 'Cancel', variant: 'warning' }
        ).then(function(ok){ if (ok) sfRunBulkFix($btn, allIds, issue); });
    });
    function sfRunBulkFix($btn, allIds, issue) {
        var orig = $btn.html();
        $btn.addClass('loading').prop('disabled', true);

        var total = allIds.length;
        var fixedAll = [];   // accumulates fixed_ids across batches
        var okCount  = 0;
        var batches  = [];
        for (var i = 0; i < allIds.length; i += BULK_CHUNK) {
            batches.push(allIds.slice(i, i + BULK_CHUNK));
        }

        function finish(extraMsg) {
            // Apply row removals ONCE at the end so we don't thrash the DOM
            // mid-run and so a later batch's failure doesn't undo earlier
            // removals.
            $btn.text('Fixed ' + okCount + '/' + total + (extraMsg ? (' · ' + extraMsg) : ''))
                .css({background:'#059669',color:'#fff'})
                .prop('disabled', true);
            var $issueBlock = $btn.closest('.sf-audit-issue');
            setTimeout(function() {
                fixedAll.forEach(function(pid) {
                    $issueBlock.find('.sf-audit-issue-fix[data-post="' + pid + '"]').remove();
                    $issueBlock.find('a.sf-audit-issue-post').each(function() {
                        var href = jQuery(this).attr('href') || '';
                        if (href.indexOf('post=' + pid) !== -1) jQuery(this).remove();
                    });
                });
                if ($issueBlock.find('.sf-audit-issue-post').length === 0) {
                    $issueBlock.slideUp(250, function(){ jQuery(this).remove(); });
                } else {
                    var $countBadge = $issueBlock.find('.sf-audit-issue-count');
                    if ($countBadge.length) {
                        var remaining = $issueBlock.find('.sf-audit-issue-post').length;
                        if (remaining > 1) $countBadge.text('×' + remaining);
                        else $countBadge.remove();
                    }
                }
            }, 800);
        }

        function fail(msg) {
            $btn.removeClass('loading')
                .prop('disabled', false)
                .html(orig);
            sfAlert(msg || 'Bulk fix failed', { title: 'Bulk fix stopped', variant: 'danger' });
        }

        function runBatch(idx) {
            if (idx >= batches.length) { finish(); return; }
            var chunk = batches[idx];
            var done = idx * BULK_CHUNK;
            $btn.text('Fixing ' + Math.min(done + chunk.length, total) + '/' + total + '…');

            $.ajax({
                url: seoforgeAdmin.restUrl + '/fix/bulk',
                method: 'POST',
                contentType: 'application/json',
                data: JSON.stringify({ post_ids: chunk, issue_message: issue }),
                headers: { 'X-WP-Nonce': seoforgeAdmin.restNonce },
                // One batch shouldn't need more than ~45s, but Cloudflare
                // caps at 100s anyway. Give jQuery enough room.
                timeout: 90000,
                success: function(r) {
                    if (isCreditsExhausted(r)) {
                        showCreditsExhausted(r);
                        finish('credits exhausted');
                        return;
                    }
                    if (r && r.success) {
                        okCount += (r.fixed || 0);
                        if (Array.isArray(r.fixed_ids)) fixedAll = fixedAll.concat(r.fixed_ids);
                        runBatch(idx + 1);
                    } else {
                        // Server returned success:false (e.g. AI not configured)
                        // — stop the whole run, user needs to act.
                        fail((r && r.message) || 'Bulk fix stopped');
                    }
                },
                error: function(xhr) {
                    // A single batch failed (504, network, etc). Surface
                    // partial progress and let the user retry the remainder
                    // by clicking Fix again — the fixed rows are already gone
                    // from the audit cache on their next run.
                    var hint = 'Batch ' + (idx + 1) + '/' + batches.length + ' failed';
                    if (xhr && xhr.status) hint += ' (HTTP ' + xhr.status + ')';
                    if (okCount > 0) finish(hint);
                    else fail(hint);
                }
            });
        }

        runBatch(0);
    }

    // ── SEO metabox: Fix all N (applies AI fix for every listed issue on this post) ──
    $(document).on('click', '.sf-fix-all-btn', function(e) {
        e.preventDefault();
        e.stopPropagation();
        e.stopImmediatePropagation();
        var $btn = $(this);
        var pid = $btn.data('post');
        var issues = [];
        try { issues = JSON.parse($btn.attr('data-issues') || '[]'); } catch(err) { issues = []; }
        if (!issues.length) return;
        var n = issues.length;
        // Show the real total from the Fix-all badge (sum of per-action costs:
        // generate_meta=1, fix_issue=2, auto_insert_links=3), not a flat "~2 each".
        var totalCredits = parseInt(($btn.find('.sf-credits-badge').text() || '').replace(/[^0-9]/g, ''), 10) || 0;
        var creditMsg = totalCredits > 0
            ? ('This spends about ' + totalCredits + ' credit' + (totalCredits === 1 ? '' : 's') + ' in total.')
            : 'This spends AI credits.';
        sfConfirm(
            'Apply AI fix to ' + n + ' issue' + (n === 1 ? '' : 's') + '? ' + creditMsg,
            { title: 'Apply AI fix to ' + n + ' issues?', okText: 'Fix all', cancelText: 'Cancel', variant: 'warning' }
        ).then(function(ok){ if (ok) sfRunFixAll($btn, pid, issues); });
    });
    // Route ONE issue to the correct backend action and return a Promise
    // resolving to { resp, type }. Most issues can't be fixed by rewriting the
    // post content: meta-field issues (missing/short/long title or description,
    // keyword not in title/description) must regenerate the matching meta field
    // via /meta/generate, and missing internal links must be auto-inserted. All
    // routed endpoints persist server-side (generate_meta saves post meta, /fix
    // saves content, links/auto-insert saves links).
    // NOTE: routing mirrors the single `.sf-fix-btn` handler below — keep both
    // in sync.
    function sfSmartFixOne(pid, issue) {
        var s = (issue || '').toLowerCase();
        var base = seoforgeAdmin.restUrl, headers = { 'X-WP-Nonce': seoforgeAdmin.restNonce };
        function gen(type) {
            var body = { post_id: pid, type: type };
            if (type === 'title') body.issue = issue; // lets the server do a targeted title rewrite
            return $.ajax({ url: base + '/meta/generate', method: 'POST', contentType: 'application/json',
                data: JSON.stringify(body), headers: headers })
                .then(function(r){ return { resp: r, type: type }; });
        }
        var kwAbsent = /\b(no|missing|empty)\s+focus\s+keyword/.test(s) || /focus\s+keyword\s+(is\s+)?(missing|empty|not\s+set)/.test(s);
        if (kwAbsent) return gen('keyword');
        if ((/(meta title|seo.?title|\btitle\b)/.test(s) && /(missing|too short|too long|not in title|not near the start|add a number|power or emotional)/.test(s)) || /focus keyword not in title/.test(s)) return gen('title');
        if ((/(meta description|description)/.test(s) && /(missing|too short|too long|not in|empty)/.test(s)) || /focus keyword not in (meta )?description/.test(s)) return gen('description');
        if (/internal link/.test(s)) {
            return $.ajax({ url: base + '/links/auto-insert/' + pid, method: 'POST', headers: headers })
                .then(function(r){ return { resp: r, type: 'links' }; });
        }
        return $.ajax({ url: base + '/fix/' + pid, method: 'POST', contentType: 'application/json',
            data: JSON.stringify({ issue_message: issue }), headers: headers })
            .then(function(r){ return { resp: r, type: 'content' }; });
    }

    // Apply the DOM side of a resolved smart fix on the post-edit metabox (fill
    // the field so the user sees it before reload) and report success.
    function sfApplyFixResult(out) {
        var r = out.resp || {}, type = out.type;
        if (type === 'keyword')     { if (r.keyword)     { $('#sf-keyword').val(r.keyword).trigger('input'); return true; } return false; }
        if (type === 'title')       { if (r.title)       { $('#sf-meta-title').val(r.title).trigger('input'); return true; } return false; }
        if (type === 'description') { if (r.description) { $('#sf-meta-desc').val(r.description).trigger('input'); return true; } return false; }
        return !!r.success; // links / content
    }

    function sfRunFixAll($btn, pid, issues) {
        var orig = $btn.html();
        var total = issues.length;
        $btn.addClass('loading').prop('disabled', true).text('Fixing ' + total + '…');
        var done = 0, fails = 0;
        function next() {
            if (!issues.length) {
                $btn.text('Fixed ' + done + (fails ? (' / ' + fails + ' failed') : ''));
                setTimeout(function(){ location.reload(); }, 1200);
                return;
            }
            var msg = issues.shift();
            sfSmartFixOne(pid, msg).then(function(out){
                if (isCreditsExhausted(out.resp)) { $btn.removeClass('loading').prop('disabled', false).html(orig); showCreditsExhausted(out.resp); return; }
                if (sfApplyFixResult(out)) done++; else fails++;
                $btn.text('Fixing ' + (done + fails) + '/' + total + '…');
                next();
            }, function(){ fails++; next(); });
        }
        next();
    }

    // ── Fix with AI ──
    $(document).on('click', '.sf-fix-btn:not(.sf-fix-all-btn)', function(e) {
        e.preventDefault();
        e.stopPropagation();
        var $btn = $(this), pid = $btn.data('post'), issue = $btn.data('issue');

        // Smart routing: most issues can't be fixed by rewriting content.
        // Route each issue type to the matching backend action so Fix
        // actually changes the relevant field.
        var issueLc = (issue || '').toLowerCase();

        // When the Fix button sits inside Site Audit (not the post metabox),
        // we don't want to just reload — a reload would re-render the same
        // audit snapshot and the fixed post would still appear as failing
        // until the user re-runs the audit. Instead, visually remove the
        // affected row (and the whole issue block if it becomes empty).
        var $auditIssue = $btn.closest('.sf-audit-issue');
        var onAuditPage = $auditIssue.length > 0;

        function finishAuditFix(label) {
            $btn.text(label).css({background:'#059669'});
            if (!onAuditPage) {
                setTimeout(function(){ location.reload(); }, 800);
                return;
            }
            // Remove this post's row (the <a> link + the <button> pair).
            var $postLink = $btn.prev('a.sf-audit-issue-post');
            setTimeout(function(){
                $btn.fadeOut(200);
                $postLink.fadeOut(200, function(){
                    $postLink.remove();
                    $btn.remove();
                    // Decrement the ×N badge on issue header.
                    var $countBadge = $auditIssue.find('.sf-audit-issue-count');
                    if ($countBadge.length) {
                        var remaining = $auditIssue.find('.sf-audit-issue-post').length;
                        if (remaining > 1) $countBadge.text('×' + remaining);
                        else $countBadge.remove();
                    }
                    // If the issue has no posts left, fade the whole block.
                    if ($auditIssue.find('.sf-audit-issue-post').length === 0) {
                        $auditIssue.slideUp(250, function(){ $(this).remove(); });
                    }
                });
            }, 500);
        }

        function afterSmartFix(label, saveField, val) {
            if (saveField) {
                $.post(seoforgeAdmin.ajaxUrl, {
                    action: 'seoforge_save_meta_field',
                    post_id: pid,
                    field: saveField,
                    value: val,
                    nonce: seoforgeAdmin.nonce
                }).always(function(){
                    finishAuditFix(label);
                });
            } else {
                finishAuditFix(label);
            }
        }

        // 1. Focus keyword is ABSENT (no/missing/empty set) → generate one (1 credit).
        //    Deliberately NOT matched for "focus keyword not in title/description/content"
        //    — those need to rewrite the target field, not the keyword itself.
        var kwAbsent = /\b(no|missing|empty)\s+focus\s+keyword/.test(issueLc)
                     || /focus\s+keyword\s+(is\s+)?(missing|empty|not\s+set)/.test(issueLc);
        if (kwAbsent) {
            $btn.addClass('loading').text('Fixing...');
            $.ajax({
                url: seoforgeAdmin.restUrl + '/meta/generate', method: 'POST',
                contentType: 'application/json',
                data: JSON.stringify({ post_id: pid, type: 'keyword' }),
                headers: { 'X-WP-Nonce': seoforgeAdmin.restNonce },
                success: function(r) {
                    if (isCreditsExhausted(r)) { $btn.removeClass('loading').text('Fix'); showCreditsExhausted(r); return; }
                    if (r.keyword) {
                        $('#sf-keyword').val(r.keyword).trigger('input');
                        afterSmartFix('Fixed!', 'keyword', r.keyword);
                    } else { $btn.removeClass('loading').text('Fix'); sfAlert('Could not generate keyword.', { title: 'AI generation failed', variant: 'danger' }); }
                },
                error: function() { $btn.removeClass('loading').text('Fix'); sfAlert('Request failed', { title: 'AI request failed', variant: 'danger' }); }
            });
            return;
        }

        // 2. Title-quality issues (missing, too short/long, keyword not in /
        //    near the start of the title, add a number, power word) → rewrite
        //    the SEO title via /meta/generate?type=title (1 credit). Passing the
        //    issue lets the server do a targeted rewrite that reliably adds the
        //    number / power word.
        if ((/(meta title|seo.?title|\btitle\b)/.test(issueLc) &&
             /(missing|too short|too long|not in title|not near the start|add a number|power or emotional)/.test(issueLc))
            || /focus keyword not in title/.test(issueLc)) {
            $btn.addClass('loading').text('Fixing...');
            $.ajax({
                url: seoforgeAdmin.restUrl + '/meta/generate', method: 'POST',
                contentType: 'application/json',
                data: JSON.stringify({ post_id: pid, type: 'title', issue: issue }),
                headers: { 'X-WP-Nonce': seoforgeAdmin.restNonce },
                success: function(r) {
                    if (isCreditsExhausted(r)) { $btn.removeClass('loading').text('Fix'); showCreditsExhausted(r); return; }
                    if (r.title) {
                        $('#sf-meta-title').val(r.title).trigger('input');
                        afterSmartFix('Fixed!', 'meta_title', r.title);
                    } else { $btn.removeClass('loading').text('Fix'); sfAlert('Could not generate title.', { title: 'AI generation failed', variant: 'danger' }); }
                },
                error: function() { $btn.removeClass('loading').text('Fix'); sfAlert('Request failed', { title: 'AI request failed', variant: 'danger' }); }
            });
            return;
        }

        // 3. Meta description issues (missing, too short/long, keyword not in description)
        if ((/(meta description|description)/.test(issueLc) &&
             /(missing|too short|too long|not in|empty)/.test(issueLc))
            || /focus keyword not in (meta )?description/.test(issueLc)) {
            $btn.addClass('loading').text('Fixing...');
            $.ajax({
                url: seoforgeAdmin.restUrl + '/meta/generate', method: 'POST',
                contentType: 'application/json',
                data: JSON.stringify({ post_id: pid, type: 'description' }),
                headers: { 'X-WP-Nonce': seoforgeAdmin.restNonce },
                success: function(r) {
                    if (isCreditsExhausted(r)) { $btn.removeClass('loading').text('Fix'); showCreditsExhausted(r); return; }
                    if (r.description) {
                        $('#sf-meta-desc').val(r.description).trigger('input');
                        afterSmartFix('Fixed!', 'meta_description', r.description);
                    } else { $btn.removeClass('loading').text('Fix'); sfAlert('Could not generate description.', { title: 'AI generation failed', variant: 'danger' }); }
                },
                error: function() { $btn.removeClass('loading').text('Fix'); sfAlert('Request failed', { title: 'AI request failed', variant: 'danger' }); }
            });
            return;
        }

        // 4. Internal links missing → /links/auto-insert/:id (3 credits)
        if (/internal link/.test(issueLc)) {
            $btn.addClass('loading').text('Fixing...');
            $.ajax({
                url: seoforgeAdmin.restUrl + '/links/auto-insert/' + pid, method: 'POST',
                headers: { 'X-WP-Nonce': seoforgeAdmin.restNonce },
                success: function(r) {
                    if (isCreditsExhausted(r)) { $btn.removeClass('loading').text('Fix'); showCreditsExhausted(r); return; }
                    if (r && r.success) afterSmartFix('Fixed!', null, null);
                    else { $btn.removeClass('loading').text('Fix'); sfAlert((r && r.message) || 'Could not insert links.', { title: 'Could not insert links', variant: 'danger' }); }
                },
                error: function() { $btn.removeClass('loading').text('Fix'); sfAlert('Request failed', { title: 'AI request failed', variant: 'danger' }); }
            });
            return;
        }

        $btn.addClass('loading').text('Fixing...');
        $.ajax({
            url: seoforgeAdmin.restUrl + '/fix/' + pid,
            method: 'POST',
            contentType: 'application/json',
            data: JSON.stringify({ issue_message: issue }),
            headers: { 'X-WP-Nonce': seoforgeAdmin.restNonce },
            success: function(r) {
                if (isCreditsExhausted(r)) { $btn.removeClass('loading').text('Fix'); showCreditsExhausted(r); return; }
                if (r.success) {
                    finishAuditFix('Fixed!');
                } else {
                    $btn.removeClass('loading').text('Fix');
                    sfAlert(r.message || 'Fix failed', { title: 'Fix failed', variant: 'danger' });
                }
            },
            error: function() { $btn.removeClass('loading').text('Fix'); sfAlert('Request failed', { title: 'AI request failed', variant: 'danger' }); }
        });
    });

    // ── Auto Internal Linking ──
    $(document).on('click', '#sf-auto-link', function(e) {
        e.preventDefault();
        var $btn = $(this), pid = $btn.data('post');
        $btn.addClass('loading').html('<span class="dashicons dashicons-update" style="animation:spin 1s linear infinite;font-size:14px;width:13px;height:13px;"></span> Linking...');
        $.ajax({
            url: seoforgeAdmin.restUrl + '/links/auto-insert/' + pid,
            method: 'POST',
            contentType: 'application/json',
            data: '{}',
            headers: { 'X-WP-Nonce': seoforgeAdmin.restNonce },
            success: function(r) {
                if (isCreditsExhausted(r)) { $btn.removeClass('loading').html('<span class="dashicons dashicons-admin-links"></span> Auto-Link'); showCreditsExhausted(r); return; }
                if (r.success) {
                    $btn.html('<span class="dashicons dashicons-yes"></span> Linked!').css({color:'#059669',borderColor:'#059669'});
                    setTimeout(function(){ location.reload(); }, 1000);
                } else {
                    $btn.removeClass('loading').html('<span class="dashicons dashicons-admin-links"></span> Auto-Link');
                    sfAlert(r.message || 'Auto-link failed', { title: 'Auto-link failed', variant: 'danger' });
                }
            },
            error: function() { $btn.removeClass('loading').html('<span class="dashicons dashicons-admin-links"></span> Auto-Link'); }
        });
    });

    // ── Content Optimizer ──
    $(document).on('click', '#sf-optimize-content', function(e) {
        e.preventDefault();
        var $btn = $(this), pid = $btn.data('post');
        var $result = $('#sf-optimizer-result');
        var origHtml = $btn.html();
        $btn.addClass('loading').html('<span class="dashicons dashicons-update" style="animation:spin 1s linear infinite;font-size:15px;width:15px;height:15px;"></span> ' + (seoforgeAdmin.i18n && seoforgeAdmin.i18n.analyzingContent || 'Analyzing content…'));
        $.ajax({
            url: seoforgeAdmin.restUrl + '/optimize/' + pid,
            method: 'POST',
            contentType: 'application/json',
            data: JSON.stringify({ force: false }),
            headers: { 'X-WP-Nonce': seoforgeAdmin.restNonce },
            success: function(r) {
                $btn.removeClass('loading').html(origHtml);
                if (isCreditsExhausted(r)) { showCreditsExhausted(r); return; }
                if (!r.success) {
                    sfAlert(r.message || 'Optimization failed', { title: 'Optimization failed', variant: 'danger' });
                    return;
                }
                var html = '';
                // Score bar
                var sc = r.score_before || 0;
                var scColor = sc >= 70 ? '#059669' : (sc >= 40 ? '#d97706' : '#dc2626');
                html += '<div class="sf-opt-score-bar">';
                html += '<div class="sf-opt-score-num" style="color:' + scColor + '">' + sc + '</div>';
                html += '<div><strong style="font-size:14px;">SEO Content Strength</strong>';
                if (r.word_count) html += '<br><span style="font-size:14px;color:#94a3b8;">' + r.word_count + ' words';
                if (r.keyword) html += ' &middot; keyword: ' + $('<span>').text(r.keyword).html();
                html += '</span></div></div>';

                // Suggestion cards
                if (r.suggestions && r.suggestions.length) {
                    var typeIcons = {
                        'add_section': 'dashicons-plus-alt2',
                        'expand_section': 'dashicons-editor-expand',
                        'add_table': 'dashicons-editor-table',
                        'update_data': 'dashicons-chart-bar',
                        'improve_heading': 'dashicons-heading',
                        'add_faq': 'dashicons-format-chat',
                        'add_images': 'dashicons-format-image',
                        'improve_intro': 'dashicons-editor-paragraph',
                        'improve_conclusion': 'dashicons-flag',
                        'add_list': 'dashicons-editor-ul'
                    };
                    r.suggestions.forEach(function(s, idx) {
                        var pClass = 'p' + (s.priority || 2);
                        var pLabel = 'P' + (s.priority || 2);
                        var icon = typeIcons[s.type] || 'dashicons-lightbulb';
                        var hasExample = s.example && s.example.trim().length > 0;

                        html += '<div class="sf-opt-card" data-idx="' + idx + '">';
                        html += '<div class="sf-opt-card-header">';
                        html += '<span class="sf-opt-priority ' + pClass + '">' + pLabel + '</span>';
                        html += '<span class="dashicons ' + icon + ' sf-opt-type-icon"></span>';
                        html += '<span class="sf-opt-card-title">' + $('<span>').text(s.title).html() + '</span>';
                        html += '</div>';
                        html += '<div class="sf-opt-card-desc">' + $('<span>').text(s.description).html() + '</div>';

                        if (hasExample) {
                            html += '<div class="sf-opt-card-example" id="sf-opt-example-' + idx + '">' + s.example + '</div>';
                            html += '<div class="sf-opt-card-actions">';
                            html += '<button type="button" class="sf-opt-toggle-example" data-idx="' + idx + '">' + (seoforgeAdmin.i18n && seoforgeAdmin.i18n.example || 'Example') + '</button>';
                            // Only show Apply for types that insert content
                            var applyTypes = ['add_section', 'add_table', 'add_faq', 'add_list', 'improve_intro', 'improve_conclusion'];
                            if (applyTypes.indexOf(s.type) !== -1) {
                                html += '<button type="button" class="sf-opt-apply-btn" data-post="' + pid + '" data-idx="' + idx + '" data-type="' + s.type + '">' + (seoforgeAdmin.i18n && seoforgeAdmin.i18n.apply || 'Apply') + '</button>';
                            }
                            html += '</div>';
                        }
                        html += '</div>';
                    });
                }
                $result.html(html).show();
                // Store suggestions data for apply
                $result.data('suggestions', r.suggestions);
            },
            error: function(xhr) {
                $btn.removeClass('loading').html(origHtml);
                sfAlert('Content optimization failed. Check API key in SEO Forge Settings.', { title: 'Optimization failed', variant: 'danger' });
            }
        });
    });

    // Toggle example in optimizer card
    $(document).on('click', '.sf-opt-toggle-example', function(e) {
        e.preventDefault();
        var idx = $(this).data('idx');
        $('#sf-opt-example-' + idx).toggleClass('open');
        $(this).text($('#sf-opt-example-' + idx).hasClass('open') ? (seoforgeAdmin.i18n && seoforgeAdmin.i18n.hide || 'Hide') : (seoforgeAdmin.i18n && seoforgeAdmin.i18n.example || 'Example'));
    });

    // Apply optimization suggestion
    $(document).on('click', '.sf-opt-apply-btn', function(e) {
        e.preventDefault();
        var $btn = $(this), pid = $btn.data('post'), idx = $btn.data('idx'), type = $btn.data('type');
        var suggestions = $('#sf-optimizer-result').data('suggestions');
        if (!suggestions || !suggestions[idx] || !suggestions[idx].example) return;

        var position = 'before_end';
        if (type === 'improve_intro') position = 'after_intro';
        if (type === 'improve_conclusion' || type === 'add_faq') position = 'before_end';

        $btn.addClass('loading').text(seoforgeAdmin.i18n && seoforgeAdmin.i18n.applying || 'Applying…');
        $.ajax({
            url: seoforgeAdmin.restUrl + '/optimize/apply/' + pid,
            method: 'POST',
            contentType: 'application/json',
            data: JSON.stringify({
                type: type,
                content: suggestions[idx].example,
                position: position
            }),
            headers: { 'X-WP-Nonce': seoforgeAdmin.restNonce },
            success: function(r) {
                if (r.success) {
                    $btn.text(seoforgeAdmin.i18n && seoforgeAdmin.i18n.applied || 'Applied!').css({background:'#059669'});
                    setTimeout(function(){ location.reload(); }, 1200);
                } else {
                    $btn.removeClass('loading').text(seoforgeAdmin.i18n && seoforgeAdmin.i18n.apply || 'Apply');
                    sfAlert(r.message || 'Apply failed', { title: 'Apply failed', variant: 'danger' });
                }
            },
            error: function() {
                $btn.removeClass('loading').text(seoforgeAdmin.i18n && seoforgeAdmin.i18n.apply || 'Apply');
                sfAlert('Request failed', { title: 'Request failed', variant: 'danger' });
            }
        });
    });

    // ── AI Readiness Score ──
    $(document).on('click', '#sf-ai-readiness', function(e) {
        e.preventDefault();
        var $btn = $(this), pid = $btn.data('post');
        var $result = $('#sf-ai-readiness-result');
        $btn.addClass('loading');
        $.ajax({
            url: seoforgeAdmin.restUrl + '/ai-readiness/' + pid,
            method: 'GET',
            headers: { 'X-WP-Nonce': seoforgeAdmin.restNonce },
            success: function(r) {
                $btn.removeClass('loading');
                if (r && r.score !== undefined) {
                    var scoreClass = r.score >= 70 ? '' : (r.score >= 40 ? ' mid' : ' low');
                    var scoreColor = r.score >= 70 ? '#059669' : (r.score >= 40 ? '#d97706' : '#dc2626');
                    var html = '<div class="sf-aeo-score' + scoreClass + '">'
                        + '<div class="sf-aeo-num" style="color:' + scoreColor + '">' + r.score + '</div>'
                        + '<div><strong>AI Search Readiness</strong><br><span style="font-size:14px;color:#64748b;">How well your content performs in AI search engines</span></div>'
                        + '</div>';

                    if (r.checks && r.checks.length) {
                        html += '<div class="sf-aeo-checks">';
                        r.checks.forEach(function(c) {
                            html += '<div class="sf-aeo-check ' + (c.passed ? 'pass' : 'fail') + '">'
                                + (c.passed ? '&#10003;' : '&#10007;') + ' ' + c.name
                                + (c.message ? ' — <span style="color:#64748b;">' + c.message + '</span>' : '')
                                + '</div>';
                        });
                        html += '</div>';
                    }
                    if (r.suggestions && r.suggestions.length) {
                        html += '<div style="margin-top:6px;font-size:14px;color:#FF6B35;"><strong>Suggestions:</strong></div>';
                        r.suggestions.forEach(function(s) {
                            html += '<div style="font-size:14px;color:#64748b;padding:1px 0;">• ' + s + '</div>';
                        });
                    }
                    $result.html(html).show();
                }
            },
            error: function() { $btn.removeClass('loading'); }
        });
    });

    // Settings vertical tabs. This lives in the enqueued admin asset so
    // production pages keep the layout even if the inline fallback is stripped
    // or the page response ends before the footer script block.
    window.seoforgeInitSettingsTabs = function() {
        var $items = $('.sf-settings-nav__item');
        var $cards = $('.sf-settings-cards .sf-card[data-sf-tab]');
        var $heads = $('[data-sf-panel-head]');
        var $select = $('.sf-settings-nav__select');
        if (!$items.length || !$cards.length) return;
        if ($items.data('sf-settings-tabs-ready')) return;
        $items.data('sf-settings-tabs-ready', true);

        window.seoforgeSettingsTabsReady = true;

        var TAB_KEY = 'sf_settings_tab_v1';
        var DEFAULT_TAB = 'general';
        var validTabs = $items.map(function() { return $(this).data('sf-tab'); }).get();

        function setActive(tab, opts) {
            opts = opts || {};
            if (validTabs.indexOf(tab) === -1) tab = DEFAULT_TAB;

            $items
                .removeClass('is-active')
                .attr('aria-selected', 'false')
                .filter('[data-sf-tab="' + tab + '"]')
                .addClass('is-active')
                .attr('aria-selected', 'true');

            $cards.removeClass('is-active').filter('[data-sf-tab="' + tab + '"]').addClass('is-active');
            $heads.removeClass('is-active').filter('[data-sf-panel-head="' + tab + '"]').addClass('is-active');
            if ($select.length && $select.val() !== tab) $select.val(tab);

            try { localStorage.setItem(TAB_KEY, tab); } catch (e) {}
            if (opts.updateHash !== false) {
                if (history.replaceState) history.replaceState(null, '', '#sf-tab-' + tab);
                else window.location.hash = 'sf-tab-' + tab;
            }
        }

        var initial = DEFAULT_TAB;
        var hashMatch = (location.hash || '').match(/^#sf-tab-([a-z0-9-]+)/i);
        var isAfterSave = /[?&]settings-updated=true/.test(location.search);
        if (hashMatch && validTabs.indexOf(hashMatch[1]) !== -1) {
            initial = hashMatch[1];
        } else if (isAfterSave) {
            try {
                var saved = localStorage.getItem(TAB_KEY);
                if (saved && validTabs.indexOf(saved) !== -1) initial = saved;
            } catch (e) {}
        }
        setActive(initial, { updateHash: false });

        $items.on('click', function(e) {
            e.preventDefault();
            setActive($(this).data('sf-tab'));
            var content = $('.sf-settings-content')[0];
            if (content && content.scrollIntoView) {
                content.scrollIntoView({ behavior: 'smooth', block: 'start' });
            }
        });

        $select.on('change', function() { setActive($(this).val()); });

        $items.on('keydown', function(e) {
            var idx = $items.index(this);
            var next = null;
            if (e.key === 'ArrowDown' || e.key === 'ArrowRight') next = $items.eq((idx + 1) % $items.length);
            else if (e.key === 'ArrowUp' || e.key === 'ArrowLeft') next = $items.eq((idx - 1 + $items.length) % $items.length);
            else if (e.key === 'Home') next = $items.first();
            else if (e.key === 'End') next = $items.last();
            if (next && next.length) {
                e.preventDefault();
                next.focus().trigger('click');
            }
        });

        var $errorPanel = $cards.has('.notice-error, .form-invalid').first();
        if ($errorPanel.length) setActive($errorPanel.data('sf-tab'));

        $('.sf-settings-cards :input').on('input change', function() {
            var tab = $(this).closest('[data-sf-tab]').data('sf-tab');
            if (!tab) return;
            $items.filter('[data-sf-tab="' + tab + '"]').addClass('is-dirty');
        });

        $('.sf-settings-form').on('submit', function() {
            $items.removeClass('is-dirty');
        });
    };
    window.seoforgeInitSettingsTabs();
})(jQuery);
