<?php

namespace WPSN\Modules\SiteKit\Admin;

use WPSN\Core\Capabilities;
use WPSN\Core\Container;
use WPSN\Admin\TasksWorkbenchContext;
use WP_Error;
use WP_REST_Request;
use WP_REST_Response;

defined('ABSPATH') || exit;

/**
 * WP SEO Next ↔ Google Site Kit integration.
 *
 * Goal (similar to Yoast):
 * - One place to install/activate/setup Site Kit
 * - Show key metrics inside WP SEO Next (no iframe embedding of Site Kit UI)
 *
 * NOTE: Site Kit setup itself still requires a Google OAuth flow.
 * We keep the UX inside WP SEO Next and open the Site Kit setup in a popup/new tab.
 */
final class SiteKitAdmin
{
    public const PAGE_SLUG = 'wpsn-site-kit';

    private const SITE_KIT_PLUGIN = 'google-site-kit/google-site-kit.php';
    private const SITE_KIT_SLUG = 'google-site-kit';

    private const OPT_CONSENT = 'wpsn_sitekit_consent';

    private const CACHE_TTL = 600; // 10 minutes

    private Container $container;

    /** @var mixed */
    private $settings;

    /** @var string|null */
    private ?string $hookSuffix = null;

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

    public function register(): void
    {
        add_action('admin_menu', [$this, 'addMenu']);
        add_action('admin_enqueue_scripts', [$this, 'enqueueAssets']);

        add_action('admin_post_wpsn_sitekit_install', [$this, 'handleInstall']);
        add_action('admin_post_wpsn_sitekit_activate', [$this, 'handleActivate']);
        add_action('admin_post_wpsn_sitekit_consent', [$this, 'handleConsent']);
        add_action('admin_post_wpsn_sitekit_disconnect', [$this, 'handleDisconnect']);
        add_action('admin_post_wpsn_sitekit_clear_cache', [$this, 'handleClearCache']);

        // Quick actions from URL details (queue-first).
        add_action('admin_post_wpsn_si_brief_queue', [$this, 'handleQueueBrief']);
        add_action('admin_post_wpsn_si_serp_gap_queue', [$this, 'handleQueueSerpGap']);
        add_action('admin_post_wpsn_si_generate_il', [$this, 'handleQueueInternalLinks']);

        // Opportunities quick actions (queue-first, auto-picks top query for the URL).
        add_action('admin_post_wpsn_si_opp_brief', [$this, 'handleOpportunityBrief']);
        add_action('admin_post_wpsn_si_opp_serp_gap', [$this, 'handleOpportunitySerpGap']);
    }

    public function addMenu(): void
    {
        $this->hookSuffix = add_management_page(
            __('Search Insights', 'wp-seo-next'),
            __('Search Insights', 'wp-seo-next'),
            Capabilities::CAP_TOOLS,
            self::PAGE_SLUG,
            [$this, 'render']
        );
    }

    public function enqueueAssets(string $hookSuffix): void
    {
        if ($this->hookSuffix === null || $hookSuffix !== $this->hookSuffix) {
            return;
        }

        wp_enqueue_style(
            'wpsn-sitekit-admin',
            WPSN_PLUGIN_URL . 'assets/admin/sitekit.css',
            [],
            WPSN_VERSION
        );

        wp_enqueue_script(
            'wpsn-sitekit-admin',
            WPSN_PLUGIN_URL . 'assets/admin/sitekit.js',
            [],
            WPSN_VERSION,
            true
        );

        wp_localize_script('wpsn-sitekit-admin', 'WPSN_SiteKit', [
            'setupUrl' => $this->siteKitDashboardUrl(),
            'strings' => [
                'opening' => __('Opening…', 'wp-seo-next'),
            ],
        ]);
    }

    private function pageUrl(array $args = []): string
    {
        return TasksWorkbenchContext::pageUrl(self::PAGE_SLUG, $args);
    }

    private function siteKitDashboardUrl(): string
    {
        // Site Kit uses a single dashboard page; if not set up yet, it shows the setup flow.
        return admin_url('admin.php?page=googlesitekit-dashboard');
    }

    private function isSiteKitInstalled(): bool
    {
        // Use the canonical plugin file path.
        // This is safer than hard-coding the directory/file name, and stays consistent with is_plugin_active().
        $pluginPath = WP_PLUGIN_DIR . '/' . self::SITE_KIT_PLUGIN;
        return file_exists($pluginPath);
    }

    private function isSiteKitActive(): bool
    {
        if (!function_exists('is_plugin_active')) {
            require_once ABSPATH . 'wp-admin/includes/plugin.php';
        }
        return is_plugin_active(self::SITE_KIT_PLUGIN);
    }

    private function hasConsent(): bool
    {
        return (bool) get_option(self::OPT_CONSENT, false);
    }

    public function handleInstall(): void
    {
        if ((bool) $this->settings->get('privacy.disable_sitekit_install_helper', false)) {
            wp_safe_redirect($this->pageUrl(['wpsn_notice' => 'sitekit_install_disabled']));
            exit;
        }

        if (!current_user_can('install_plugins') || !current_user_can('activate_plugins')) {
            wp_die(esc_html__('You do not have permission to install plugins.', 'wp-seo-next'));
        }
        check_admin_referer('wpsn_sitekit_install');

        if ($this->isSiteKitInstalled()) {
            // Already installed → attempt activation as a convenience.
            if (!$this->isSiteKitActive()) {
                $ok = $this->activateSiteKit();
                // Invalidate cached status/widgets so the next screen reflects the new state immediately.
                $this->clearCache();
                wp_safe_redirect($this->pageUrl(['wpsn_notice' => $ok ? 'sitekit_activated' : 'sitekit_activate_failed']));
                exit;
            }
            // Ensure we don't show stale "not installed" status due to cached transients.
            $this->clearCache();
            wp_safe_redirect($this->pageUrl(['wpsn_notice' => 'sitekit_already_installed']));
            exit;
        }

        // Avoid emitting upgrader output inside admin-post.
        // We want Yoast-like UX: click → install → redirect back with a notice.
        $obLevel = null;
        if (!headers_sent()) {
            // Capture any accidental output (upgrader skins can echo progress)
            // so redirects keep working.
            $obLevel = ob_get_level();
            ob_start();
        }

        require_once ABSPATH . 'wp-admin/includes/plugin-install.php';
        require_once ABSPATH . 'wp-admin/includes/class-wp-upgrader.php';
        require_once ABSPATH . 'wp-admin/includes/file.php';
        require_once ABSPATH . 'wp-admin/includes/plugin.php';

        // If the filesystem method is not direct, WordPress may require credentials.
        // In that case, instruct the user to install via the standard WP UI.
        $fsMethod = function_exists('get_filesystem_method') ? (string) get_filesystem_method() : '';
        if ($fsMethod !== '' && $fsMethod !== 'direct') {
            if ($obLevel !== null) {
                while (ob_get_level() > $obLevel) {
                    ob_end_clean();
                }
            }
            wp_safe_redirect($this->pageUrl(['wpsn_notice' => 'sitekit_install_needs_filesystem']));
            exit;
        }

        $api = plugins_api('plugin_information', [
            'slug' => self::SITE_KIT_SLUG,
            'fields' => [
                'sections' => false,
            ],
        ]);

        if (is_wp_error($api) || empty($api->download_link)) {
            wp_safe_redirect($this->pageUrl(['wpsn_notice' => 'sitekit_install_failed']));
            exit;
        }

        // Silent install.
        $skin = new QuietUpgraderSkin();
        $upgrader = new \Plugin_Upgrader($skin);
        $result = $upgrader->install($api->download_link);

        if ($obLevel !== null) {
            // Discard any unexpected output before redirecting.
            while (ob_get_level() > $obLevel) {
                ob_end_clean();
            }
        }

        if ($result === true && $this->isSiteKitInstalled()) {
            // Auto-activate to reduce friction.
            if (function_exists('wp_clean_plugins_cache')) {
                wp_clean_plugins_cache(false);
            }
            $ok = $this->activateSiteKit();
            // Invalidate cached status/widgets so the next screen reflects the new state immediately.
            $this->clearCache();
            wp_safe_redirect($this->pageUrl(['wpsn_notice' => $ok ? 'sitekit_installed_activated' : 'sitekit_installed_activate_failed']));
            exit;
        }

        // Store a short-lived debug hint.
        $errMsg = $skin->getErrorMessage();
        if ($errMsg !== '') {
            set_transient('wpsn_sitekit_last_install_error', $errMsg, 300);
        }

        wp_safe_redirect($this->pageUrl(['wpsn_notice' => 'sitekit_install_failed']));
        exit;
    }

    public function handleActivate(): void
    {
        if ((bool) $this->settings->get('privacy.disable_sitekit_install_helper', false)) {
            wp_safe_redirect($this->pageUrl(['wpsn_notice' => 'sitekit_activate_disabled']));
            exit;
        }

        if (!current_user_can('activate_plugins')) {
            wp_die(esc_html__('You do not have permission to activate plugins.', 'wp-seo-next'));
        }
        check_admin_referer('wpsn_sitekit_activate');

        $ok = $this->activateSiteKit();
        // Invalidate cached status/widgets so the next screen reflects the new state immediately.
        $this->clearCache();
        wp_safe_redirect($this->pageUrl(['wpsn_notice' => $ok ? 'sitekit_activated' : 'sitekit_activate_failed']));
        exit;
    }

    private function activateSiteKit(): bool
    {
        if (!function_exists('activate_plugin')) {
            require_once ABSPATH . 'wp-admin/includes/plugin.php';
        }

        $res = activate_plugin(self::SITE_KIT_PLUGIN);
        return !is_wp_error($res);
    }

    public function handleConsent(): void
    {
        if ((bool) $this->settings->get('privacy.disable_sitekit_install_helper', false)) {
            wp_safe_redirect($this->pageUrl(['wpsn_notice' => 'sitekit_consent_disabled']));
            exit;
        }

        if (!Capabilities::canManageTools()) {
            wp_die(esc_html__('You do not have permission to perform this action.', 'wp-seo-next'));
        }
        check_admin_referer('wpsn_sitekit_consent');

        update_option(self::OPT_CONSENT, time(), false);
        $this->clearCache();

        wp_safe_redirect($this->pageUrl(['wpsn_notice' => 'sitekit_consent_granted']));
        exit;
    }

    public function handleDisconnect(): void
    {
        if (!Capabilities::canManageTools()) {
            wp_die(esc_html__('You do not have permission to perform this action.', 'wp-seo-next'));
        }
        check_admin_referer('wpsn_sitekit_disconnect');

        delete_option(self::OPT_CONSENT);
        $this->clearCache();

        wp_safe_redirect($this->pageUrl(['wpsn_notice' => 'sitekit_disconnected']));
        exit;
    }

    public function handleClearCache(): void
    {
        if (!Capabilities::canManageTools()) {
            wp_die(esc_html__('You do not have permission to perform this action.', 'wp-seo-next'));
        }
        check_admin_referer('wpsn_sitekit_clear_cache');

        $this->clearCache();

        wp_safe_redirect($this->pageUrl(['wpsn_notice' => 'sitekit_cache_cleared']));
        exit;
    }

    /**
     * Queue-first: generate a Content Brief for a post (from URL details).
     */
    public function handleQueueBrief(): void
    {
        if (!Capabilities::canManageGsc()) {
            wp_die(esc_html__('You do not have permission to perform this action.', 'wp-seo-next'));
        }

        check_admin_referer('wpsn_si_brief_queue');

        $postId = isset($_GET['post_id']) ? absint((string) wp_unslash($_GET['post_id'])) : 0;
        $query = isset($_GET['query']) ? sanitize_text_field((string) wp_unslash($_GET['query'])) : '';
        $returnTo = isset($_GET['return_to']) ? esc_url_raw((string) wp_unslash($_GET['return_to'])) : '';

        if ($postId <= 0 || $query === '') {
            $this->redirectBack($returnTo, 'si_brief_invalid');
        }
        if (!current_user_can('edit_post', $postId)) {
            $this->redirectBack($returnTo, 'si_brief_permission');
        }

        $settings = $this->container->get('settings');

        // Default: local. Allow OpenAI only when enabled and allowed.
        $mode = isset($_GET['mode']) ? sanitize_key((string) wp_unslash($_GET['mode'])) : 'auto';
        $finalMode = 'local';
        if ($mode === 'openai') {
            $finalMode = 'openai';
        }
        if ((bool) $settings->get('privacy.local_only', false)) {
            $finalMode = 'local';
        }
        if ($finalMode === 'openai') {
            $aiEnabled = (bool) $settings->get('ai.external.enabled', false);
            if (!$aiEnabled || !Capabilities::canUseAI()) {
                $finalMode = 'local';
            }
        }

        /** @var \WPSN\Core\Queue\QueueInterface $queue */
        $queue = $this->container->get('queue');

        $queue->enqueue(\WPSN\Modules\SearchConsole\ContentBriefJobs::JOB_GENERATE, [
            'post_id' => (int) $postId,
            'query' => (string) $query,
            'preset' => 'auto',
            'mode' => (string) $finalMode,
            'persist' => 1,
            'user_id' => (int) (function_exists('get_current_user_id') ? get_current_user_id() : 0),
        ], time() + 1);

        $this->redirectBack($returnTo, 'si_brief_queued');
    }

