<?php

namespace WPSN\Modules\FrontendInspector;

use WPSN\Core\Capabilities;
use WPSN\Core\Container;
use WPSN\Core\RateLimiter;
use WPSN\Modules\AI\AICallRepository;
use WPSN\Modules\AI\AISecretsRepository;
use WPSN\Modules\AI\AIUsageRepository;
use WPSN\Modules\AI\LocalSuggestionEngine;
use WPSN\Modules\AI\OpenAIClient;
use WPSN\Modules\AI\PromptLibrary;
use WPSN\Modules\AI\Redactor;
use WPSN\Modules\AI\SaaSAIProxyClient;
use WPSN\Modules\SaaSSync\SaaSSyncService;
use WPSN\SEO\MetaKeys;

defined('ABSPATH') || exit;

final class FrontendInspector
{
    private const USERMETA_AI_DRAFTS = '_wpsn_inspector_ai_drafts';
    private const AI_DRAFT_TTL = 3600; // seconds

    private Container $container;

    public function __construct(Container $container)
    {
        $this->container = $container;
    }

    public function register(): void
    {
        // Save handler (non-AJAX). Must be registered even on admin-post.php requests.
        add_action('admin_post_wpsn_frontend_seo_save', [$this, 'handleSave']);
        add_action('admin_post_wpsn_frontend_seo_ai_fill', [$this, 'handleAiFill']);
        add_action('admin_post_wpsn_frontend_seo_ai_discard', [$this, 'handleAiDiscard']);

        // Frontend UI hooks (do not run in wp-admin).
        if (!is_admin()) {
            add_action('wp_enqueue_scripts', [$this, 'enqueueAssets'], 99);
            add_action('wp_footer', [$this, 'renderPanel'], 99);
        }

        // Admin-bar shortcut (works on frontend + admin).
        add_action('admin_bar_menu', [$this, 'addAdminBarNode'], 95);
    }

    private function canShow(): bool
    {
        if (!is_user_logged_in()) {
            return false;
        }

        // Avoid showing to visitors; show only to users who can edit content.
        if (!current_user_can('edit_posts') && !Capabilities::canManageTools()) {
            return false;
        }

        // Allow site owners to disable completely.
        $enabled = (bool) apply_filters('wpsn/frontend_inspector/enabled', true);
        return $enabled;
    }

    private function inspectorRequested(): bool
    {
        return isset($_GET['wpsn_inspector']) && (string) wp_unslash($_GET['wpsn_inspector']) === '1';
    }

    private function onDemandEnabled(): bool
    {
        try {
            $settings = $this->container->get('settings');
            return (bool) $settings->get('performance.frontend.inspector_on_demand', true);
        } catch (\Throwable $e) {
            return true;
        }
    }

    private function shouldLoadInspector(): bool
    {
        if (!$this->canShow()) {
            return false;
        }

        // On-demand mode prevents caches from accidentally serving the inspector UI to visitors.
        if ($this->onDemandEnabled()) {
            return $this->inspectorRequested();
        }

        return true;
    }

    public function enqueueAssets(): void
    {
        if (!$this->shouldLoadInspector()) {
            return;
        }

        // Keep assets very small and fully isolated.
        $base = trailingslashit(WPSN_PLUGIN_URL) . 'assets/frontend/';
        $ver  = defined('WPSN_VERSION') ? (string) WPSN_VERSION : null;

        wp_enqueue_style('wpsn-frontend-inspector', $base . 'inspector.css', [], $ver);
        wp_enqueue_script('wpsn-frontend-inspector', $base . 'inspector.js', [], $ver, true);
    }

    public function addAdminBarNode($wpAdminBar): void
    {
        if (!$this->canShow()) {
            return;
        }
        if (!($wpAdminBar instanceof \WP_Admin_Bar)) {
            return;
        }
        if (!function_exists('is_admin_bar_showing') || !is_admin_bar_showing()) {
            return;
        }

        // Add under the existing WP SEO Next admin-bar menu when present.
        $parent = 'wpsn';
        if (!$wpAdminBar->get_node($parent)) {
            $parent = 'top-secondary';
        }

        $current = home_url('/');
        if (isset($_SERVER['REQUEST_URI'])) {
            $current = home_url((string) wp_unslash($_SERVER['REQUEST_URI']));
        }
        $url = add_query_arg(['wpsn_inspector' => '1'], (string) $current);

        $wpAdminBar->add_node([
            'id'     => 'wpsn-frontend-inspector',
            'parent' => $parent,
            'title'  => __('SEO Panel', 'wp-seo-next'),
            'href'   => $url . '#wpsn-inspector',
            'meta'   => ['class' => 'wpsn-frontend-inspector-link'],
        ]);
    }

    public function renderPanel(): void
    {
        if (!$this->shouldLoadInspector()) {
            return;
        }

        // Resolve current document context.
        /** @var \WPSN\SEO\DocumentResolver $resolver */
        $resolver = $this->container->get('document_resolver');
        $ctx = $resolver->resolve();

        /** @var \WPSN\SEO\SeoMetaRepository $repo */
        $repo = $this->container->get('meta_repo');
        $meta = $repo->forContext($ctx);

        $type = $ctx->type;
        $objectId = (int) $ctx->objectId;
        $subType = (string) ($ctx->objectSubType ?? '');

        // Pull overrides (postmeta/termmeta).
        $overrides = $this->loadOverrides($ctx);

        $settings = $this->container->get('settings');
        $hideAiUi = (bool) $settings->get('privacy.hide_ai_ui', false);

                // Optional AI draft (per-user, per-context). This is NOT saved yet.
        $ctxHash = $this->ctxHash($type, $objectId, $subType, (string) $ctx->url, (int) $ctx->paged);
        $aiDraft = [];
        $applyAiDraft = false;
        $aiFields = [];
        if (!$hideAiUi) {
            $aiDraft = $this->getAiDraft((int) get_current_user_id(), $ctxHash);
            $applyAiDraft = (!empty($aiDraft) && isset($_GET['wpsn_ai_draft']) && (string) $_GET['wpsn_ai_draft'] === '1');
            $aiFields = ($applyAiDraft && isset($aiDraft['fields']) && is_array($aiDraft['fields'])) ? $aiDraft['fields'] : [];
        }

        $analysisScore = '';
        $focusKeyword = '';
        $effectiveFocus = '';
        $taxSummary = [];
        $keywordsPayload = [];
        $editUrl = '';

        if ($ctx->isSingular() && $objectId > 0) {
            $analysisScore = (string) get_post_meta($objectId, MetaKeys::ANALYSIS_SCORE, true);
            $focusKeyword = (string) get_post_meta($objectId, MetaKeys::FOCUS_KEYWORD, true);
            $focusKeyword = is_string($focusKeyword) ? trim($focusKeyword) : '';

            // Effective focus keyword may be overridden.
            $effectiveFocus = isset($overrides[MetaKeys::FOCUS_KEYWORD]) ? (string) $overrides[MetaKeys::FOCUS_KEYWORD] : $focusKeyword;
            $effectiveFocus = is_string($effectiveFocus) ? trim($effectiveFocus) : '';

            // If an AI draft is applied, use its focus keyword for analysis UI.
            if ($applyAiDraft && isset($aiFields[MetaKeys::FOCUS_KEYWORD])) {
                $tmp = is_string($aiFields[MetaKeys::FOCUS_KEYWORD]) ? trim((string) $aiFields[MetaKeys::FOCUS_KEYWORD]) : '';
                if ($tmp !== '') {
                    $effectiveFocus = $tmp;
                }
            }

            $editUrl = (string) get_edit_post_link($objectId, '');

            // Collect all taxonomy terms for this post type (not only category/post_tag).
            $postType = (string) get_post_type($objectId);
            if ($postType !== '') {
                $taxObjects = get_object_taxonomies($postType, 'objects');
                if (is_array($taxObjects)) {
                    foreach ($taxObjects as $taxObj) {
                        if (!($taxObj instanceof \WP_Taxonomy)) {
                            continue;
                        }
                        // Skip internal/mostly-noisy taxonomies.
                        if (in_array($taxObj->name, ['post_format', 'nav_menu', 'link_category'], true)) {
                            continue;
                        }
                        $terms = get_the_terms($objectId, $taxObj->name);
                        if (!is_array($terms)) {
                            continue;
                        }
                        $names = [];
                        foreach ($terms as $t) {
                            if ($t instanceof \WP_Term) {
                                $nm = is_string($t->name) ? trim($t->name) : '';
                                if ($nm !== '') {
                                    $names[] = $nm;
                                }
                            }
                        }
                        if (!empty($names)) {
                            $label = isset($taxObj->labels->singular_name) ? (string) $taxObj->labels->singular_name : (string) $taxObj->label;
                            $label = $label !== '' ? $label : $taxObj->name;
                            $taxSummary[$label] = $names;
                        }
                    }
                }
            }

            // Build keyword payload for JS analysis.
            $kwMap = [];
            // 1) Focus keyword(s)
            if ($effectiveFocus !== '') {
                $parts = preg_split('/[\n\r,;]+/', $effectiveFocus);
                if (is_array($parts)) {
                    foreach ($parts as $p) {
                        $p = is_string($p) ? trim($p) : '';
                        if ($p === '') {
                            continue;
                        }
                        $key = function_exists('mb_strtolower') ? (string) mb_strtolower($p) : strtolower($p);
                        if (!isset($kwMap[$key])) {
                            $kwMap[$key] = ['k' => $p, 'srcLabel' => __('Focus keyword', 'wp-seo-next')];
                        }
                    }
                }
            }
            // 2) Taxonomy terms
            foreach ($taxSummary as $taxLabel => $names) {
                foreach ($names as $nm) {
                    $nm = is_string($nm) ? trim($nm) : '';
                    if ($nm === '') {
                        continue;
                    }
                    $key = function_exists('mb_strtolower') ? (string) mb_strtolower($nm) : strtolower($nm);
                    if (!isset($kwMap[$key])) {
                        $kwMap[$key] = ['k' => $nm, 'srcLabel' => (string) $taxLabel];
                    }
                }
            }

            // Limit to avoid huge overlays.
            $keywordsPayload = array_values($kwMap);
            if (count($keywordsPayload) > 40) {
                $keywordsPayload = array_slice($keywordsPayload, 0, 40);
            }
        } elseif ($ctx->isTerm() && $objectId > 0) {
            $termEditUrl = admin_url('term.php');
            $termEditUrl = add_query_arg([
                'taxonomy' => $subType,
                'tag_ID' => $objectId,
                'post_type' => 'post',
            ], $termEditUrl);
            $editUrl = (string) $termEditUrl;
        }

        // Basic checks/suggestions.
        $suggestions = $this->buildSuggestions($ctx, $meta);

        $open = isset($_GET['wpsn_inspector']) && (string) $_GET['wpsn_inspector'] === '1';
        $saved = isset($_GET['wpsn_inspector_saved']) && (string) $_GET['wpsn_inspector_saved'] === '1';
        $error = isset($_GET['wpsn_inspector_error']) ? sanitize_text_field((string) wp_unslash($_GET['wpsn_inspector_error'])) : '';
        $aiGenerated = isset($_GET['wpsn_ai_generated']) && (string) $_GET['wpsn_ai_generated'] === '1';
        $aiDiscarded = isset($_GET['wpsn_ai_discarded']) && (string) $_GET['wpsn_ai_discarded'] === '1';

        $returnUrl = (string) home_url(add_query_arg([]));
        // Use current request URI if available.
        if (isset($_SERVER['REQUEST_URI'])) {
            $returnUrl = home_url((string) wp_unslash($_SERVER['REQUEST_URI']));
        }
        // Remove ephemeral automation params to avoid loops when using one-click actions.
        $returnUrl = remove_query_arg([
            'wpsn_ai_gen',
            'wpsn_ai_mode',
            'wpsn_ai_lang',
            'wpsn_ai_tone',
            'wpsn_ai_only_empty',
        ], $returnUrl);

        $actionUrl = admin_url('admin-post.php');
        $nonce = wp_create_nonce('wpsn_inspector_save');

        // Render.
        $keywordsJson = !empty($keywordsPayload) ? wp_json_encode($keywordsPayload) : '[]';
        $keywordsJson = is_string($keywordsJson) ? $keywordsJson : '[]';

        $seoTitleForJs = (string) ($meta->title ?? '');
        $seoDescForJs  = (string) ($meta->description ?? '');
        if ($applyAiDraft) {
            if (isset($aiFields[MetaKeys::TITLE]) && is_string($aiFields[MetaKeys::TITLE]) && trim($aiFields[MetaKeys::TITLE]) !== '') {
                $seoTitleForJs = (string) $aiFields[MetaKeys::TITLE];
            }
            if (isset($aiFields[MetaKeys::DESCRIPTION]) && is_string($aiFields[MetaKeys::DESCRIPTION]) && trim($aiFields[MetaKeys::DESCRIPTION]) !== '') {
                $seoDescForJs = (string) $aiFields[MetaKeys::DESCRIPTION];
            }
        }

        echo "\n<!-- WP SEO Next: Frontend Inspector -->\n";
        echo '<div id="wpsn-inspector" class="wpsn-inspector"'
            . ' data-open="' . ($open ? '1' : '0') . '"'
            . ' data-keywords="' . esc_attr($keywordsJson) . '"'
            . ' data-seo-title="' . esc_attr($seoTitleForJs) . '"'
            . ' data-seo-desc="' . esc_attr($seoDescForJs) . '"'
            . '>';
        echo '<button type="button" class="wpsn-inspector__toggle" aria-expanded="' . ($open ? 'true' : 'false') . '">SEO</button>';
        echo '<div class="wpsn-inspector__panel" role="dialog" aria-label="WP SEO Next">';

        echo '<div class="wpsn-inspector__head">';
        echo '<div class="wpsn-inspector__title">WP SEO Next</div>';
        echo '<div class="wpsn-inspector__meta">' . esc_html($this->labelForContext($ctx)) . '</div>';
        echo '<button type="button" class="wpsn-inspector__close" aria-label="Close">×</button>';
        echo '</div>';

        echo '<div class="wpsn-inspector__body">';

        if ($saved) {
            echo '<div class="wpsn-inspector__notice success">' . esc_html__('Saved.', 'wp-seo-next') . '</div>';
        }
        if ($aiGenerated) {
            $prov = (isset($aiDraft['provider']) && is_string($aiDraft['provider'])) ? (string) $aiDraft['provider'] : '';
            $scopeLabel = (isset($aiDraft['scope']) && is_string($aiDraft['scope'])) ? (string) $aiDraft['scope'] : '';
            $modeLabel = (isset($aiDraft['mode']) && is_string($aiDraft['mode'])) ? (string) $aiDraft['mode'] : '';
            $onlyEmpty = !empty($aiDraft['only_empty']);
            $parts = [];
            if ($scopeLabel !== '') {
                $parts[] = sprintf(__('scope: %s', 'wp-seo-next'), $scopeLabel);
            }
            if ($modeLabel !== '') {
                $parts[] = sprintf(__('mode: %s', 'wp-seo-next'), $modeLabel);
            }
            if ($prov !== '') {
                $parts[] = sprintf(__('provider: %s', 'wp-seo-next'), $prov);
            }
            if ($onlyEmpty) {
                $parts[] = __('only empty', 'wp-seo-next');
            }
            $suffix = !empty($parts) ? (' (' . implode(', ', $parts) . ')') : '';
            echo '<div class="wpsn-inspector__notice success">' . esc_html__('AI draft generated. Review it in Quick edit.', 'wp-seo-next') . esc_html($suffix) . '</div>';
        }
        if ($aiDiscarded) {
            echo '<div class="wpsn-inspector__notice info">' . esc_html__('AI draft discarded.', 'wp-seo-next') . '</div>';
        }
        if ($error !== '') {
            echo '<div class="wpsn-inspector__notice error">' . esc_html($error) . '</div>';
        }

        if (!empty($aiDraft) && isset($aiDraft['provider_hint']) && is_string($aiDraft['provider_hint']) && trim((string) $aiDraft['provider_hint']) !== '') {
            echo '<div class="wpsn-inspector__notice info">' . esc_html((string) $aiDraft['provider_hint']) . '</div>';
        }

        if ($applyAiDraft && !empty($aiDraft) && isset($aiDraft['warnings']) && is_array($aiDraft['warnings']) && !empty($aiDraft['warnings'])) {
            $w = [];
            foreach ((array) $aiDraft['warnings'] as $msg) {
                if (is_string($msg) && trim($msg) !== '') {
                    $w[] = trim($msg);
                }
            }
            if (!empty($w)) {
                echo '<div class="wpsn-inspector__notice info">';
                echo '<strong>' . esc_html__('AI checks:', 'wp-seo-next') . '</strong>';
                echo '<ul style="margin:6px 0 0 18px">';
                foreach ($w as $msg) {
                    echo '<li>' . esc_html($msg) . '</li>';
                }
                echo '</ul>';
                echo '</div>';
            }
        }

        // Overview.
        echo '<div class="wpsn-inspector__section">';
        echo '<div class="wpsn-inspector__section-title">' . esc_html__('Overview', 'wp-seo-next') . '</div>';
        echo '<div class="wpsn-inspector__kv"><span>' . esc_html__('WP title', 'wp-seo-next') . '</span><span>' . esc_html($wpTitle) . '</span></div>';
        echo '<div class="wpsn-inspector__kv"><span>' . esc_html__('Canonical', 'wp-seo-next') . '</span><span>' . esc_html($meta->canonical) . '</span></div>';
        echo '<div class="wpsn-inspector__kv"><span>' . esc_html__('Robots', 'wp-seo-next') . '</span><span>' . esc_html($this->robotsLabel($meta->robots)) . '</span></div>';
        if ($analysisScore !== '') {
            echo '<div class="wpsn-inspector__kv"><span>' . esc_html__('Analysis score', 'wp-seo-next') . '</span><span>' . esc_html($analysisScore) . '</span></div>';
        }
        $fk = $effectiveFocus !== '' ? $effectiveFocus : $focusKeyword;
        if ($fk !== '') {
            echo '<div class="wpsn-inspector__kv"><span>' . esc_html__('Focus keyword', 'wp-seo-next') . '</span><span>' . esc_html($fk) . '</span></div>';
        }
        if (!empty($taxSummary)) {
            foreach ($taxSummary as $label => $names) {
                echo '<div class="wpsn-inspector__kv"><span>' . esc_html((string) $label) . '</span><span>' . esc_html(implode(', ', (array) $names)) . '</span></div>';
            }
        }
        if ($editUrl !== '') {
            echo '<div class="wpsn-inspector__actions">';
            echo '<a class="button" href="' . esc_url($editUrl) . '">' . esc_html__('Edit in WP Admin', 'wp-seo-next') . '</a>';
            echo '</div>';
        }
        echo '</div>';

        // Keywords (client-side analysis: count occurrences in the rendered page text).
        echo '<div class="wpsn-inspector__section">';
        echo '<div class="wpsn-inspector__section-title">' . esc_html__('Keywords', 'wp-seo-next') . '</div>';
        echo '<div class="wpsn-inspector__keyword-actions">';
        echo '<button type="button" class="button wpsn-inspector__analyze">' . esc_html__('Analyze text', 'wp-seo-next') . '</button>';
        echo '<button type="button" class="button wpsn-inspector__pick">' . esc_html__('Pick content area', 'wp-seo-next') . '</button>';
        echo '<button type="button" class="button wpsn-inspector__pick_reset">' . esc_html__('Reset area', 'wp-seo-next') . '</button>';
        echo '</div>';
        echo '<div class="wpsn-inspector__keywords"><div class="wpsn-inspector__muted">' . esc_html__('Open the panel to analyze keywords in the current page text.', 'wp-seo-next') . '</div></div>';
        echo '</div>';

        // Suggestions.
        if (!empty($suggestions)) {
            echo '<div class="wpsn-inspector__section">';
            echo '<div class="wpsn-inspector__section-title">' . esc_html__('Suggestions', 'wp-seo-next') . '</div>';
            echo '<ul class="wpsn-inspector__list">';
            foreach ($suggestions as $s) {
                echo '<li>' . esc_html($s) . '</li>';
            }
            echo '</ul>';
            echo '</div>';
        }

        // Editable fields (only for singular/term).
        $editable = $this->isEditableContext($ctx);
        echo '<div class="wpsn-inspector__section">';
        echo '<div class="wpsn-inspector__section-title">' . esc_html__('Quick edit', 'wp-seo-next') . '</div>';
        if (!$editable) {
            echo '<div class="wpsn-inspector__muted">' . esc_html__('This page type has no per-URL overrides. Use templates/settings instead.', 'wp-seo-next') . '</div>';
        } else {
            echo '<form class="wpsn-inspector__form" method="post" action="' . esc_url($actionUrl) . '">';
            echo '<input type="hidden" name="action" value="wpsn_frontend_seo_save" />';
            echo '<input type="hidden" name="wpsn_nonce" value="' . esc_attr($nonce) . '" />';
            echo '<input type="hidden" name="ctx_type" value="' . esc_attr($type) . '" />';
            echo '<input type="hidden" name="ctx_object_id" value="' . esc_attr((string) $objectId) . '" />';
            echo '<input type="hidden" name="ctx_subtype" value="' . esc_attr($subType) . '" />';
            echo '<input type="hidden" name="ctx_url" value="' . esc_attr($ctx->url) . '" />';
            echo '<input type="hidden" name="ctx_paged" value="' . esc_attr((string) $ctx->paged) . '" />';
            echo '<input type="hidden" name="return" value="' . esc_attr($returnUrl) . '" />';

            // Current form values come from saved overrides.
            // If an AI draft is applied we start from the draft "base_fields" (the form state at the moment of generation)
            // so the user's unsaved edits are not lost on redirect.
            $formValues = $overrides;
            $aiApplied = [];
            $aiSuggested = [];
            $aiBefore = [];
            if ($applyAiDraft && is_array($aiDraft)) {
                $base = $overrides;
                if (isset($aiDraft['base_fields']) && is_array($aiDraft['base_fields'])) {
                    $base = $this->sanitizeDraftFields((array) $aiDraft['base_fields']);
                }

                $onlyEmpty = !empty($aiDraft['only_empty']);
                [$formValues, $aiApplied, $aiSuggested, $aiBefore] = $this->applyAiFieldsToBase($base, $aiFields, (bool) $onlyEmpty);
            }

            if (!empty($aiDraft) && !$applyAiDraft) {
                $applyUrl = $returnUrl;
                $applyUrl = add_query_arg(['wpsn_inspector' => '1', 'wpsn_ai_draft' => '1'], $applyUrl);
                echo '<div class="wpsn-inspector__notice info">' .
                    esc_html__('AI draft is available for this page.', 'wp-seo-next') .
                    ' <a href="' . esc_url($applyUrl) . '">' . esc_html__('Apply draft', 'wp-seo-next') . '</a>' .
                    '</div>';
            }
            if ($applyAiDraft) {
                echo '<div class="wpsn-inspector__notice info">' . esc_html__('AI draft applied. Review and click “Save overrides”.', 'wp-seo-next') . '</div>';
            }

            $this->renderTextField(MetaKeys::TITLE, __('SEO title override', 'wp-seo-next'), $formValues[MetaKeys::TITLE] ?? '', $meta->title, $this->aiInfoForKey(MetaKeys::TITLE, $aiFields, $aiApplied, $aiSuggested, $aiBefore));
            $this->renderTextareaField(MetaKeys::DESCRIPTION, __('Meta description override', 'wp-seo-next'), $formValues[MetaKeys::DESCRIPTION] ?? '', $meta->description, $this->aiInfoForKey(MetaKeys::DESCRIPTION, $aiFields, $aiApplied, $aiSuggested, $aiBefore));
            $this->renderTextField(MetaKeys::CANONICAL, __('Canonical override', 'wp-seo-next'), $formValues[MetaKeys::CANONICAL] ?? '', $meta->canonical, $this->aiInfoForKey(MetaKeys::CANONICAL, $aiFields, $aiApplied, $aiSuggested, $aiBefore));

            $this->renderRobotsField($formValues, [
                MetaKeys::ROBOTS_INDEX => $this->aiInfoForKey(MetaKeys::ROBOTS_INDEX, $aiFields, $aiApplied, $aiSuggested, $aiBefore),
                MetaKeys::ROBOTS_FOLLOW => $this->aiInfoForKey(MetaKeys::ROBOTS_FOLLOW, $aiFields, $aiApplied, $aiSuggested, $aiBefore),
            ]);

            echo '<div class="wpsn-inspector__subhead">' . esc_html__('OpenGraph', 'wp-seo-next') . '</div>';
            $this->renderTextField(MetaKeys::OG_TITLE, __('OG title override', 'wp-seo-next'), $formValues[MetaKeys::OG_TITLE] ?? '', $meta->ogTitle, $this->aiInfoForKey(MetaKeys::OG_TITLE, $aiFields, $aiApplied, $aiSuggested, $aiBefore));
            $this->renderTextareaField(MetaKeys::OG_DESCRIPTION, __('OG description override', 'wp-seo-next'), $formValues[MetaKeys::OG_DESCRIPTION] ?? '', $meta->ogDescription, $this->aiInfoForKey(MetaKeys::OG_DESCRIPTION, $aiFields, $aiApplied, $aiSuggested, $aiBefore));
            $this->renderTextField(MetaKeys::OG_IMAGE, __('OG image override (URL)', 'wp-seo-next'), $formValues[MetaKeys::OG_IMAGE] ?? '', $meta->ogImage, $this->aiInfoForKey(MetaKeys::OG_IMAGE, $aiFields, $aiApplied, $aiSuggested, $aiBefore));

            echo '<div class="wpsn-inspector__subhead">' . esc_html__('Twitter', 'wp-seo-next') . '</div>';
            $this->renderTextField(MetaKeys::TWITTER_TITLE, __('Twitter title override', 'wp-seo-next'), $formValues[MetaKeys::TWITTER_TITLE] ?? '', $meta->twitterTitle, $this->aiInfoForKey(MetaKeys::TWITTER_TITLE, $aiFields, $aiApplied, $aiSuggested, $aiBefore));
            $this->renderTextareaField(MetaKeys::TWITTER_DESCRIPTION, __('Twitter description override', 'wp-seo-next'), $formValues[MetaKeys::TWITTER_DESCRIPTION] ?? '', $meta->twitterDescription, $this->aiInfoForKey(MetaKeys::TWITTER_DESCRIPTION, $aiFields, $aiApplied, $aiSuggested, $aiBefore));
            $this->renderTextField(MetaKeys::TWITTER_IMAGE, __('Twitter image override (URL)', 'wp-seo-next'), $formValues[MetaKeys::TWITTER_IMAGE] ?? '', $meta->twitterImage, $this->aiInfoForKey(MetaKeys::TWITTER_IMAGE, $aiFields, $aiApplied, $aiSuggested, $aiBefore));

            echo '<div class="wpsn-inspector__subhead">' . esc_html__('Other', 'wp-seo-next') . '</div>';
            $this->renderTextField(MetaKeys::FOCUS_KEYWORD, __('Focus keyword', 'wp-seo-next'), $formValues[MetaKeys::FOCUS_KEYWORD] ?? $focusKeyword, $focusKeyword, $this->aiInfoForKey(MetaKeys::FOCUS_KEYWORD, $aiFields, $aiApplied, $aiSuggested, $aiBefore));
            $this->renderSchemaTypeField($formValues[MetaKeys::SCHEMA_TYPE] ?? '', $this->aiInfoForKey(MetaKeys::SCHEMA_TYPE, $aiFields, $aiApplied, $aiSuggested, $aiBefore));

            echo '<div class="wpsn-inspector__actions">';
            echo '<button type="submit" class="button button-primary">' . esc_html__('Save overrides', 'wp-seo-next') . '</button>';
            $this->renderAiAutofillActions($ctx, $returnUrl, $ctxHash);
            echo '<button type="button" class="button wpsn-inspector__fill">' . esc_html__('Fill with computed', 'wp-seo-next') . '</button>';
            echo '<button type="button" class="button wpsn-inspector__clear">' . esc_html__('Clear all overrides', 'wp-seo-next') . '</button>';
            echo '</div>';

            echo '</form>';
        }
        echo '</div>';

        // Debug section.
        echo '<div class="wpsn-inspector__section">';
        echo '<div class="wpsn-inspector__section-title">' . esc_html__('Debug', 'wp-seo-next') . '</div>';
        echo '<div class="wpsn-inspector__kv"><span>' . esc_html__('Context', 'wp-seo-next') . '</span><span>' . esc_html($type . ($subType !== '' ? ' (' . $subType . ')' : '') . ' #' . (string) $objectId) . '</span></div>';
        echo '<div class="wpsn-inspector__kv"><span>' . esc_html__('Resolved URL', 'wp-seo-next') . '</span><span>' . esc_html($ctx->url) . '</span></div>';
        echo '</div>';

        echo '</div>'; // body
        echo '</div>'; // panel
        echo '</div>'; // root
        echo "\n<!-- /WP SEO Next: Frontend Inspector -->\n";
    }