    /**
     * Queue-first: start a SERP Gap run for a post + query (from URL details).
     */
    public function handleQueueSerpGap(): void
    {
        if (!Capabilities::canManageTools()) {
            wp_die(esc_html__('You do not have permission to perform this action.', 'wp-seo-next'));
        }

        check_admin_referer('wpsn_si_serp_gap_queue');

        $postId = isset($_GET['post_id']) ? absint((string) wp_unslash($_GET['post_id'])) : 0;
        $query = isset($_GET['query']) ? sanitize_text_field((string) wp_unslash($_GET['query'])) : '';
        $returnTo = isset($_GET['return_to']) ? esc_url_raw((string) wp_unslash($_GET['return_to'])) : '';

        if ($postId <= 0 || $query === '') {
            $this->redirectBack($returnTo, 'si_serp_invalid');
        }
        if (!current_user_can('edit_post', $postId)) {
            $this->redirectBack($returnTo, 'si_serp_permission');
        }

        $settings = $this->container->get('settings');
        $providerId = (string) $settings->get('serp_gap.provider_id', 'manual');
        $providerId = $providerId !== '' ? $providerId : 'manual';

        // Avoid queuing runs that will surely fail.
        if ($providerId === 'manual') {
            $this->redirectBack($returnTo, 'si_serp_provider_manual');
        }
        if ($providerId === 'serper') {
            $hasKey = (new \WPSN\Modules\SerpGap\SerpSecretsRepository())->hasSerperKey();
            if (!$hasKey) {
                $this->redirectBack($returnTo, 'si_serp_provider_missing_key');
            }
        }

        $limit = (int) $settings->get('serp_gap.limit', 5);
        $limit = max(3, min(20, $limit));

        $mode = 'local';
        if (!(bool) $settings->get('privacy.local_only', false)) {
            $prefer = (string) $settings->get('serp_gap.mode', 'local');
            if ($prefer === 'openai' && (bool) $settings->get('ai.external.enabled', false) && Capabilities::canUseAI()) {
                $mode = 'openai';
            }
        }

        $gl = sanitize_key((string) $settings->get('serp_gap.serper.gl', ''));
        $hl = sanitize_key((string) $settings->get('serp_gap.serper.hl', ''));

        $repo = new \WPSN\Modules\SerpGap\SerpGapRunRepository();
        $runId = $repo->insert([
            'query' => $query,
            'provider_id' => $providerId,
            'mode' => $mode,
            'target_post_id' => $postId,
            'target_url' => (string) get_permalink($postId),
            'status' => 'queued',
            'error_message' => '',
            'results_json' => null,
            'report_json' => null,
            'params_json' => wp_json_encode([
                'provider_id' => $providerId,
                'limit' => $limit,
                'mode' => $mode,
                'manual_input' => '',
                'gl' => $gl,
                'hl' => $hl,
                'depth' => 'quick',
            ]),
        ]);

        if ($runId > 0) {
            /** @var \WPSN\Core\Queue\QueueInterface $queue */
            $queue = $this->container->get('queue');
            $queue->enqueue(\WPSN\Modules\SerpGap\SerpGapJobs::JOB_FETCH_SERP, [
                'run_id' => (int) $runId,
            ], time() + 1);
        }

        $this->redirectBack($returnTo, 'si_serp_queued');
    }

    /**
     * Queue-first: generate internal link drafts for this post (from URL details).
     */
    public function handleQueueInternalLinks(): void
    {
        if (!Capabilities::canManageGsc()) {
            wp_die(esc_html__('You do not have permission to perform this action.', 'wp-seo-next'));
        }

        check_admin_referer('wpsn_si_generate_il');

        $postId = isset($_GET['post_id']) ? absint((string) wp_unslash($_GET['post_id'])) : 0;
        $returnTo = isset($_GET['return_to']) ? esc_url_raw((string) wp_unslash($_GET['return_to'])) : '';

        if ($postId <= 0) {
            $this->redirectBack($returnTo, 'si_il_invalid');
        }
        if (!current_user_can('edit_post', $postId)) {
            $this->redirectBack($returnTo, 'si_il_permission');
        }

        $settings = $this->container->get('settings');
        if (!$settings->moduleEnabled('search_console', false) || !has_filter('wpsn_queue_handle_' . \WPSN\Modules\SearchConsole\InternalLinkJobs::JOB_GENERATE)) {
            $this->redirectBack($returnTo, 'si_il_gsc_disabled');
        }

        $property = (string) $settings->get('search_console.property', '');
        if (trim($property) === '') {
            $this->redirectBack($returnTo, 'si_il_property_missing');
        }

        // Ensure required tables exist.
        \WPSN\Modules\SearchConsole\SearchConsoleInstaller::maybeInstall();

        $rangeDays = (int) $settings->get('search_console.range_days', 28);
        $rangeDays = max(1, min(180, $rangeDays));

        $endDate = gmdate('Y-m-d');
        $startDate = gmdate('Y-m-d', time() - ($rangeDays * DAY_IN_SECONDS));

        $payload = [
            'property' => $property,
            'start_date' => $startDate,
            'end_date' => $endDate,
            'target_post_ids' => [$postId],
            'max_targets' => 1,
        ];

        /** @var \WPSN\Core\Queue\QueueInterface $queue */
        $queue = $this->container->get('queue');
        $queue->enqueue(\WPSN\Modules\SearchConsole\InternalLinkJobs::JOB_GENERATE, $payload, time() + 1);

        $this->redirectBack($returnTo, 'si_il_queued');
    }

    /**
     * Opportunities: queue-first brief generation.
     *
     * We auto-pick the top query for the URL (if Search Console exposes it).
     */
    public function handleOpportunityBrief(): void
    {
        if (!Capabilities::canManageGsc()) {
            wp_die(esc_html__('You do not have permission to perform this action.', 'wp-seo-next'));
        }

        check_admin_referer('wpsn_si_opp_brief');

        $url = isset($_GET['url']) ? trim((string) wp_unslash($_GET['url'])) : '';
        $url = $url !== '' ? esc_url_raw($url) : '';
        $returnTo = isset($_GET['return_to']) ? esc_url_raw((string) wp_unslash($_GET['return_to'])) : '';

        if ($url === '' || !preg_match('#^https?://#i', $url)) {
            $this->redirectBack($returnTo, 'si_opp_invalid');
        }

        $postId = url_to_postid($url);
        if ($postId <= 0) {
            $this->redirectBack($returnTo, 'si_opp_no_post');
        }
        if (!current_user_can('edit_post', $postId)) {
            $this->redirectBack($returnTo, 'si_opp_permission');
        }

        $queries = $this->getTopQueriesForUrl($url, 28, 1);
        if (isset($queries['error']) && is_wp_error($queries['error'])) {
            $this->redirectBack($returnTo, 'si_opp_no_query');
        }

        $first = is_array($queries) && isset($queries['rows'][0]) && is_array($queries['rows'][0]) ? $queries['rows'][0] : [];
        $query = isset($first['query']) ? sanitize_text_field((string) $first['query']) : '';
        if ($query === '') {
            $this->redirectBack($returnTo, 'si_opp_no_query');
        }

        $settings = $this->container->get('settings');
        $mode = isset($_GET['mode']) ? sanitize_key((string) wp_unslash($_GET['mode'])) : 'auto';

        // Default: local. Allow OpenAI only when enabled and allowed.
        $finalMode = 'local';
        if ($mode === 'openai') {
            $finalMode = 'openai';
        }
        if ((bool) $settings->get('privacy.local_only', false)) {
            $finalMode = 'local';
        }
        if ($finalMode === 'openai') {
            $aiEnabled = (bool) $settings->get('ai.external.enabled', false);
            if (!$aiEnabled || !Capabilities::canUseAI()) {
                $finalMode = 'local';
            }
        }

        /** @var \WPSN\Core\Queue\QueueInterface $queue */
        $queue = $this->container->get('queue');
        $queue->enqueue(\WPSN\Modules\SearchConsole\ContentBriefJobs::JOB_GENERATE, [
            'post_id' => (int) $postId,
            'query' => (string) $query,
            'preset' => 'auto',
            'mode' => (string) $finalMode,
            'persist' => 1,
            'user_id' => (int) (function_exists('get_current_user_id') ? get_current_user_id() : 0),
        ], time() + 1);

        $this->redirectBack($returnTo, 'si_opp_brief_queued');
    }

    /**
     * Opportunities: queue-first SERP gap run.
     *
     * We auto-pick the top query for the URL.
     */
    public function handleOpportunitySerpGap(): void
    {
        if (!Capabilities::canManageTools()) {
            wp_die(esc_html__('You do not have permission to perform this action.', 'wp-seo-next'));
        }

        check_admin_referer('wpsn_si_opp_serp_gap');

        $url = isset($_GET['url']) ? trim((string) wp_unslash($_GET['url'])) : '';
        $url = $url !== '' ? esc_url_raw($url) : '';
        $returnTo = isset($_GET['return_to']) ? esc_url_raw((string) wp_unslash($_GET['return_to'])) : '';

        if ($url === '' || !preg_match('#^https?://#i', $url)) {
            $this->redirectBack($returnTo, 'si_opp_invalid');
        }

        $postId = url_to_postid($url);
        if ($postId <= 0) {
            $this->redirectBack($returnTo, 'si_opp_no_post');
        }
        if (!current_user_can('edit_post', $postId)) {
            $this->redirectBack($returnTo, 'si_opp_permission');
        }

        $queries = $this->getTopQueriesForUrl($url, 28, 1);
        if (isset($queries['error']) && is_wp_error($queries['error'])) {
            $this->redirectBack($returnTo, 'si_opp_no_query');
        }

        $first = is_array($queries) && isset($queries['rows'][0]) && is_array($queries['rows'][0]) ? $queries['rows'][0] : [];
        $query = isset($first['query']) ? sanitize_text_field((string) $first['query']) : '';
        if ($query === '') {
            $this->redirectBack($returnTo, 'si_opp_no_query');
        }

        $settings = $this->container->get('settings');
        $providerId = (string) $settings->get('serp_gap.provider_id', 'manual');
        $providerId = $providerId !== '' ? $providerId : 'manual';

        // Avoid queuing runs that will surely fail.
        if ($providerId === 'manual') {
            $this->redirectBack($returnTo, 'si_serp_provider_manual');
        }
        if ($providerId === 'serper') {
            $hasKey = (new \WPSN\Modules\SerpGap\SerpSecretsRepository())->hasSerperKey();
            if (!$hasKey) {
                $this->redirectBack($returnTo, 'si_serp_provider_missing_key');
            }
        }

        $limit = (int) $settings->get('serp_gap.limit', 5);
        $limit = max(3, min(20, $limit));

        $mode = 'local';
        if (!(bool) $settings->get('privacy.local_only', false)) {
            $prefer = (string) $settings->get('serp_gap.mode', 'local');
            if ($prefer === 'openai' && (bool) $settings->get('ai.external.enabled', false) && Capabilities::canUseAI()) {
                $mode = 'openai';
            }
        }

        $gl = sanitize_key((string) $settings->get('serp_gap.serper.gl', ''));
        $hl = sanitize_key((string) $settings->get('serp_gap.serper.hl', ''));

        $repo = new \WPSN\Modules\SerpGap\SerpGapRunRepository();
        $runId = $repo->insert([
            'query' => $query,
            'provider_id' => $providerId,
            'mode' => $mode,
            'target_post_id' => $postId,
            'target_url' => (string) get_permalink($postId),
            'status' => 'queued',
            'error_message' => '',
            'results_json' => null,
            'report_json' => null,
            'params_json' => wp_json_encode([
                'provider_id' => $providerId,
                'limit' => $limit,
                'mode' => $mode,
                'manual_input' => '',
                'gl' => $gl,
                'hl' => $hl,
                'depth' => 'quick',
            ]),
        ]);

        if ($runId > 0) {
            /** @var \WPSN\Core\Queue\QueueInterface $queue */
            $queue = $this->container->get('queue');
            $queue->enqueue(\WPSN\Modules\SerpGap\SerpGapJobs::JOB_FETCH_SERP, [
                'run_id' => (int) $runId,
            ], time() + 1);
        }

        $this->redirectBack($returnTo, 'si_opp_serp_queued');
    }

    private function redirectBack(string $returnTo, string $notice): void
    {
        $fallback = $this->pageUrl();
        $target = $returnTo !== '' ? $returnTo : $fallback;
        $target = wp_validate_redirect($target, $fallback);

        wp_safe_redirect(add_query_arg(['wpsn_notice' => $notice], $target));
        exit;
    }

    private function clearCache(): void
    {
        $keys = [
            // Overall status and setup detection.
            'status',
            'core_setup',
            'sc_tiny',

            // Main widgets.
            'sc_summary',
            'sc_top_queries',
            'sc_top_pages',
            'sc_clicks_ts',
            'ga_sessions_ts',
        ];
        foreach ($keys as $suffix) {
            delete_transient($this->cacheKey($suffix));
        }

        // Also clear any per-URL caches (details view) for the current user.
        global $wpdb;
        if (isset($wpdb) && property_exists($wpdb, 'options')) {
            $blogId = function_exists('get_current_blog_id') ? (int) get_current_blog_id() : 0;
            $userId = function_exists('get_current_user_id') ? (int) get_current_user_id() : 0;
            $base = 'wpsn_sitekit_' . $blogId . '_' . $userId . '_sc_url_';
            $like1 = $wpdb->esc_like('_transient_' . $base) . '%';
            $like2 = $wpdb->esc_like('_transient_timeout_' . $base) . '%';
            // phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery,WordPress.DB.DirectDatabaseQuery.NoCaching
            $wpdb->query($wpdb->prepare("DELETE FROM {$wpdb->options} WHERE option_name LIKE %s OR option_name LIKE %s", $like1, $like2));

            // And opportunities caches.
            $baseOpp = 'wpsn_sitekit_' . $blogId . '_' . $userId . '_sc_opp_';
            $like3 = $wpdb->esc_like('_transient_' . $baseOpp) . '%';
            $like4 = $wpdb->esc_like('_transient_timeout_' . $baseOpp) . '%';
            // phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery,WordPress.DB.DirectDatabaseQuery.NoCaching
            $wpdb->query($wpdb->prepare("DELETE FROM {$wpdb->options} WHERE option_name LIKE %s OR option_name LIKE %s", $like3, $like4));
        }
    }

    private function cacheKey(string $suffix): string
    {
        $blogId = function_exists('get_current_blog_id') ? (int) get_current_blog_id() : 0;
        $userId = function_exists('get_current_user_id') ? (int) get_current_user_id() : 0;
        return 'wpsn_sitekit_' . $blogId . '_' . $userId . '_' . sanitize_key($suffix);
    }

    /**
     * Server-side request to Site Kit REST endpoints.
     *
     * Yoast SEO moved to server-side HTTP requests against Site Kit endpoints for stability and maintainability.
     * We do the same using rest_do_request (internal WP REST dispatch).
     *
     * @return array|WP_Error
     */
    private function siteKitRestGet(string $route, array $params = [], string $cacheSuffix = '', int $ttl = self::CACHE_TTL)
    {
        $cacheSuffix = $cacheSuffix !== '' ? $cacheSuffix : md5($route . '|' . wp_json_encode($params));
        $key = $this->cacheKey($cacheSuffix);

        $cached = get_transient($key);
        if (is_array($cached) && array_key_exists('ok', $cached)) {
            if ($cached['ok'] === true) {
                $data = isset($cached['data']) ? $cached['data'] : [];
                $data = $this->normalizeSiteKitData($data);
                return is_array($data) ? $data : [];
            }
            $msg = isset($cached['message']) ? (string) $cached['message'] : 'Error';
            $code = isset($cached['code']) ? (string) $cached['code'] : 'error';
            return new WP_Error($code, $msg);
        }

        $req = new WP_REST_Request('GET', $route);
        foreach ($params as $k => $v) {
            $req->set_param($k, $v);
        }

        $res = rest_do_request($req);
        if ($res instanceof WP_REST_Response) {
            // NOTE: When calling Site Kit endpoints internally via rest_do_request(),
            // we can get Google API model objects (Google\\Model / Google\\Collection)
            // as response data. Normalize everything to plain arrays.
            $data = $this->normalizeSiteKitData($res->get_data());
            $data = is_array($data) ? $data : [];
            set_transient($key, [
                'ok' => true,
                'data' => $data,
            ], $ttl);
            return $data;
        }

        if ($res instanceof WP_Error) {
            $msg = $res->get_error_message();
            $code = $res->get_error_code();
            set_transient($key, [
                'ok' => false,
                'message' => (string) $msg,
                'code' => (string) $code,
            ], 60);
            return $res;
        }

        set_transient($key, [
            'ok' => false,
            'message' => 'Unknown response',
            'code' => 'unknown',
        ], 60);
        return new WP_Error('unknown', 'Unknown response');
    }