    /**
     * @return array<string,string>
     */
    private function loadOverrides($ctx): array
    {
        $keys = [
            MetaKeys::TITLE,
            MetaKeys::DESCRIPTION,
            MetaKeys::CANONICAL,
            MetaKeys::ROBOTS_INDEX,
            MetaKeys::ROBOTS_FOLLOW,
            MetaKeys::OG_TITLE,
            MetaKeys::OG_DESCRIPTION,
            MetaKeys::OG_IMAGE,
            MetaKeys::TWITTER_TITLE,
            MetaKeys::TWITTER_DESCRIPTION,
            MetaKeys::TWITTER_IMAGE,
            MetaKeys::SCHEMA_TYPE,
            MetaKeys::FOCUS_KEYWORD,
        ];

        $out = [];
        foreach ($keys as $k) {
            $v = '';
            if ($ctx->isSingular() && $ctx->objectId > 0) {
                $v = (string) get_post_meta((int) $ctx->objectId, $k, true);
            } elseif ($ctx->isTerm() && $ctx->objectId > 0) {
                $v = (string) get_term_meta((int) $ctx->objectId, $k, true);
            }
            $v = is_string($v) ? trim($v) : '';
            if ($v !== '') {
                $out[$k] = $v;
            }
        }
        return $out;
    }

    private function isEditableContext($ctx): bool
    {
        if ($ctx->isSingular() && $ctx->objectId > 0) {
            return current_user_can('edit_post', (int) $ctx->objectId);
        }
        if ($ctx->isTerm() && $ctx->objectId > 0) {
            // Best-effort: WP meta cap for terms exists in modern WP.
            if (current_user_can('edit_term', (int) $ctx->objectId)) {
                return true;
            }
            // Fallback.
            return current_user_can('manage_categories');
        }
        return false;
    }

    /**
     * @param array<string,string|bool> $robots
     */
    private function robotsLabel(array $robots): string
    {
        $parts = [];
        $parts[] = (!empty($robots['noindex']) ? 'noindex' : 'index');
        $parts[] = (!empty($robots['nofollow']) ? 'nofollow' : 'follow');
        return implode(', ', $parts);
    }

    private function labelForContext($ctx): string
    {
        $t = (string) $ctx->type;
        if ($t === 'singular') {
            return __('Singular', 'wp-seo-next');
        }
        if ($t === 'term') {
            return __('Term archive', 'wp-seo-next');
        }
        if ($t === 'home') {
            return __('Home', 'wp-seo-next');
        }
        if ($t === 'search') {
            return __('Search', 'wp-seo-next');
        }
        if ($t === '404') {
            return __('404', 'wp-seo-next');
        }
        if ($t === 'archive') {
            return __('Archive', 'wp-seo-next');
        }
        return $t;
    }

    /**
     * @return array<int,string>
     */
    private function buildSuggestions($ctx, $meta): array
    {
        $out = [];

        $title = isset($meta->title) ? (string) $meta->title : '';
        $desc = isset($meta->description) ? (string) $meta->description : '';
        $ogImg = isset($meta->ogImage) ? (string) $meta->ogImage : '';
        $canonical = isset($meta->canonical) ? (string) $meta->canonical : '';

        $titleLen = function_exists('mb_strlen') ? (int) mb_strlen($title) : strlen($title);
        if ($titleLen < 20) {
            $out[] = __('SEO title is quite short (aim ~30–60 chars).', 'wp-seo-next');
        } elseif ($titleLen > 70) {
            $out[] = __('SEO title is long (may be truncated).', 'wp-seo-next');
        }

        $descLen = function_exists('mb_strlen') ? (int) mb_strlen($desc) : strlen($desc);
        if (trim($desc) === '') {
            $out[] = __('Missing meta description.', 'wp-seo-next');
        } elseif ($descLen < 50) {
            $out[] = __('Meta description is short (aim ~120–160 chars).', 'wp-seo-next');
        } elseif ($descLen > 180) {
            $out[] = __('Meta description is long (may be truncated).', 'wp-seo-next');
        }

        if ($ctx->type !== '404' && trim($canonical) === '') {
            $out[] = __('Missing canonical.', 'wp-seo-next');
        }

        if (trim($ogImg) === '') {
            $out[] = __('Missing OpenGraph image.', 'wp-seo-next');
        }

        if (isset($meta->robots) && is_array($meta->robots) && !empty($meta->robots['noindex'])) {
            $out[] = __('This page is set to noindex.', 'wp-seo-next');
        }

        return $out;
    }

    /**
     * @param array<string,mixed> $aiInfo
     */
    private function renderTextField(string $key, string $label, string $value, string $computed, array $aiInfo = []): void
    {
        $cls = 'wpsn-inspector__field';
        if (!empty($aiInfo['applied'])) {
            $cls .= ' wpsn-inspector__field--ai-applied';
        } elseif (!empty($aiInfo['suggested'])) {
            $cls .= ' wpsn-inspector__field--ai-suggested';
        }

        echo '<label class="' . esc_attr($cls) . '">';
        echo '<span class="wpsn-inspector__field-label">' . esc_html($label) . '</span>';
        echo '<input type="text" name="fields[' . esc_attr($key) . ']" value="' . esc_attr($value) . '" data-computed="' . esc_attr($computed) . '" />';

        // AI hints.
        $this->renderAiHint($aiInfo);

        echo '<span class="wpsn-inspector__field-help">' . esc_html__('Computed:', 'wp-seo-next') . ' ' . esc_html($computed) . '</span>';
        echo '</label>';
    }

    /**
     * @param array<string,mixed> $aiInfo
     */
    private function renderTextareaField(string $key, string $label, string $value, string $computed, array $aiInfo = []): void
    {
        $cls = 'wpsn-inspector__field';
        if (!empty($aiInfo['applied'])) {
            $cls .= ' wpsn-inspector__field--ai-applied';
        } elseif (!empty($aiInfo['suggested'])) {
            $cls .= ' wpsn-inspector__field--ai-suggested';
        }

        echo '<label class="' . esc_attr($cls) . '">';
        echo '<span class="wpsn-inspector__field-label">' . esc_html($label) . '</span>';
        echo '<textarea name="fields[' . esc_attr($key) . ']" rows="2" data-computed="' . esc_attr($computed) . '">' . esc_textarea($value) . '</textarea>';

        // AI hints.
        $this->renderAiHint($aiInfo);

        echo '<span class="wpsn-inspector__field-help">' . esc_html__('Computed:', 'wp-seo-next') . ' ' . esc_html($computed) . '</span>';
        echo '</label>';
    }

    /**
     * @param array<string,string> $values
     * @param array<string,array<string,mixed>> $aiInfo
     */
    private function renderRobotsField(array $values, array $aiInfo = []): void
    {
        $idx = $values[MetaKeys::ROBOTS_INDEX] ?? '';
        $fol = $values[MetaKeys::ROBOTS_FOLLOW] ?? '';

        echo '<div class="wpsn-inspector__subhead">' . esc_html__('Robots overrides', 'wp-seo-next') . '</div>';
        echo '<div class="wpsn-inspector__row">';

        $clsIdx = 'wpsn-inspector__field';
        $infoIdx = $aiInfo[MetaKeys::ROBOTS_INDEX] ?? [];
        if (!empty($infoIdx['applied'])) {
            $clsIdx .= ' wpsn-inspector__field--ai-applied';
        } elseif (!empty($infoIdx['suggested'])) {
            $clsIdx .= ' wpsn-inspector__field--ai-suggested';
        }

        echo '<label class="' . esc_attr($clsIdx) . '">';
        echo '<span class="wpsn-inspector__field-label">' . esc_html__('Index', 'wp-seo-next') . '</span>';
        echo '<select name="fields[' . esc_attr(MetaKeys::ROBOTS_INDEX) . ']">';
        $this->renderOption('', __('Default', 'wp-seo-next'), $idx);
        $this->renderOption('index', __('Index', 'wp-seo-next'), $idx);
        $this->renderOption('noindex', __('Noindex', 'wp-seo-next'), $idx);
        echo '</select>';
        $this->renderAiHint($infoIdx);
        echo '</label>';

        $clsFol = 'wpsn-inspector__field';
        $infoFol = $aiInfo[MetaKeys::ROBOTS_FOLLOW] ?? [];
        if (!empty($infoFol['applied'])) {
            $clsFol .= ' wpsn-inspector__field--ai-applied';
        } elseif (!empty($infoFol['suggested'])) {
            $clsFol .= ' wpsn-inspector__field--ai-suggested';
        }

        echo '<label class="' . esc_attr($clsFol) . '">';
        echo '<span class="wpsn-inspector__field-label">' . esc_html__('Follow', 'wp-seo-next') . '</span>';
        echo '<select name="fields[' . esc_attr(MetaKeys::ROBOTS_FOLLOW) . ']">';
        $this->renderOption('', __('Default', 'wp-seo-next'), $fol);
        $this->renderOption('follow', __('Follow', 'wp-seo-next'), $fol);
        $this->renderOption('nofollow', __('Nofollow', 'wp-seo-next'), $fol);
        echo '</select>';
        $this->renderAiHint($infoFol);
        echo '</label>';

        echo '</div>';
    }

    /**
     * Schema type select to avoid invalid values.
     *
     * Allowed: default (empty), webpage, article, product, none.
     *
     * @param array<string,mixed> $aiInfo
     */
    private function renderSchemaTypeField(string $current, array $aiInfo = []): void
    {
        $current = is_string($current) ? trim($current) : '';
        // Accept legacy values.
        if ($current === 'default') {
            $current = '';
        }

        $cls = 'wpsn-inspector__field';
        if (!empty($aiInfo['applied'])) {
            $cls .= ' wpsn-inspector__field--ai-applied';
        } elseif (!empty($aiInfo['suggested'])) {
            $cls .= ' wpsn-inspector__field--ai-suggested';
        }

        echo '<label class="' . esc_attr($cls) . '">';
        echo '<span class="wpsn-inspector__field-label">' . esc_html__('Schema type override', 'wp-seo-next') . '</span>';
        echo '<select name="fields[' . esc_attr(MetaKeys::SCHEMA_TYPE) . ']">';
        $this->renderOption('', __('Default', 'wp-seo-next'), $current);
        $this->renderOption('webpage', __('WebPage', 'wp-seo-next'), $current);
        $this->renderOption('article', __('Article', 'wp-seo-next'), $current);
        $this->renderOption('product', __('Product', 'wp-seo-next'), $current);
        $this->renderOption('none', __('None (disable JSON-LD)', 'wp-seo-next'), $current);
        echo '</select>';
        $this->renderAiHint($aiInfo);
        echo '</label>';
    }

    private function renderOption(string $value, string $label, string $current): void
    {
        $sel = ($current === $value) ? ' selected' : '';
        echo '<option value="' . esc_attr($value) . '"' . $sel . '>' . esc_html($label) . '</option>';
    }

    /**
     * @param array<string,mixed> $aiInfo
     */
    private function renderAiHint(array $aiInfo): void
    {
        $suggestion = isset($aiInfo['suggestion']) && is_string($aiInfo['suggestion']) ? trim($aiInfo['suggestion']) : '';
        $before = isset($aiInfo['before']) && is_string($aiInfo['before']) ? trim($aiInfo['before']) : '';
        $applied = !empty($aiInfo['applied']);
        $suggestedOnly = !empty($aiInfo['suggested']) && !$applied;

        if ($suggestion === '' && $before === '') {
            return;
        }

        if ($applied) {
            // Applied: show what changed.
            if ($before !== '' && $before !== $suggestion) {
                echo '<span class="wpsn-inspector__ai-hint">' . esc_html__('AI applied. Before:', 'wp-seo-next') . ' ' . esc_html($before) . '</span>';
            } else {
                echo '<span class="wpsn-inspector__ai-hint">' . esc_html__('AI applied.', 'wp-seo-next') . '</span>';
            }
            return;
        }

        if ($suggestedOnly) {
            // Suggestion available but not applied.
            if ($suggestion !== '') {
                echo '<span class="wpsn-inspector__ai-hint">' . esc_html__('AI suggestion:', 'wp-seo-next') . ' ' . esc_html($suggestion);
                echo ' <button type="button" class="wpsn-inspector__ai-apply" data-suggestion="' . esc_attr($suggestion) . '">' . esc_html__('Apply', 'wp-seo-next') . '</button>';
                echo '</span>';
            }
        }
    }

    /**
     * Build per-field AI info.
     *
     * @param array<string,string> $aiFields
     * @param array<int,string> $aiApplied
     * @param array<int,string> $aiSuggested
     * @param array<string,string> $aiBefore
     * @return array<string,mixed>
     */
    private function aiInfoForKey(string $key, array $aiFields, array $aiApplied, array $aiSuggested, array $aiBefore): array
    {
        $key = (string) $key;
        $sugg = isset($aiFields[$key]) && is_string($aiFields[$key]) ? trim((string) $aiFields[$key]) : '';
        if ($sugg === '') {
            return [];
        }
        return [
            'suggestion' => $sugg,
            'before' => isset($aiBefore[$key]) ? (string) $aiBefore[$key] : '',
            'applied' => in_array($key, $aiApplied, true),
            'suggested' => in_array($key, $aiSuggested, true),
        ];
    }

    /**
     * Sanitize draft/base fields stored in usermeta.
     *
     * @param array<string,mixed> $fields
     * @return array<string,string>
     */
    private function sanitizeDraftFields(array $fields): array
    {
        $out = [];
        foreach ($fields as $k => $v) {
            if (!is_string($k)) {
                continue;
            }
            $out[$k] = is_string($v) ? (string) $v : '';
        }
        return $out;
    }

    /**
     * Merge AI suggestions onto base field values.
     *
     * @param array<string,string> $base
     * @param array<string,string> $aiFields
     * @return array{0:array<string,string>,1:array<int,string>,2:array<int,string>,3:array<string,string>}
     */
    private function applyAiFieldsToBase(array $base, array $aiFields, bool $onlyEmpty): array
    {
        $merged = $base;
        $applied = [];
        $suggested = [];
        $before = [];

        foreach ($aiFields as $k => $v) {
            if (!is_string($k) || !is_string($v)) {
                continue;
            }
            $s = trim($v);
            if ($s === '') {
                continue;
            }

            $prev = isset($base[$k]) ? (string) $base[$k] : '';
            $prevTrim = is_string($prev) ? trim($prev) : '';
            $before[$k] = $prevTrim;

            if ($onlyEmpty) {
                if ($prevTrim === '') {
                    $merged[$k] = $s;
                    $applied[] = $k;
                } else {
                    if ($prevTrim !== $s) {
                        $suggested[] = $k;
                    }
                }
            } else {
                $merged[$k] = $s;
                if ($prevTrim !== $s) {
                    $applied[] = $k;
                }
            }
        }

        return [$merged, $applied, $suggested, $before];
    }

    /**
     * List of meta keys that Inspector can override.
     *
     * @return array<int,string>
     */
    private function allowedOverrideKeys(): array
    {
        return [
            MetaKeys::TITLE,
            MetaKeys::DESCRIPTION,
            MetaKeys::CANONICAL,
            MetaKeys::ROBOTS_INDEX,
            MetaKeys::ROBOTS_FOLLOW,
            MetaKeys::OG_TITLE,
            MetaKeys::OG_DESCRIPTION,
            MetaKeys::OG_IMAGE,
            MetaKeys::TWITTER_TITLE,
            MetaKeys::TWITTER_DESCRIPTION,
            MetaKeys::TWITTER_IMAGE,
            MetaKeys::FOCUS_KEYWORD,
            MetaKeys::SCHEMA_TYPE,
        ];
    }

    /**
     * Keep only fields that match the requested generation scope.
     *
     * @param array<string,string> $fields
     * @return array<string,string>
     */
    private function filterAiFieldsByScope(array $fields, string $scope): array
    {
        $scope = sanitize_key($scope);
        if ($scope === '' || $scope === 'all') {
            return $fields;
        }

        $allowed = [];
        if ($scope === 'snippet') {
            $allowed = [
                MetaKeys::TITLE,
                MetaKeys::DESCRIPTION,
                MetaKeys::OG_TITLE,
                MetaKeys::OG_DESCRIPTION,
                MetaKeys::TWITTER_TITLE,
                MetaKeys::TWITTER_DESCRIPTION,
            ];
        } elseif ($scope === 'title') {
            $allowed = [MetaKeys::TITLE, MetaKeys::OG_TITLE, MetaKeys::TWITTER_TITLE];
        } elseif ($scope === 'description') {
            $allowed = [MetaKeys::DESCRIPTION, MetaKeys::OG_DESCRIPTION, MetaKeys::TWITTER_DESCRIPTION];
        } elseif ($scope === 'keywords') {
            $allowed = [MetaKeys::FOCUS_KEYWORD];
        }

        $out = [];
        foreach ($allowed as $k) {
            if (isset($fields[$k]) && is_string($fields[$k])) {
                $v = trim((string) $fields[$k]);
                if ($v !== '') {
                    $out[$k] = $v;
                }
            }
        }

        return $out;
    }

    /**
     * Inspector-specific post-processing: length checks, safe truncation and simple duplicate avoidance.
     *
     * @param array<string,string> $fields
     * @param array<string,mixed> $ctx
     * @return array{0:array<string,string>,1:array<int,string>}
     */
    private function postProcessInspectorAiFields(array $fields, array $ctx): array
    {
        $warnings = [];

        $titleMax = isset($ctx['title_max']) ? (int) $ctx['title_max'] : 60;
        $descMax = isset($ctx['desc_max']) ? (int) $ctx['desc_max'] : 160;
        $siteName = isset($ctx['site_name']) ? (string) $ctx['site_name'] : '';
        $h1 = isset($ctx['h1']) ? (string) $ctx['h1'] : '';

        $norm = function (string $s): string {
            $s = strtolower(trim(wp_strip_all_tags($s)));
            $s = preg_replace('/\s+/u', ' ', $s);
            return is_string($s) ? $s : '';
        };

        // Title.
        if (isset($fields[MetaKeys::TITLE])) {
            $t = trim((string) $fields[MetaKeys::TITLE]);

            // Enforce max length.
            $t2 = $this->truncateAtWordBoundary($t, $titleMax);
            if ($t2 !== $t) {
                $warnings[] = __('AI: Title was truncated to fit length limit.', 'wp-seo-next');
                $t = $t2;
            }

            // Avoid Title == H1.
            if ($h1 !== '' && $norm($t) === $norm($h1)) {
                $sep = ' | ';
                if ($siteName !== '' && stripos($t, $siteName) === false) {
                    $candidate = $t . $sep . $siteName;
                    $candidate = $this->truncateAtWordBoundary($candidate, $titleMax);
                    if ($norm($candidate) !== $norm($h1)) {
                        $warnings[] = __('AI: Title was adjusted to avoid matching H1.', 'wp-seo-next');
                        $t = $candidate;
                    }
                } else {
                    // Minimal tweak.
                    $candidate = $t . $sep . __('Guide', 'wp-seo-next');
                    $candidate = $this->truncateAtWordBoundary($candidate, $titleMax);
                    if ($norm($candidate) !== $norm($h1)) {
                        $warnings[] = __('AI: Title was adjusted to avoid matching H1.', 'wp-seo-next');
                        $t = $candidate;
                    }
                }
            }

            $fields[MetaKeys::TITLE] = $t;

            // Social title fallbacks.
            if (!isset($fields[MetaKeys::OG_TITLE]) || trim((string) $fields[MetaKeys::OG_TITLE]) === '') {
                $fields[MetaKeys::OG_TITLE] = $t;
            }
            if (!isset($fields[MetaKeys::TWITTER_TITLE]) || trim((string) $fields[MetaKeys::TWITTER_TITLE]) === '') {
                $fields[MetaKeys::TWITTER_TITLE] = $t;
            }
        }

        // Description.
        if (isset($fields[MetaKeys::DESCRIPTION])) {
            $d = trim((string) $fields[MetaKeys::DESCRIPTION]);
            $d2 = $this->truncateAtWordBoundary($d, $descMax);
            if ($d2 !== $d) {
                $warnings[] = __('AI: Description was truncated to fit length limit.', 'wp-seo-next');
                $d = $d2;
            }
            $fields[MetaKeys::DESCRIPTION] = $d;

            // Social description fallbacks.
            if (!isset($fields[MetaKeys::OG_DESCRIPTION]) || trim((string) $fields[MetaKeys::OG_DESCRIPTION]) === '') {
                $fields[MetaKeys::OG_DESCRIPTION] = $d;
            }
            if (!isset($fields[MetaKeys::TWITTER_DESCRIPTION]) || trim((string) $fields[MetaKeys::TWITTER_DESCRIPTION]) === '') {
                $fields[MetaKeys::TWITTER_DESCRIPTION] = $d;
            }
        }

        // Focus keyword.
        if (isset($fields[MetaKeys::FOCUS_KEYWORD])) {
            $fk = trim((string) $fields[MetaKeys::FOCUS_KEYWORD]);
            if ($fk !== '') {
                $fk = preg_replace('/\s+/u', ' ', $fk);
                $fk = is_string($fk) ? $fk : '';
                $fk = $this->truncateAtWordBoundary($fk, 80);
                $fields[MetaKeys::FOCUS_KEYWORD] = $fk;
            }
        }

        // Schema type validation.
        if (isset($fields[MetaKeys::SCHEMA_TYPE])) {
            $schema = sanitize_key((string) $fields[MetaKeys::SCHEMA_TYPE]);
            if ($schema === 'default') {
                $schema = '';
            }
            if (!in_array($schema, ['', 'webpage', 'article', 'product', 'none'], true)) {
                unset($fields[MetaKeys::SCHEMA_TYPE]);
            } elseif ($schema !== '') {
                $fields[MetaKeys::SCHEMA_TYPE] = $schema;
            }
        }

        // Robots validation (if present).
        if (isset($fields[MetaKeys::ROBOTS_INDEX])) {
            $idx = sanitize_key((string) $fields[MetaKeys::ROBOTS_INDEX]);
            if (!in_array($idx, ['', 'index', 'noindex'], true)) {
                unset($fields[MetaKeys::ROBOTS_INDEX]);
            } else {
                $fields[MetaKeys::ROBOTS_INDEX] = $idx;
            }
        }
        if (isset($fields[MetaKeys::ROBOTS_FOLLOW])) {
            $fol = sanitize_key((string) $fields[MetaKeys::ROBOTS_FOLLOW]);
            if (!in_array($fol, ['', 'follow', 'nofollow'], true)) {
                unset($fields[MetaKeys::ROBOTS_FOLLOW]);
            } else {
                $fields[MetaKeys::ROBOTS_FOLLOW] = $fol;
            }
        }

        // URLs validation.
        foreach ([MetaKeys::CANONICAL, MetaKeys::OG_IMAGE, MetaKeys::TWITTER_IMAGE] as $k) {
            if (isset($fields[$k])) {
                $u = trim((string) $fields[$k]);
                if ($u === '') {
                    unset($fields[$k]);
                    continue;
                }
                $u2 = esc_url_raw($u);
                if ($u2 === '') {
                    unset($fields[$k]);
                } else {
                    $fields[$k] = $u2;
                }
            }
        }

        return [$fields, $warnings];
    }