    /**
     * Normalize Site Kit REST responses to plain arrays.
     *
     * Site Kit often returns Google API model objects for datapoints (e.g. Search Console
     * SearchAnalytics rows). In a normal HTTP request you'd get JSON-decoded arrays,
     * but when we call Site Kit internally via rest_do_request(), we receive the PHP
     * objects directly. Our UI renderers expect arrays, so we convert recursively.
     *
     * @param mixed $data Any data.
     * @return mixed Normalized data.
     */
    private function normalizeSiteKitData($data)
    {
        if (is_array($data)) {
            foreach ($data as $k => $v) {
                $data[$k] = $this->normalizeSiteKitData($v);
            }
            return $data;
        }

        if (is_object($data)) {
            // wp_json_encode handles JsonSerializable objects (Google API models) reliably.
            $decoded = json_decode(wp_json_encode($data), true);
            if (null !== $decoded) {
                return $this->normalizeSiteKitData($decoded);
            }

            // Fallback: cast public properties.
            return (array) $data;
        }

        return $data;
    }

    public function getStatus(): array
    {
        // IMPORTANT:
        // Do not cache the overall status here. We previously cached it for 2 minutes, which caused confusing UX:
        // user clicks "Install & Activate" and the page still shows "Not installed" until the cache expires.
        // Local checks (file_exists / is_plugin_active / option) are cheap.
        // Remote checks below already have their own caches via siteKitRestGet().
        $installed = $this->isSiteKitInstalled();
        $active = $installed ? $this->isSiteKitActive() : false;
        $consent = $this->hasConsent();

        // Best-effort setup detection:
        // 1) Try core/site setup endpoint (if exists)
        // 2) Fallback to making a tiny Search Console request.
        $setupComplete = false;
        $setupHint = '';

        if ($active) {
            $core = $this->siteKitRestGet('/google-site-kit/v1/core/site/data/setup', [], 'core_setup', 120);
            if (!is_wp_error($core) && is_array($core)) {
                foreach (['setupComplete', 'setup_complete', 'isSetupComplete', 'is_setup_complete'] as $k) {
                    if (array_key_exists($k, $core)) {
                        $setupComplete = (bool) $core[$k];
                        break;
                    }
                }
                // Some versions may expose more granular flags.
                if (!$setupComplete && isset($core['isConnected'])) {
                    $setupComplete = (bool) $core['isConnected'];
                }
                if (!$setupComplete && isset($core['connected'])) {
                    $setupComplete = (bool) $core['connected'];
                }

                if (isset($core['message']) && is_string($core['message'])) {
                    $setupHint = $core['message'];
                }
            }

            if (!$setupComplete) {
                $end = gmdate('Y-m-d', strtotime('-1 day'));
                $start = gmdate('Y-m-d', strtotime('-7 days'));
                $tiny = $this->siteKitRestGet('/google-site-kit/v1/modules/search-console/data/searchanalytics', [
                    'startDate' => $start,
                    'endDate' => $end,
                    'dimensions' => [],
                    'rowLimit' => 1,
                ], 'sc_tiny', 120);

                if (!is_wp_error($tiny)) {
                    $setupComplete = true;
                } else {
                    $setupHint = $tiny->get_error_message();
                }
            }
        }

        return [
            'installed' => $installed,
            'active' => $active,
            'setupComplete' => $setupComplete,
            'consent' => $consent,
            'setupHint' => $setupHint,
        ];
    }

    private function fetchSearchAnalytics(string $startDate, string $endDate, array $dimensions = [], int $rowLimit = 1000, string $url = '')
    {
        $params = [
            'startDate' => $startDate,
            'endDate' => $endDate,
            'dimensions' => $dimensions,
            'rowLimit' => $rowLimit,
        ];
        if ($url !== '') {
            $params['url'] = $url;
        }

        $cacheSuffix = 'sc_' . md5(wp_json_encode($params));
        return $this->siteKitRestGet('/google-site-kit/v1/modules/search-console/data/searchanalytics', $params, $cacheSuffix, self::CACHE_TTL);
    }

    private function parseTotals($data): array
    {
        // Site Kit's Search Console datapoint returns an array of rows.
        // Each row has metrics + optional `keys` (dimension values).
        // We compute weighted average position (by impressions).

        $clicks = 0.0;
        $impr = 0.0;
        $posWeighted = 0.0;

        if (!is_array($data)) {
            return [
                'clicks' => 0,
                'impressions' => 0,
                'ctr' => 0,
                'position' => 0,
            ];
        }

        foreach ($data as $row) {
            if (!is_array($row)) {
                continue;
            }
            $c = isset($row['clicks']) ? (float) $row['clicks'] : 0.0;
            $i = isset($row['impressions']) ? (float) $row['impressions'] : 0.0;
            $p = isset($row['position']) ? (float) $row['position'] : 0.0;

            $clicks += $c;
            $impr += $i;
            $posWeighted += ($p * $i);
        }

        $ctr = $impr > 0 ? ($clicks / $impr) : 0.0;
        $pos = $impr > 0 ? ($posWeighted / $impr) : 0.0;

        return [
            'clicks' => (int) round($clicks),
            'impressions' => (int) round($impr),
            'ctr' => $ctr,
            'position' => $pos,
        ];
    }

    private function computeDelta(float $current, float $previous): float
    {
        if ($previous == 0.0) {
            return $current == 0.0 ? 0.0 : 100.0;
        }
        return (($current - $previous) / $previous) * 100.0;
    }

    /**
     * Filter Search Console rows by the "device" dimension value.
     *
     * @param mixed $rows
     * @return array<int, array<string,mixed>>
     */
    private function filterRowsByDeviceKey($rows, int $deviceKeyIndex, string $device): array
    {
        if (!is_array($rows)) {
            return [];
        }

        $device = strtolower(trim($device));
        if ($device === '') {
            /** @var array<int, array<string,mixed>> $rows */
            return $rows;
        }

        $out = [];
        foreach ($rows as $row) {
            if (!is_array($row)) {
                continue;
            }

            $keys = $row['keys'] ?? null;
            if (!is_array($keys)) {
                continue;
            }

            $k = isset($keys[$deviceKeyIndex]) ? strtolower((string) $keys[$deviceKeyIndex]) : '';
            if ($k === $device) {
                $out[] = $row;
            }
        }

        return $out;
    }

    private function getSearchConsoleSummary(int $days = 28, string $device = ''): array
    {
        $deviceKey = $device !== '' ? $device : 'all';
        $cacheKey = $this->cacheKey('sc_summary_' . $days . '_' . $deviceKey);
        $cached = get_transient($cacheKey);
        if (is_array($cached) && isset($cached['current'])) {
            return $cached;
        }

        $end = gmdate('Y-m-d', strtotime('-1 day'));
        $start = gmdate('Y-m-d', strtotime('-' . $days . ' days'));

        $prevEnd = gmdate('Y-m-d', strtotime('-' . ($days + 1) . ' days'));
        $prevStart = gmdate('Y-m-d', strtotime('-' . (2 * $days) . ' days'));

        $dimensions = [];
        if ($device !== '') {
            $dimensions = ['device'];
        }

        $currentRaw = $this->fetchSearchAnalytics($start, $end, $dimensions, 1000);
        if (is_wp_error($currentRaw)) {
            return ['error' => $currentRaw];
        }

        $prevRaw = $this->fetchSearchAnalytics($prevStart, $prevEnd, $dimensions, 1000);
        if (is_wp_error($prevRaw)) {
            return ['error' => $prevRaw];
        }

        if ($device !== '') {
            $currentRaw = $this->filterRowsByDeviceKey($currentRaw, 0, $device);
            $prevRaw = $this->filterRowsByDeviceKey($prevRaw, 0, $device);
        }

        $cur = $this->parseTotals($currentRaw);
        $prev = $this->parseTotals($prevRaw);

        $out = [
            'days' => $days,
            'range' => [
                'start' => $start,
                'end' => $end,
                'prev_start' => $prevStart,
                'prev_end' => $prevEnd,
            ],
            'current' => $cur,
            'previous' => $prev,
            'delta' => [
                'clicks' => $this->computeDelta((float) $cur['clicks'], (float) $prev['clicks']),
                'impressions' => $this->computeDelta((float) $cur['impressions'], (float) $prev['impressions']),
                'ctr' => $this->computeDelta((float) $cur['ctr'], (float) $prev['ctr']),
                'position' => $this->computeDelta((float) $cur['position'], (float) $prev['position']),
            ],
        ];

        set_transient($cacheKey, $out, self::CACHE_TTL);
        return $out;
    }

    private function getTopQueries(int $days = 28, int $limit = 5, string $device = '')
    {
        $deviceKey = $device !== '' ? $device : 'all';
        $cacheKey = $this->cacheKey('sc_top_queries_' . $days . '_' . $deviceKey . '_' . $limit);
        $cached = get_transient($cacheKey);
        if (is_array($cached) && isset($cached['rows'])) {
            return $cached;
        }

        $end = gmdate('Y-m-d', strtotime('-1 day'));
        $start = gmdate('Y-m-d', strtotime('-' . $days . ' days'));

        $dimensions = ['query'];
        $rowLimit = $limit;
        if ($device !== '') {
            $dimensions = ['query', 'device'];
            // When adding a second dimension, increase rowLimit to avoid sampling.
            $rowLimit = max(1000, $limit * 50);
        }

        $raw = $this->fetchSearchAnalytics($start, $end, $dimensions, $rowLimit);
        if (is_wp_error($raw)) {
            return ['error' => $raw];
        }

        // Aggregate by query (and filter by device if requested).
        $agg = [];
        foreach ($raw as $row) {
            if (!is_array($row)) {
                continue;
            }

            $q = isset($row['keys'][0]) ? (string) $row['keys'][0] : '';
            $q = trim($q);
            if ($q === '') {
                continue;
            }

            if ($device !== '') {
                $devKey = isset($row['keys'][1]) ? strtolower((string) $row['keys'][1]) : '';
                if ($devKey !== strtolower($device)) {
                    continue;
                }
            }

            $clicks = isset($row['clicks']) ? (float) $row['clicks'] : 0.0;
            $impr = isset($row['impressions']) ? (float) $row['impressions'] : 0.0;
            $pos = isset($row['position']) ? (float) $row['position'] : 0.0;

            if (!isset($agg[$q])) {
                $agg[$q] = [
                    'query' => $q,
                    'clicks' => 0.0,
                    'impressions' => 0.0,
                    'pos_weighted' => 0.0,
                ];
            }

            $agg[$q]['clicks'] += $clicks;
            $agg[$q]['impressions'] += $impr;
            $agg[$q]['pos_weighted'] += ($pos * $impr);
        }

        $rows = [];
        foreach ($agg as $q => $a) {
            $impr = (float) ($a['impressions'] ?? 0.0);
            $clicks = (float) ($a['clicks'] ?? 0.0);
            $ctr = $impr > 0.0 ? ($clicks / $impr) : 0.0;
            $pos = $impr > 0.0 ? ((float) ($a['pos_weighted'] ?? 0.0) / $impr) : 0.0;

            $rows[] = [
                'query' => (string) ($a['query'] ?? $q),
                'clicks' => (int) round($clicks),
                'impressions' => (int) round($impr),
                'ctr' => $ctr,
                'position' => $pos,
            ];
        }

        usort($rows, static function ($a, $b) {
            $ca = isset($a['clicks']) ? (int) $a['clicks'] : 0;
            $cb = isset($b['clicks']) ? (int) $b['clicks'] : 0;
            if ($ca !== $cb) {
                return $cb <=> $ca;
            }
            $ia = isset($a['impressions']) ? (int) $a['impressions'] : 0;
            $ib = isset($b['impressions']) ? (int) $b['impressions'] : 0;
            return $ib <=> $ia;
        });

        $rows = array_slice($rows, 0, $limit);

        $out = [
            'days' => $days,
            'rows' => $rows,
        ];

        set_transient($cacheKey, $out, self::CACHE_TTL);
        return $out;
    }

    private function shortenUrl(string $url): string
    {
        $url = trim($url);
        if ($url === '') {
            return '';
        }

        $home = home_url('/');
        $home = rtrim((string) $home, '/');

        // Normalize schemes for comparison.
        $homeNoScheme = preg_replace('#^https?://#i', '', $home);
        $urlNoScheme = preg_replace('#^https?://#i', '', $url);

        if (is_string($homeNoScheme) && is_string($urlNoScheme) && stripos($urlNoScheme, $homeNoScheme) === 0) {
            $rest = substr($urlNoScheme, strlen($homeNoScheme));
            $rest = $rest !== false ? $rest : '';
            $rest = $rest === '' ? '/' : $rest;
            if ($rest[0] !== '/') {
                $rest = '/' . $rest;
            }
            return $rest;
        }

        // Fallback to path.
        $parts = wp_parse_url($url);
        if (is_array($parts) && isset($parts['path'])) {
            $path = (string) $parts['path'];
            if ($path === '') {
                $path = '/';
            }
            if (isset($parts['query']) && is_string($parts['query']) && $parts['query'] !== '') {
                $path .= '?' . $parts['query'];
            }
            return $path;
        }

        return $url;
    }