    /**
     * Truncate string at a word boundary (best-effort).
     */
    private function truncateAtWordBoundary(string $text, int $maxChars): string
    {
        $text = trim((string) $text);
        if ($text === '' || $maxChars <= 0) {
            return '';
        }

        $len = function (string $s): int {
            return function_exists('mb_strlen') ? (int) mb_strlen($s) : strlen($s);
        };
        $sub = function (string $s, int $start, int $length): string {
            if (function_exists('mb_substr')) {
                return (string) mb_substr($s, $start, $length);
            }
            return (string) substr($s, $start, $length);
        };

        if ($len($text) <= $maxChars) {
            return $text;
        }

        $cut = $sub($text, 0, $maxChars);
        // Try to cut at last whitespace.
        $pos = strrpos($cut, ' ');
        if ($pos !== false && $pos > 20) {
            $cut = (string) substr($cut, 0, $pos);
        }
        return trim($cut);
    }

    /**
     * Renders buttons + hidden inputs inside the Quick Edit form.
     * NOTE: Must not output nested <form> tags.
     */
    private function renderAiAutofillActions($ctx, string $returnUrl, string $ctxHash): void
    {
        $settings = $this->container->get('settings');
        if ((bool) $settings->get('privacy.hide_ai_ui', false)) {
            return;
        }

        // Always provide hidden fields so JS can submit the same form as AI autofill.
        $aiNonce = wp_create_nonce('wpsn_inspector_ai_fill');
        $discardNonce = wp_create_nonce('wpsn_inspector_ai_discard');

        echo '<input type="hidden" name="wpsn_ai_nonce" value="' . esc_attr($aiNonce) . '" />';
        echo '<input type="hidden" name="wpsn_ai_discard_nonce" value="' . esc_attr($discardNonce) . '" />';
        echo '<input type="hidden" name="keywords_hint" value="" class="wpsn-inspector__keywords_hint" />';
        echo '<textarea name="page_text" class="wpsn-inspector__page_text" style="display:none"></textarea>';
        echo '<input type="hidden" name="h1_text" value="" class="wpsn-inspector__h1_text" />';
        echo '<input type="hidden" name="ai_scope" value="all" class="wpsn-inspector__ai_scope" />';

        $localOnly = (bool) $settings->get('privacy.local_only', false);
        $externalEnabled = (bool) $settings->get('ai.external.enabled', false);
        $canUseExternal = $externalEnabled && !$localOnly && Capabilities::canUseAI();

        // External readiness matrix (Cloud/OpenAI + Proxy/SaaS).
        $cloudReady = false;
        $proxyReady = false;
        $cloudHint = '';
        $proxyHint = '';

        if ($canUseExternal) {
            $secrets = new AISecretsRepository($settings);
            $cloudReady = $secrets->hasOpenAIKey();
            if (!$cloudReady) {
                $cloudHint = __('missing key (Settings → AI)', 'wp-seo-next');
            }

            try {
                $logger = $this->container->get('logger');
                $saas = new SaaSSyncService($settings, $logger);
                $proxyReady = $saas->isEnabled() && $saas->isTrusted() && $saas->getAIEndpoint() !== '';
                if (!$proxyReady) {
                    $proxyHint = __('not ready (Settings → SaaS Sync)', 'wp-seo-next');
                }
            } catch (\Throwable $e) {
                $proxyReady = false;
                $proxyHint = __('not ready (Settings → SaaS Sync)', 'wp-seo-next');
            }
        }

        // Draft state (for showing Discard).
        $draft = $this->getAiDraft((int) get_current_user_id(), $ctxHash);

        echo '<div class="wpsn-inspector__ai">';

        echo '<div class="wpsn-inspector__ai-controls">';
        echo '<label class="wpsn-inspector__ai-control">';
        echo '<span>' . esc_html__('Mode', 'wp-seo-next') . '</span>';
        echo '<select name="ai_mode">';
        $this->renderOption('auto', __('Auto', 'wp-seo-next'), 'auto');
        $this->renderOption('local', __('Local', 'wp-seo-next'), 'auto');
        $this->renderOption('cloud', __('Cloud', 'wp-seo-next'), 'auto');
        $this->renderOption('proxy', __('Proxy', 'wp-seo-next'), 'auto');
        echo '</select>';
        echo '</label>';

        echo '<label class="wpsn-inspector__ai-control">';
        echo '<span>' . esc_html__('Language', 'wp-seo-next') . '</span>';
        echo '<select name="ai_language">';
        $this->renderOption('auto', __('Site locale', 'wp-seo-next'), 'auto');
        $this->renderOption('ru', __('Russian', 'wp-seo-next'), 'auto');
        $this->renderOption('en', __('English', 'wp-seo-next'), 'auto');
        echo '</select>';
        echo '</label>';

        echo '<label class="wpsn-inspector__ai-control">';
        echo '<span>' . esc_html__('Tone', 'wp-seo-next') . '</span>';
        echo '<select name="ai_tone">';
        $this->renderOption('neutral', __('Neutral', 'wp-seo-next'), 'neutral');
        $this->renderOption('professional', __('Professional', 'wp-seo-next'), 'neutral');
        $this->renderOption('friendly', __('Friendly', 'wp-seo-next'), 'neutral');
        $this->renderOption('direct', __('Direct', 'wp-seo-next'), 'neutral');
        echo '</select>';
        echo '</label>';

        echo '<label class="wpsn-inspector__ai-control wpsn-inspector__ai-control--check">';
        echo '<input type="checkbox" name="ai_only_empty" value="1" checked />';
        echo '<span>' . esc_html__('Fill only empty', 'wp-seo-next') . '</span>';
        echo '</label>';

        echo '</div>'; // controls

        echo '<div class="wpsn-inspector__ai-buttons">';
        echo '<button type="button" class="button wpsn-inspector__ai_gen" data-scope="all">' . esc_html__('AI: All fields', 'wp-seo-next') . '</button>';
        echo '<button type="button" class="button wpsn-inspector__ai_gen" data-scope="snippet">' . esc_html__('Snippet', 'wp-seo-next') . '</button>';
        echo '<button type="button" class="button wpsn-inspector__ai_gen" data-scope="title">' . esc_html__('Title', 'wp-seo-next') . '</button>';
        echo '<button type="button" class="button wpsn-inspector__ai_gen" data-scope="description">' . esc_html__('Description', 'wp-seo-next') . '</button>';
        echo '<button type="button" class="button wpsn-inspector__ai_gen" data-scope="keywords">' . esc_html__('Keywords', 'wp-seo-next') . '</button>';
        if (!empty($draft)) {
            echo '<button type="button" class="button wpsn-inspector__ai_discard">' . esc_html__('Discard AI draft', 'wp-seo-next') . '</button>';
        }
        echo '</div>'; // buttons

        echo '<div class="wpsn-inspector__ai-status">';
        if ($localOnly) {
            echo '<span class="wpsn-inspector__muted">' . esc_html__('Privacy mode: local-only is enabled. External AI is disabled.', 'wp-seo-next') . '</span>';
        } else {
            $parts = [];
            $parts[] = __('Local: ready', 'wp-seo-next');
            $parts[] = $cloudReady ? __('Cloud: ready', 'wp-seo-next') : sprintf(__('Cloud: %s', 'wp-seo-next'), $cloudHint !== '' ? $cloudHint : __('not ready', 'wp-seo-next'));
            $parts[] = $proxyReady ? __('Proxy: ready', 'wp-seo-next') : sprintf(__('Proxy: %s', 'wp-seo-next'), $proxyHint !== '' ? $proxyHint : __('not ready', 'wp-seo-next'));
            echo '<span class="wpsn-inspector__muted">' . esc_html(implode(' · ', $parts)) . '</span>';
        }
        echo '</div>';

        echo '</div>'; // ai
    }

    public function handleAiDiscard(): void
    {
        if (!$this->canShow()) {
            wp_die(esc_html__('You do not have permission to perform this action.', 'wp-seo-next'));
        }

        // Explicit capability check (helps prevent regressions and is picked up by the security sweep).
        if (!current_user_can('edit_posts') && !Capabilities::canManageTools()) {
            wp_die(esc_html__('You do not have permission to perform this action.', 'wp-seo-next'));
        }

        $nonce = isset($_POST['wpsn_ai_discard_nonce']) ? (string) wp_unslash($_POST['wpsn_ai_discard_nonce']) : '';
        if (!wp_verify_nonce($nonce, 'wpsn_inspector_ai_discard')) {
            wp_die(esc_html__('Invalid nonce.', 'wp-seo-next'));
        }

        $type = isset($_POST['ctx_type']) ? sanitize_key((string) wp_unslash($_POST['ctx_type'])) : '';
        $objectId = isset($_POST['ctx_object_id']) ? (int) wp_unslash($_POST['ctx_object_id']) : 0;
        $subType = isset($_POST['ctx_subtype']) ? sanitize_key((string) wp_unslash($_POST['ctx_subtype'])) : '';
        $ctxUrl = isset($_POST['ctx_url']) ? esc_url_raw((string) wp_unslash($_POST['ctx_url'])) : '';
        $paged = isset($_POST['ctx_paged']) ? (int) wp_unslash($_POST['ctx_paged']) : 1;

        $return = isset($_POST['return']) ? (string) wp_unslash($_POST['return']) : home_url('/');
        $return = wp_validate_redirect($return, home_url('/'));

        $ctxHash = $this->ctxHash($type, $objectId, $subType, (string) $ctxUrl, (int) $paged);
        $this->deleteAiDraft((int) get_current_user_id(), $ctxHash);

        $return = add_query_arg(['wpsn_inspector' => '1', 'wpsn_ai_discarded' => '1'], $return);
        wp_safe_redirect($return . '#wpsn-inspector');
        exit;
    }