    private function getTopPages(int $days = 28, int $limit = 5, string $device = '')
    {
        $deviceKey = $device !== '' ? $device : 'all';
        $cacheKey = $this->cacheKey('sc_top_pages_' . $days . '_' . $deviceKey . '_' . $limit);
        $cached = get_transient($cacheKey);
        if (is_array($cached) && isset($cached['rows'])) {
            return $cached;
        }

        $end = gmdate('Y-m-d', strtotime('-1 day'));
        $start = gmdate('Y-m-d', strtotime('-' . $days . ' days'));

        $dimensions = ['page'];
        $rowLimit = $limit;
        if ($device !== '') {
            $dimensions = ['page', 'device'];
            $rowLimit = max(1000, $limit * 50);
        }

        $raw = $this->fetchSearchAnalytics($start, $end, $dimensions, $rowLimit);
        if (is_wp_error($raw)) {
            return ['error' => $raw];
        }

        // Aggregate by page (and filter by device if requested).
        $agg = [];
        foreach ($raw as $row) {
            if (!is_array($row)) {
                continue;
            }

            $u = isset($row['keys'][0]) ? (string) $row['keys'][0] : '';
            $u = trim($u);
            if ($u === '') {
                continue;
            }

            if ($device !== '') {
                $devKey = isset($row['keys'][1]) ? strtolower((string) $row['keys'][1]) : '';
                if ($devKey !== strtolower($device)) {
                    continue;
                }
            }

            $clicks = isset($row['clicks']) ? (float) $row['clicks'] : 0.0;
            $impr = isset($row['impressions']) ? (float) $row['impressions'] : 0.0;
            $pos = isset($row['position']) ? (float) $row['position'] : 0.0;

            if (!isset($agg[$u])) {
                $agg[$u] = [
                    'url' => $u,
                    'clicks' => 0.0,
                    'impressions' => 0.0,
                    'pos_weighted' => 0.0,
                ];
            }

            $agg[$u]['clicks'] += $clicks;
            $agg[$u]['impressions'] += $impr;
            $agg[$u]['pos_weighted'] += ($pos * $impr);
        }

        $rows = [];
        foreach ($agg as $u => $a) {
            $impr = (float) ($a['impressions'] ?? 0.0);
            $clicks = (float) ($a['clicks'] ?? 0.0);
            $ctr = $impr > 0.0 ? ($clicks / $impr) : 0.0;
            $pos = $impr > 0.0 ? ((float) ($a['pos_weighted'] ?? 0.0) / $impr) : 0.0;

            $rows[] = [
                'url' => (string) ($a['url'] ?? $u),
                'label' => $this->shortenUrl((string) ($a['url'] ?? $u)),
                'clicks' => (int) round($clicks),
                'impressions' => (int) round($impr),
                'ctr' => $ctr,
                'position' => $pos,
            ];
        }

        usort($rows, static function ($a, $b) {
            $ca = isset($a['clicks']) ? (int) $a['clicks'] : 0;
            $cb = isset($b['clicks']) ? (int) $b['clicks'] : 0;
            if ($ca !== $cb) {
                return $cb <=> $ca;
            }
            $ia = isset($a['impressions']) ? (int) $a['impressions'] : 0;
            $ib = isset($b['impressions']) ? (int) $b['impressions'] : 0;
            return $ib <=> $ia;
        });

        $rows = array_slice($rows, 0, $limit);

        $out = [
            'days' => $days,
            'rows' => $rows,
        ];

        set_transient($cacheKey, $out, self::CACHE_TTL);
        return $out;
    }

    /**
     * @return array{min_impr:int,max_ctr:float,pos_min:float,pos_max:float,target_ctr:float,limit:int}
     */
    private function opportunityFilters(): array
    {
        $minImpr = isset($_GET['opp_min_impr']) ? absint((string) wp_unslash($_GET['opp_min_impr'])) : 200;
        $minImpr = max(0, min(10000000, $minImpr));

        $maxCtrPct = isset($_GET['opp_max_ctr']) ? (float) str_replace(',', '.', (string) wp_unslash($_GET['opp_max_ctr'])) : 3.0;
        $maxCtrPct = max(0.0, min(100.0, $maxCtrPct));
        $maxCtr = $maxCtrPct / 100.0;

        $posMin = isset($_GET['opp_pos_min']) ? (float) str_replace(',', '.', (string) wp_unslash($_GET['opp_pos_min'])) : 3.0;
        $posMax = isset($_GET['opp_pos_max']) ? (float) str_replace(',', '.', (string) wp_unslash($_GET['opp_pos_max'])) : 20.0;

        $posMin = max(0.0, min(1000.0, $posMin));
        $posMax = max(0.0, min(1000.0, $posMax));
        if ($posMax < $posMin) {
            $tmp = $posMin;
            $posMin = $posMax;
            $posMax = $tmp;
        }

        $targetCtrPct = isset($_GET['opp_target_ctr']) ? (float) str_replace(',', '.', (string) wp_unslash($_GET['opp_target_ctr'])) : 5.0;
        $targetCtrPct = max(0.0, min(100.0, $targetCtrPct));
        $targetCtr = $targetCtrPct / 100.0;

        $limit = isset($_GET['opp_limit']) ? absint((string) wp_unslash($_GET['opp_limit'])) : 25;
        $limit = max(5, min(200, $limit));

        return [
            'min_impr' => $minImpr,
            'max_ctr' => $maxCtr,
            'pos_min' => $posMin,
            'pos_max' => $posMax,
            'target_ctr' => $targetCtr,
            'limit' => $limit,
        ];
    }

    /**
     * @param array{min_impr:int,max_ctr:float,pos_min:float,pos_max:float,target_ctr:float,limit:int} $filters
     * @return array<string, string|int|float>
     */
    private function opportunityStateArgs(array $filters, int $days = 28, string $device = ''): array
    {
        return [
            'days' => $days,
            'device' => $device,
            'opp_min_impr' => (int) $filters['min_impr'],
            'opp_max_ctr' => (float) ($filters['max_ctr'] * 100.0),
            'opp_pos_min' => (float) $filters['pos_min'],
            'opp_pos_max' => (float) $filters['pos_max'],
            'opp_target_ctr' => (float) ($filters['target_ctr'] * 100.0),
            'opp_limit' => (int) $filters['limit'],
        ];
    }

    /**
     * @param array{min_impr:int,max_ctr:float,pos_min:float,pos_max:float,target_ctr:float,limit:int} $filters
     * @return array{rows:array<int,array<string,mixed>>}|array{error:\WP_Error}
     */
    private function getOpportunities(array $filters, int $days = 28, string $device = '')
    {
        $deviceKey = $device !== '' ? $device : 'all';
        $cacheSuffix = 'sc_opp_' . md5(wp_json_encode([$days, $deviceKey, $filters]));
        $cacheKey = $this->cacheKey($cacheSuffix);
        $cached = get_transient($cacheKey);
        if (is_array($cached) && isset($cached['rows']) && is_array($cached['rows'])) {
            return $cached;
        }

        $end = gmdate('Y-m-d', strtotime('-1 day'));
        $start = gmdate('Y-m-d', strtotime('-' . $days . ' days'));

        $dimensions = ['page'];
        $rowLimit = 5000;
        if ($device !== '') {
            // Device adds combinations, so request more to reduce sampling.
            $dimensions = ['page', 'device'];
            $rowLimit = (int) apply_filters('wpsn/sitekit/opportunities/row_limit', 15000, $device);
            if ($rowLimit < 1000) {
                $rowLimit = 1000;
            }
        }

        // We fetch a large slice and rank locally.
        $raw = $this->fetchSearchAnalytics($start, $end, $dimensions, $rowLimit);
        if (is_wp_error($raw)) {
            return ['error' => $raw];
        }

        $rows = [];
        foreach ($raw as $row) {
            if (!is_array($row)) {
                continue;
            }

            $u = isset($row['keys'][0]) ? (string) $row['keys'][0] : '';
            $u = trim($u);
            if ($u === '' || !preg_match('#^https?://#i', $u)) {
                continue;
            }

            if ($device !== '') {
                $devKey = isset($row['keys'][1]) ? strtolower((string) $row['keys'][1]) : '';
                if ($devKey !== strtolower($device)) {
                    continue;
                }
            }

            $clicks = isset($row['clicks']) ? (int) round((float) $row['clicks']) : 0;
            $impr = isset($row['impressions']) ? (int) round((float) $row['impressions']) : 0;
            $ctr = isset($row['ctr']) ? (float) $row['ctr'] : 0.0;
            $pos = isset($row['position']) ? (float) $row['position'] : 0.0;

            if ($impr < (int) $filters['min_impr']) {
                continue;
            }
            if ($ctr > (float) $filters['max_ctr']) {
                continue;
            }
            if ($pos < (float) $filters['pos_min'] || $pos > (float) $filters['pos_max']) {
                continue;
            }

            $potential = (int) round($impr * (float) $filters['target_ctr']);
            $missing = max(0, $potential - $clicks);

            $rows[] = [
                'url' => $u,
                'label' => $this->shortenUrl($u),
                'clicks' => $clicks,
                'impressions' => $impr,
                'ctr' => $ctr,
                'position' => $pos,
                'potential_clicks' => $potential,
                'missing_clicks' => $missing,
            ];
        }

        usort($rows, static function ($a, $b) {
            $ma = isset($a['missing_clicks']) ? (int) $a['missing_clicks'] : 0;
            $mb = isset($b['missing_clicks']) ? (int) $b['missing_clicks'] : 0;
            if ($ma !== $mb) {
                return $mb <=> $ma;
            }
            $ia = isset($a['impressions']) ? (int) $a['impressions'] : 0;
            $ib = isset($b['impressions']) ? (int) $b['impressions'] : 0;
            return $ib <=> $ia;
        });

        $rows = array_slice($rows, 0, (int) $filters['limit']);

        $out = [
            'rows' => $rows,
        ];

        set_transient($cacheKey, $out, self::CACHE_TTL);
        return $out;
    }

    private function renderOpportunities(int $days = 28, string $device = ''): void
    {
        $filters = $this->opportunityFilters();
        $stateArgs = $this->opportunityStateArgs($filters, $days, $device);

        echo '<div class="wpsn-sk-card">';
        echo '<h2>' . esc_html__('Opportunities', 'wp-seo-next') . '</h2>';
        echo '<p class="description">' . esc_html__('Pages with high impressions but low CTR in a given position range. Use this list to quickly improve titles/descriptions and generate briefs or SERP gap runs.', 'wp-seo-next') . '</p>';

        echo '<form method="get" action="' . esc_url(admin_url('tools.php')) . '" style="margin: 12px 0 10px;">';
        echo '<input type="hidden" name="page" value="' . esc_attr(self::PAGE_SLUG) . '" />';
        echo '<input type="hidden" name="days" value="' . esc_attr((string) $days) . '" />';
        echo '<input type="hidden" name="device" value="' . esc_attr((string) $device) . '" />';
        echo '<div style="display:flex; gap:10px; flex-wrap:wrap; align-items:flex-end;">';

        echo '<label style="display:flex; flex-direction:column; gap:4px;">'
            . '<span style="font-size:12px; color:#50575e;">' . esc_html__('Min impressions', 'wp-seo-next') . '</span>'
            . '<input type="number" min="0" step="1" name="opp_min_impr" value="' . esc_attr((string) $filters['min_impr']) . '" style="width:140px;" />'
            . '</label>';

        echo '<label style="display:flex; flex-direction:column; gap:4px;">'
            . '<span style="font-size:12px; color:#50575e;">' . esc_html__('Max CTR (%)', 'wp-seo-next') . '</span>'
            . '<input type="number" min="0" max="100" step="0.1" name="opp_max_ctr" value="' . esc_attr((string) ($filters['max_ctr'] * 100.0)) . '" style="width:120px;" />'
            . '</label>';

        echo '<label style="display:flex; flex-direction:column; gap:4px;">'
            . '<span style="font-size:12px; color:#50575e;">' . esc_html__('Position min', 'wp-seo-next') . '</span>'
            . '<input type="number" min="0" step="0.1" name="opp_pos_min" value="' . esc_attr((string) $filters['pos_min']) . '" style="width:110px;" />'
            . '</label>';

        echo '<label style="display:flex; flex-direction:column; gap:4px;">'
            . '<span style="font-size:12px; color:#50575e;">' . esc_html__('Position max', 'wp-seo-next') . '</span>'
            . '<input type="number" min="0" step="0.1" name="opp_pos_max" value="' . esc_attr((string) $filters['pos_max']) . '" style="width:110px;" />'
            . '</label>';

        echo '<label style="display:flex; flex-direction:column; gap:4px;">'
            . '<span style="font-size:12px; color:#50575e;">' . esc_html__('Target CTR (%)', 'wp-seo-next') . '</span>'
            . '<input type="number" min="0" max="100" step="0.1" name="opp_target_ctr" value="' . esc_attr((string) ($filters['target_ctr'] * 100.0)) . '" style="width:120px;" />'
            . '</label>';

        echo '<label style="display:flex; flex-direction:column; gap:4px;">'
            . '<span style="font-size:12px; color:#50575e;">' . esc_html__('Rows', 'wp-seo-next') . '</span>'
            . '<input type="number" min="5" max="200" step="1" name="opp_limit" value="' . esc_attr((string) $filters['limit']) . '" style="width:90px;" />'
            . '</label>';

        echo '<button type="submit" class="button button-secondary">' . esc_html__('Apply filters', 'wp-seo-next') . '</button>';
        echo '<a class="button" href="' . esc_url($this->pageUrl(['days' => $days, 'device' => $device])) . '">' . esc_html__('Reset', 'wp-seo-next') . '</a>';

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

        $data = $this->getOpportunities($filters, $days, $device);
        if (isset($data['error']) && is_wp_error($data['error'])) {
            echo '<div class="notice notice-warning"><p>' . esc_html($data['error']->get_error_message()) . '</p></div>';
            echo '</div>';
            return;
        }

        $rows = isset($data['rows']) && is_array($data['rows']) ? $data['rows'] : [];
        if ($rows === []) {
            echo '<p class="wpsn-sk-empty">' . esc_html__('No opportunities match your filters yet.', 'wp-seo-next') . '</p>';
            echo '</div>';
            return;
        }

        $returnTo = $this->pageUrl($stateArgs);

        echo '<table class="widefat striped wpsn-sk-table">';
        echo '<thead><tr>';
        echo '<th>' . esc_html__('URL', 'wp-seo-next') . '</th>';
        echo '<th style="width:110px;">' . esc_html__('Impr.', 'wp-seo-next') . '</th>';
        echo '<th style="width:90px;">' . esc_html__('Clicks', 'wp-seo-next') . '</th>';
        echo '<th style="width:90px;">' . esc_html__('CTR', 'wp-seo-next') . '</th>';
        echo '<th style="width:110px;">' . esc_html__('Position', 'wp-seo-next') . '</th>';
        echo '<th style="width:140px;">' . esc_html__('Missing clicks', 'wp-seo-next') . '</th>';
        echo '<th style="width:340px;">' . esc_html__('Actions', 'wp-seo-next') . '</th>';
        echo '</tr></thead>';
        echo '<tbody>';

        foreach ($rows as $r) {
            if (!is_array($r)) {
                continue;
            }

            $url = isset($r['url']) ? (string) $r['url'] : '';
            $label = isset($r['label']) ? (string) $r['label'] : $url;
            $impr = isset($r['impressions']) ? (int) $r['impressions'] : 0;
            $clicks = isset($r['clicks']) ? (int) $r['clicks'] : 0;
            $ctr = isset($r['ctr']) ? (float) $r['ctr'] : 0.0;
            $pos = isset($r['position']) ? (float) $r['position'] : 0.0;
            $missing = isset($r['missing_clicks']) ? (int) $r['missing_clicks'] : 0;

            $detailsUrl = $this->pageUrl(array_merge($stateArgs, ['url' => $url]));
            $inspectUrl = add_query_arg(['wpsn_inspector' => '1'], $url);
            $aiSnippetUrl = add_query_arg([
                'wpsn_inspector' => '1',
                'wpsn_ai_gen' => 'snippet',
                'wpsn_ai_only_empty' => '1',
            ], $url);

            $postId = url_to_postid($url);

            $briefUrl = '';
            $serpUrl = '';
            if ($postId > 0) {
                $briefUrl = wp_nonce_url(
                    admin_url('admin-post.php?action=wpsn_si_opp_brief&url=' . rawurlencode($url) . '&return_to=' . rawurlencode($returnTo)),
                    'wpsn_si_opp_brief'
                );
                $serpUrl = wp_nonce_url(
                    admin_url('admin-post.php?action=wpsn_si_opp_serp_gap&url=' . rawurlencode($url) . '&return_to=' . rawurlencode($returnTo)),
                    'wpsn_si_opp_serp_gap'
                );
            }

            echo '<tr>';
            echo '<td><a href="' . esc_url($detailsUrl) . '"><code>' . esc_html($label) . '</code></a></td>';
            echo '<td>' . esc_html($this->fmtInt($impr)) . '</td>';
            echo '<td>' . esc_html($this->fmtInt($clicks)) . '</td>';
            echo '<td>' . esc_html($this->fmtPct($ctr)) . '</td>';
            echo '<td>' . esc_html($this->fmtPos($pos)) . '</td>';
            echo '<td>' . esc_html($this->fmtInt($missing)) . '</td>';

            echo '<td>';
            echo '<a class="button button-small" href="' . esc_url($inspectUrl) . '" target="_blank" rel="noopener noreferrer">' . esc_html__('Edit SEO', 'wp-seo-next') . '</a> ';
            if (!(bool) $this->settings->get('privacy.hide_ai_ui', false)) {
                echo '<a class="button button-small" href="' . esc_url($aiSnippetUrl) . '" target="_blank" rel="noopener noreferrer">' . esc_html__('AI Snippet', 'wp-seo-next') . '</a> ';
            }
            echo '<a class="button button-small" href="' . esc_url($detailsUrl) . '">' . esc_html__('Details', 'wp-seo-next') . '</a> ';
            if ($briefUrl !== '') {
                echo '<a class="button button-small" href="' . esc_url($briefUrl) . '">' . esc_html__('Brief (top query)', 'wp-seo-next') . '</a> ';
            } else {
                echo '<span class="button button-small disabled" style="opacity:.5; cursor:not-allowed;">' . esc_html__('Brief', 'wp-seo-next') . '</span> ';
            }
            if ($serpUrl !== '') {
                echo '<a class="button button-small" href="' . esc_url($serpUrl) . '">' . esc_html__('SERP Gap (top query)', 'wp-seo-next') . '</a>';
            } else {
                echo '<span class="button button-small disabled" style="opacity:.5; cursor:not-allowed;">' . esc_html__('SERP Gap', 'wp-seo-next') . '</span>';
            }
            echo '</td>';

            echo '</tr>';
        }

        echo '</tbody></table>';

        echo '<p class="description" style="margin-top:10px;">'
            . esc_html__('One-click actions require the URL to map to a WordPress post. For other URLs, open Details and use rules / inspection tools.', 'wp-seo-next')
            . '</p>';

        echo '</div>';
    }

    private function parseDateKey(string $key): string
    {
        $key = trim($key);
        if ($key === '') {
            return '';
        }

        // Search Console uses YYYY-MM-DD.
        if (preg_match('/^\d{4}-\d{2}-\d{2}$/', $key)) {
            return $key;
        }

        // GA sometimes returns YYYYMMDD.
        if (preg_match('/^\d{8}$/', $key)) {
            return substr($key, 0, 4) . '-' . substr($key, 4, 2) . '-' . substr($key, 6, 2);
        }

        return $key;
    }

    private function getSearchClicksTimeseries(int $days = 28, string $device = '')
    {
        $deviceKey = $device !== '' ? $device : 'all';
        $cacheKey = $this->cacheKey('sc_clicks_ts_' . $days . '_' . $deviceKey);
        $cached = get_transient($cacheKey);
        if (is_array($cached) && isset($cached['points'])) {
            return $cached;
        }

        $end = gmdate('Y-m-d', strtotime('-1 day'));
        $start = gmdate('Y-m-d', strtotime('-' . $days . ' days'));

        $dimensions = ['date'];
        $deviceKeyIndex = -1;
        if ($device !== '') {
            $dimensions = ['date', 'device'];
            $deviceKeyIndex = 1;
        }

        $raw = $this->fetchSearchAnalytics($start, $end, $dimensions, 1000);
        if (is_wp_error($raw)) {
            return ['error' => $raw];
        }

        $points = [];
        foreach ($raw as $row) {
            if (!is_array($row)) {
                continue;
            }
            if ($device !== '' && $deviceKeyIndex >= 0) {
                $devKey = isset($row['keys'][$deviceKeyIndex]) ? strtolower((string) $row['keys'][$deviceKeyIndex]) : '';
                if ($devKey !== strtolower($device)) {
                    continue;
                }
            }

            $d = '';
            if (isset($row['keys'][0])) {
                $d = $this->parseDateKey((string) $row['keys'][0]);
            }
            if ($d === '') {
                continue;
            }
            $val = isset($row['clicks']) ? (float) $row['clicks'] : 0.0;
            $points[$d] = isset($points[$d]) ? ((float) $points[$d] + $val) : $val;
        }

        // Fill missing days with 0.
        $filled = [];
        $cursor = strtotime($start);
        $endTs = strtotime($end);
        while ($cursor !== false && $endTs !== false && $cursor <= $endTs) {
            $d = gmdate('Y-m-d', $cursor);
            $filled[] = [
                'date' => $d,
                'value' => isset($points[$d]) ? (float) $points[$d] : 0.0,
            ];
            $cursor = strtotime('+1 day', $cursor);
        }

        $out = [
            'days' => $days,
            'label' => $device !== '' ? ('Clicks (Search Console) · ' . $this->deviceLabel($device)) : 'Clicks (Search Console)',
            'points' => $filled,
        ];

        set_transient($cacheKey, $out, self::CACHE_TTL);
        return $out;
    }

    private function getOrganicSessionsTimeseries(int $days = 28)
    {
        // Best-effort: try GA4 first, then UA. If both fail, return WP_Error.
        $cacheKey = $this->cacheKey('ga_sessions_ts_' . $days);
        $cached = get_transient($cacheKey);
        if (is_array($cached) && isset($cached['points'])) {
            return $cached;
        }

        $end = gmdate('Y-m-d', strtotime('-1 day'));
        $start = gmdate('Y-m-d', strtotime('-' . $days . ' days'));

        // 1) GA4
        $ga4 = $this->siteKitRestGet('/google-site-kit/v1/modules/analytics-4/data/report', [
            'startDate' => $start,
            'endDate' => $end,
            'metrics' => ['sessions'],
            'dimensions' => ['date'],
            // Try to request organic-only; Site Kit may ignore if unsupported.
            'dimensionFilters' => [
                [
                    'dimensionName' => 'sessionDefaultChannelGroup',
                    'operator' => 'EXACT',
                    'expressions' => ['Organic Search'],
                ],
            ],
        ], 'ga4_sessions', self::CACHE_TTL);

        if (!is_wp_error($ga4)) {
            $parsed = $this->parseTimeseriesGeneric($ga4, 'sessions');
            if ($parsed !== null) {
                $out = [
                    'days' => $days,
                    'label' => 'Organic sessions (Analytics)',
                    'points' => $parsed,
                ];
                set_transient($cacheKey, $out, self::CACHE_TTL);
                return $out;
            }
        }

        // 2) UA
        $ua = $this->siteKitRestGet('/google-site-kit/v1/modules/analytics/data/report', [
            'startDate' => $start,
            'endDate' => $end,
            'metrics' => ['sessions'],
            'dimensions' => ['date'],
            'dimensionFilters' => [
                [
                    'dimensionName' => 'ga:channelGrouping',
                    'operator' => 'EXACT',
                    'expressions' => ['Organic Search'],
                ],
            ],
        ], 'ua_sessions', self::CACHE_TTL);

        if (!is_wp_error($ua)) {
            $parsed = $this->parseTimeseriesGeneric($ua, 'sessions');
            if ($parsed !== null) {
                $out = [
                    'days' => $days,
                    'label' => 'Organic sessions (Analytics)',
                    'points' => $parsed,
                ];
                set_transient($cacheKey, $out, self::CACHE_TTL);
                return $out;
            }
        }

        // If GA calls failed, surface one meaningful error.
        $errMsg = is_wp_error($ga4) ? $ga4->get_error_message() : 'Analytics data not available.';
        return new WP_Error('ga_unavailable', $errMsg);
    }

    /**
     * Parse a timeseries from various possible Site Kit response formats.
     * Returns list of [date,value] rows or null if format unknown.
     */
    private function parseTimeseriesGeneric($data, string $metricKey): ?array
    {
        if (!is_array($data)) {
            return null;
        }

        $pointsByDate = [];

        // Format A: { rows: [ { keys: [date], sessions: 123 }, ... ] }
        if (isset($data['rows']) && is_array($data['rows'])) {
            foreach ($data['rows'] as $row) {
                if (!is_array($row)) {
                    continue;
                }
                $date = '';
                if (isset($row['date'])) {
                    $date = $this->parseDateKey((string) $row['date']);
                } elseif (isset($row['keys'][0])) {
                    $date = $this->parseDateKey((string) $row['keys'][0]);
                } elseif (isset($row['dimensions'][0])) {
                    $date = $this->parseDateKey((string) $row['dimensions'][0]);
                }

                if ($date === '') {
                    continue;
                }

                $val = null;
                if (isset($row[$metricKey])) {
                    $val = (float) $row[$metricKey];
                } elseif (isset($row['metrics'][0]['values'][0])) {
                    $val = (float) $row['metrics'][0]['values'][0];
                }

                if ($val === null) {
                    continue;
                }

                $pointsByDate[$date] = $val;
            }
        } else {
            // Format B: [ { keys:[date], sessions:123 }, ... ]
            foreach ($data as $row) {
                if (!is_array($row)) {
                    continue;
                }
                $date = '';
                if (isset($row['date'])) {
                    $date = $this->parseDateKey((string) $row['date']);
                } elseif (isset($row['keys'][0])) {
                    $date = $this->parseDateKey((string) $row['keys'][0]);
                } elseif (isset($row['dimensions'][0])) {
                    $date = $this->parseDateKey((string) $row['dimensions'][0]);
                }
                if ($date === '') {
                    continue;
                }

                if (isset($row[$metricKey])) {
                    $pointsByDate[$date] = (float) $row[$metricKey];
                } elseif (isset($row['metrics'][0]['values'][0])) {
                    $pointsByDate[$date] = (float) $row['metrics'][0]['values'][0];
                }
            }
        }

        if ($pointsByDate === []) {
            return null;
        }

        ksort($pointsByDate);

        $out = [];
        foreach ($pointsByDate as $d => $v) {
            $out[] = [
                'date' => $d,
                'value' => (float) $v,
            ];
        }

        return $out;
    }

    private function renderNotice(string $notice): void
    {
        $map = [
            'sitekit_installed_activated' => ['success', __('Google connector installed and activated.', 'wp-seo-next')],
            'sitekit_installed_activate_failed' => ['warning', __('Google connector installed, but activation failed.', 'wp-seo-next')],
            'sitekit_activated' => ['success', __('Google connector activated.', 'wp-seo-next')],
            'sitekit_activate_failed' => ['error', __('Failed to activate Google connector.', 'wp-seo-next')],
            'sitekit_already_installed' => ['info', __('Google connector is already installed.', 'wp-seo-next')],
            'sitekit_install_needs_filesystem' => ['warning', __('Your server requires filesystem credentials to install plugins. Please install the required Google connector plugin from Plugins → Add New.', 'wp-seo-next')],
            'sitekit_install_failed' => ['error', __('Failed to install the required Google connector plugin. Please install it from Plugins → Add New.', 'wp-seo-next')],
            'sitekit_install_disabled' => ['warning', __('Install helper is disabled by Trust/Privacy policy. Please install the required Google connector plugin from Plugins → Add New.', 'wp-seo-next')],
            'sitekit_activate_disabled' => ['warning', __('Activate helper is disabled by Trust/Privacy policy. Please activate the Google connector plugin from Plugins.', 'wp-seo-next')],
            'sitekit_consent_disabled' => ['warning', __('Consent helper is disabled by Trust/Privacy policy. Please complete consent in Google setup.', 'wp-seo-next')],
            'sitekit_consent_granted' => ['success', __('Connected. WP SEO Next can now read metrics from your Google connection.', 'wp-seo-next')],
            'sitekit_disconnected' => ['success', __('Disconnected.', 'wp-seo-next')],
            'sitekit_cache_cleared' => ['success', __('Cache cleared.', 'wp-seo-next')],

            // URL details quick actions.
            'si_brief_queued' => ['success', __('Content brief generation queued. Open Search Console → Briefs to view the result.', 'wp-seo-next')],
            'si_brief_invalid' => ['error', __('Missing post or query for brief generation.', 'wp-seo-next')],
            'si_brief_permission' => ['error', __('You do not have permission to edit this post.', 'wp-seo-next')],

            'si_serp_queued' => ['success', __('SERP gap analysis queued. Open SERP Gap tool to view the run.', 'wp-seo-next')],
            'si_serp_invalid' => ['error', __('Missing post or query for SERP gap.', 'wp-seo-next')],
            'si_serp_permission' => ['error', __('You do not have permission to edit this post.', 'wp-seo-next')],
            'si_serp_provider_manual' => ['warning', __('SERP provider is set to Manual. Configure a provider in SERP Gap settings first.', 'wp-seo-next')],
            'si_serp_provider_missing_key' => ['warning', __('SERP provider requires an API key. Configure it in SERP Gap settings.', 'wp-seo-next')],

            'si_il_queued' => ['success', __('Internal link suggestions job queued. Open Search Console → Internal Links to review drafts.', 'wp-seo-next')],
            'si_il_invalid' => ['error', __('Missing post for internal links.', 'wp-seo-next')],
            'si_il_permission' => ['error', __('You do not have permission to edit this post.', 'wp-seo-next')],
            'si_il_gsc_disabled' => ['warning', __('Search Console module is disabled. Enable it to generate internal link suggestions.', 'wp-seo-next')],
            'si_il_property_missing' => ['warning', __('Search Console property is not configured. Pick a property in Search Console settings first.', 'wp-seo-next')],

            // Opportunities quick actions.
            'si_opp_brief_queued' => ['success', __('Content brief queued for the top query of this URL. Open Search Console → Briefs to view the result.', 'wp-seo-next')],
            'si_opp_serp_queued' => ['success', __('SERP gap queued for the top query of this URL. Open SERP Gap tool to view the run.', 'wp-seo-next')],
            'si_opp_invalid' => ['error', __('Invalid URL for this action.', 'wp-seo-next')],
            'si_opp_no_post' => ['warning', __('This URL does not map to a WordPress post, so one-click actions are not available. Use Details instead.', 'wp-seo-next')],
            'si_opp_no_query' => ['warning', __('No query data is available for this URL yet (Search Console may hide low-volume queries).', 'wp-seo-next')],
            'si_opp_permission' => ['error', __('You do not have permission to edit this post.', 'wp-seo-next')],
        ];

        if (!isset($map[$notice])) {
            return;
        }

        [$type, $msg] = $map[$notice];

        // Optional debug hint for install failures.
        if ($notice === 'sitekit_install_failed') {
            $hint = get_transient('wpsn_sitekit_last_install_error');
            if (is_string($hint) && $hint !== '') {
                $hint = trim(wp_strip_all_tags($hint));
                if (strlen($hint) > 240) {
                    $hint = substr($hint, 0, 240) . '…';
                }
                $msg .= ' ' . $hint;
            }
        }
        $class = 'notice';
        if ($type === 'success') {
            $class .= ' notice-success';
        } elseif ($type === 'warning') {
            $class .= ' notice-warning';
        } elseif ($type === 'error') {
            $class .= ' notice-error';
        } else {
            $class .= ' notice-info';
        }

        echo '<div class="' . esc_attr($class) . '"><p>' . esc_html($msg) . '</p></div>';
    }