    public function handleAiFill(): void
    {
        if (!$this->canShow()) {
            wp_die(esc_html__('You do not have permission to perform this action.', 'wp-seo-next'));
        }

        $nonce = isset($_POST['wpsn_ai_nonce']) ? (string) wp_unslash($_POST['wpsn_ai_nonce']) : '';
        if (!wp_verify_nonce($nonce, 'wpsn_inspector_ai_fill')) {
            wp_die(esc_html__('Invalid nonce.', 'wp-seo-next'));
        }

        $type = isset($_POST['ctx_type']) ? sanitize_key((string) wp_unslash($_POST['ctx_type'])) : '';
        $objectId = isset($_POST['ctx_object_id']) ? (int) wp_unslash($_POST['ctx_object_id']) : 0;
        $subType = isset($_POST['ctx_subtype']) ? sanitize_key((string) wp_unslash($_POST['ctx_subtype'])) : '';
        $ctxUrl = isset($_POST['ctx_url']) ? esc_url_raw((string) wp_unslash($_POST['ctx_url'])) : '';
        $paged = isset($_POST['ctx_paged']) ? (int) wp_unslash($_POST['ctx_paged']) : 1;

        $return = isset($_POST['return']) ? (string) wp_unslash($_POST['return']) : home_url('/');
        $return = wp_validate_redirect($return, home_url('/'));

        // Capability check (same as Save).
        if ($type === 'singular') {
            if ($objectId <= 0 || !current_user_can('edit_post', $objectId)) {
                wp_safe_redirect(add_query_arg(['wpsn_inspector' => '1', 'wpsn_inspector_error' => rawurlencode('No permission.')], $return));
                exit;
            }
        } elseif ($type === 'term') {
            if ($objectId <= 0 || (!current_user_can('edit_term', $objectId) && !current_user_can('manage_categories'))) {
                wp_safe_redirect(add_query_arg(['wpsn_inspector' => '1', 'wpsn_inspector_error' => rawurlencode('No permission.')], $return));
                exit;
            }
        } else {
            wp_safe_redirect(add_query_arg(['wpsn_inspector' => '1', 'wpsn_inspector_error' => rawurlencode('Not editable.')], $return));
            exit;
        }

        $pageText = isset($_POST['page_text']) ? (string) wp_unslash($_POST['page_text']) : '';
        $pageText = is_string($pageText) ? trim($pageText) : '';
        // Hard cap to avoid huge requests on shared hosting.
        if (function_exists('mb_substr') && function_exists('mb_strlen')) {
            if (mb_strlen($pageText) > 12000) {
                $pageText = (string) mb_substr($pageText, 0, 12000);
            }
        } else {
            if (strlen($pageText) > 12000) {
                $pageText = substr($pageText, 0, 12000);
            }
        }

        $keywordsHint = isset($_POST['keywords_hint']) ? (string) wp_unslash($_POST['keywords_hint']) : '';
        $keywordsHint = is_string($keywordsHint) ? trim($keywordsHint) : '';
        if (strlen($keywordsHint) > 500) {
            $keywordsHint = substr($keywordsHint, 0, 500);
        }

        $h1Text = isset($_POST['h1_text']) ? (string) wp_unslash($_POST['h1_text']) : '';
        $h1Text = is_string($h1Text) ? trim(wp_strip_all_tags($h1Text)) : '';
        if (function_exists('mb_substr') && function_exists('mb_strlen')) {
            if (mb_strlen($h1Text) > 300) {
                $h1Text = (string) mb_substr($h1Text, 0, 300);
            }
        } else {
            if (strlen($h1Text) > 300) {
                $h1Text = substr($h1Text, 0, 300);
            }
        }

        $scope = isset($_POST['ai_scope']) ? sanitize_key((string) wp_unslash($_POST['ai_scope'])) : 'all';
        $allowedScopes = ['all', 'snippet', 'title', 'description', 'keywords'];
        if (!in_array($scope, $allowedScopes, true)) {
            $scope = 'all';
        }

        $mode = isset($_POST['ai_mode']) ? sanitize_key((string) wp_unslash($_POST['ai_mode'])) : 'auto';
        $allowedModes = ['auto', 'local', 'cloud', 'proxy'];
        if (!in_array($mode, $allowedModes, true)) {
            $mode = 'auto';
        }

        $tone = isset($_POST['ai_tone']) ? sanitize_key((string) wp_unslash($_POST['ai_tone'])) : 'neutral';
        $allowedTones = ['neutral', 'professional', 'friendly', 'direct'];
        if (!in_array($tone, $allowedTones, true)) {
            $tone = 'neutral';
        }

        $langChoice = isset($_POST['ai_language']) ? sanitize_key((string) wp_unslash($_POST['ai_language'])) : 'auto';
        $allowedLangs = ['auto', 'ru', 'en'];
        if (!in_array($langChoice, $allowedLangs, true)) {
            $langChoice = 'auto';
        }

        $onlyEmpty = isset($_POST['ai_only_empty']) && (string) wp_unslash($_POST['ai_only_empty']) === '1';

        // Preserve the current (possibly unsaved) form state as AI baseline.
        $baseFields = [];
        $postedFields = isset($_POST['fields']) && is_array($_POST['fields']) ? (array) wp_unslash($_POST['fields']) : [];
        $allowedKeys = $this->allowedOverrideKeys();
        foreach ($allowedKeys as $k) {
            $vv = isset($postedFields[$k]) ? (string) $postedFields[$k] : '';
            $vv = is_string($vv) ? trim($vv) : '';
            $baseFields[$k] = $vv;
        }

        $localOnly = (bool) $settings->get('privacy.local_only', false);
        $externalEnabled = (bool) $settings->get('ai.external.enabled', false);
        $provider = (string) $settings->get('ai.external.provider', 'openai');
        $model = (string) $settings->get('ai.external.model', 'gpt-4o-mini');
        $maxOutput = (int) $settings->get('ai.external.max_output_tokens', 260);
        $temperature = (float) $settings->get('ai.external.temperature', 0.4);

        $titleMax = (int) $settings->get('ai.local.title_max_length', 60);
        $descMax = (int) $settings->get('ai.local.description_max_length', 155);

        // Prepare context.
        $siteName = (string) get_bloginfo('name');
        $siteLocale = (string) get_locale();
        $language = ($langChoice === 'auto') ? $siteLocale : $langChoice;

        $postTitle = '';
        $excerpt = '';
        $currentTitle = '';
        $currentDesc = '';
        $computedCanonical = '';
        $computedOgImage = '';
        $computedTwitterImage = '';
        $currentFocus = '';

        // Compute current meta (best-effort) so the AI can avoid duplicates.
        try {
            /** @var \WPSN\SEO\SeoMetaRepository $repo */
            $repo = $this->container->get('meta_repo');
            $tmpCtx = new \WPSN\SEO\SeoDocumentContext($type, $objectId, $subType, (string) $ctxUrl, (int) $paged);
            $meta = $repo->forContext($tmpCtx);
            $currentTitle = isset($meta->title) ? (string) $meta->title : '';
            $currentDesc = isset($meta->description) ? (string) $meta->description : '';
            $computedCanonical = isset($meta->canonical) ? (string) $meta->canonical : '';
            $computedOgImage = isset($meta->ogImage) ? (string) $meta->ogImage : '';
            $computedTwitterImage = isset($meta->twitterImage) ? (string) $meta->twitterImage : '';
        } catch (\Throwable $e) {
            // ignore
        }

        if ($type === 'singular' && $objectId > 0) {
            $postTitle = (string) get_the_title($objectId);
            $excerpt = (string) get_the_excerpt($objectId);
            $currentFocus = (string) get_post_meta($objectId, MetaKeys::FOCUS_KEYWORD, true);
        } elseif ($type === 'term' && $objectId > 0) {
            $term = get_term($objectId, $subType);
            if ($term instanceof \WP_Term) {
                $postTitle = (string) $term->name;
                $excerpt = (string) $term->description;
            }
            $currentFocus = (string) get_term_meta($objectId, MetaKeys::FOCUS_KEYWORD, true);
        }

        $postTitle = is_string($postTitle) ? trim($postTitle) : '';
        $excerpt = is_string($excerpt) ? trim(wp_strip_all_tags($excerpt)) : '';
        $currentFocus = is_string($currentFocus) ? trim($currentFocus) : '';

        // Local fallback suggestions (also used to fill missing fields).
        $local = new LocalSuggestionEngine();
        $localSuggestedFocus = '';
        try {
            $sugg = $local->suggest(
                $type === 'singular' ? $objectId : 0,
                $postTitle,
                $excerpt,
                $pageText !== '' ? $pageText : $excerpt,
                $siteName,
                $titleMax,
                $descMax
            );
            if (isset($sugg['focus_keyword']) && is_string($sugg['focus_keyword'])) {
                $localSuggestedFocus = trim((string) $sugg['focus_keyword']);
            }
        } catch (\Throwable $e) {
            // ignore
        }

        $fields = [];
        $usedProvider = 'local';

        $canUseExternal = $externalEnabled && !$localOnly && Capabilities::canUseAI();
        $providerHint = '';

        // Decide provider based on UI mode.
        $requestedProvider = 'local';
        if ($mode === 'cloud') {
            $requestedProvider = 'openai';
        } elseif ($mode === 'proxy') {
            $requestedProvider = 'saas_proxy';
        } elseif ($mode === 'local') {
            $requestedProvider = 'local';
        } else {
            // auto
            $requestedProvider = in_array($provider, ['openai', 'saas_proxy'], true) ? $provider : 'openai';
        }

        if (!$canUseExternal) {
            if ($requestedProvider !== 'local') {
                $providerHint = __('External AI is disabled or not available. Using local suggestions.', 'wp-seo-next');
            }
            $requestedProvider = 'local';
        }

        if ($canUseExternal && $requestedProvider !== 'local') {
            // Basic per-minute rate limit (best-effort).
            try {
                /** @var RateLimiter $rl */
                $rl = $this->container->get('rate_limiter');
                $maxPerMin = (int) $settings->get('ai.external.ratelimit.requests_per_minute', 20);
                $perUser = (bool) $settings->get('ai.external.ratelimit.per_user', true);
                $bucket = $perUser ? ('ai_inspector:' . (string) get_current_user_id()) : 'ai_inspector:global';
                if ($maxPerMin > 0 && !$rl->allow($bucket, $maxPerMin, 60)) {
                    wp_safe_redirect(add_query_arg(['wpsn_inspector' => '1', 'wpsn_inspector_error' => rawurlencode('AI rate limit. Try later.')], $return));
                    exit;
                }
            } catch (\Throwable $e) {
                // ignore
            }

            $promptLib = new PromptLibrary($settings);
            $contextLabel = ($type === 'singular') ? 'Singular' : (($type === 'term') ? 'Term' : $type);

            $task = 'Generate all SEO fields.';
            if ($scope === 'snippet') {
                $task = 'Generate ONLY title + description + og_title + og_description + twitter_title + twitter_description. Leave other fields as empty strings.';
            } elseif ($scope === 'title') {
                $task = 'Generate ONLY title + og_title + twitter_title. Leave other fields as empty strings.';
            } elseif ($scope === 'description') {
                $task = 'Generate ONLY description + og_description + twitter_description. Leave other fields as empty strings.';
            } elseif ($scope === 'keywords') {
                $task = 'Generate ONLY focus_keyword (one primary keyphrase). Leave other fields as empty strings.';
            }

            $schemaAllowed = 'default, webpage, article, product, none';

            $built = $promptLib->build(PromptLibrary::KEY_SEO_AUTOFILL, [
                '{sitename}' => $siteName,
                '{language}' => $language,
                '{tone}' => $tone,
                '{task}' => $task,
                '{schema_allowed}' => $schemaAllowed,
                '{title_max_length}' => (string) $titleMax,
                '{description_max_length}' => (string) $descMax,
                '{context_label}' => $contextLabel,
                '{post_title}' => $postTitle,
                '{h1}' => $h1Text,
                '{excerpt}' => $excerpt,
                '{content}' => $pageText !== '' ? $pageText : $excerpt,
                '{current_seo_title}' => $currentTitle,
                '{current_meta_description}' => $currentDesc,
                '{current_focus_keyword}' => $currentFocus,
                '{keywords_hint}' => $keywordsHint,
            ]);
            $prompt = (string) ($built['prompt'] ?? '');

            if ((bool) $settings->get('ai.privacy.redact_pii', true)) {
                $prompt = Redactor::redactPersonalData($prompt);
            }

            try {
                if ($requestedProvider === 'openai') {
                    $secrets = new AISecretsRepository($settings);
                    $apiKey = (string) $secrets->getOpenAIKey();
                    if ($apiKey === '') {
                        $providerHint = __('OpenAI API key is missing (Settings → AI). Using local suggestions.', 'wp-seo-next');
                    } else {
                        $client = new OpenAIClient(
                            $apiKey,
                            (string) $settings->get('ai.external.openai_org', ''),
                            (string) $settings->get('ai.external.openai_project', ''),
                            $this->container->get('logger')
                        );
                        $res = $client->generateText($model, $prompt, $maxOutput, $temperature);
                        $text = (string) ($res['text'] ?? '');
                        $usedProvider = 'openai';
                        $fields = $this->fieldsFromAiJson($text, $titleMax, $descMax);

                        // Best-effort usage stats.
                        if (isset($res['usage']) && is_array($res['usage'])) {
                            $usageRepo = new AIUsageRepository($settings);
                            $usageRepo->addToday([
                                'requests' => 1,
                                'tokens_in' => (int) ($res['usage']['input'] ?? 0),
                                'tokens_out' => (int) ($res['usage']['output'] ?? 0),
                                'tokens_total' => (int) ($res['usage']['total'] ?? 0),
                                'cost_usd' => (float) ($res['cost_usd'] ?? 0.0),
                            ]);
                        }

                        // Best-effort call log.
                        try {
                            $calls = new AICallRepository();
                            $calls->insert([
                                'provider' => 'openai',
                                'model' => $model,
                                'purpose' => 'inspector_autofill',
                                'post_id' => ($type === 'singular') ? $objectId : 0,
                                'prompt_key' => (string) ($built['key'] ?? ''),
                                'prompt_version' => (int) ($built['version'] ?? 1),
                                'prompt_hash' => (string) ($built['hash'] ?? ''),
                                'tokens_in' => (int) ($res['usage']['input'] ?? 0),
                                'tokens_out' => (int) ($res['usage']['output'] ?? 0),
                                'tokens_total' => (int) ($res['usage']['total'] ?? 0),
                                'cost_usd' => (float) ($res['cost_usd'] ?? 0.0),
                                'success' => 1,
                                'created_at' => gmdate('Y-m-d H:i:s'),
                            ]);
                        } catch (\Throwable $e) {
                            // ignore
                        }
                    }
                } elseif ($requestedProvider === 'saas_proxy') {
                    $logger = $this->container->get('logger');
                    $saas = new SaaSSyncService($settings, $logger);
                    if (!$saas->isEnabled() || !$saas->isTrusted() || $saas->getAIEndpoint() === '') {
                        $providerHint = __('SaaS AI proxy is not ready (Settings → SaaS Sync). Using local suggestions.', 'wp-seo-next');
                    } else {
                        $client = new SaaSAIProxyClient($saas, $logger, $settings);
                        $res = $client->generateText($model, $prompt, $maxOutput, $temperature, 'inspector_autofill', [
                            'type' => $type,
                            'object_id' => $objectId,
                            'subtype' => $subType,
                        ]);
                        $text = (string) ($res['text'] ?? '');
                        $usedProvider = 'saas_proxy';
                        $fields = $this->fieldsFromAiJson($text, $titleMax, $descMax);

                        if (isset($res['usage']) && is_array($res['usage'])) {
                            $usageRepo = new AIUsageRepository($settings);
                            $usageRepo->addToday([
                                'requests' => 1,
                                'tokens_in' => (int) ($res['usage']['input'] ?? 0),
                                'tokens_out' => (int) ($res['usage']['output'] ?? 0),
                                'tokens_total' => (int) ($res['usage']['total'] ?? 0),
                                'cost_usd' => (float) ($res['cost_usd'] ?? 0.0),
                            ]);
                        }

                        try {
                            $calls = new AICallRepository();
                            $calls->insert([
                                'provider' => 'saas_proxy',
                                'model' => $model,
                                'purpose' => 'inspector_autofill',
                                'post_id' => ($type === 'singular') ? $objectId : 0,
                                'prompt_key' => (string) ($built['key'] ?? ''),
                                'prompt_version' => (int) ($built['version'] ?? 1),
                                'prompt_hash' => (string) ($built['hash'] ?? ''),
                                'tokens_in' => (int) ($res['usage']['input'] ?? 0),
                                'tokens_out' => (int) ($res['usage']['output'] ?? 0),
                                'tokens_total' => (int) ($res['usage']['total'] ?? 0),
                                'cost_usd' => (float) ($res['cost_usd'] ?? 0.0),
                                'success' => 1,
                                'created_at' => gmdate('Y-m-d H:i:s'),
                            ]);
                        } catch (\Throwable $e) {
                            // ignore
                        }
                    }
                }
            } catch (\Throwable $e) {
                // external failed → fallback to local
                $fields = [];
                $usedProvider = 'local';
                if ($providerHint === '') {
                    $providerHint = __('External AI failed. Using local suggestions.', 'wp-seo-next');
                }
            }
        }

        // If external didn't produce usable fields, fallback to local generation.
        if (empty($fields)) {
            try {
                $fields = [];
                // Basic title + description from LocalSuggestionEngine.
                $s = $local->suggest(
                    $type === 'singular' ? $objectId : 0,
                    $postTitle,
                    $excerpt,
                    $pageText !== '' ? $pageText : $excerpt,
                    $siteName,
                    $titleMax,
                    $descMax
                );
                if (isset($s['title']) && is_string($s['title'])) {
                    $fields[MetaKeys::TITLE] = $this->cleanAiText((string) $s['title']);
                }
                if (isset($s['description']) && is_string($s['description'])) {
                    $fields[MetaKeys::DESCRIPTION] = $this->cleanAiText((string) $s['description']);
                }
                if (isset($s['focus_keyword']) && is_string($s['focus_keyword'])) {
                    $fields[MetaKeys::FOCUS_KEYWORD] = $this->cleanAiText((string) $s['focus_keyword']);
                }
                // Social fallbacks.
                if (!isset($fields[MetaKeys::OG_TITLE]) && isset($fields[MetaKeys::TITLE])) {
                    $fields[MetaKeys::OG_TITLE] = $fields[MetaKeys::TITLE];
                }
                if (!isset($fields[MetaKeys::OG_DESCRIPTION]) && isset($fields[MetaKeys::DESCRIPTION])) {
                    $fields[MetaKeys::OG_DESCRIPTION] = $fields[MetaKeys::DESCRIPTION];
                }
                if (!isset($fields[MetaKeys::TWITTER_TITLE]) && isset($fields[MetaKeys::TITLE])) {
                    $fields[MetaKeys::TWITTER_TITLE] = $fields[MetaKeys::TITLE];
                }
                if (!isset($fields[MetaKeys::TWITTER_DESCRIPTION]) && isset($fields[MetaKeys::DESCRIPTION])) {
                    $fields[MetaKeys::TWITTER_DESCRIPTION] = $fields[MetaKeys::DESCRIPTION];
                }
            } catch (\Throwable $e) {
                wp_safe_redirect(add_query_arg(['wpsn_inspector' => '1', 'wpsn_inspector_error' => rawurlencode('AI failed.')], $return));
                exit;
            }
        }

        // Fill missing focus keyword from local suggestion (only when requested).
        if (in_array($scope, ['all', 'keywords'], true) && (!isset($fields[MetaKeys::FOCUS_KEYWORD]) || trim((string) $fields[MetaKeys::FOCUS_KEYWORD]) === '') && $localSuggestedFocus !== '') {
            $fields[MetaKeys::FOCUS_KEYWORD] = $localSuggestedFocus;
        }

        // Scope filter + post-processing.
        $fields = $this->filterAiFieldsByScope($fields, $scope);

        // When generating "all fields", also autofill the technical fields from computed meta / WP.
        if ($scope === 'all') {
            if ($computedCanonical !== '') {
                $fields[MetaKeys::CANONICAL] = $this->cleanAiText($computedCanonical);
            }

            $featured = '';
            if ($type === 'singular' && $objectId > 0) {
                $thumbId = (int) get_post_thumbnail_id($objectId);
                if ($thumbId > 0) {
                    $src = wp_get_attachment_image_src($thumbId, 'full');
                    if (is_array($src) && isset($src[0])) {
                        $featured = (string) $src[0];
                    }
                }
            }
            $ogImg = $featured !== '' ? $featured : $computedOgImage;
            $twImg = $computedTwitterImage !== '' ? $computedTwitterImage : $ogImg;

            if ($ogImg !== '') {
                $fields[MetaKeys::OG_IMAGE] = esc_url_raw($ogImg);
            }
            if ($twImg !== '') {
                $fields[MetaKeys::TWITTER_IMAGE] = esc_url_raw($twImg);
            }

            // Schema type heuristic.
            if (!isset($fields[MetaKeys::SCHEMA_TYPE]) || trim((string) $fields[MetaKeys::SCHEMA_TYPE]) === '') {
                $schema = 'webpage';
                if ($type === 'singular' && $objectId > 0) {
                    $pt = (string) get_post_type($objectId);
                    if ($pt === 'post') {
                        $schema = 'article';
                    } elseif ($pt === 'product') {
                        $schema = 'product';
                    }
                }
                $fields[MetaKeys::SCHEMA_TYPE] = $schema;
            }
        }

        [$fields, $warnings] = $this->postProcessInspectorAiFields($fields, [
            'scope' => $scope,
            'site_name' => $siteName,
            'post_title' => $postTitle,
            'h1' => $h1Text,
            'page_text' => $pageText,
            'excerpt' => $excerpt,
            'title_max' => $titleMax,
            'desc_max' => $descMax,
        ]);

        // Save draft.
        $ctxHash = $this->ctxHash($type, $objectId, $subType, (string) $ctxUrl, (int) $paged);
        $draft = [
            'created_at' => time(),
            'provider' => $usedProvider,
            'provider_hint' => $providerHint,
            'mode' => $mode,
            'scope' => $scope,
            'only_empty' => $onlyEmpty ? 1 : 0,
            'language' => $language,
            'tone' => $tone,
            'base_fields' => $baseFields,
            'warnings' => $warnings,
            'fields' => $fields,
        ];
        $this->saveAiDraft((int) get_current_user_id(), $ctxHash, $draft);

        $return = add_query_arg(['wpsn_inspector' => '1', 'wpsn_ai_draft' => '1', 'wpsn_ai_generated' => '1'], $return);
        wp_safe_redirect($return . '#wpsn-inspector');
        exit;
    }