    private function fmtInt(int $n): string
    {
        return number_format_i18n($n);
    }

    private function fmtPct(float $v, int $decimals = 1): string
    {
        return number_format_i18n($v * 100, $decimals) . '%';
    }

    private function fmtDelta(float $pct, int $decimals = 1): string
    {
        $sign = $pct > 0 ? '+' : '';
        return $sign . number_format_i18n($pct, $decimals) . '%';
    }

    private function fmtPos(float $pos): string
    {
        return number_format_i18n($pos, 1);
    }

    private function renderSparkline(array $points): string
    {
        // points: list of [date,value]
        if ($points === []) {
            return '';
        }

        $values = array_map(static function ($p) {
            return isset($p['value']) ? (float) $p['value'] : 0.0;
        }, $points);

        $min = min($values);
        $max = max($values);

        // If all values are identical (often 0 on new sites), draw a centered flat line
        // instead of a line glued to the bottom.
        $flat = abs($max - $min) < 1e-9;
        $range = $flat ? 1.0 : ($max - $min);

        $w = 600;
        $h = 120;
        $pad = 6;

        $n = count($values);
        $dx = $n > 1 ? (($w - 2 * $pad) / ($n - 1)) : 0;

        $coords = [];
        for ($i = 0; $i < $n; $i++) {
            $x = $pad + $i * $dx;
            $norm = $flat ? 0.5 : (($values[$i] - $min) / $range);
            $y = $h - $pad - ($norm * ($h - 2 * $pad));
            $coords[] = number_format($x, 2, '.', '') . ',' . number_format($y, 2, '.', '');
        }

        $poly = implode(' ', $coords);

        // Basic sparkline using current admin color.
        return '<svg class="wpsn-sk-spark" viewBox="0 0 ' . (int) $w . ' ' . (int) $h . '" preserveAspectRatio="none" aria-hidden="true">'
            . '<polyline fill="none" stroke="currentColor" stroke-width="2" points="' . esc_attr($poly) . '" />'
            . '</svg>';
    }

    private function sumPoints(array $points): float
    {
        $sum = 0.0;
        foreach ($points as $p) {
            if (!is_array($p)) {
                continue;
            }
            $sum += isset($p['value']) ? (float) $p['value'] : 0.0;
        }
        return $sum;
    }

    public function render(): void
    {
        if (!Capabilities::canManageTools()) {
            wp_die(esc_html__('You do not have permission to access this page.', 'wp-seo-next'));
        }

        $status = $this->getStatus();
        $notice = isset($_GET['wpsn_notice']) ? sanitize_key((string) wp_unslash($_GET['wpsn_notice'])) : '';

        echo '<div class="wrap wpsn-sitekit">';
        echo '<h1>' . esc_html__('Search Insights', 'wp-seo-next') . '</h1>';
        echo '<p class="description">' . esc_html__('Connect your Google data to view Search Console and Analytics insights inside WP SEO Next.', 'wp-seo-next') . '</p>';

        if ($notice !== '') {
            $this->renderNotice($notice);
        }

        echo '<div class="wpsn-sk-actions-row">';
        echo '<a class="button" href="' . esc_url($this->pageUrl()) . '">' . esc_html__('Refresh page', 'wp-seo-next') . '</a> ';
        if ($status['active']) {
            echo '<a class="button" href="' . esc_url($this->siteKitDashboardUrl()) . '" target="_blank" rel="noopener noreferrer">' . esc_html__('Open Google setup (new tab)', 'wp-seo-next') . '</a> ';
        }
        echo '</div>';

        // Setup card (Yoast-like steps).
        $this->renderSetupCard($status);

        // Insights
        if ($status['installed'] && $status['active'] && $status['setupComplete'] && $status['consent']) {
            $this->renderInsights();
        } else {
            echo '<div class="notice notice-info"><p>'
                . esc_html__('Finish the steps above to view insights.', 'wp-seo-next')
                . '</p></div>';
        }

        echo '</div>';
    }

    private function renderSetupCard(array $status): void
    {
        $installed = (bool) ($status['installed'] ?? false);
        $active = (bool) ($status['active'] ?? false);
        $setup = (bool) ($status['setupComplete'] ?? false);
        $consent = (bool) ($status['consent'] ?? false);
        $setupHint = isset($status['setupHint']) ? (string) $status['setupHint'] : '';
        $disableInstallHelper = (bool) $this->settings->get('privacy.disable_sitekit_install_helper', false);

        echo '<div class="wpsn-sk-card">';
        echo '<h2>' . esc_html__('Connection', 'wp-seo-next') . '</h2>';
        echo '<p class="description">' . esc_html__('This flow is designed to be similar to Yoast: install → activate → set up → connect.', 'wp-seo-next') . '</p>';

        // Visual progress indicator (Yoast-like).
        echo '<div class="wpsn-sk-progress" aria-hidden="true">';
        $prog = [
            [__('Install', 'wp-seo-next'), $installed],
            [__('Activate', 'wp-seo-next'), $active],
            [__('Set up', 'wp-seo-next'), $setup],
            [__('Connect', 'wp-seo-next'), $consent],
        ];
        foreach ($prog as $p) {
            [$label, $done] = $p;
            $cls = $done ? 'done' : 'todo';
            echo '<div class="skp-step ' . esc_attr($cls) . '">'
                . '<span class="dot"></span>'
                . '<span class="lbl">' . esc_html($label) . '</span>'
                . '</div>';
        }
        echo '</div>';

        echo '<ol class="wpsn-sk-steps">';

        // Step 1: Install
        echo '<li class="' . esc_attr($installed ? 'done' : 'todo') . '">';
        echo '<div class="step-title">' . esc_html__('1. Install Google Site Kit plugin', 'wp-seo-next') . '</div>';
        if ($installed) {
            echo '<div class="step-status">' . esc_html__('Installed', 'wp-seo-next') . '</div>';
        } else {
            echo '<div class="step-status">' . esc_html__('Not installed', 'wp-seo-next') . '</div>';
            if ($disableInstallHelper) {
                echo '<p class="description">' . esc_html__('Install helper is disabled by Trust/Privacy policy. Install the required Google connector plugin from Plugins → Add New.', 'wp-seo-next') . '</p>';
            } else {
                echo '<form method="post" action="' . esc_url(admin_url('admin-post.php')) . '">';
                echo '<input type="hidden" name="action" value="wpsn_sitekit_install" />';
                wp_nonce_field('wpsn_sitekit_install');
                submit_button(__('Install & Activate', 'wp-seo-next'), 'primary', 'submit', false);
                echo '</form>';
            }
        }
        echo '</li>';

        // Step 2: Activate
        echo '<li class="' . esc_attr($active ? 'done' : 'todo') . '">';
        echo '<div class="step-title">' . esc_html__('2. Activate connector', 'wp-seo-next') . '</div>';
        if (!$installed) {
            echo '<div class="step-status">' . esc_html__('Install first', 'wp-seo-next') . '</div>';
        } elseif ($active) {
            echo '<div class="step-status">' . esc_html__('Active', 'wp-seo-next') . '</div>';
        } else {
            echo '<div class="step-status">' . esc_html__('Inactive', 'wp-seo-next') . '</div>';
            if ($disableInstallHelper) {
                echo '<p class="description">' . esc_html__('Activate helper is disabled by Trust/Privacy policy. Activate the Google connector plugin from Plugins.', 'wp-seo-next') . '</p>';
            } else {
                echo '<form method="post" action="' . esc_url(admin_url('admin-post.php')) . '">';
                echo '<input type="hidden" name="action" value="wpsn_sitekit_activate" />';
                wp_nonce_field('wpsn_sitekit_activate');
                submit_button(__('Activate', 'wp-seo-next'), 'primary', 'submit', false);
                echo '</form>';
            }
        }
        echo '</li>';

        // Step 3: Setup
        echo '<li class="' . esc_attr($setup ? 'done' : 'todo') . '">';
        echo '<div class="step-title">' . esc_html__('3. Google setup', 'wp-seo-next') . '</div>';
        if (!$active) {
            echo '<div class="step-status">' . esc_html__('Activate first', 'wp-seo-next') . '</div>';
        } elseif ($setup) {
            echo '<div class="step-status">' . esc_html__('Setup complete', 'wp-seo-next') . '</div>';
        } else {
            echo '<div class="step-status">' . esc_html__('Needs setup', 'wp-seo-next') . '</div>';
            echo '<p class="description">' . esc_html__('Click to open Google setup. Complete authorization and return here.', 'wp-seo-next') . '</p>';
            echo '<a class="button button-primary wpsn-sk-popup" data-wpsn-popup="1" href="' . esc_url($this->siteKitDashboardUrl()) . '">' . esc_html__('Open setup', 'wp-seo-next') . '</a>';
            if ($setupHint !== '') {
                echo '<p class="description"><strong>' . esc_html__('Hint:', 'wp-seo-next') . '</strong> ' . esc_html($setupHint) . '</p>';
            }
        }
        echo '</li>';

        // Step 4: Connect (consent)
        echo '<li class="' . esc_attr($consent ? 'done' : 'todo') . '">';
        echo '<div class="step-title">' . esc_html__('4. Grant access to WP SEO Next', 'wp-seo-next') . '</div>';
        if (!$setup) {
            echo '<div class="step-status">' . esc_html__('Finish setup first', 'wp-seo-next') . '</div>';
        } elseif ($consent) {
            echo '<div class="step-status">' . esc_html__('Connected', 'wp-seo-next') . '</div>';
            echo '<div class="step-actions">';
            echo '<form method="post" action="' . esc_url(admin_url('admin-post.php')) . '" style="display:inline-block;margin-right:8px;">';
            echo '<input type="hidden" name="action" value="wpsn_sitekit_clear_cache" />';
            wp_nonce_field('wpsn_sitekit_clear_cache');
            submit_button(__('Refresh data', 'wp-seo-next'), 'secondary', 'submit', false);
            echo '</form>';
            echo '<form method="post" action="' . esc_url(admin_url('admin-post.php')) . '" style="display:inline-block;">';
            echo '<input type="hidden" name="action" value="wpsn_sitekit_disconnect" />';
            wp_nonce_field('wpsn_sitekit_disconnect');
            submit_button(__('Disconnect', 'wp-seo-next'), 'delete', 'submit', false);
            echo '</form>';
            echo '</div>';
        } else {
            echo '<div class="step-status">' . esc_html__('Not connected', 'wp-seo-next') . '</div>';
            echo '<p class="description">' . esc_html__('Grant consent so WP SEO Next can read your Google metrics and show them here.', 'wp-seo-next') . '</p>';
            if ($disableInstallHelper) {
                echo '<p class="description">' . esc_html__('Consent helper is disabled by Trust/Privacy policy. Please complete consent in Google setup.', 'wp-seo-next') . '</p>';
            } else {
                echo '<form method="post" action="' . esc_url(admin_url('admin-post.php')) . '">';
                echo '<input type="hidden" name="action" value="wpsn_sitekit_consent" />';
                wp_nonce_field('wpsn_sitekit_consent');
                submit_button(__('Grant consent', 'wp-seo-next'), 'primary', 'submit', false);
                echo '</form>';
            }
        }
        echo '</li>';

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

    private function renderInsights(): void
    {
        echo '<hr />';
        echo '<h2>' . esc_html__('Insights', 'wp-seo-next') . '</h2>';
        echo '<p class="description">' . esc_html__('These widgets read data from your Google connection and do not store it permanently.', 'wp-seo-next') . '</p>';

        $selectedUrl = $this->selectedUrl();
        $days = $this->selectedDays();
        $device = $this->selectedDevice();

        // Filters (non-AJAX).
        echo '<form method="get" action="' . esc_url(admin_url('tools.php')) . '" style="margin: 12px 0 14px;">';
        echo '<input type="hidden" name="page" value="' . esc_attr(self::PAGE_SLUG) . '" />';
        if ($selectedUrl !== '') {
            echo '<input type="hidden" name="url" value="' . esc_attr($selectedUrl) . '" />';
        }
        echo '<div style="display:flex; gap:10px; flex-wrap:wrap; align-items:flex-end;">';

        echo '<label style="display:flex; flex-direction:column; gap:4px;">'
            . '<span style="font-size:12px; color:#50575e;">' . esc_html__('Period', 'wp-seo-next') . '</span>'
            . '<select name="days" style="width:160px;">'
            . '<option value="7"' . selected($days, 7, false) . '>' . esc_html__('Last 7 days', 'wp-seo-next') . '</option>'
            . '<option value="28"' . selected($days, 28, false) . '>' . esc_html__('Last 28 days', 'wp-seo-next') . '</option>'
            . '<option value="90"' . selected($days, 90, false) . '>' . esc_html__('Last 90 days', 'wp-seo-next') . '</option>'
            . '</select>'
            . '</label>';

        echo '<label style="display:flex; flex-direction:column; gap:4px;">'
            . '<span style="font-size:12px; color:#50575e;">' . esc_html__('Device', 'wp-seo-next') . '</span>'
            . '<select name="device" style="width:160px;">'
            . '<option value=""' . selected($device, '', false) . '>' . esc_html__('All devices', 'wp-seo-next') . '</option>'
            . '<option value="desktop"' . selected($device, 'desktop', false) . '>' . esc_html__('Desktop', 'wp-seo-next') . '</option>'
            . '<option value="mobile"' . selected($device, 'mobile', false) . '>' . esc_html__('Mobile', 'wp-seo-next') . '</option>'
            . '<option value="tablet"' . selected($device, 'tablet', false) . '>' . esc_html__('Tablet', 'wp-seo-next') . '</option>'
            . '</select>'
            . '</label>';

        echo '<button type="submit" class="button button-secondary">' . esc_html__('Apply', 'wp-seo-next') . '</button>';
        echo '<a class="button" href="' . esc_url($this->pageUrl()) . '">' . esc_html__('Reset', 'wp-seo-next') . '</a>';

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

        // 1) Search summary
        $summary = $this->getSearchConsoleSummary($days, $device);
        if (isset($summary['error']) && is_wp_error($summary['error'])) {
            $err = $summary['error'];
            echo '<div class="notice notice-warning"><p>'
                . esc_html__('Search Console data is not available yet. Please finish Google setup and make sure Search Console is connected.', 'wp-seo-next')
                . ' ' . esc_html($err->get_error_message())
                . '</p></div>';
            return;
        }

        $cur = $summary['current'];
        $delta = $summary['delta'];

        if ($selectedUrl !== '') {
            $this->renderUrlDetails($selectedUrl, $days, $device);
        }

        echo '<div class="wpsn-sk-grid">';

        echo '<div class="wpsn-sk-widget">';
        $perfTitle = sprintf(
            /* translators: %d: days */
            __('Search performance (last %d days)', 'wp-seo-next'),
            $days
        );
        if ($device !== '') {
            $perfTitle .= ' · ' . $this->deviceLabel($device);
        }
        echo '<h3>' . esc_html($perfTitle) . '</h3>';
        echo '<div class="wpsn-sk-metrics">';

        $this->renderMetricBox(__('Impressions', 'wp-seo-next'), $this->fmtInt((int) $cur['impressions']), $this->fmtDelta((float) $delta['impressions']));
        $this->renderMetricBox(__('Clicks', 'wp-seo-next'), $this->fmtInt((int) $cur['clicks']), $this->fmtDelta((float) $delta['clicks']));
        $this->renderMetricBox(__('CTR', 'wp-seo-next'), $this->fmtPct((float) $cur['ctr']), $this->fmtDelta((float) $delta['ctr']));

        // For position, negative delta is actually "better". We still show the raw delta.
        $this->renderMetricBox(__('Avg position', 'wp-seo-next'), $this->fmtPos((float) $cur['position']), $this->fmtDelta((float) $delta['position']));

        echo '</div>'; // metrics
        $cmp = sprintf(
            /* translators: %d: days */
            __('Compared to previous %d days.', 'wp-seo-next'),
            $days
        );
        if ($device !== '') {
            $cmp .= ' ' . sprintf(
                /* translators: %s: device label */
                __('Device filter: %s.', 'wp-seo-next'),
                $this->deviceLabel($device)
            );
        }
        echo '<p class="description">' . esc_html($cmp) . '</p>';
        echo '</div>'; // widget

        // 2) Sessions chart (GA) with fallback to clicks chart.
        echo '<div class="wpsn-sk-widget">';
        $sessTitle = sprintf(
            /* translators: %d: days */
            __('Organic sessions (last %d days)', 'wp-seo-next'),
            $days
        );
        echo '<h3>' . esc_html($sessTitle) . '</h3>';

        $sessions = $this->getOrganicSessionsTimeseries($days);
        if (is_wp_error($sessions)) {
            // Fallback to Search Console clicks.
            $clicksTs = $this->getSearchClicksTimeseries($days, $device);
            if (isset($clicksTs['error']) && is_wp_error($clicksTs['error'])) {
                echo '<div class="notice notice-warning"><p>'
                    . esc_html__('Analytics data is not available. Connect Analytics in Google setup to see sessions.', 'wp-seo-next')
                    . '</p></div>';
            } else {
                $total = $this->sumPoints($clicksTs['points']);
                echo '<div class="wpsn-sk-big"><div class="num">' . esc_html($this->fmtInt((int) round($total))) . '</div><div class="label">' . esc_html__('Clicks (Search Console)', 'wp-seo-next') . '</div></div>';
                echo '<p class="description">' . esc_html__('Analytics is not connected. Showing Search Console clicks as a fallback chart.', 'wp-seo-next') . '</p>';
                echo '<div class="wpsn-sk-chart">' . $this->renderSparkline($clicksTs['points']) . '</div>';

                // Helpful shortcut.
                echo '<p class="description"><a href="' . esc_url($this->siteKitDashboardUrl()) . '" target="_blank" rel="noopener noreferrer">'
                    . esc_html__('Connect Analytics in Google setup', 'wp-seo-next')
                    . '</a></p>';
            }
        } else {
            $total = $this->sumPoints($sessions['points']);
            echo '<div class="wpsn-sk-big"><div class="num">' . esc_html($this->fmtInt((int) round($total))) . '</div><div class="label">' . esc_html__('Sessions (Analytics)', 'wp-seo-next') . '</div></div>';
            echo '<div class="wpsn-sk-chart">' . $this->renderSparkline($sessions['points']) . '</div>';
        }

        echo '</div>'; // widget

        // 3) Top queries
        echo '<div class="wpsn-sk-widget">';
        $qTitle = sprintf(
            /* translators: %d: days */
            __('Top queries (last %d days)', 'wp-seo-next'),
            $days
        );
        if ($device !== '') {
            $qTitle .= ' · ' . $this->deviceLabel($device);
        }
        echo '<h3>' . esc_html($qTitle) . '</h3>';
        $queries = $this->getTopQueries($days, 5, $device);
        if (isset($queries['error']) && is_wp_error($queries['error'])) {
            echo '<div class="notice notice-warning"><p>' . esc_html($queries['error']->get_error_message()) . '</p></div>';
        } else {
            $this->renderTopQueriesTable($queries['rows']);
        }
        echo '</div>'; // widget

        // 4) Top content
        echo '<div class="wpsn-sk-widget">';
        $pTitle = sprintf(
            /* translators: %d: days */
            __('Top content (last %d days)', 'wp-seo-next'),
            $days
        );
        if ($device !== '') {
            $pTitle .= ' · ' . $this->deviceLabel($device);
        }
        echo '<h3>' . esc_html($pTitle) . '</h3>';
        $pages = $this->getTopPages($days, 5, $device);
        if (isset($pages['error']) && is_wp_error($pages['error'])) {
            echo '<div class="notice notice-warning"><p>' . esc_html($pages['error']->get_error_message()) . '</p></div>';
        } else {
            $this->renderTopPagesTable($pages['rows'], $selectedUrl);
        }
        echo '</div>'; // widget

        echo '</div>'; // grid

        // Opportunities are most useful on the overview screen.
        if ($selectedUrl === '') {
            $this->renderOpportunities($days, $device);
        }
    }

    private function selectedUrl(): string
    {
        if (!isset($_GET['url'])) {
            return '';
        }
        $raw = (string) wp_unslash($_GET['url']);
        $raw = trim($raw);
        if ($raw === '') {
            return '';
        }

        $url = esc_url_raw($raw);
        if ($url === '' || !preg_match('#^https?://#i', $url)) {
            return '';
        }

        return $url;
    }

    private function selectedDays(): int
    {
        $days = isset($_GET['days']) ? absint((string) wp_unslash($_GET['days'])) : 28;
        $allowed = [7, 28, 90];
        if (!in_array($days, $allowed, true)) {
            $days = 28;
        }
        return $days;
    }

    private function selectedDevice(): string
    {
        $device = isset($_GET['device']) ? sanitize_key((string) wp_unslash($_GET['device'])) : '';
        $allowed = ['', 'desktop', 'mobile', 'tablet'];
        if (!in_array($device, $allowed, true)) {
            $device = '';
        }
        return $device;
    }

    private function deviceLabel(string $device): string
    {
        $device = strtolower(trim($device));
        if ($device === 'desktop') {
            return __('Desktop', 'wp-seo-next');
        }
        if ($device === 'mobile') {
            return __('Mobile', 'wp-seo-next');
        }
        if ($device === 'tablet') {
            return __('Tablet', 'wp-seo-next');
        }
        return __('All devices', 'wp-seo-next');
    }

    /**
     * Current insights filter args (days/device) for URL building.
     *
     * @return array{days:int,device:string}
     */
    private function insightsFilterArgs(): array
    {
        return [
            'days' => $this->selectedDays(),
            'device' => $this->selectedDevice(),
        ];
    }

    private function getSearchConsoleUrlSummary(string $url, int $days = 28, string $device = ''): array
    {
        $hash = md5($url);
        $deviceKey = $device !== '' ? $device : 'all';
        $cacheKey = $this->cacheKey('sc_url_summary_' . $hash . '_' . $days . '_' . $deviceKey);
        $cached = get_transient($cacheKey);
        if (is_array($cached) && isset($cached['current'], $cached['delta'])) {
            return $cached;
        }

        $end = gmdate('Y-m-d', strtotime('-1 day'));
        $start = gmdate('Y-m-d', strtotime('-' . $days . ' days'));

        $prevEnd = gmdate('Y-m-d', strtotime('-' . ($days + 1) . ' days'));
        $prevStart = gmdate('Y-m-d', strtotime('-' . (2 * $days) . ' days'));

        $dimensions = [];
        if ($device !== '') {
            $dimensions = ['device'];
        }

        $rawCur = $this->fetchSearchAnalytics($start, $end, $dimensions, 1000, $url);
        if (is_wp_error($rawCur)) {
            return ['error' => $rawCur];
        }

        $rawPrev = $this->fetchSearchAnalytics($prevStart, $prevEnd, $dimensions, 1000, $url);
        if (is_wp_error($rawPrev)) {
            return ['error' => $rawPrev];
        }

        if ($device !== '') {
            $rawCur = $this->filterRowsByDeviceKey($rawCur, 0, $device);
            $rawPrev = $this->filterRowsByDeviceKey($rawPrev, 0, $device);
        }

        $cur = $this->parseTotals($rawCur);
        $prev = $this->parseTotals($rawPrev);

        $delta = [
            'clicks' => $this->computeDelta((float) $cur['clicks'], (float) $prev['clicks']),
            'impressions' => $this->computeDelta((float) $cur['impressions'], (float) $prev['impressions']),
            'ctr' => $this->computeDelta((float) $cur['ctr'], (float) $prev['ctr']),
            'position' => $this->computeDelta((float) $cur['position'], (float) $prev['position']),
        ];

        $out = ['current' => $cur, 'delta' => $delta];
        set_transient($cacheKey, $out, 10 * MINUTE_IN_SECONDS);

        return $out;
    }

    private function getTopQueriesForUrl(string $url, int $days = 28, int $limit = 20, string $device = ''): array
    {
        $hash = md5($url);
        $deviceKey = $device !== '' ? $device : 'all';
        $cacheKey = $this->cacheKey('sc_url_queries_' . $hash . '_' . $days . '_' . $deviceKey . '_' . $limit);
        $cached = get_transient($cacheKey);
        if (is_array($cached) && isset($cached['rows'])) {
            return $cached;
        }

        $end = gmdate('Y-m-d', strtotime('-1 day'));
        $start = gmdate('Y-m-d', strtotime('-' . $days . ' days'));

        $dimensions = ['query'];
        $rowLimit = $limit;
        if ($device !== '') {
            $dimensions = ['query', 'device'];
            $rowLimit = max(1000, $limit * 50);
        }

        $raw = $this->fetchSearchAnalytics($start, $end, $dimensions, $rowLimit, $url);
        if (is_wp_error($raw)) {
            return ['error' => $raw];
        }

        // Aggregate by query (and filter by device if requested).
        $agg = [];
        foreach ($raw as $row) {
            if (!is_array($row)) {
                continue;
            }
            $q = isset($row['keys'][0]) ? (string) $row['keys'][0] : '';
            $q = trim($q);
            if ($q === '') {
                continue;
            }

            if ($device !== '') {
                $devKey = isset($row['keys'][1]) ? strtolower((string) $row['keys'][1]) : '';
                if ($devKey !== strtolower($device)) {
                    continue;
                }
            }

            $clicks = isset($row['clicks']) ? (float) $row['clicks'] : 0.0;
            $impr = isset($row['impressions']) ? (float) $row['impressions'] : 0.0;
            $pos = isset($row['position']) ? (float) $row['position'] : 0.0;

            if (!isset($agg[$q])) {
                $agg[$q] = [
                    'query' => $q,
                    'clicks' => 0.0,
                    'impressions' => 0.0,
                    'pos_weighted' => 0.0,
                ];
            }

            $agg[$q]['clicks'] += $clicks;
            $agg[$q]['impressions'] += $impr;
            $agg[$q]['pos_weighted'] += ($pos * $impr);
        }

        $rows = [];
        foreach ($agg as $q => $a) {
            $impr = (float) ($a['impressions'] ?? 0.0);
            $clicks = (float) ($a['clicks'] ?? 0.0);
            $ctr = $impr > 0.0 ? ($clicks / $impr) : 0.0;
            $pos = $impr > 0.0 ? ((float) ($a['pos_weighted'] ?? 0.0) / $impr) : 0.0;

            $rows[] = [
                'query' => (string) ($a['query'] ?? $q),
                'clicks' => (int) round($clicks),
                'impressions' => (int) round($impr),
                'ctr' => $ctr,
                'position' => $pos,
            ];
        }

        usort($rows, static function ($a, $b) {
            $ca = isset($a['clicks']) ? (int) $a['clicks'] : 0;
            $cb = isset($b['clicks']) ? (int) $b['clicks'] : 0;
            if ($ca !== $cb) {
                return $cb <=> $ca;
            }
            $ia = isset($a['impressions']) ? (int) $a['impressions'] : 0;
            $ib = isset($b['impressions']) ? (int) $b['impressions'] : 0;
            return $ib <=> $ia;
        });

        $rows = array_slice($rows, 0, $limit);

        $out = ['rows' => $rows];
        set_transient($cacheKey, $out, 10 * MINUTE_IN_SECONDS);

        return $out;
    }

    private function renderUrlDetails(string $url, int $days = 28, string $device = ''): void
    {
        echo '<div class="wpsn-sk-card">';
        echo '<h2>' . esc_html__('URL details', 'wp-seo-next') . '</h2>';
        $desc = sprintf(
            /* translators: %d: days */
            __('Showing Search Console metrics for a single URL (last %d days).', 'wp-seo-next'),
            $days
        );
        if ($device !== '') {
            $desc .= ' ' . sprintf(
                /* translators: %s: device label */
                __('Device filter: %s.', 'wp-seo-next'),
                $this->deviceLabel($device)
            );
        }
        echo '<p class="description">' . esc_html($desc) . '</p>';

        echo '<p><code>' . esc_html($url) . '</code></p>';
        echo '<p><a href="' . esc_url($this->pageUrl($this->insightsFilterArgs())) . '">&larr; ' . esc_html__('Back to overview', 'wp-seo-next') . '</a></p>';

        echo '<div class="wpsn-sk-actions">';
        echo '<a class="button" href="' . esc_url($url) . '" target="_blank" rel="noopener noreferrer">' . esc_html__('Open URL', 'wp-seo-next') . '</a>';

        $postId = url_to_postid($url);
        if ($postId > 0) {
            $edit = get_edit_post_link($postId, '');
            if ($edit) {
                echo '<a class="button" href="' . esc_url($edit) . '">' . esc_html__('Edit', 'wp-seo-next') . '</a>';
            }
        }

        if ($this->settings->moduleEnabled('drift_monitor', false)) {
            echo '<form method="post" action="' . esc_url(admin_url('admin-post.php')) . '" style="display:inline-block; margin-left:8px;">';
            echo '<input type="hidden" name="action" value="wpsn_drift_add_url" />';
            echo '<input type="hidden" name="url" value="' . esc_attr($url) . '" />';
            echo '<input type="hidden" name="label" value="' . esc_attr($this->shortenUrl($url)) . '" />';
            wp_nonce_field('wpsn_drift_add_url', 'wpsn_nonce');
            submit_button(__('Track in Drift Monitor', 'wp-seo-next'), 'secondary', 'submit', false);
            echo '</form>';
        }

        if ($this->settings->moduleEnabled('robots', false)) {
            $path = wp_parse_url($url, PHP_URL_PATH);
            $path = is_string($path) ? $path : '';
            $path = '/' . ltrim($path, '/');
            $regex = '^' . preg_quote($path, '#') . '/?$';
            echo '<form method="post" action="' . esc_url(admin_url('admin-post.php')) . '" style="display:inline-block; margin-left:8px;">';
            echo '<input type="hidden" name="action" value="wpsn_url_rules_add" />';
            echo '<input type="hidden" name="match" value="path_regex" />';
            echo '<input type="hidden" name="pattern" value="' . esc_attr($regex) . '" />';
            echo '<input type="hidden" name="action_type" value="noindex" />';
            echo '<input type="hidden" name="note" value="' . esc_attr__('Added from Search Insights URL details', 'wp-seo-next') . '" />';
            wp_nonce_field('wpsn_url_rules_add');
            submit_button(__('Add noindex rule', 'wp-seo-next'), 'secondary', 'submit', false);
            echo '</form>';
        }

        // Internal links draft suggestions (queue-first).
        if ($postId > 0 && $this->settings->moduleEnabled('search_console', false)) {
            $returnTo = $this->pageUrl(array_merge($this->insightsFilterArgs(), ['url' => $url]));
            $ilUrl = wp_nonce_url(
                admin_url('admin-post.php?action=wpsn_si_generate_il&post_id=' . $postId . '&return_to=' . rawurlencode($returnTo)),
                'wpsn_si_generate_il'
            );
            echo '<a class="button" href="' . esc_url($ilUrl) . '" style="margin-left:8px;">' . esc_html__('Generate internal link drafts', 'wp-seo-next') . '</a>';
        }

        echo '</div>';

        $sum = $this->getSearchConsoleUrlSummary($url, $days, $device);
        if (isset($sum['error']) && is_wp_error($sum['error'])) {
            echo '<div class="notice notice-warning"><p>' . esc_html($sum['error']->get_error_message()) . '</p></div>';
            echo '</div>';
            return;
        }

        $cur = $sum['current'];
        $delta = $sum['delta'];

        echo '<div class="wpsn-sk-metrics" style="margin-top:12px;">';
        $this->renderMetricBox(__('Impressions', 'wp-seo-next'), $this->fmtInt((int) $cur['impressions']), $this->fmtDelta((float) $delta['impressions']));
        $this->renderMetricBox(__('Clicks', 'wp-seo-next'), $this->fmtInt((int) $cur['clicks']), $this->fmtDelta((float) $delta['clicks']));
        $this->renderMetricBox(__('CTR', 'wp-seo-next'), $this->fmtPct((float) $cur['ctr']), $this->fmtDelta((float) $delta['ctr']));
        $this->renderMetricBox(__('Avg position', 'wp-seo-next'), $this->fmtPos((float) $cur['position']), $this->fmtDelta((float) $delta['position']));
        echo '</div>';

        $cmp = sprintf(
            /* translators: %d: days */
            __('Compared to previous %d days.', 'wp-seo-next'),
            $days
        );
        if ($device !== '') {
            $cmp .= ' ' . sprintf(
                /* translators: %s: device label */
                __('Device filter: %s.', 'wp-seo-next'),
                $this->deviceLabel($device)
            );
        }
        echo '<p class="description">' . esc_html($cmp) . '</p>';

        $qTitle = sprintf(
            /* translators: %d: days */
            __('Top queries for this URL (last %d days)', 'wp-seo-next'),
            $days
        );
        if ($device !== '') {
            $qTitle .= ' · ' . $this->deviceLabel($device);
        }
        echo '<h3 style="margin-top:16px;">' . esc_html($qTitle) . '</h3>';
        $queries = $this->getTopQueriesForUrl($url, $days, 20, $device);
        if (isset($queries['error']) && is_wp_error($queries['error'])) {
            echo '<div class="notice notice-warning"><p>' . esc_html($queries['error']->get_error_message()) . '</p></div>';
        } else {
            $this->renderTopQueriesTable($queries['rows'], $postId, $url);
        }

        echo '</div>';
    }

    private function renderMetricBox(string $label, string $value, string $delta): void
    {
        echo '<div class="wpsn-sk-metric">';
        echo '<div class="metric-label">' . esc_html($label) . '</div>';
        echo '<div class="metric-value">' . esc_html($value) . '</div>';
        echo '<div class="metric-delta">' . esc_html($delta) . '</div>';
        echo '</div>';
    }

    private function renderTopQueriesTable(array $rows, int $postId = 0, string $url = ''): void
    {
        if ($rows === []) {
            echo '<p class="wpsn-sk-empty">' . esc_html__('No query data to display yet. Your site may need more impressions (Search Console can hide low‑volume queries).', 'wp-seo-next') . '</p>';
            return;
        }

        $canPostActions = ($postId > 0) && current_user_can('edit_post', $postId);
        $returnArgs = $this->insightsFilterArgs();
        if ($url !== '') {
            $returnArgs['url'] = $url;
        }
        $returnTo = $this->pageUrl($returnArgs);

        // Is SERP Gap usable for one-click run?
        $serpProviderId = (string) $this->settings->get('serp_gap.provider_id', 'manual');
        $serpProviderId = $serpProviderId !== '' ? $serpProviderId : 'manual';
        $serpReady = $serpProviderId !== 'manual';
        if ($serpProviderId === 'serper') {
            $serpReady = (new \WPSN\Modules\SerpGap\SerpSecretsRepository())->hasSerperKey();
        }
        $serpConfigUrl = admin_url('tools.php?page=' . \WPSN\Modules\SerpGap\Admin\SerpGapAdmin::PAGE_SLUG);

        echo '<table class="widefat striped">';
        echo '<thead><tr>';
        echo '<th>' . esc_html__('Query', 'wp-seo-next') . '</th>';
        echo '<th>' . esc_html__('Clicks', 'wp-seo-next') . '</th>';
        echo '<th>' . esc_html__('Impressions', 'wp-seo-next') . '</th>';
        echo '<th>' . esc_html__('CTR', 'wp-seo-next') . '</th>';
        echo '<th>' . esc_html__('Position', 'wp-seo-next') . '</th>';
        echo '<th>' . esc_html__('Actions', 'wp-seo-next') . '</th>';
        echo '</tr></thead>';
        echo '<tbody>';
        foreach ($rows as $r) {
            $q = isset($r['query']) ? (string) $r['query'] : '';
            echo '<tr>';
            echo '<td>' . esc_html($q) . '</td>';
            echo '<td>' . esc_html($this->fmtInt((int) ($r['clicks'] ?? 0))) . '</td>';
            echo '<td>' . esc_html($this->fmtInt((int) ($r['impressions'] ?? 0))) . '</td>';
            echo '<td>' . esc_html($this->fmtPct((float) ($r['ctr'] ?? 0.0))) . '</td>';
            echo '<td>' . esc_html($this->fmtPos((float) ($r['position'] ?? 0.0))) . '</td>';

            $actions = [];
            if ($canPostActions && $q !== '') {
                $briefUrl = wp_nonce_url(
                    admin_url('admin-post.php?action=wpsn_si_brief_queue&post_id=' . $postId . '&query=' . rawurlencode($q) . '&return_to=' . rawurlencode($returnTo)),
                    'wpsn_si_brief_queue'
                );
                $actions[] = '<a href="' . esc_url($briefUrl) . '">' . esc_html__('Brief', 'wp-seo-next') . '</a>';

                if ($serpReady) {
                    $sgUrl = wp_nonce_url(
                        admin_url('admin-post.php?action=wpsn_si_serp_gap_queue&post_id=' . $postId . '&query=' . rawurlencode($q) . '&return_to=' . rawurlencode($returnTo)),
                        'wpsn_si_serp_gap_queue'
                    );
                    $actions[] = '<a href="' . esc_url($sgUrl) . '">' . esc_html__('SERP Gap', 'wp-seo-next') . '</a>';
                } else {
                    $actions[] = '<a href="' . esc_url($serpConfigUrl) . '">' . esc_html__('Configure SERP', 'wp-seo-next') . '</a>';
                }
            } else {
                $actions[] = '—';
            }
            echo '<td>' . implode(' | ', $actions) . '</td>';
            echo '</tr>';
        }
        echo '</tbody>';
        echo '</table>';
    }

    private function renderTopPagesTable(array $rows, string $selectedUrl = ''): void
    {
        if ($rows === []) {
            echo '<p class="wpsn-sk-empty">' . esc_html__('No pages to display yet.', 'wp-seo-next') . '</p>';
            return;
        }

        echo '<table class="widefat striped">';
        echo '<thead><tr>';
        echo '<th>' . esc_html__('Page', 'wp-seo-next') . '</th>';
        echo '<th>' . esc_html__('Clicks', 'wp-seo-next') . '</th>';
        echo '<th>' . esc_html__('Impressions', 'wp-seo-next') . '</th>';
        echo '<th>' . esc_html__('CTR', 'wp-seo-next') . '</th>';
        echo '<th>' . esc_html__('Position', 'wp-seo-next') . '</th>';
        echo '<th>' . esc_html__('Actions', 'wp-seo-next') . '</th>';
        echo '</tr></thead>';
        echo '<tbody>';
        $selectedUrl = trim($selectedUrl);
        foreach ($rows as $r) {
            $url = isset($r['url']) ? (string) $r['url'] : '';
            $label = isset($r['label']) ? (string) $r['label'] : $url;

            $actions = [];
            if ($url !== '') {
                $actions[] = '<a href="' . esc_url($this->pageUrl(array_merge($this->insightsFilterArgs(), ['url' => $url]))) . '">' . esc_html__('Details', 'wp-seo-next') . '</a>';
                $actions[] = '<a href="' . esc_url($url) . '" target="_blank" rel="noopener noreferrer">' . esc_html__('Open', 'wp-seo-next') . '</a>';

                $postId = function_exists('url_to_postid') ? (int) url_to_postid($url) : 0;
                if ($postId > 0) {
                    $edit = get_edit_post_link($postId);
                    if (is_string($edit) && $edit !== '') {
                        $actions[] = '<a href="' . esc_url($edit) . '">' . esc_html__('Edit', 'wp-seo-next') . '</a>';
                    }
                }
            }

            $isSelected = ($selectedUrl !== '' && $url === $selectedUrl);
            echo '<tr' . ($isSelected ? ' class="is-selected"' : '') . '>';
            echo '<td><code>' . esc_html($label) . '</code></td>';
            echo '<td>' . esc_html($this->fmtInt((int) ($r['clicks'] ?? 0))) . '</td>';
            echo '<td>' . esc_html($this->fmtInt((int) ($r['impressions'] ?? 0))) . '</td>';
            echo '<td>' . esc_html($this->fmtPct((float) ($r['ctr'] ?? 0.0))) . '</td>';
            echo '<td>' . esc_html($this->fmtPos((float) ($r['position'] ?? 0.0))) . '</td>';
            echo '<td>' . implode(' | ', $actions) . '</td>';
            echo '</tr>';
        }
        echo '</tbody>';
        echo '</table>';
    }
}

/**
 * Silent upgrader skin used from admin-post handlers.
 *
 * The default upgrader skins echo progress directly, which breaks redirects
 * and produces "headers already sent" warnings.
 */
if (!class_exists('\\WP_Upgrader_Skin')) {
    // Ensure core upgrader classes are loaded before defining the skin.
    if (defined('ABSPATH')) {
        if (file_exists(ABSPATH . 'wp-admin/includes/class-wp-upgrader.php')) {
            require_once ABSPATH . 'wp-admin/includes/class-wp-upgrader.php';
        }
        if (file_exists(ABSPATH . 'wp-admin/includes/class-wp-upgrader-skin.php')) {
            require_once ABSPATH . 'wp-admin/includes/class-wp-upgrader-skin.php';
        }
    }
}

final class QuietUpgraderSkin extends \WP_Upgrader_Skin
{
    /** @var string[] */
    private array $messages = [];

    /** @var string */
    private string $errorMessage = '';

    public function header(): void
    {
        // no-op
    }

    public function footer(): void
    {
        // no-op
    }

    /**
     * Capture feedback messages but do not output.
     *
     * @param string|\WP_Error $feedback
     * @param mixed ...$args
     */
    public function feedback($feedback, ...$args): void
    {
        if ($feedback instanceof \WP_Error) {
            $this->error($feedback);
            return;
        }

        $msg = is_string($feedback) ? $feedback : '';
        if ($msg !== '' && !empty($args)) {
            // phpcs:ignore WordPress.PHP.DevelopmentFunctions.error_log_vprintf
            $msg = vsprintf($msg, $args);
        }

        if ($msg !== '') {
            $this->messages[] = $msg;
        }
    }

    /**
     * @param string|array|\WP_Error $errors
     */
    public function error($errors): void
    {
        if ($errors instanceof \WP_Error) {
            $this->errorMessage = (string) $errors->get_error_message();
            return;
        }

        if (is_array($errors)) {
            $this->errorMessage = implode('; ', array_map('strval', $errors));
            return;
        }

        $this->errorMessage = (string) $errors;
    }

    public function getErrorMessage(): string
    {
        return $this->errorMessage;
    }
}