    private function ctxHash(string $type, int $objectId, string $subType, string $url, int $paged): string
    {
        $type = sanitize_key($type);
        $subType = sanitize_key($subType);
        $url = is_string($url) ? $url : '';
        $paged = max(1, (int) $paged);
        return (string) md5($type . '|' . (string) $objectId . '|' . $subType . '|' . $url . '|' . (string) $paged);
    }

    /**
     * @return array<string,mixed>
     */
    private function loadAiDrafts(int $userId): array
    {
        if ($userId <= 0) {
            return [];
        }
        $drafts = get_user_meta($userId, self::USERMETA_AI_DRAFTS, true);
        if (!is_array($drafts)) {
            $drafts = [];
        }

        $now = time();
        $changed = false;
        foreach ($drafts as $h => $d) {
            if (!is_string($h) || !is_array($d)) {
                unset($drafts[$h]);
                $changed = true;
                continue;
            }
            $ts = isset($d['created_at']) ? (int) $d['created_at'] : 0;
            if ($ts <= 0 || ($ts + self::AI_DRAFT_TTL) < $now) {
                unset($drafts[$h]);
                $changed = true;
            }
        }

        if ($changed) {
            update_user_meta($userId, self::USERMETA_AI_DRAFTS, $drafts);
        }

        return $drafts;
    }

    /**
     * @return array<string,mixed>
     */
    private function getAiDraft(int $userId, string $ctxHash): array
    {
        $drafts = $this->loadAiDrafts($userId);
        $d = $drafts[$ctxHash] ?? [];
        return is_array($d) ? $d : [];
    }

    /**
     * @param array<string,mixed> $draft
     */
    private function saveAiDraft(int $userId, string $ctxHash, array $draft): void
    {
        if ($userId <= 0 || $ctxHash === '') {
            return;
        }
        $drafts = $this->loadAiDrafts($userId);
        $drafts[$ctxHash] = $draft;
        update_user_meta($userId, self::USERMETA_AI_DRAFTS, $drafts);
    }

    private function deleteAiDraft(int $userId, string $ctxHash): void
    {
        if ($userId <= 0 || $ctxHash === '') {
            return;
        }
        $drafts = $this->loadAiDrafts($userId);
        if (isset($drafts[$ctxHash])) {
            unset($drafts[$ctxHash]);
            update_user_meta($userId, self::USERMETA_AI_DRAFTS, $drafts);
        }
    }

    private function cleanAiText(string $s): string
    {
        $s = trim((string) $s);
        // Strip surrounding quotes.
        $s = preg_replace('/^(["\'“”])|(["\'“”])$/u', '', $s);
        $s = is_string($s) ? trim($s) : '';
        // Collapse whitespace.
        $s = (string) preg_replace('/\s+/u', ' ', $s);
        return trim($s);
    }

    /**
     * Parse JSON payload from AI response into draft fields.
     *
     * @return array<string,string>
     */
    private function fieldsFromAiJson(string $text, int $titleMax, int $descMax): array
    {
        $data = $this->extractJsonObject($text);
        if (empty($data)) {
            return [];
        }

        $title = $this->truncateChars($this->cleanAiText((string) ($data['title'] ?? '')), $titleMax);
        $desc  = $this->truncateChars($this->cleanAiText((string) ($data['description'] ?? '')), $descMax);
        $ogT   = $this->truncateChars($this->cleanAiText((string) ($data['og_title'] ?? '')), $titleMax);
        $ogD   = $this->truncateChars($this->cleanAiText((string) ($data['og_description'] ?? '')), $descMax);
        $twT   = $this->truncateChars($this->cleanAiText((string) ($data['twitter_title'] ?? '')), $titleMax);
        $twD   = $this->truncateChars($this->cleanAiText((string) ($data['twitter_description'] ?? '')), $descMax);
        $fk    = $this->truncateChars($this->cleanAiText((string) ($data['focus_keyword'] ?? '')), 80);
        $schema = sanitize_key((string) ($data['schema_type'] ?? ''));
        if ($schema === 'default') {
            $schema = '';
        }

        $out = [];
        if ($title !== '') {
            $out[MetaKeys::TITLE] = $title;
        }
        if ($desc !== '') {
            $out[MetaKeys::DESCRIPTION] = $desc;
        }

        // Social fallbacks.
        $ogT = $ogT !== '' ? $ogT : $title;
        $ogD = $ogD !== '' ? $ogD : $desc;
        $twT = $twT !== '' ? $twT : $title;
        $twD = $twD !== '' ? $twD : $desc;

        if ($ogT !== '') {
            $out[MetaKeys::OG_TITLE] = $ogT;
        }
        if ($ogD !== '') {
            $out[MetaKeys::OG_DESCRIPTION] = $ogD;
        }
        if ($twT !== '') {
            $out[MetaKeys::TWITTER_TITLE] = $twT;
        }
        if ($twD !== '') {
            $out[MetaKeys::TWITTER_DESCRIPTION] = $twD;
        }
        if ($fk !== '') {
            $out[MetaKeys::FOCUS_KEYWORD] = $fk;
        }

        if (in_array($schema, ['', 'webpage', 'article', 'product', 'none'], true)) {
            if ($schema !== '') {
                $out[MetaKeys::SCHEMA_TYPE] = $schema;
            }
        }

        return $out;
    }

    /**
     * @return array<string,mixed>
     */
    private function extractJsonObject(string $text): array
    {
        $text = trim((string) $text);
        if ($text === '') {
            return [];
        }
        // Remove code fences if any.
        $text = (string) preg_replace('/^```[a-zA-Z0-9_-]*\s*/', '', $text);
        $text = (string) preg_replace('/\s*```\s*$/', '', $text);

        $start = strpos($text, '{');
        $end = strrpos($text, '}');
        if ($start === false || $end === false || $end <= $start) {
            return [];
        }

        $json = substr($text, $start, ($end - $start + 1));
        $data = json_decode($json, true);
        return is_array($data) ? $data : [];
    }

    private function truncateChars(string $s, int $max): string
    {
        $s = trim((string) $s);
        $max = max(1, (int) $max);
        if ($s === '') {
            return '';
        }
        if (function_exists('mb_strlen') && function_exists('mb_substr')) {
            if (mb_strlen($s) > $max) {
                return (string) mb_substr($s, 0, $max);
            }
            return $s;
        }
        if (strlen($s) > $max) {
            return substr($s, 0, $max);
        }
        return $s;
    }

    public function handleSave(): void
    {
        if (!$this->canShow()) {
            wp_die(esc_html__('You do not have permission to perform this action.', 'wp-seo-next'));
        }

        $nonce = isset($_POST['wpsn_nonce']) ? (string) wp_unslash($_POST['wpsn_nonce']) : '';
        if (!wp_verify_nonce($nonce, 'wpsn_inspector_save')) {
            wp_die(esc_html__('Invalid nonce.', 'wp-seo-next'));
        }

        $type = isset($_POST['ctx_type']) ? sanitize_key((string) wp_unslash($_POST['ctx_type'])) : '';
        $objectId = isset($_POST['ctx_object_id']) ? (int) wp_unslash($_POST['ctx_object_id']) : 0;
        $subType = isset($_POST['ctx_subtype']) ? sanitize_key((string) wp_unslash($_POST['ctx_subtype'])) : '';
        $ctxUrl = isset($_POST['ctx_url']) ? esc_url_raw((string) wp_unslash($_POST['ctx_url'])) : '';
        $paged = isset($_POST['ctx_paged']) ? (int) wp_unslash($_POST['ctx_paged']) : 1;

        $return = isset($_POST['return']) ? (string) wp_unslash($_POST['return']) : home_url('/');
        $return = wp_validate_redirect($return, home_url('/'));

        // Capability check.
        if ($type === 'singular') {
            if ($objectId <= 0 || !current_user_can('edit_post', $objectId)) {
                wp_safe_redirect(add_query_arg(['wpsn_inspector' => '1', 'wpsn_inspector_error' => rawurlencode('No permission.')], $return));
                exit;
            }
        } elseif ($type === 'term') {
            if ($objectId <= 0 || (!current_user_can('edit_term', $objectId) && !current_user_can('manage_categories'))) {
                wp_safe_redirect(add_query_arg(['wpsn_inspector' => '1', 'wpsn_inspector_error' => rawurlencode('No permission.')], $return));
                exit;
            }
        } else {
            wp_safe_redirect(add_query_arg(['wpsn_inspector' => '1', 'wpsn_inspector_error' => rawurlencode('Not editable.')], $return));
            exit;
        }

        $fields = isset($_POST['fields']) && is_array($_POST['fields']) ? (array) wp_unslash($_POST['fields']) : [];

        $allowed = [
            MetaKeys::TITLE,
            MetaKeys::DESCRIPTION,
            MetaKeys::CANONICAL,
            MetaKeys::ROBOTS_INDEX,
            MetaKeys::ROBOTS_FOLLOW,
            MetaKeys::OG_TITLE,
            MetaKeys::OG_DESCRIPTION,
            MetaKeys::OG_IMAGE,
            MetaKeys::TWITTER_TITLE,
            MetaKeys::TWITTER_DESCRIPTION,
            MetaKeys::TWITTER_IMAGE,
            MetaKeys::SCHEMA_TYPE,
            MetaKeys::FOCUS_KEYWORD,
        ];

        // Snapshot before state for revisions (SEO overrides only).
        $metaType = ($type === 'singular') ? 'post' : 'term';
        $beforeMeta = [];
        foreach ($allowed as $k) {
            $key = (string) $k;
            $exists = function_exists('metadata_exists') ? metadata_exists($metaType, $objectId, $key) : true;
            if ($metaType === 'post') {
                $beforeMeta[$key] = $exists ? (string) get_post_meta($objectId, $key, true) : null;
            } else {
                $beforeMeta[$key] = $exists ? (string) get_term_meta($objectId, $key, true) : null;
            }
        }

        foreach ($allowed as $k) {
            $raw = $fields[$k] ?? '';
            $v = is_scalar($raw) ? (string) $raw : '';
            $v = trim($v);

            // Sanitize per field.
            if ($k === MetaKeys::CANONICAL || $k === MetaKeys::OG_IMAGE || $k === MetaKeys::TWITTER_IMAGE) {
                $v = $v !== '' ? (string) esc_url_raw($v) : '';
            } elseif ($k === MetaKeys::DESCRIPTION || $k === MetaKeys::OG_DESCRIPTION || $k === MetaKeys::TWITTER_DESCRIPTION) {
                $v = (string) sanitize_textarea_field($v);
            } elseif ($k === MetaKeys::ROBOTS_INDEX) {
                $v = in_array($v, ['index', 'noindex'], true) ? $v : '';
            } elseif ($k === MetaKeys::ROBOTS_FOLLOW) {
                $v = in_array($v, ['follow', 'nofollow'], true) ? $v : '';
            } else {
                $v = (string) sanitize_text_field($v);
            }

            if ($type === 'singular') {
                if ($v === '') {
                    delete_post_meta($objectId, $k);
                } else {
                    update_post_meta($objectId, $k, $v);
                }
            } elseif ($type === 'term') {
                if ($v === '') {
                    delete_term_meta($objectId, $k);
                } else {
                    update_term_meta($objectId, $k, $v);
                }
            }
        }

        // Snapshot after state + store revision if something changed.
        $afterMeta = [];
        foreach ($allowed as $k) {
            $key = (string) $k;
            $exists = function_exists('metadata_exists') ? metadata_exists($metaType, $objectId, $key) : true;
            if ($metaType === 'post') {
                $afterMeta[$key] = $exists ? (string) get_post_meta($objectId, $key, true) : null;
            } else {
                $afterMeta[$key] = $exists ? (string) get_term_meta($objectId, $key, true) : null;
            }
        }

        if ($beforeMeta !== $afterMeta) {
            try {
                \WPSN\Modules\Revisions\RevisionsInstaller::maybeInstall();
                $repo = new \WPSN\Modules\Revisions\RevisionsRepository($this->container->get('logger'));

                $objectSubtype = '';
                if ($type === 'singular') {
                    $objectSubtype = sanitize_key((string) get_post_type($objectId));
                } elseif ($type === 'term') {
                    $objectSubtype = $subType;
                }

                $repo->insert([
                    'object_type' => $metaType,
                    'object_id' => $objectId,
                    'object_subtype' => $objectSubtype,
                    'action' => 'update',
                    'source' => 'frontend_inspector',
                    'actor_user_id' => (int) get_current_user_id(),
                    'before' => ['meta' => $beforeMeta],
                    'after' => ['meta' => $afterMeta],
                    'meta' => [
                        'context' => 'frontend_inspector_save',
                    ],
                ]);
            } catch (\Throwable $e) {
                // Best-effort; never block saving overrides.
            }
        }

        // Notify dependent modules (sitemaps, analysis, etc.).
        do_action('seo/meta/updated', $objectId, $metaType);

        // Bust cached computed meta for this context.
        try {
            /** @var \WPSN\Core\Cache $cache */
            $cache = $this->container->get('cache');
            if ($ctxUrl !== '') {
                $cacheKey = 'meta_' . md5($type . '|' . (string) $objectId . '|' . (string) $subType . '|' . $ctxUrl . '|' . (string) max(1, $paged));
                $cache->delete($cacheKey);
            }
        } catch (\Throwable $e) {
            // Best-effort.
        }

        // If the user had an AI draft for this page, drop it after saving.
        try {
            $ctxHash = $this->ctxHash($type, $objectId, $subType, (string) $ctxUrl, (int) $paged);
            $this->deleteAiDraft((int) get_current_user_id(), $ctxHash);
        } catch (\Throwable $e) {
            // ignore
        }

        $return = add_query_arg(['wpsn_inspector' => '1', 'wpsn_inspector_saved' => '1'], $return);
        wp_safe_redirect($return . '#wpsn-inspector');
        exit;
    }
}
