<?php

declare(strict_types=1);

namespace SeoScope\Http;

use PDO;
use Throwable;
use SeoScope\Auth\AdminCsrf;
use SeoScope\Auth\SessionAuth;
use SeoScope\Database\MigrationRunner;
use SeoScope\Integrations\GoogleOAuth;
use SeoScope\Integrations\GoogleSearchConsoleClient;
use SeoScope\Integrations\GoogleTokenManager;
use SeoScope\Repositories\GooglePropertyRepository;
use SeoScope\Repositories\ProjectRepository;
use SeoScope\Repositories\GscDailyPageRepository;
use SeoScope\Repositories\GscDailyPropertyCountryRepository;
use SeoScope\Repositories\GscDailyPropertyDeviceRepository;
use SeoScope\Repositories\GscKpiRepository;
use SeoScope\Repositories\GscDailyQueryRepository;
use SeoScope\Repositories\GscDailyQueryPageRepository;
use SeoScope\Repositories\GscDailyPropertyAggRepository;
use SeoScope\Repositories\GscDashboardWidgetsRepository;
use SeoScope\Repositories\GscKeywordsRepository;
use SeoScope\Repositories\TrackedQueryRepository;
use SeoScope\Repositories\TrackedPageRepository;
use SeoScope\Repositories\TrackedCategoryRepository;
use SeoScope\Repositories\GscOpportunitiesRepository;
use SeoScope\Repositories\GscCtrBenchmarkRepository;
use SeoScope\Repositories\GscPagesRepository;
use SeoScope\Repositories\GscDecayRepository;
use SeoScope\Repositories\GscCannibalizationRepository;
use SeoScope\Repositories\GscQuickWinRepository;
use SeoScope\Repositories\GscLongTailRepository;
use SeoScope\Repositories\GscQuestionQueriesRepository;
use SeoScope\Repositories\SettingsRepository;
use SeoScope\Repositories\UserRepository;
use SeoScope\Repositories\SyncLogRepository;
use SeoScope\Repositories\SyncJobRepository;
use SeoScope\Repositories\DashboardCacheRepository;
use SeoScope\Repositories\SeoAiTokenUsageRepository;
use SeoScope\Services\GscAggregationService;
use SeoScope\Services\GscKpiViewService;
use SeoScope\Services\GscSyncService;
use SeoScope\Services\AiConnectionTestService;
use SeoScope\Services\AiSettingsService;
use SeoScope\Services\OpenAiModelCatalog;
use SeoScope\Services\ResetService;
use SeoScope\Services\SeoAiAssistantCacheKey;
use SeoScope\Services\SeoAiAssistantDashboardCacheEnvelope;
use SeoScope\Services\SeoAiAssistantRateLimiter;
use SeoScope\Services\SeoAiAssistantPayloadService;
use SeoScope\Services\SeoAiAssistantPrompts;
use SeoScope\Services\SeoAiCompletionService;
use SeoScope\Services\SeoAiCtrOpportunityPayloadService;
use SeoScope\Services\SeoAiCtrOpportunityPrompts;
use SeoScope\Services\SeoAiIqqiPayloadService;
use SeoScope\Services\SeoAiIqqiPrompts;
use SeoScope\Services\SeoAiLowCtrPagesPayloadService;
use SeoScope\Services\SeoAiLowCtrPagesPrompts;
use SeoScope\Services\SeoAiTopicalGapsPayloadService;
use SeoScope\Services\SeoAiTopicalGapsPrompts;
use SeoScope\Services\SeoAiKeywordsPayloadService;
use SeoScope\Services\SeoAiKeywordsPrompts;
use SeoScope\Services\SeoAiPagesPayloadService;
use SeoScope\Services\SeoAiPagesPrompts;
use SeoScope\Services\SeoAiCannibalizationPayloadService;
use SeoScope\Services\SeoAiCannibalizationPrompts;
use SeoScope\Services\SeoAiLongTailPayloadService;
use SeoScope\Services\SeoAiLongTailPrompts;
use SeoScope\Services\SeoAiQuestionQueriesPayloadService;
use SeoScope\Services\SeoAiQuestionQueriesPrompts;
use SeoScope\Services\SeoAiDecayPayloadService;
use SeoScope\Services\SeoAiDecayPrompts;
use SeoScope\i18n\SupportedLocales;
use SeoScope\i18n\TranslatorBootstrap;
use SeoScope\Licensing\LicenseClient;
use SeoScope\Licensing\LicenseGuard;
use SeoScope\Licensing\LicenseState;
use SeoScope\Licensing\LicenseStorage;

final class WebDispatcher
{
    /** @var list<string> */
    private const QUICK_WIN_SEO_AI_TABS = ['iqqi', 'ctr_opportunity', 'low_ctr_pages', 'topical_gaps'];

    public function __construct(
        private string $root,
        private array $config,
        private string $tablePrefix
    ) {
        $GLOBALS['seoscope_config'] = $config;
    }

    public function dispatch(string $method, string $requestPath): void
    {
        require_once $this->root . '/app/Views/http_helpers.php';

        $path = $this->normalizeRequestPath($requestPath);
        $method = strtoupper($method);

        // Static assets: some hosts rewrite all requests to index.php even when the file exists.
        // Serve /assets/* directly to avoid auth redirects breaking CSS/JS/fonts.
        if (($method === 'GET' || $method === 'HEAD') && str_starts_with($path, '/assets/')) {
            if ($this->tryServeStaticAsset($path, $method === 'HEAD')) {
                return;
            }
        }

        $router = new Router();

        // Public routes
        $router->add('GET', '/license', function (): void {
            $this->runMigrations();
            $this->showLicense();
        });
        $router->add('POST', '/license', function (): void {
            $this->runMigrations();
            $this->handleLicensePost();
        });
        $router->add('GET', '/login', function (): void {
            $this->runMigrations();
            $this->handleLogin('GET');
        });
        $router->add('POST', '/login', function (): void {
            $this->runMigrations();
            $this->handleLogin('POST');
        });
        $router->add('GET', '/setup', function (): void {
            $this->runMigrations();
            $this->handleSetup('GET');
        });
        $router->add('POST', '/setup', function (): void {
            $this->runMigrations();
            $this->handleSetup('POST');
        });
        $router->add('GET', '/forgot-password', function (): void {
            $this->runMigrations();
            $this->handleForgotPassword('GET');
        });
        $router->add('POST', '/forgot-password', function (): void {
            $this->runMigrations();
            $this->handleForgotPassword('POST');
        });
        $router->add('GET', '/reset-password', function (): void {
            $this->runMigrations();
            $this->handleResetPassword('GET');
        });
        $router->add('POST', '/reset-password', function (): void {
            $this->runMigrations();
            $this->handleResetPassword('POST');
        });
        $router->add('POST', '/logout', fn () => $this->handleLogout());
        $router->add('GET', '/account', function (): void {
            $this->runMigrations();
            $this->showAccount();
        });
        $router->add('POST', '/account', function (): void {
            $this->runMigrations();
            $this->handleAccountPost();
        });
        $router->add('GET', '/account/password', function (): void {
            $this->runMigrations();
            $this->showAccountPassword();
        });
        $router->add('POST', '/account/password', function (): void {
            $this->runMigrations();
            $this->handleAccountPasswordPost();
        });
        $router->add('GET', '/cron/sync', function (): void {
            $this->runMigrations();
            $this->handleCronSyncGet();
        });

        if ($router->dispatch($method, $path)) {
            return;
        }

        // Auth-gated area
        if (!SessionAuth::check()) {
            $this->redirect(seoscope_url('/login'));
            return;
        }

        // License gating for authenticated area
        $guard = new LicenseGuard($this->root, $this->config);
        $redir = $guard->enforce($path);
        if (is_string($redir) && $redir !== '') {
            $this->redirect($redir);
            return;
        }

        // Dashboard aliases
        $router->add('GET', '/', function (): void {
            $this->runMigrations();
            $this->showDashboard();
        });
        $router->add('GET', '/dashboard', function (): void {
            $this->runMigrations();
            $this->showDashboard();
        });
        $router->add('POST', '/dashboard/seo-ai-assistant', function (): void {
            $this->runMigrations();
            $this->handleDashboardSeoAiAssistantPost();
        });

        // Settings
        $router->add('GET', '/settings', function (): void {
            $this->runMigrations();
            $this->showSettings();
        });
        $router->add('POST', '/settings/interface', function (): void {
            $this->runMigrations();
            $this->handleSettingsInterfacePost();
        });
        $router->add('POST', '/settings/ai', function (): void {
            $this->runMigrations();
            $this->handleSettingsAiPost();
        });
        $router->add('POST', '/settings/ai/enabled', function (): void {
            $this->runMigrations();
            $this->handleSettingsAiEnabledPost();
        });
        $router->add('POST', '/settings/ai/test', function (): void {
            $this->runMigrations();
            $this->handleSettingsAiTestPost();
        });
        $router->add('POST', '/settings/ai/models/refresh', function (): void {
            $this->runMigrations();
            $this->handleSettingsAiModelsRefreshPost();
        });
        $router->add('POST', '/settings/interface/languages/create', function (): void {
            $this->runMigrations();
            $this->handleSettingsInterfaceLanguageCreatePost();
        });
        $router->add('GET', '/settings/interface/languages/download', function (): void {
            $this->runMigrations();
            $this->handleSettingsInterfaceLanguageDownloadGet();
        });
        $router->add('POST', '/settings/interface/languages/upload', function (): void {
            $this->runMigrations();
            $this->handleSettingsInterfaceLanguageUploadPost();
        });
        $router->add('POST', '/settings/interface/languages/delete', function (): void {
            $this->runMigrations();
            $this->handleSettingsInterfaceLanguageDeletePost();
        });
        $router->add('GET', '/settings/diagnostics', function (): void {
            $this->runMigrations();
            $this->showSettingsDiagnostics();
        });
        $router->add('POST', '/settings/diagnostics/export', function (): void {
            $this->runMigrations();
            $this->handleDiagnosticsExportPost();
        });
        $router->add('GET', '/settings/automation', function (): void {
            $this->runMigrations();
            $this->showSettingsAutomation();
        });
        $router->add('POST', '/settings/automation/token', function (): void {
            $this->runMigrations();
            $this->handleSettingsAutomationTokenPost();
        });
        $router->add('POST', '/settings/update/upload', function (): void {
            $this->runMigrations();
            $this->handleSettingsUpdateUploadPost();
        });
        $router->add('GET', '/settings/decay', function (): void {
            $this->runMigrations();
            $this->showSettingsDecay();
        });
        $router->add('POST', '/settings/decay', function (): void {
            $this->runMigrations();
            $this->handleSettingsDecayPost();
        });
        $router->add('GET', '/settings/google', function (): void {
            $this->runMigrations();
            $this->showSettingsGoogle();
        });
        $router->add('POST', '/settings/google', function (): void {
            $this->runMigrations();
            $this->handleSettingsGooglePost();
        });
        $router->add('POST', '/settings/google/diagnostics', function (): void {
            $this->runMigrations();
            $this->handleSettingsGoogleDiagnosticsPost();
        });
        $router->add('POST', '/settings/google/connect', function (): void {
            $this->runMigrations();
            $this->handleGoogleConnectPost();
        });
        $router->add('GET', '/oauth/google/callback', function (): void {
            $this->runMigrations();
            $this->handleGoogleCallbackGet();
        });
        $router->add('POST', '/settings/google/refresh', function (): void {
            $this->runMigrations();
            $this->handleGoogleRefreshPost();
        });
        $router->add('POST', '/settings/google/disconnect', function (): void {
            $this->runMigrations();
            $this->handleGoogleDisconnectPost();
        });
        $router->add('POST', '/settings/google/properties/fetch', function (): void {
            $this->runMigrations();
            $this->handleGoogleFetchPropertiesPost();
        });
        $router->add('POST', '/settings/google/properties/toggle', function (): void {
            $this->runMigrations();
            $this->handleGoogleTogglePropertyPost();
        });
        $router->add('POST', '/settings/google/sync-config', function (): void {
            $this->runMigrations();
            $this->handleGoogleSyncConfigPost();
        });
        $router->add('POST', '/settings/google/sync-safety', function (): void {
            $this->runMigrations();
            $this->handleGoogleSyncSafetyPost();
        });
        $router->add('POST', '/settings/google/sync/run', function (): void {
            $this->runMigrations();
            $this->handleGoogleSyncRunPost();
        });
        $router->add('POST', '/settings/reset/properties', function (): void {
            $this->runMigrations();
            $this->handleSettingsResetPropertiesPost();
        });
        $router->add('POST', '/settings/reset/all', function (): void {
            $this->runMigrations();
            $this->handleSettingsResetAllPost();
        });
        $router->add('POST', '/settings/google/quick-win', function (): void {
            $this->runMigrations();
            $this->handleGoogleQuickWinPost();
        });

        // Reports
        $router->add('GET', '/keywords', function (): void {
            $this->runMigrations();
            $this->showKeywords();
        });
        $router->add('POST', '/keywords/seo-ai-assistant', function (): void {
            $this->runMigrations();
            $this->handleKeywordsSeoAiAssistantPost();
        });
        $router->add('POST', '/keywords/track', function (): void {
            $this->runMigrations();
            $this->handleKeywordsTrackPost();
        });
        $router->add('POST', '/keywords/untrack', function (): void {
            $this->runMigrations();
            $this->handleKeywordsUntrackPost();
        });
        $router->add('POST', '/tracked/categories/create', function (): void {
            $this->runMigrations();
            $this->handleTrackedCategoryCreatePost();
        });
        $router->add('POST', '/tracked/categories/delete', function (): void {
            $this->runMigrations();
            $this->handleTrackedCategoryDeletePost();
        });
        $router->add('POST', '/tracked/categories/rename', function (): void {
            $this->runMigrations();
            $this->handleTrackedCategoryRenamePost();
        });
        $router->add('POST', '/keywords/category/assign', function (): void {
            $this->runMigrations();
            $this->handleKeywordsCategoryAssignPost();
        });
        $router->add('GET', '/keywords/pages', function (): void {
            $this->runMigrations();
            $this->handleKeywordPagesJson();
        });
        $router->add('GET', '/pages', function (): void {
            $this->runMigrations();
            $this->showPages();
        });
        $router->add('POST', '/pages/seo-ai-assistant', function (): void {
            $this->runMigrations();
            $this->handlePagesSeoAiAssistantPost();
        });
        $router->add('GET', '/pages/keywords', function (): void {
            $this->runMigrations();
            $this->handlePageKeywordsJson();
        });
        $router->add('POST', '/pages/track', function (): void {
            $this->runMigrations();
            $this->handlePagesTrackPost();
        });
        $router->add('POST', '/pages/untrack', function (): void {
            $this->runMigrations();
            $this->handlePagesUntrackPost();
        });
        $router->add('POST', '/pages/category/assign', function (): void {
            $this->runMigrations();
            $this->handlePagesCategoryAssignPost();
        });
        $router->add('GET', '/opportunities', function (): void {
            $this->runMigrations();
            // Backward-compatible alias: Opportunities => Quick Win
            $this->redirect(seoscope_url('/quick-win'));
        });
        $router->add('GET', '/decay', function (): void {
            $this->runMigrations();
            $this->showDecay();
        });
        $router->add('POST', '/decay/seo-ai-assistant', function (): void {
            $this->runMigrations();
            $this->handleDecaySeoAiAssistantPost();
        });
        $router->add('GET', '/cannibalization', function (): void {
            $this->runMigrations();
            $this->showCannibalization();
        });
        $router->add('POST', '/cannibalization/seo-ai-assistant', function (): void {
            $this->runMigrations();
            $this->handleCannibalizationSeoAiAssistantPost();
        });
        $router->add('GET', '/quick-win', function (): void {
            $this->runMigrations();
            $this->showQuickWin();
        });
        $router->add('POST', '/quick-win/seo-ai-assistant', function (): void {
            $this->runMigrations();
            $this->handleQuickWinSeoAiAssistantPost();
        });
        $router->add('GET', '/quick-win/pages', function (): void {
            $this->runMigrations();
            $this->handleQuickWinPagesJson();
        });
        $router->add('GET', '/long-tail', function (): void {
            $this->runMigrations();
            $this->showLongTail();
        });
        $router->add('POST', '/long-tail/seo-ai-assistant', function (): void {
            $this->runMigrations();
            $this->handleLongTailSeoAiAssistantPost();
        });
        $router->add('GET', '/question-queries', function (): void {
            $this->runMigrations();
            $this->showQuestionQueries();
        });
        $router->add('POST', '/question-queries/terms', function (): void {
            $this->runMigrations();
            $this->handleQuestionQueriesTermsPost();
        });
        $router->add('POST', '/question-queries/seo-ai-assistant', function (): void {
            $this->runMigrations();
            $this->handleQuestionQueriesSeoAiAssistantPost();
        });

        // Exports (CSV)
        $router->add('GET', '/export/keywords.csv', function (): void {
            $this->runMigrations();
            $this->handleExportKeywordsCsv();
        });
        $router->add('GET', '/export/pages.csv', function (): void {
            $this->runMigrations();
            $this->handleExportPagesCsv();
        });
        $router->add('GET', '/export/opportunities.csv', function (): void {
            $this->runMigrations();
            $this->handleExportOpportunitiesCsv();
        });

        // Projects (prefix-based)
        if (str_starts_with($path, '/projects')) {
            $this->runMigrations();
            $parts = explode('/', trim($path, '/'));
            $this->handleProjects($method, $parts);
            return;
        }

        if ($router->dispatch($method, $path)) {
            return;
        }

        http_response_code(404);
        echo 'Not Found';
    }

    private function tryServeStaticAsset(string $path, bool $headOnly): bool
    {
        $rel = ltrim($path, '/'); // assets/...
        if (!str_starts_with($rel, 'assets/')) {
            return false;
        }

        // Prevent traversal.
        if (str_contains($rel, '..')) {
            http_response_code(404);
            return true;
        }

        $candidates = [
            $this->root . '/public/' . $rel, // official builds may mirror assets into public/assets
            $this->root . '/' . $rel,        // local builds keep assets at root/assets
        ];

        $file = null;
        foreach ($candidates as $abs) {
            if (is_file($abs) && is_readable($abs)) {
                $file = $abs;
                break;
            }
        }
        if ($file === null) {
            return false;
        }

        $ext = strtolower((string) pathinfo($file, PATHINFO_EXTENSION));
        $types = [
            'css' => 'text/css; charset=utf-8',
            'js' => 'application/javascript; charset=utf-8',
            'map' => 'application/json; charset=utf-8',
            'json' => 'application/json; charset=utf-8',
            'png' => 'image/png',
            'jpg' => 'image/jpeg',
            'jpeg' => 'image/jpeg',
            'gif' => 'image/gif',
            'svg' => 'image/svg+xml',
            'woff' => 'font/woff',
            'woff2' => 'font/woff2',
            'ttf' => 'font/ttf',
            'eot' => 'application/vnd.ms-fontobject',
        ];
        $ctype = $types[$ext] ?? 'application/octet-stream';

        header('Content-Type: ' . $ctype);
        header('X-Content-Type-Options: nosniff');
        header('Cache-Control: public, max-age=604800, immutable');
        $mtime = @filemtime($file) ?: time();
        header('Last-Modified: ' . gmdate('D, d M Y H:i:s', $mtime) . ' GMT');

        if ($headOnly) {
            return true;
        }

        $size = @filesize($file);
        if (is_int($size) && $size >= 0) {
            header('Content-Length: ' . $size);
        }
        readfile($file);
        return true;
    }

    private function normalizeRequestPath(string $requestPath): string
    {
        $path = '/' . trim($requestPath, '/');
        if ($path === '/index.php') {
            return '/';
        }
        return $path === '/' ? '/' : $path;
    }

    private function pdo(): PDO
    {
        return \SeoScope\Database\ConnectionFactory::fromConfig($this->config);
    }

    private function currentUserId(): int
    {
        $u = SessionAuth::user();
        return is_array($u) && !empty($u['id']) ? (int) $u['id'] : 1;
    }

    private function runMigrations(): void
    {
        $runner = new MigrationRunner($this->pdo(), $this->tablePrefix);
        $runner->runAll($this->root . '/database/migrations');
    }

    private function redirect(string $location): void
    {
        header('Location: ' . $location, true, 302);
        exit;
    }

    private function handleLogin(string $method): void
    {
        SessionAuth::start();
        if (SessionAuth::check()) {
            $this->redirect(seoscope_url('/'));
            return;
        }
        // If the app is installed but not yet licensed, guide the user to /license first.
        $storage = new LicenseStorage($this->root);
        $state = $storage->load();
        if (($state->token ?? '') === '') {
            $this->redirect(seoscope_url('/license'));
            return;
        }
        // If the platform has no users (e.g. after a destructive reset), bootstrap via /setup.
        try {
            $table = '`' . str_replace('`', '``', $this->tablePrefix . 'users') . '`';
            $cnt = (int) (($this->pdo()->query("SELECT COUNT(*) FROM {$table}")?->fetchColumn()) ?: 0);
            if ($cnt < 1) {
                $this->redirect(seoscope_url('/setup'));
                return;
            }
        } catch (\Throwable) {
        }
        if ($method === 'GET') {
            // token() in login.php ensures one stable CSRF per session; refresh() here broke multi-tab/back.
            $error = $_SESSION['_login_error'] ?? null;
            unset($_SESSION['_login_error']);
            require $this->root . '/app/Views/login.php';
            return;
        }
        if ($method === 'POST') {
            $ip = trim((string) ($_SERVER['REMOTE_ADDR'] ?? ''));
            $throttle = $this->checkLoginThrottle($ip);
            if ($throttle['blocked']) {
                $_SESSION['_login_error'] = __('auth.too_many_attempts', ['seconds' => (string) $throttle['retry_after']]);
                $this->redirect(seoscope_url('/login'));
                return;
            }
            if (!AdminCsrf::validate($_POST['_csrf'] ?? null)) {
                AdminCsrf::refresh();
                $_SESSION['_login_error'] = __('auth.csrf_invalid');
                $this->redirect(seoscope_url('/login'));
                return;
            }
            $email = trim((string) ($_POST['email'] ?? ''));
            $password = (string) ($_POST['password'] ?? '');
            $repo = new UserRepository($this->pdo(), $this->tablePrefix);
            $user = $repo->findByEmail($email);
            if ($user === null || !password_verify($password, $user['password_hash'])) {
                $this->recordLoginFailure($ip);
                $_SESSION['_login_error'] = __('auth.invalid_credentials');
                $this->redirect(seoscope_url('/login'));
                return;
            }
            if (!empty($user['is_active']) && (int) $user['is_active'] !== 1) {
                $this->recordLoginFailure($ip);
                $_SESSION['_login_error'] = __('auth.invalid_credentials');
                $this->redirect(seoscope_url('/login'));
                return;
            }
            SessionAuth::login([
                'id' => $user['id'],
                'email' => $user['email'],
                'name' => $user['name'],
            ]);
            try {
                $s = new SettingsRepository($this->pdo(), $this->tablePrefix, (int) $user['id']);
                $stored = $s->get(SupportedLocales::SETTING_KEY);
                SupportedLocales::setUiLocaleCookie(SupportedLocales::normalize($stored), $this->config);
            } catch (Throwable) {
            }
            $this->clearLoginFailures($ip);
            AdminCsrf::refresh();
            $this->redirect(seoscope_url('/'));
            return;
        }
        http_response_code(405);
    }

    private function handleForgotPassword(string $method): void
    {
        SessionAuth::start();
        if (SessionAuth::check()) {
            $this->redirect(seoscope_url('/'));
            return;
        }
        // If there are no users, password reset cannot work. Redirect to setup.
        try {
            $table = '`' . str_replace('`', '``', $this->tablePrefix . 'users') . '`';
            $cnt = (int) (($this->pdo()->query("SELECT COUNT(*) FROM {$table}")?->fetchColumn()) ?: 0);
            if ($cnt < 1) {
                $this->redirect(seoscope_url('/setup'));
                return;
            }
        } catch (\Throwable) {
        }

        if ($method === 'GET') {
            $flash = $this->pullFlash();
            $sent = $_SESSION['_forgot_sent'] ?? null;
            unset($_SESSION['_forgot_sent']);
            require $this->root . '/app/Views/forgot_password.php';
            return;
        }

        if ($method !== 'POST') {
            http_response_code(405);
            return;
        }

        $ip = trim((string) ($_SERVER['REMOTE_ADDR'] ?? ''));
        $throttle = $this->checkGenericThrottle('forgot', $ip, 5, 10 * 60);
        if ($throttle['blocked']) {
            $_SESSION['_flash_error'] = __('auth.too_many_attempts', ['seconds' => (string) $throttle['retry_after']]);
            $this->redirect(seoscope_url('/forgot-password'));
            return;
        }

        if (!AdminCsrf::validate($_POST['_csrf'] ?? null)) {
            AdminCsrf::refresh();
            $_SESSION['_flash_error'] = __('auth.csrf_invalid');
            $this->redirect(seoscope_url('/forgot-password'));
            return;
        }

        $email = trim((string) ($_POST['email'] ?? ''));
        $repo = new UserRepository($this->pdo(), $this->tablePrefix);
        $token = $repo->createPasswordResetToken($email, 3600);

        // Always respond with success (avoid user enumeration).
        $this->recordGenericFailure('forgot', $ip);
        if (is_string($token) && $token !== '') {
            $base = rtrim((string) (($this->config['app']['url'] ?? '') ?: ''), '/');
            $path = seoscope_url('/reset-password?t=' . rawurlencode($token));
            $resetUrl = ($base !== '') ? ($base . $path) : $path;
            $this->sendPasswordResetEmail($email, $resetUrl);
        }
        $_SESSION['_forgot_sent'] = true;
        $this->redirect(seoscope_url('/forgot-password'));
    }

    private function handleResetPassword(string $method): void
    {
        SessionAuth::start();
        if (SessionAuth::check()) {
            $this->redirect(seoscope_url('/'));
            return;
        }

        $token = trim((string) ($_GET['t'] ?? ($_POST['t'] ?? '')));
        $repo = new UserRepository($this->pdo(), $this->tablePrefix);
        $user = $repo->findByValidResetToken($token);

        if ($method === 'GET') {
            $flash = $this->pullFlash();
            $invalid = $user === null;
            require $this->root . '/app/Views/reset_password.php';
            return;
        }

        if ($method !== 'POST') {
            http_response_code(405);
            return;
        }

        if (!AdminCsrf::validate($_POST['_csrf'] ?? null)) {
            AdminCsrf::refresh();
            $_SESSION['_flash_error'] = __('auth.csrf_invalid');
            $this->redirect(seoscope_url('/reset-password?t=' . rawurlencode($token)));
            return;
        }

        if ($user === null) {
            $_SESSION['_flash_error'] = __('auth.reset_invalid');
            $this->redirect(seoscope_url('/forgot-password'));
            return;
        }

        $pw = (string) ($_POST['password'] ?? '');
        $pw2 = (string) ($_POST['password_confirm'] ?? '');
        if (strlen($pw) < 12) {
            $_SESSION['_flash_error'] = __('auth.reset_password_short');
            $this->redirect(seoscope_url('/reset-password?t=' . rawurlencode($token)));
            return;
        }
        if (!hash_equals($pw, $pw2)) {
            $_SESSION['_flash_error'] = __('auth.reset_password_mismatch');
            $this->redirect(seoscope_url('/reset-password?t=' . rawurlencode($token)));
            return;
        }

        $hash = password_hash($pw, PASSWORD_DEFAULT);
        $ok = $repo->consumeResetTokenAndSetPassword($token, $hash);
        if (!$ok) {
            $_SESSION['_flash_error'] = __('auth.reset_invalid');
            $this->redirect(seoscope_url('/forgot-password'));
            return;
        }

        $_SESSION['_flash_ok'] = __('auth.reset_success');
        $this->redirect(seoscope_url('/login'));
    }

    /**
     * @return array{blocked:bool,retry_after:int}
     */
    private function checkGenericThrottle(string $bucket, string $ip, int $maxAttempts, int $windowSeconds): array
    {
        $ip = $ip !== '' ? $ip : 'unknown';
        $now = time();
        $path = $this->genericThrottleFile($bucket, $ip);
        $attempts = $this->readThrottleAttempts($path);
        $attempts = array_values(array_filter($attempts, static fn (int $ts): bool => $ts > ($now - $windowSeconds)));
        if (count($attempts) >= $maxAttempts) {
            $oldest = (int) min($attempts);
            $retryAfter = max(10, ($oldest + $windowSeconds) - $now);
            return ['blocked' => true, 'retry_after' => $retryAfter];
        }
        return ['blocked' => false, 'retry_after' => 0];
    }

    private function recordGenericFailure(string $bucket, string $ip): void
    {
        $ip = $ip !== '' ? $ip : 'unknown';
        $path = $this->genericThrottleFile($bucket, $ip);
        $attempts = $this->readThrottleAttempts($path);
        $attempts[] = time();
        $this->writeThrottleAttempts($path, $attempts);
    }

    private function genericThrottleFile(string $bucket, string $ip): string
    {
        $lockDir = $this->root . '/storage/locks';
        if (!is_dir($lockDir)) {
            @mkdir($lockDir, 0775, true);
        }
        $hash = substr(hash('sha256', $bucket . '|' . $ip), 0, 24);
        return $lockDir . '/' . $bucket . '-throttle-' . $hash . '.json';
    }

    private function sendPasswordResetEmail(string $email, string $resetUrl): void
    {
        $email = trim($email);
        if ($email === '' || !filter_var($email, FILTER_VALIDATE_EMAIL)) {
            return;
        }
        $appUrl = (string) (($this->config['app']['url'] ?? '') ?: '');
        $host = parse_url($appUrl, PHP_URL_HOST);
        $fromDomain = is_string($host) && $host !== '' ? $host : 'example.com';
        $from = 'noreply@' . $fromDomain;
        $subject = 'SeoScope password reset';
        $body = "A password reset was requested for your SeoScope admin account.\n\n"
            . "Reset link (valid for 60 minutes):\n"
            . $resetUrl . "\n\n"
            . "If you did not request this, you can ignore this email.\n";
        $headers = "From: {$from}\r\n"
            . "Content-Type: text/plain; charset=UTF-8\r\n";

        // Best-effort: on some shared hosts mail() may be disabled.
        @mail($email, $subject, $body, $headers);
    }

    /**
     * Very lightweight brute-force protection (shared hosting friendly).
     *
     * - Tracks failed attempts per IP in storage/locks (JSON)
     * - Blocks when too many failures occur in a short window
     *
     * @return array{blocked:bool,retry_after:int}
     */
    private function checkLoginThrottle(string $ip): array
    {
        $ip = $ip !== '' ? $ip : 'unknown';
        $now = time();
        $windowSeconds = 10 * 60;
        $maxAttempts = 8;

        $path = $this->loginThrottleFile($ip);
        $attempts = $this->readThrottleAttempts($path);
        $attempts = array_values(array_filter($attempts, static fn (int $ts): bool => $ts > ($now - $windowSeconds)));

        if (count($attempts) >= $maxAttempts) {
            $oldest = (int) min($attempts);
            $retryAfter = max(10, ($oldest + $windowSeconds) - $now);
            return ['blocked' => true, 'retry_after' => $retryAfter];
        }
        return ['blocked' => false, 'retry_after' => 0];
    }

    private function recordLoginFailure(string $ip): void
    {
        $ip = $ip !== '' ? $ip : 'unknown';
        $path = $this->loginThrottleFile($ip);
        $attempts = $this->readThrottleAttempts($path);
        $attempts[] = time();
        $this->writeThrottleAttempts($path, $attempts);
    }

    private function clearLoginFailures(string $ip): void
    {
        $ip = $ip !== '' ? $ip : 'unknown';
        $path = $this->loginThrottleFile($ip);
        @unlink($path);
    }

    private function loginThrottleFile(string $ip): string
    {
        $lockDir = $this->root . '/storage/locks';
        if (!is_dir($lockDir)) {
            @mkdir($lockDir, 0775, true);
        }
        $hash = substr(hash('sha256', $ip), 0, 24);
        return $lockDir . '/login-throttle-' . $hash . '.json';
    }

    /**
     * @return list<int>
     */
    private function readThrottleAttempts(string $path): array
    {
        $raw = @file_get_contents($path);
        if (!is_string($raw) || trim($raw) === '') {
            return [];
        }
        $decoded = json_decode($raw, true);
        if (!is_array($decoded)) {
            return [];
        }
        $out = [];
        foreach ($decoded as $v) {
            if (is_int($v) || (is_string($v) && ctype_digit($v))) {
                $out[] = (int) $v;
            }
        }
        return $out;
    }

    /**
     * @param list<int> $attempts
     */
    private function writeThrottleAttempts(string $path, array $attempts): void
    {
        $tmp = $path . '.tmp';
        @file_put_contents($tmp, json_encode(array_values($attempts), JSON_UNESCAPED_SLASHES), LOCK_EX);
        @rename($tmp, $path);
    }

    private function handleLogout(): void
    {
        SessionAuth::start();
        if (!AdminCsrf::validate($_POST['_csrf'] ?? null)) {
            http_response_code(403);
            echo 'Invalid CSRF';
            return;
        }
        SessionAuth::logout();
        $this->redirect(seoscope_url('/login'));
    }

    private function showAccount(): void
    {
        $flash = $this->pullFlash();
        $u = SessionAuth::user();
        if (!is_array($u) || empty($u['email'])) {
            $this->redirect(seoscope_url('/login'));
            return;
        }

        $user = [
            'id' => (int) ($u['id'] ?? 0),
            'email' => (string) ($u['email'] ?? ''),
            'name' => isset($u['name']) ? (string) $u['name'] : null,
        ];

        require $this->root . '/app/Views/account.php';
    }

    private function handleAccountPost(): void
    {
        $this->assertPostCsrf();

        $u = SessionAuth::user();
        if (!is_array($u) || empty($u['id'])) {
            $this->redirect(seoscope_url('/login'));
            return;
        }

        $uid = (int) $u['id'];
        $email = trim((string) ($_POST['email'] ?? ''));
        $username = trim((string) ($_POST['username'] ?? ''));

        if ($email === '' || !filter_var($email, FILTER_VALIDATE_EMAIL)) {
            $_SESSION['_flash_error'] = __('account.invalid_email');
            $this->redirect(seoscope_url('/account'));
            return;
        }

        if ($username !== '' && mb_strlen($username) > 255) {
            $_SESSION['_flash_error'] = __('account.invalid_username');
            $this->redirect(seoscope_url('/account'));
            return;
        }

        $repo = new UserRepository($this->pdo(), $this->tablePrefix);
        if ($repo->emailExists($email, $uid)) {
            $_SESSION['_flash_error'] = __('account.email_taken');
            $this->redirect(seoscope_url('/account'));
            return;
        }

        $repo->updateProfile($uid, $email, $username === '' ? null : $username);

        // Update current session.
        SessionAuth::start();
        $_SESSION['user_email'] = strtolower($email);
        $_SESSION['user_name'] = ($username === '' ? null : $username);

        $_SESSION['_flash_ok'] = __('account.saved');
        $this->redirect(seoscope_url('/account'));
    }

    private function showAccountPassword(): void
    {
        $flash = $this->pullFlash();
        $u = SessionAuth::user();
        if (!is_array($u) || empty($u['email'])) {
            $this->redirect(seoscope_url('/login'));
            return;
        }

        $user = [
            'id' => (int) ($u['id'] ?? 0),
            'email' => (string) ($u['email'] ?? ''),
            'name' => isset($u['name']) ? (string) $u['name'] : null,
        ];

        require $this->root . '/app/Views/account_password.php';
    }

    private function handleAccountPasswordPost(): void
    {
        $this->assertPostCsrf();

        $u = SessionAuth::user();
        if (!is_array($u) || empty($u['id'])) {
            $this->redirect(seoscope_url('/login'));
            return;
        }

        $uid = (int) $u['id'];
        $current = (string) ($_POST['current_password'] ?? '');
        $pw = (string) ($_POST['password'] ?? '');
        $pw2 = (string) ($_POST['password_confirm'] ?? '');

        if (mb_strlen($pw) < 12) {
            $_SESSION['_flash_error'] = __('auth.reset_password_short');
            $this->redirect(seoscope_url('/account/password'));
            return;
        }
        if ($pw !== $pw2) {
            $_SESSION['_flash_error'] = __('auth.reset_password_mismatch');
            $this->redirect(seoscope_url('/account/password'));
            return;
        }

        $repo = new UserRepository($this->pdo(), $this->tablePrefix);
        $dbUser = $repo->findById($uid);
        if (!is_array($dbUser) || empty($dbUser['password_hash']) || !password_verify($current, (string) $dbUser['password_hash'])) {
            $_SESSION['_flash_error'] = __('account.current_password_invalid');
            $this->redirect(seoscope_url('/account/password'));
            return;
        }

        $hash = password_hash($pw, PASSWORD_DEFAULT);
        $repo->updatePasswordHash($uid, $hash);

        $_SESSION['_flash_ok'] = __('account.password_saved');
        $this->redirect(seoscope_url('/account'));
    }

    /**
     * KPI totals and daily series for dashboard global filters (widgets loaded separately).
     *
     * @return array{
     *   propRepo: GooglePropertyRepository,
     *   filters: array<string, mixed>,
     *   activeProp: ?array<string, mixed>,
     *   preset: string,
     *   compare: bool,
     *   device: string,
     *   country: string,
     *   kpiStart: string,
     *   kpiEnd: string,
     *   rangeDays: int,
     *   kpi: ?array{clicks:int,impressions:int,ctr:float,position:float},
     *   kpiPrev: ?array{clicks:int,impressions:int,ctr:float,position:float},
     *   kpiPrevStart: ?string,
     *   kpiPrevEnd: ?string,
     *   dailySeries: list<array<string, mixed>>,
     *   dataAvailableThrough: ?string,
     *   importedRange: ?array{min:string,max:string},
     *   kpiView: GscKpiViewService,
     * }
     */
    private function bootstrapDashboardKpiLayer(int $uid): array
    {
        $propRepo = new GooglePropertyRepository($this->pdo(), $this->tablePrefix, $uid);
        $kpiRepo = new GscKpiRepository($this->pdo(), $this->tablePrefix);
        $kpiView = new GscKpiViewService(
            new SettingsRepository($this->pdo(), $this->tablePrefix, $uid),
            $kpiRepo
        );

        $filters = $this->resolveGlobalFilters($propRepo);
        $activeProp = $filters['activeProp'];
        $preset = $filters['preset'];
        $compare = !empty($filters['compare']);
        $device = (string) ($filters['device'] ?? 'all');
        $country = (string) ($filters['country'] ?? 'all');
        ['start' => $kpiStart, 'end' => $kpiEnd] = $this->rangeFromPreset($preset);
        $rangeDays = max(1, (int) ((strtotime($kpiEnd) - strtotime($kpiStart)) / 86400) + 1);

        $kpi = null;
        $kpiPrev = null;
        $kpiPrevStart = null;
        $kpiPrevEnd = null;
        $dailySeries = [];
        $dataAvailableThrough = null;
        $importedRange = null;
        if (is_array($activeProp) && !empty($activeProp['id'])) {
            $pid = (int) $activeProp['id'];
            $propSite = (string) ($activeProp['site_url'] ?? '');
            $kpi = $kpiView->totalsForGlobalFilters($pid, $propSite, $kpiStart, $kpiEnd, $device, $country);
            $dailySeries = $kpiView->dailySeriesForGlobalFilters($pid, $propSite, $kpiStart, $kpiEnd, $device, $country);

            try {
                $tAgg = '`' . str_replace('`', '``', $this->tablePrefix . 'gsc_daily_property_agg') . '`';
                $stmtAgg = $this->pdo()->prepare(
                    "SELECT
                        MIN(CASE WHEN (clicks > 0 OR impressions > 0) THEN date ELSE NULL END) AS min_date,
                        MAX(CASE WHEN (clicks > 0 OR impressions > 0) THEN date ELSE NULL END) AS max_date
                     FROM {$tAgg}
                     WHERE property_id = ?
                     LIMIT 1"
                );
                $stmtAgg->execute([$pid]);
                $rAgg = $stmtAgg->fetch(\PDO::FETCH_ASSOC) ?: [];
                $min = is_string($rAgg['min_date'] ?? null) ? (string) $rAgg['min_date'] : '';
                $max = is_string($rAgg['max_date'] ?? null) ? (string) $rAgg['max_date'] : '';
                if ($min !== '' && $max !== '') {
                    $importedRange = ['min' => $min, 'max' => $max];
                }
            } catch (\Throwable) {
            }
            if ($dailySeries !== []) {
                for ($i = count($dailySeries) - 1; $i >= 0; $i--) {
                    $r = $dailySeries[$i] ?? null;
                    if (!is_array($r)) {
                        continue;
                    }
                    $clicks = (int) ($r['clicks'] ?? 0);
                    $impr = (int) ($r['impressions'] ?? 0);
                    if ($clicks > 0 || $impr > 0) {
                        $dataAvailableThrough = (string) ($r['date'] ?? '');
                        break;
                    }
                }
            }

            if ($compare) {
                $kpiPrevEnd = date('Y-m-d', strtotime($kpiStart . ' -1 day'));
                $lenDays = max(1, (int) ((strtotime($kpiEnd) - strtotime($kpiStart)) / 86400) + 1);
                $kpiPrevStart = date('Y-m-d', strtotime($kpiPrevEnd . ' -' . ($lenDays - 1) . ' days'));
                $kpiPrev = $kpiView->totalsForGlobalFilters($pid, $propSite, $kpiPrevStart, $kpiPrevEnd, $device, $country);
            }
        }

        return [
            'propRepo' => $propRepo,
            'filters' => $filters,
            'activeProp' => is_array($activeProp) ? $activeProp : null,
            'preset' => (string) $preset,
            'compare' => $compare,
            'device' => $device,
            'country' => $country,
            'kpiStart' => $kpiStart,
            'kpiEnd' => $kpiEnd,
            'rangeDays' => $rangeDays,
            'kpi' => is_array($kpi) ? $kpi : null,
            'kpiPrev' => is_array($kpiPrev) ? $kpiPrev : null,
            'kpiPrevStart' => is_string($kpiPrevStart ?? null) ? $kpiPrevStart : null,
            'kpiPrevEnd' => is_string($kpiPrevEnd ?? null) ? $kpiPrevEnd : null,
            'dailySeries' => $dailySeries,
            'dataAvailableThrough' => is_string($dataAvailableThrough ?? null) ? $dataAvailableThrough : null,
            'importedRange' => is_array($importedRange) ? $importedRange : null,
            'kpiView' => $kpiView,
        ];
    }

    /**
     * @param null|array<string, mixed> $row From {@see DashboardCacheRepository::getPayload()}
     */
    private function seoAiAssistantTryServeCachedResponse(?array $row, int $ttlSeconds): bool
    {
        if (!is_array($row)) {
            return false;
        }
        $computedAt = (string) ($row['_computed_at'] ?? '');
        unset($row['_computed_at']);
        if ($computedAt === '') {
            return false;
        }
        $ts = strtotime($computedAt);
        if ($ts === false || (time() - $ts) > $ttlSeconds) {
            return false;
        }
        if (!SeoAiAssistantDashboardCacheEnvelope::isAssistantEnvelopeV1($row)) {
            return false;
        }
        $body = $row[SeoAiAssistantDashboardCacheEnvelope::RESPONSE_BODY_KEY];
        if (!is_array($body) || empty($body['ok'])) {
            return false;
        }
        $cachedAtIso = date(DATE_ATOM, $ts);
        $out = $body;
        $out['cached'] = true;
        $out['cached_at'] = $cachedAtIso;
        $out['cache_ttl_seconds'] = $ttlSeconds;
        echo json_encode($out, JSON_UNESCAPED_UNICODE);
        return true;
    }

    /**
     * Whether a successful SEO AI insight is cached for the current dashboard filters (same rules as POST cache hit).
     *
     * @param null|array<string, mixed> $row From {@see DashboardCacheRepository::getPayload()}
     */
    private function seoAiAssistantHasFreshCachedSuccess(?array $row, int $ttlSeconds): bool
    {
        if (!is_array($row)) {
            return false;
        }
        $computedAt = (string) ($row['_computed_at'] ?? '');
        if ($computedAt === '') {
            return false;
        }
        $ts = strtotime($computedAt);
        if ($ts === false || (time() - $ts) > $ttlSeconds) {
            return false;
        }
        if (!SeoAiAssistantDashboardCacheEnvelope::isAssistantEnvelopeV1($row)) {
            return false;
        }
        $body = $row[SeoAiAssistantDashboardCacheEnvelope::RESPONSE_BODY_KEY];

        return is_array($body) && !empty($body['ok']);
    }

    /**
     * @param array<string, mixed> $row From {@see DashboardCacheRepository::getPayload()} (includes `_computed_at`)
     *
     * @return null|array{
     *   executive_summary: string,
     *   seo_interpretation: string,
     *   priority_recommendation: string,
     *   seo_health: string,
     * }
     */
    private function seoAiAssistantExtractSuccessBodyFromCacheRow(array $row): ?array
    {
        if (!SeoAiAssistantDashboardCacheEnvelope::isAssistantEnvelopeV1($row)) {
            return null;
        }
        $body = $row[SeoAiAssistantDashboardCacheEnvelope::RESPONSE_BODY_KEY];
        if (!is_array($body) || empty($body['ok'])) {
            return null;
        }

        return [
            'executive_summary' => (string) ($body['executive_summary'] ?? ''),
            'seo_interpretation' => (string) ($body['seo_interpretation'] ?? ''),
            'priority_recommendation' => (string) ($body['priority_recommendation'] ?? ''),
            'seo_health' => (string) ($body['seo_health'] ?? ''),
        ];
    }

    private function seoAiAssistantClientIp(): ?string
    {
        $ip = trim((string) ($_SERVER['REMOTE_ADDR'] ?? ''));

        return $ip !== '' ? $ip : null;
    }

    private function showDashboard(): void
    {
        $flash = $this->pullFlash();
        $uid = $this->currentUserId();
        $layer = $this->bootstrapDashboardKpiLayer($uid);
        $propRepo = $layer['propRepo'];
        $widgets = new GscDashboardWidgetsRepository($this->pdo(), $this->tablePrefix);
        $filters = $layer['filters'];
        $activeProp = $layer['activeProp'];
        $preset = $layer['preset'];
        $compare = $layer['compare'];
        $device = $layer['device'];
        $country = $layer['country'];
        $kpiStart = $layer['kpiStart'];
        $kpiEnd = $layer['kpiEnd'];
        $rangeDays = $layer['rangeDays'];
        $kpi = $layer['kpi'];
        $kpiPrev = $layer['kpiPrev'];
        $kpiPrevStart = $layer['kpiPrevStart'];
        $kpiPrevEnd = $layer['kpiPrevEnd'];
        $dailySeries = $layer['dailySeries'];
        $dataAvailableThrough = $layer['dataAvailableThrough'];
        $importedRange = $layer['importedRange'];
        $widgetsDeferred = $rangeDays > 90;
        $trendDeferred = $widgetsDeferred;
        $trafficDeferred = $widgetsDeferred;

        $deviceClicks = [];
        $topCountries = [];
        $kwDist = ['top3' => 0, 'top10' => 0, 'top50' => 0, 'top100' => 0, 'total' => 0];
        $rankTrend = ['volatility_score' => 0, 'trend' => 'neutral', 'moved_up' => 0, 'moved_down' => 0];
        if (is_array($activeProp) && !empty($activeProp['id'])) {
            $pid = (int) $activeProp['id'];
            // Expose per-group defer flags to the view. They can become false if cache hits exist.
            $trendDeferred = $widgetsDeferred;
            $trafficDeferred = $widgetsDeferred;

            if (!$widgetsDeferred) {
                // Widgets: always use the full property slice (ignore global device/country filters).
                $deviceClicks = $widgets->clicksByDevice($pid, $kpiStart, $kpiEnd);
                $topCountries = $widgets->topCountriesByClicks($pid, $kpiStart, $kpiEnd, 5);
                $kwDist = $widgets->keywordDistribution($pid, $kpiStart, $kpiEnd);

                // Ranking trend widgets: always compare to previous equivalent period, respecting device/country.
                $lenDays = max(1, (int) ((strtotime($kpiEnd) - strtotime($kpiStart)) / 86400) + 1);
                $prevEnd = date('Y-m-d', strtotime($kpiStart . ' -1 day'));
                $prevStart = date('Y-m-d', strtotime($prevEnd . ' -' . ($lenDays - 1) . ' days'));
                $rankTrend = $widgets->rankingTrend($pid, $kpiStart, $kpiEnd, $prevStart, $prevEnd, $device, $country, 10);
            } else {
                // If already computed for this (property,preset,end=yesterday), reuse cached payload and avoid showing Compute.
                $cache = new DashboardCacheRepository($this->pdo(), $this->tablePrefix);
                $computedForEnd = $kpiEnd; // yesterday

                $trend = $cache->getPayload($uid, $pid, (string) $preset, 'trend', $computedForEnd);
                if (is_array($trend) && isset($trend['rankTrend']) && is_array($trend['rankTrend'])) {
                    $rankTrend = $trend['rankTrend'];
                    $trendDeferred = false;
                }

                $traffic = $cache->getPayload($uid, $pid, (string) $preset, 'traffic', $computedForEnd);
                if (is_array($traffic)) {
                    if (isset($traffic['deviceClicks']) && is_array($traffic['deviceClicks'])) {
                        $deviceClicks = $traffic['deviceClicks'];
                    }
                    if (isset($traffic['topCountries']) && is_array($traffic['topCountries'])) {
                        $topCountries = $traffic['topCountries'];
                    }
                    if (isset($traffic['kwDist']) && is_array($traffic['kwDist'])) {
                        $kwDist = $traffic['kwDist'];
                    }
                    // Only consider it a hit if we have at least one of the widget payloads.
                    if (!empty($traffic['deviceClicks']) || !empty($traffic['topCountries']) || !empty($traffic['kwDist'])) {
                        $trafficDeferred = false;
                    }
                }
            }
        }
        $globalFilters = $filters['globalFilters'];
        $dashboardTrackedKeywordsRows = [];
        $dashboardTrackedPagesRows = [];
        $dashboardTrackedPreviewLimit = 10;
        $dashboardTrackedCompare = $compare
            && is_string($kpiPrevStart ?? null) && $kpiPrevStart !== ''
            && is_string($kpiPrevEnd ?? null) && $kpiPrevEnd !== '';
        if (is_array($activeProp) && !empty($activeProp['id'])) {
            $pidDash = (int) $activeProp['id'];
            $tqDash = new TrackedQueryRepository($this->pdo(), $this->tablePrefix);
            $tpDash = new TrackedPageRepository($this->pdo(), $this->tablePrefix);
            $trackedQueries = $tqDash->listQueries($uid, $pidDash);
            if ($trackedQueries !== []) {
                $kwRepoDash = new GscKeywordsRepository($this->pdo(), $this->tablePrefix);
                $kwTot = $kwRepoDash->totalsForQueries($pidDash, $kpiStart, $kpiEnd, $device, $country, $trackedQueries);
                $kwPrevTot = [];
                if ($dashboardTrackedCompare) {
                    $kwPrevTot = $kwRepoDash->totalsForQueries($pidDash, $kpiPrevStart, $kpiPrevEnd, $device, $country, $trackedQueries);
                }
                $kwRows = [];
                foreach ($trackedQueries as $q) {
                    if (!is_string($q) || $q === '') {
                        continue;
                    }
                    $m = $kwTot[$q] ?? ['clicks' => 0, 'impressions' => 0, 'ctr' => 0.0, 'position' => 0.0];
                    $pm = is_array($kwPrevTot[$q] ?? null) ? $kwPrevTot[$q] : ['clicks' => 0, 'impressions' => 0];
                    $kwRows[] = [
                        'query' => $q,
                        'clicks' => (int) ($m['clicks'] ?? 0),
                        'impressions' => (int) ($m['impressions'] ?? 0),
                        'prev_clicks' => (int) ($pm['clicks'] ?? 0),
                        'prev_impressions' => (int) ($pm['impressions'] ?? 0),
                    ];
                }
                usort($kwRows, static function (array $a, array $b): int {
                    $cmp = ($b['impressions'] ?? 0) <=> ($a['impressions'] ?? 0);

                    return $cmp !== 0 ? $cmp : strcmp((string) ($a['query'] ?? ''), (string) ($b['query'] ?? ''));
                });
                $dashboardTrackedKeywordsRows = array_slice($kwRows, 0, $dashboardTrackedPreviewLimit);
            }
            $trackedPages = $tpDash->listPages($uid, $pidDash);
            if ($trackedPages !== []) {
                $pgRepoDash = new GscPagesRepository($this->pdo(), $this->tablePrefix);
                $pgTot = $pgRepoDash->totalsForPages($pidDash, $kpiStart, $kpiEnd, $device, $country, $trackedPages);
                $pgPrevTot = [];
                if ($dashboardTrackedCompare) {
                    $pgPrevTot = $pgRepoDash->totalsForPages($pidDash, $kpiPrevStart, $kpiPrevEnd, $device, $country, $trackedPages);
                }
                $pgRows = [];
                foreach ($trackedPages as $p) {
                    if (!is_string($p) || $p === '') {
                        continue;
                    }
                    $m = $pgTot[$p] ?? ['clicks' => 0, 'impressions' => 0, 'ctr' => 0.0, 'position' => 0.0];
                    $pm = is_array($pgPrevTot[$p] ?? null) ? $pgPrevTot[$p] : ['clicks' => 0, 'impressions' => 0];
                    $pgRows[] = [
                        'page' => $p,
                        'clicks' => (int) ($m['clicks'] ?? 0),
                        'impressions' => (int) ($m['impressions'] ?? 0),
                        'prev_clicks' => (int) ($pm['clicks'] ?? 0),
                        'prev_impressions' => (int) ($pm['impressions'] ?? 0),
                    ];
                }
                usort($pgRows, static function (array $a, array $b): int {
                    $cmp = ($b['impressions'] ?? 0) <=> ($a['impressions'] ?? 0);

                    return $cmp !== 0 ? $cmp : strcmp((string) ($a['page'] ?? ''), (string) ($b['page'] ?? ''));
                });
                $dashboardTrackedPagesRows = array_slice($pgRows, 0, $dashboardTrackedPreviewLimit);
            }
        }

        $settingsRepoAi = new SettingsRepository($this->pdo(), $this->tablePrefix, $uid);
        $aiDash = new AiSettingsService($settingsRepoAi);
        $seoAiInsightsEnabled = $aiDash->isEnabled();
        $seoAiAssistantInsight = [
            'ready' => false,
            'seo_health' => SeoAiAssistantPayloadService::HEALTH_WARNING,
            'health_penalty_score' => 0.0,
            'payload' => [],
        ];
        $seoAiAssistantAiReady = false;
        $seoAiAssistantHasCachedInsight = false;
        $seoAiAssistantCachedBody = null;
        if ($seoAiInsightsEnabled) {
            $seoAiAssistantInsight = (new SeoAiAssistantPayloadService())->build([
                'kpi' => is_array($kpi) ? $kpi : null,
                'kpi_prev' => is_array($kpiPrev) ? $kpiPrev : null,
                'kpi_start' => $kpiStart,
                'kpi_end' => $kpiEnd,
                'kpi_prev_start' => is_string($kpiPrevStart) ? $kpiPrevStart : null,
                'kpi_prev_end' => is_string($kpiPrevEnd) ? $kpiPrevEnd : null,
                'preset' => (string) $preset,
                'device' => $device,
                'country' => $country,
                'compare_enabled' => $compare,
                'daily_series' => $dailySeries,
                'data_available_through' => is_string($dataAvailableThrough) ? $dataAvailableThrough : null,
            ]);
            $seoAiAssistantAiReady = $aiDash->isReady();
        }
        if ($seoAiInsightsEnabled && $seoAiAssistantInsight['ready'] && is_array($activeProp) && !empty($activeProp['id'])) {
            $cacheTtlProbe = SeoAiAssistantCacheKey::cacheTtlSeconds($this->config);
            $uiLocaleProbe = SupportedLocales::normalize((string) ($settingsRepoAi->get(SupportedLocales::SETTING_KEY) ?? ''));
            $pidProbe = (int) $activeProp['id'];
            $presetProbe = (string) $preset;
            $kpiEndProbe = (string) $kpiEnd;
            $layerKeyProbe = [
                'preset' => $presetProbe,
                'compare' => $compare,
                'device' => $device,
                'country' => $country,
                'kpiStart' => (string) $kpiStart,
                'kpiEnd' => $kpiEndProbe,
            ];
            $aiProv = $aiDash->provider();
            $groupKeyProbe = SeoAiAssistantCacheKey::dashboardCacheGroupKey32(
                $pidProbe,
                $layerKeyProbe,
                $uiLocaleProbe,
                $aiProv,
                $aiProv === AiSettingsService::PROVIDER_ANTHROPIC ? $aiDash->anthropicModel() : $aiDash->openaiModel(),
                $seoAiAssistantInsight['payload'],
            );
            $cacheProbe = new DashboardCacheRepository($this->pdo(), $this->tablePrefix);
            $cachedRow = $cacheProbe->getPayload($uid, $pidProbe, $presetProbe, $groupKeyProbe, $kpiEndProbe);
            if ($this->seoAiAssistantHasFreshCachedSuccess($cachedRow, $cacheTtlProbe)) {
                $seoAiAssistantHasCachedInsight = true;
                $seoAiAssistantCachedBody = $this->seoAiAssistantExtractSuccessBodyFromCacheRow($cachedRow);
            }
        }

        $seoAiAssistantCsrf = AdminCsrf::token();

        require $this->root . '/app/Views/dashboard.php';
    }

    private function handleDashboardSeoAiAssistantPost(): void
    {
        $this->assertPostCsrf();
        header('Content-Type: application/json; charset=utf-8');
        header('Cache-Control: no-store, no-cache, must-revalidate');

        $uid = $this->currentUserId();
        $settingsRepo = new SettingsRepository($this->pdo(), $this->tablePrefix, $uid);
        $ai = new AiSettingsService($settingsRepo);
        if (!$ai->isEnabled()) {
            echo json_encode([
                'ok' => false,
                'error_code' => 'not_enabled',
                'message' => __('seo_ai.completion.not_enabled'),
            ], JSON_UNESCAPED_UNICODE);
            exit;
        }

        $layer = $this->bootstrapDashboardKpiLayer($uid);
        $activeProp = $layer['activeProp'];
        if (!is_array($activeProp) || empty($activeProp['id'])) {
            echo json_encode([
                'ok' => false,
                'error_code' => 'no_property',
                'message' => __('seo_ai.dashboard.no_property'),
            ], JSON_UNESCAPED_UNICODE);
            exit;
        }

        $insight = (new SeoAiAssistantPayloadService())->build([
            'kpi' => is_array($layer['kpi']) ? $layer['kpi'] : null,
            'kpi_prev' => is_array($layer['kpiPrev']) ? $layer['kpiPrev'] : null,
            'kpi_start' => $layer['kpiStart'],
            'kpi_end' => $layer['kpiEnd'],
            'kpi_prev_start' => is_string($layer['kpiPrevStart'] ?? null) ? $layer['kpiPrevStart'] : null,
            'kpi_prev_end' => is_string($layer['kpiPrevEnd'] ?? null) ? $layer['kpiPrevEnd'] : null,
            'preset' => (string) $layer['preset'],
            'device' => (string) $layer['device'],
            'country' => (string) $layer['country'],
            'compare_enabled' => (bool) $layer['compare'],
            'daily_series' => is_array($layer['dailySeries']) ? $layer['dailySeries'] : [],
            'data_available_through' => is_string($layer['dataAvailableThrough'] ?? null) ? $layer['dataAvailableThrough'] : null,
        ]);

        if (!$insight['ready']) {
            echo json_encode([
                'ok' => false,
                'error_code' => 'not_ready',
                'message' => __('seo_ai.dashboard.not_ready'),
            ], JSON_UNESCAPED_UNICODE);
            exit;
        }

        $uiLocale = SupportedLocales::normalize((string) ($settingsRepo->get(SupportedLocales::SETTING_KEY) ?? ''));

        $cache = new DashboardCacheRepository($this->pdo(), $this->tablePrefix);
        $pid = (int) $activeProp['id'];
        $preset = (string) $layer['preset'];
        $computedForEnd = (string) $layer['kpiEnd'];
        $cacheTtl = SeoAiAssistantCacheKey::cacheTtlSeconds($this->config);
        $layerKey = [
            'preset' => $preset,
            'compare' => (bool) $layer['compare'],
            'device' => (string) $layer['device'],
            'country' => (string) $layer['country'],
            'kpiStart' => (string) $layer['kpiStart'],
            'kpiEnd' => $computedForEnd,
        ];
        $aiProv = $ai->provider();
        $groupKey = SeoAiAssistantCacheKey::dashboardCacheGroupKey32(
            $pid,
            $layerKey,
            $uiLocale,
            $aiProv,
            $aiProv === AiSettingsService::PROVIDER_ANTHROPIC ? $ai->anthropicModel() : $ai->openaiModel(),
            $insight['payload'],
        );
        $regenerate = isset($_POST['regenerate']) && (string) $_POST['regenerate'] === '1';

        if (!$regenerate) {
            $cached = $cache->getPayload($uid, $pid, $preset, $groupKey, $computedForEnd);
            if ($this->seoAiAssistantTryServeCachedResponse($cached, $cacheTtl)) {
                exit;
            }
        }

        if (!$ai->isReady()) {
            echo json_encode([
                'ok' => false,
                'error_code' => 'ai_not_configured',
                'message' => __('seo_ai.dashboard.ai_not_configured'),
            ], JSON_UNESCAPED_UNICODE);
            exit;
        }

        $tokenUsageRepo = new SeoAiTokenUsageRepository($this->pdo(), $this->tablePrefix);
        $monthStart = SeoAiTokenUsageRepository::calendarMonthStart();
        $limit = $aiProv === AiSettingsService::PROVIDER_ANTHROPIC
            ? $ai->anthropicMonthlyTokenLimit()
            : $ai->openaiMonthlyTokenLimit();
        if ($limit > 0) {
            $usedMonth = $tokenUsageRepo->sumTotalTokens($uid, $aiProv, $monthStart);
            if ($usedMonth >= $limit) {
                echo json_encode([
                    'ok' => false,
                    'error_code' => 'token_limit_reached',
                    'message' => __('seo_ai.completion.token_limit_reached'),
                ], JSON_UNESCAPED_UNICODE);
                exit;
            }
        }

        $pepper = (string) (($this->config['app'] ?? [])['key'] ?? '');
        $limiter = new SeoAiAssistantRateLimiter(
            $this->config,
            $this->root . '/storage/rate_limit',
            $pepper,
        );
        if (!$limiter->tryConsume($uid, $this->seoAiAssistantClientIp())) {
            $lim = SeoAiAssistantRateLimiter::resolvedLimits($this->config);
            $windows = [];
            if ($lim['session_enabled']) {
                $windows[] = $lim['session_window'];
            }
            if ($lim['ip_enabled']) {
                $windows[] = $lim['ip_window'];
            }
            $retryAfter = $windows === [] ? 60 : max(60, min(3600, min($windows)));
            header('Retry-After: ' . (string) $retryAfter);
            http_response_code(429);
            echo json_encode([
                'ok' => false,
                'error_code' => 'rate_limited',
                'message' => __('seo_ai.completion.rate_limited'),
                'retry_after_seconds' => $retryAfter,
            ], JSON_UNESCAPED_UNICODE);
            exit;
        }

        $completion = new SeoAiCompletionService($ai);
        $result = $completion->completeStructuredInsight($insight['payload'], $uiLocale, []);
        if (empty($result['ok'])) {
            echo json_encode([
                'ok' => false,
                'error_code' => isset($result['error_code']) && is_string($result['error_code']) ? $result['error_code'] : 'llm_error',
                'message' => isset($result['message']) && is_string($result['message']) ? $result['message'] : __('seo_ai.completion.parse_failed'),
            ], JSON_UNESCAPED_UNICODE);
            exit;
        }

        if (isset($result['usage']) && is_array($result['usage'])) {
            $u = $result['usage'];
            $tokenUsageRepo->record(
                $uid,
                (string) ($u['provider'] ?? $aiProv),
                (int) ($u['input_tokens'] ?? 0),
                (int) ($u['output_tokens'] ?? 0),
                (int) ($u['total_tokens'] ?? 0),
            );
        }

        $out = [
            'ok' => true,
            'executive_summary' => $result['executive_summary'] ?? '',
            'seo_interpretation' => $result['seo_interpretation'] ?? '',
            'priority_recommendation' => $result['priority_recommendation'] ?? '',
            'seo_health' => $insight['seo_health'],
            'prompt_version' => (int) ($result['prompt_version'] ?? SeoAiAssistantPrompts::PROMPT_VERSION),
            'provider' => (string) ($result['provider'] ?? ''),
            'model' => (string) ($result['model'] ?? ''),
            'cached' => false,
            'cache_ttl_seconds' => $cacheTtl,
        ];

        $cache->putPayload($uid, $pid, $preset, $groupKey, $computedForEnd, SeoAiAssistantDashboardCacheEnvelope::wrapStoredPayload($out));

        echo json_encode($out, JSON_UNESCAPED_UNICODE);
        exit;
    }

    private function handleQuickWinSeoAiAssistantPost(): void
    {
        $this->assertPostCsrf();
        header('Content-Type: application/json; charset=utf-8');
        header('Cache-Control: no-store, no-cache, must-revalidate');

        $uid = $this->currentUserId();
        $settingsRepo = new SettingsRepository($this->pdo(), $this->tablePrefix, $uid);
        $ai = new AiSettingsService($settingsRepo);
        if (!$ai->isEnabled()) {
            echo json_encode([
                'ok' => false,
                'error_code' => 'not_enabled',
                'message' => __('seo_ai.completion.not_enabled'),
            ], JSON_UNESCAPED_UNICODE);
            exit;
        }

        $propRepo = new GooglePropertyRepository($this->pdo(), $this->tablePrefix, $uid);
        $filters = $this->resolveGlobalFilters($propRepo);
        $activeProp = $filters['activeProp'];
        if (!is_array($activeProp) || empty($activeProp['id'])) {
            echo json_encode([
                'ok' => false,
                'error_code' => 'no_property',
                'message' => __('seo_ai.dashboard.no_property'),
            ], JSON_UNESCAPED_UNICODE);
            exit;
        }

        $tab = strtolower(trim((string) ($_GET['tab'] ?? $_POST['tab'] ?? 'iqqi')));
        if (!in_array($tab, self::QUICK_WIN_SEO_AI_TABS, true)) {
            echo json_encode([
                'ok' => false,
                'error_code' => 'bad_tab',
                'message' => __('seo_ai.quick_win.bad_tab'),
            ], JSON_UNESCAPED_UNICODE);
            exit;
        }

        $pid = (int) $activeProp['id'];
        $preset = (string) $filters['preset'];
        $device = (string) ($filters['device'] ?? 'all');
        $country = (string) ($filters['country'] ?? 'all');
        ['start' => $rangeStart, 'end' => $rangeEnd] = $this->rangeFromPreset($preset);

        $ctrRepo = new GscCtrBenchmarkRepository($this->pdo(), $this->tablePrefix);
        $qwRepo = new GscQuickWinRepository($this->pdo(), $this->tablePrefix);
        $ctrCurve = $ctrRepo->curveForRange($pid, $rangeStart, $rangeEnd, $device, $country);
        $uiLocale = SupportedLocales::normalize((string) ($settingsRepo->get(SupportedLocales::SETTING_KEY) ?? ''));
        $aiProv = $ai->provider();
        $model = $aiProv === AiSettingsService::PROVIDER_ANTHROPIC ? $ai->anthropicModel() : $ai->openaiModel();

        try {
            $bundle = $this->buildQuickWinSeoAiBundle(
                $tab,
                $pid,
                $preset,
                $rangeStart,
                $rangeEnd,
                $device,
                $country,
                $settingsRepo,
                $qwRepo,
                $ctrCurve,
                $uiLocale,
                $aiProv,
                $model,
                array_merge($_GET, $_POST),
            );
        } catch (\InvalidArgumentException) {
            echo json_encode([
                'ok' => false,
                'error_code' => 'bad_tab',
                'message' => __('seo_ai.quick_win.bad_tab'),
            ], JSON_UNESCAPED_UNICODE);
            exit;
        }

        $insight = $bundle['insight'];
        if (!$insight['ready']) {
            echo json_encode([
                'ok' => false,
                'error_code' => 'not_ready',
                'message' => $bundle['not_ready_message'],
            ], JSON_UNESCAPED_UNICODE);
            exit;
        }

        $cache = new DashboardCacheRepository($this->pdo(), $this->tablePrefix);
        $cacheTtl = SeoAiAssistantCacheKey::cacheTtlSeconds($this->config);
        $groupKey = $bundle['group_key'];
        $regenerate = isset($_POST['regenerate']) && (string) $_POST['regenerate'] === '1';

        if (!$regenerate) {
            $cached = $cache->getPayload($uid, $pid, $preset, $groupKey, $rangeEnd);
            if ($this->seoAiAssistantTryServeCachedResponse($cached, $cacheTtl)) {
                exit;
            }
        }

        if (!$ai->isReady()) {
            echo json_encode([
                'ok' => false,
                'error_code' => 'ai_not_configured',
                'message' => __('seo_ai.dashboard.ai_not_configured'),
            ], JSON_UNESCAPED_UNICODE);
            exit;
        }

        $tokenUsageRepo = new SeoAiTokenUsageRepository($this->pdo(), $this->tablePrefix);
        $monthStart = SeoAiTokenUsageRepository::calendarMonthStart();
        $limit = $aiProv === AiSettingsService::PROVIDER_ANTHROPIC
            ? $ai->anthropicMonthlyTokenLimit()
            : $ai->openaiMonthlyTokenLimit();
        if ($limit > 0) {
            $usedMonth = $tokenUsageRepo->sumTotalTokens($uid, $aiProv, $monthStart);
            if ($usedMonth >= $limit) {
                echo json_encode([
                    'ok' => false,
                    'error_code' => 'token_limit_reached',
                    'message' => __('seo_ai.completion.token_limit_reached'),
                ], JSON_UNESCAPED_UNICODE);
                exit;
            }
        }

        $pepper = (string) (($this->config['app'] ?? [])['key'] ?? '');
        $limiter = new SeoAiAssistantRateLimiter(
            $this->config,
            $this->root . '/storage/rate_limit',
            $pepper,
        );
        if (!$limiter->tryConsume($uid, $this->seoAiAssistantClientIp())) {
            $lim = SeoAiAssistantRateLimiter::resolvedLimits($this->config);
            $windows = [];
            if ($lim['session_enabled']) {
                $windows[] = $lim['session_window'];
            }
            if ($lim['ip_enabled']) {
                $windows[] = $lim['ip_window'];
            }
            $retryAfter = $windows === [] ? 60 : max(60, min(3600, min($windows)));
            header('Retry-After: ' . (string) $retryAfter);
            http_response_code(429);
            echo json_encode([
                'ok' => false,
                'error_code' => 'rate_limited',
                'message' => __('seo_ai.completion.rate_limited'),
                'retry_after_seconds' => $retryAfter,
            ], JSON_UNESCAPED_UNICODE);
            exit;
        }

        $locale = SupportedLocales::normalize($uiLocale);
        $label = SupportedLocales::label($locale);
        $completion = new SeoAiCompletionService($ai);
        $promptOverrides = $this->quickWinSeoAiPromptOverrides($tab, $locale, $label, $insight['payload']);
        $result = $completion->completeStructuredInsight($insight['payload'], $uiLocale, $promptOverrides);
        if (empty($result['ok'])) {
            echo json_encode([
                'ok' => false,
                'error_code' => isset($result['error_code']) && is_string($result['error_code']) ? $result['error_code'] : 'llm_error',
                'message' => isset($result['message']) && is_string($result['message']) ? $result['message'] : __('seo_ai.completion.parse_failed'),
            ], JSON_UNESCAPED_UNICODE);
            exit;
        }

        if (isset($result['usage']) && is_array($result['usage'])) {
            $u = $result['usage'];
            $tokenUsageRepo->record(
                $uid,
                (string) ($u['provider'] ?? $aiProv),
                (int) ($u['input_tokens'] ?? 0),
                (int) ($u['output_tokens'] ?? 0),
                (int) ($u['total_tokens'] ?? 0),
            );
        }

        $out = [
            'ok' => true,
            'executive_summary' => $result['executive_summary'] ?? '',
            'seo_interpretation' => $result['seo_interpretation'] ?? '',
            'priority_recommendation' => $result['priority_recommendation'] ?? '',
            'prompt_version' => (int) ($result['prompt_version'] ?? $bundle['prompt_version']),
            'provider' => (string) ($result['provider'] ?? ''),
            'model' => (string) ($result['model'] ?? ''),
            'cached' => false,
            'cache_ttl_seconds' => $cacheTtl,
        ];

        $cache->putPayload($uid, $pid, $preset, $groupKey, $rangeEnd, SeoAiAssistantDashboardCacheEnvelope::wrapStoredPayload($out));

        echo json_encode($out, JSON_UNESCAPED_UNICODE);
        exit;
    }

    private function handleKeywordsSeoAiAssistantPost(): void
    {
        $this->assertPostCsrf();
        header('Content-Type: application/json; charset=utf-8');
        header('Cache-Control: no-store, no-cache, must-revalidate');

        $uid = $this->currentUserId();
        $settingsRepo = new SettingsRepository($this->pdo(), $this->tablePrefix, $uid);
        $ai = new AiSettingsService($settingsRepo);
        if (!$ai->isEnabled()) {
            echo json_encode([
                'ok' => false,
                'error_code' => 'not_enabled',
                'message' => __('seo_ai.completion.not_enabled'),
            ], JSON_UNESCAPED_UNICODE);
            exit;
        }

        $propRepo = new GooglePropertyRepository($this->pdo(), $this->tablePrefix, $uid);
        $filters = $this->resolveGlobalFilters($propRepo);
        $activeProp = $filters['activeProp'];
        if (!is_array($activeProp) || empty($activeProp['id'])) {
            echo json_encode([
                'ok' => false,
                'error_code' => 'no_property',
                'message' => __('seo_ai.dashboard.no_property'),
            ], JSON_UNESCAPED_UNICODE);
            exit;
        }

        $pid = (int) $activeProp['id'];
        $preset = (string) $filters['preset'];
        $device = (string) ($filters['device'] ?? 'all');
        $country = (string) ($filters['country'] ?? 'all');
        ['start' => $rangeStart, 'end' => $rangeEnd] = $this->rangeFromPreset($preset);

        $kwRepo = new GscKeywordsRepository($this->pdo(), $this->tablePrefix);
        $uiLocale = SupportedLocales::normalize((string) ($settingsRepo->get(SupportedLocales::SETTING_KEY) ?? ''));
        $aiProv = $ai->provider();
        $model = $aiProv === AiSettingsService::PROVIDER_ANTHROPIC ? $ai->anthropicModel() : $ai->openaiModel();

        $bundle = $this->buildKeywordsSeoAiBundle(
            $uid,
            $pid,
            $preset,
            $rangeStart,
            $rangeEnd,
            $device,
            $country,
            $settingsRepo,
            $kwRepo,
            $uiLocale,
            $aiProv,
            $model,
            array_merge($_GET, $_POST),
        );

        $insight = $bundle['insight'];
        if (!$insight['ready']) {
            echo json_encode([
                'ok' => false,
                'error_code' => 'not_ready',
                'message' => $bundle['not_ready_message'],
            ], JSON_UNESCAPED_UNICODE);
            exit;
        }

        $cache = new DashboardCacheRepository($this->pdo(), $this->tablePrefix);
        $cacheTtl = SeoAiAssistantCacheKey::cacheTtlSeconds($this->config);
        $groupKey = $bundle['group_key'];
        $regenerate = isset($_POST['regenerate']) && (string) $_POST['regenerate'] === '1';

        if (!$regenerate) {
            $cached = $cache->getPayload($uid, $pid, $preset, $groupKey, $rangeEnd);
            if ($this->seoAiAssistantTryServeCachedResponse($cached, $cacheTtl)) {
                exit;
            }
        }

        if (!$ai->isReady()) {
            echo json_encode([
                'ok' => false,
                'error_code' => 'ai_not_configured',
                'message' => __('seo_ai.dashboard.ai_not_configured'),
            ], JSON_UNESCAPED_UNICODE);
            exit;
        }

        $tokenUsageRepo = new SeoAiTokenUsageRepository($this->pdo(), $this->tablePrefix);
        $monthStart = SeoAiTokenUsageRepository::calendarMonthStart();
        $limit = $aiProv === AiSettingsService::PROVIDER_ANTHROPIC
            ? $ai->anthropicMonthlyTokenLimit()
            : $ai->openaiMonthlyTokenLimit();
        if ($limit > 0) {
            $usedMonth = $tokenUsageRepo->sumTotalTokens($uid, $aiProv, $monthStart);
            if ($usedMonth >= $limit) {
                echo json_encode([
                    'ok' => false,
                    'error_code' => 'token_limit_reached',
                    'message' => __('seo_ai.completion.token_limit_reached'),
                ], JSON_UNESCAPED_UNICODE);
                exit;
            }
        }

        $pepper = (string) (($this->config['app'] ?? [])['key'] ?? '');
        $limiter = new SeoAiAssistantRateLimiter(
            $this->config,
            $this->root . '/storage/rate_limit',
            $pepper,
        );
        if (!$limiter->tryConsume($uid, $this->seoAiAssistantClientIp())) {
            $lim = SeoAiAssistantRateLimiter::resolvedLimits($this->config);
            $windows = [];
            if ($lim['session_enabled']) {
                $windows[] = $lim['session_window'];
            }
            if ($lim['ip_enabled']) {
                $windows[] = $lim['ip_window'];
            }
            $retryAfter = $windows === [] ? 60 : max(60, min(3600, min($windows)));
            header('Retry-After: ' . (string) $retryAfter);
            http_response_code(429);
            echo json_encode([
                'ok' => false,
                'error_code' => 'rate_limited',
                'message' => __('seo_ai.completion.rate_limited'),
                'retry_after_seconds' => $retryAfter,
            ], JSON_UNESCAPED_UNICODE);
            exit;
        }

        $locale = SupportedLocales::normalize($uiLocale);
        $label = SupportedLocales::label($locale);
        $completion = new SeoAiCompletionService($ai);
        $promptOverrides = [
            'system_prompt' => SeoAiKeywordsPrompts::systemPrompt($locale, $label),
            'user_json' => SeoAiKeywordsPrompts::userEnvelope($insight['payload']),
            'prompt_version' => SeoAiKeywordsPrompts::PROMPT_VERSION,
        ];
        $result = $completion->completeStructuredInsight($insight['payload'], $uiLocale, $promptOverrides);
        if (empty($result['ok'])) {
            echo json_encode([
                'ok' => false,
                'error_code' => isset($result['error_code']) && is_string($result['error_code']) ? $result['error_code'] : 'llm_error',
                'message' => isset($result['message']) && is_string($result['message']) ? $result['message'] : __('seo_ai.completion.parse_failed'),
            ], JSON_UNESCAPED_UNICODE);
            exit;
        }

        if (isset($result['usage']) && is_array($result['usage'])) {
            $u = $result['usage'];
            $tokenUsageRepo->record(
                $uid,
                (string) ($u['provider'] ?? $aiProv),
                (int) ($u['input_tokens'] ?? 0),
                (int) ($u['output_tokens'] ?? 0),
                (int) ($u['total_tokens'] ?? 0),
            );
        }

        $out = [
            'ok' => true,
            'executive_summary' => $result['executive_summary'] ?? '',
            'seo_interpretation' => $result['seo_interpretation'] ?? '',
            'priority_recommendation' => $result['priority_recommendation'] ?? '',
            'prompt_version' => (int) ($result['prompt_version'] ?? $bundle['prompt_version']),
            'provider' => (string) ($result['provider'] ?? ''),
            'model' => (string) ($result['model'] ?? ''),
            'cached' => false,
            'cache_ttl_seconds' => $cacheTtl,
        ];

        $cache->putPayload($uid, $pid, $preset, $groupKey, $rangeEnd, SeoAiAssistantDashboardCacheEnvelope::wrapStoredPayload($out));

        echo json_encode($out, JSON_UNESCAPED_UNICODE);
        exit;
    }

    /**
     * @param array<string, mixed> $params GET/POST merge
     *
     * @return array{
     *   insight: array{ready:bool,payload:array<string,mixed>},
     *   group_key: string,
     *   not_ready_message: string,
     *   prompt_version: int,
     * }
     */
    private function buildKeywordsSeoAiBundle(
        int $uid,
        int $pid,
        string $preset,
        string $rangeStart,
        string $rangeEnd,
        string $device,
        string $country,
        SettingsRepository $repo,
        GscKeywordsRepository $kwRepo,
        string $uiLocale,
        string $aiProvider,
        string $model,
        array $params,
    ): array {
        $max = SeoAiKeywordsPayloadService::MAX_KEYWORDS;
        $q = trim((string) ($params['q'] ?? ''));
        $sort = (string) ($params['sort'] ?? 'clicks');
        $dir = (string) ($params['dir'] ?? 'DESC');
        $compare = (string) ($params['compare'] ?? '') === '1';

        $keywordsShow = strtolower(trim((string) ($params['show'] ?? 'all')));
        if (!in_array($keywordsShow, ['all', 'tracked'], true)) {
            $keywordsShow = 'all';
        }

        $onlyQueries = null;
        $trackCatFilter = null;
        if ($keywordsShow === 'tracked') {
            $catRepo = new TrackedCategoryRepository($this->pdo(), $this->tablePrefix);
            $categories = $catRepo->listForProperty($uid, $pid);
            $trackCatFilter = $this->parseTrackCategoryFilterFromParams($categories, $params);
            $tqRepo = new TrackedQueryRepository($this->pdo(), $this->tablePrefix);
            $onlyQueries = $tqRepo->listQueries($uid, $pid, $trackCatFilter);
        }

        $aiRows = $kwRepo->listForRange($pid, $rangeStart, $rangeEnd, $q, $device, $country, $sort, $dir, 1, $max, $onlyQueries);
        $rows = is_array($aiRows['rows'] ?? null) ? $aiRows['rows'] : [];

        $prevStart = null;
        $prevEnd = null;
        if ($compare && $rows !== []) {
            $queries = [];
            foreach ($rows as $r) {
                if (is_array($r) && !empty($r['query'])) {
                    $queries[] = (string) $r['query'];
                }
            }
            $lenDays = max(1, (int) ((strtotime($rangeEnd) - strtotime($rangeStart)) / 86400) + 1);
            $prevEnd = date('Y-m-d', strtotime($rangeStart . ' -1 day'));
            $prevStart = date('Y-m-d', strtotime($prevEnd . ' -' . ($lenDays - 1) . ' days'));
            $prevMap = $kwRepo->totalsForQueries($pid, $prevStart, $prevEnd, $device, $country, $queries);
            $enriched = [];
            foreach ($rows as $r) {
                if (!is_array($r)) {
                    continue;
                }
                $pq = $prevMap[(string) ($r['query'] ?? '')] ?? ['clicks' => 0, 'impressions' => 0, 'ctr' => 0.0, 'position' => 0.0];
                $r['prev'] = $pq;
                $enriched[] = $r;
            }
            $rows = $enriched;
        }

        $insight = (new SeoAiKeywordsPayloadService())->build($rows, [
            'preset' => $preset,
            'device' => $device,
            'country' => $country,
            'range_start' => $rangeStart,
            'range_end' => $rangeEnd,
            'search_q' => $q,
            'show' => $keywordsShow,
            'track_category_filter' => $trackCatFilter,
            'compare_enabled' => $compare,
            'prev_range_start' => $prevStart,
            'prev_range_end' => $prevEnd,
            'sort' => $sort,
            'dir' => $dir,
            'total_queries' => (int) ($aiRows['total'] ?? 0),
        ]);

        $groupKey = SeoAiAssistantCacheKey::keywordsCacheGroupKey32(
            $pid,
            $preset,
            $rangeStart,
            $rangeEnd,
            $device,
            $country,
            $uiLocale,
            $aiProvider,
            $model,
            $keywordsShow,
            $q,
            $trackCatFilter,
            $compare,
            $sort,
            $dir,
            $insight['payload'],
        );

        return [
            'insight' => $insight,
            'group_key' => $groupKey,
            'not_ready_message' => __('seo_ai.keywords.not_ready'),
            'prompt_version' => SeoAiKeywordsPrompts::PROMPT_VERSION,
        ];
    }

    /**
     * @return array{
     *   ai_ready: bool,
     *   insight_ready: bool,
     *   has_cached: bool,
     *   cached_body: ?array{executive_summary:string,seo_interpretation:string,priority_recommendation:string},
     * }
     */
    private function keywordsSeoAiViewState(
        int $uid,
        int $pid,
        string $preset,
        string $rangeStart,
        string $rangeEnd,
        string $device,
        string $country,
        SettingsRepository $repo,
        GscKeywordsRepository $kwRepo,
        AiSettingsService $ai,
    ): array {
        $state = [
            'ai_ready' => $ai->isReady(),
            'insight_ready' => false,
            'has_cached' => false,
            'cached_body' => null,
        ];

        $uiLocale = SupportedLocales::normalize((string) ($repo->get(SupportedLocales::SETTING_KEY) ?? ''));
        $aiProv = $ai->provider();
        $model = $aiProv === AiSettingsService::PROVIDER_ANTHROPIC ? $ai->anthropicModel() : $ai->openaiModel();
        $bundle = $this->buildKeywordsSeoAiBundle(
            $uid,
            $pid,
            $preset,
            $rangeStart,
            $rangeEnd,
            $device,
            $country,
            $repo,
            $kwRepo,
            $uiLocale,
            $aiProv,
            $model,
            $_GET,
        );

        $state['insight_ready'] = $bundle['insight']['ready'];
        if (!$state['insight_ready']) {
            return $state;
        }

        $cache = new DashboardCacheRepository($this->pdo(), $this->tablePrefix);
        $cacheTtl = SeoAiAssistantCacheKey::cacheTtlSeconds($this->config);
        $cached = $cache->getPayload($uid, $pid, $preset, $bundle['group_key'], $rangeEnd);
        if ($this->seoAiAssistantHasFreshCachedSuccess($cached, $cacheTtl)) {
            $state['has_cached'] = true;
            $extracted = $this->seoAiAssistantExtractSuccessBodyFromCacheRow($cached);
            if (is_array($extracted)) {
                $state['cached_body'] = $extracted;
            }
        }

        return $state;
    }

    private function handlePagesSeoAiAssistantPost(): void
    {
        $this->assertPostCsrf();
        header('Content-Type: application/json; charset=utf-8');
        header('Cache-Control: no-store, no-cache, must-revalidate');

        $uid = $this->currentUserId();
        $settingsRepo = new SettingsRepository($this->pdo(), $this->tablePrefix, $uid);
        $ai = new AiSettingsService($settingsRepo);
        if (!$ai->isEnabled()) {
            echo json_encode([
                'ok' => false,
                'error_code' => 'not_enabled',
                'message' => __('seo_ai.completion.not_enabled'),
            ], JSON_UNESCAPED_UNICODE);
            exit;
        }

        $propRepo = new GooglePropertyRepository($this->pdo(), $this->tablePrefix, $uid);
        $filters = $this->resolveGlobalFilters($propRepo);
        $activeProp = $filters['activeProp'];
        if (!is_array($activeProp) || empty($activeProp['id'])) {
            echo json_encode([
                'ok' => false,
                'error_code' => 'no_property',
                'message' => __('seo_ai.dashboard.no_property'),
            ], JSON_UNESCAPED_UNICODE);
            exit;
        }

        $pid = (int) $activeProp['id'];
        $preset = (string) $filters['preset'];
        $device = (string) ($filters['device'] ?? 'all');
        $country = (string) ($filters['country'] ?? 'all');
        ['start' => $rangeStart, 'end' => $rangeEnd] = $this->rangeFromPreset($preset);

        $pgRepo = new GscPagesRepository($this->pdo(), $this->tablePrefix);
        $uiLocale = SupportedLocales::normalize((string) ($settingsRepo->get(SupportedLocales::SETTING_KEY) ?? ''));
        $aiProv = $ai->provider();
        $model = $aiProv === AiSettingsService::PROVIDER_ANTHROPIC ? $ai->anthropicModel() : $ai->openaiModel();

        $bundle = $this->buildPagesSeoAiBundle(
            $uid,
            $pid,
            $preset,
            $rangeStart,
            $rangeEnd,
            $device,
            $country,
            $settingsRepo,
            $pgRepo,
            $uiLocale,
            $aiProv,
            $model,
            array_merge($_GET, $_POST),
        );

        $insight = $bundle['insight'];
        if (!$insight['ready']) {
            echo json_encode([
                'ok' => false,
                'error_code' => 'not_ready',
                'message' => $bundle['not_ready_message'],
            ], JSON_UNESCAPED_UNICODE);
            exit;
        }

        $cache = new DashboardCacheRepository($this->pdo(), $this->tablePrefix);
        $cacheTtl = SeoAiAssistantCacheKey::cacheTtlSeconds($this->config);
        $groupKey = $bundle['group_key'];
        $regenerate = isset($_POST['regenerate']) && (string) $_POST['regenerate'] === '1';

        if (!$regenerate) {
            $cached = $cache->getPayload($uid, $pid, $preset, $groupKey, $rangeEnd);
            if ($this->seoAiAssistantTryServeCachedResponse($cached, $cacheTtl)) {
                exit;
            }
        }

        if (!$ai->isReady()) {
            echo json_encode([
                'ok' => false,
                'error_code' => 'ai_not_configured',
                'message' => __('seo_ai.dashboard.ai_not_configured'),
            ], JSON_UNESCAPED_UNICODE);
            exit;
        }

        $tokenUsageRepo = new SeoAiTokenUsageRepository($this->pdo(), $this->tablePrefix);
        $monthStart = SeoAiTokenUsageRepository::calendarMonthStart();
        $limit = $aiProv === AiSettingsService::PROVIDER_ANTHROPIC
            ? $ai->anthropicMonthlyTokenLimit()
            : $ai->openaiMonthlyTokenLimit();
        if ($limit > 0) {
            $usedMonth = $tokenUsageRepo->sumTotalTokens($uid, $aiProv, $monthStart);
            if ($usedMonth >= $limit) {
                echo json_encode([
                    'ok' => false,
                    'error_code' => 'token_limit_reached',
                    'message' => __('seo_ai.completion.token_limit_reached'),
                ], JSON_UNESCAPED_UNICODE);
                exit;
            }
        }

        $pepper = (string) (($this->config['app'] ?? [])['key'] ?? '');
        $limiter = new SeoAiAssistantRateLimiter(
            $this->config,
            $this->root . '/storage/rate_limit',
            $pepper,
        );
        if (!$limiter->tryConsume($uid, $this->seoAiAssistantClientIp())) {
            $lim = SeoAiAssistantRateLimiter::resolvedLimits($this->config);
            $windows = [];
            if ($lim['session_enabled']) {
                $windows[] = $lim['session_window'];
            }
            if ($lim['ip_enabled']) {
                $windows[] = $lim['ip_window'];
            }
            $retryAfter = $windows === [] ? 60 : max(60, min(3600, min($windows)));
            header('Retry-After: ' . (string) $retryAfter);
            http_response_code(429);
            echo json_encode([
                'ok' => false,
                'error_code' => 'rate_limited',
                'message' => __('seo_ai.completion.rate_limited'),
                'retry_after_seconds' => $retryAfter,
            ], JSON_UNESCAPED_UNICODE);
            exit;
        }

        $locale = SupportedLocales::normalize($uiLocale);
        $label = SupportedLocales::label($locale);
        $completion = new SeoAiCompletionService($ai);
        $promptOverrides = [
            'system_prompt' => SeoAiPagesPrompts::systemPrompt($locale, $label),
            'user_json' => SeoAiPagesPrompts::userEnvelope($insight['payload']),
            'prompt_version' => SeoAiPagesPrompts::PROMPT_VERSION,
        ];
        $result = $completion->completeStructuredInsight($insight['payload'], $uiLocale, $promptOverrides);
        if (empty($result['ok'])) {
            echo json_encode([
                'ok' => false,
                'error_code' => isset($result['error_code']) && is_string($result['error_code']) ? $result['error_code'] : 'llm_error',
                'message' => isset($result['message']) && is_string($result['message']) ? $result['message'] : __('seo_ai.completion.parse_failed'),
            ], JSON_UNESCAPED_UNICODE);
            exit;
        }

        if (isset($result['usage']) && is_array($result['usage'])) {
            $u = $result['usage'];
            $tokenUsageRepo->record(
                $uid,
                (string) ($u['provider'] ?? $aiProv),
                (int) ($u['input_tokens'] ?? 0),
                (int) ($u['output_tokens'] ?? 0),
                (int) ($u['total_tokens'] ?? 0),
            );
        }

        $out = [
            'ok' => true,
            'executive_summary' => $result['executive_summary'] ?? '',
            'seo_interpretation' => $result['seo_interpretation'] ?? '',
            'priority_recommendation' => $result['priority_recommendation'] ?? '',
            'prompt_version' => (int) ($result['prompt_version'] ?? $bundle['prompt_version']),
            'provider' => (string) ($result['provider'] ?? ''),
            'model' => (string) ($result['model'] ?? ''),
            'cached' => false,
            'cache_ttl_seconds' => $cacheTtl,
        ];

        $cache->putPayload($uid, $pid, $preset, $groupKey, $rangeEnd, SeoAiAssistantDashboardCacheEnvelope::wrapStoredPayload($out));

        echo json_encode($out, JSON_UNESCAPED_UNICODE);
        exit;
    }

    /**
     * @param array<string, mixed> $params GET/POST merge
     *
     * @return array{
     *   insight: array{ready:bool,payload:array<string,mixed>},
     *   group_key: string,
     *   not_ready_message: string,
     *   prompt_version: int,
     * }
     */
    private function buildPagesSeoAiBundle(
        int $uid,
        int $pid,
        string $preset,
        string $rangeStart,
        string $rangeEnd,
        string $device,
        string $country,
        SettingsRepository $repo,
        GscPagesRepository $pgRepo,
        string $uiLocale,
        string $aiProvider,
        string $model,
        array $params,
    ): array {
        $max = SeoAiPagesPayloadService::MAX_PAGES;
        $q = trim((string) ($params['q'] ?? ''));
        $sort = (string) ($params['sort'] ?? 'clicks');
        $dir = (string) ($params['dir'] ?? 'DESC');
        $compare = (string) ($params['compare'] ?? '') === '1';

        $pagesShow = strtolower(trim((string) ($params['show'] ?? 'all')));
        if (!in_array($pagesShow, ['all', 'tracked'], true)) {
            $pagesShow = 'all';
        }

        $onlyPages = null;
        $trackCatFilter = null;
        if ($pagesShow === 'tracked') {
            $catRepo = new TrackedCategoryRepository($this->pdo(), $this->tablePrefix);
            $categories = $catRepo->listForProperty($uid, $pid);
            $trackCatFilter = $this->parseTrackCategoryFilterFromParams($categories, $params);
            $tpRepo = new TrackedPageRepository($this->pdo(), $this->tablePrefix);
            $onlyPages = $tpRepo->listPages($uid, $pid, $trackCatFilter);
        }

        $aiRows = $pgRepo->listForRange($pid, $rangeStart, $rangeEnd, $q, $device, $country, $sort, $dir, 1, $max, $onlyPages);
        $rows = is_array($aiRows['rows'] ?? null) ? $aiRows['rows'] : [];

        $prevStart = null;
        $prevEnd = null;
        if ($compare && $rows !== []) {
            $pagesList = [];
            foreach ($rows as $r) {
                if (is_array($r) && !empty($r['page'])) {
                    $pagesList[] = (string) $r['page'];
                }
            }
            $lenDays = max(1, (int) ((strtotime($rangeEnd) - strtotime($rangeStart)) / 86400) + 1);
            $prevEnd = date('Y-m-d', strtotime($rangeStart . ' -1 day'));
            $prevStart = date('Y-m-d', strtotime($prevEnd . ' -' . ($lenDays - 1) . ' days'));
            $prevMap = $pgRepo->totalsForPages($pid, $prevStart, $prevEnd, $device, $country, $pagesList);
            $enriched = [];
            foreach ($rows as $r) {
                if (!is_array($r)) {
                    continue;
                }
                $pp = $prevMap[(string) ($r['page'] ?? '')] ?? ['clicks' => 0, 'impressions' => 0, 'ctr' => 0.0, 'position' => 0.0];
                $r['prev'] = $pp;
                $enriched[] = $r;
            }
            $rows = $enriched;
        }

        $insight = (new SeoAiPagesPayloadService())->build($rows, [
            'preset' => $preset,
            'device' => $device,
            'country' => $country,
            'range_start' => $rangeStart,
            'range_end' => $rangeEnd,
            'search_q' => $q,
            'show' => $pagesShow,
            'track_category_filter' => $trackCatFilter,
            'compare_enabled' => $compare,
            'prev_range_start' => $prevStart,
            'prev_range_end' => $prevEnd,
            'sort' => $sort,
            'dir' => $dir,
            'total_pages' => (int) ($aiRows['total'] ?? 0),
        ]);

        $groupKey = SeoAiAssistantCacheKey::pagesCacheGroupKey32(
            $pid,
            $preset,
            $rangeStart,
            $rangeEnd,
            $device,
            $country,
            $uiLocale,
            $aiProvider,
            $model,
            $pagesShow,
            $q,
            $trackCatFilter,
            $compare,
            $sort,
            $dir,
            $insight['payload'],
        );

        return [
            'insight' => $insight,
            'group_key' => $groupKey,
            'not_ready_message' => __('seo_ai.pages.not_ready'),
            'prompt_version' => SeoAiPagesPrompts::PROMPT_VERSION,
        ];
    }

    /**
     * @return array{
     *   ai_ready: bool,
     *   insight_ready: bool,
     *   has_cached: bool,
     *   cached_body: ?array{executive_summary:string,seo_interpretation:string,priority_recommendation:string},
     * }
     */
    private function pagesSeoAiViewState(
        int $uid,
        int $pid,
        string $preset,
        string $rangeStart,
        string $rangeEnd,
        string $device,
        string $country,
        SettingsRepository $repo,
        GscPagesRepository $pgRepo,
        AiSettingsService $ai,
    ): array {
        $state = [
            'ai_ready' => $ai->isReady(),
            'insight_ready' => false,
            'has_cached' => false,
            'cached_body' => null,
        ];

        $uiLocale = SupportedLocales::normalize((string) ($repo->get(SupportedLocales::SETTING_KEY) ?? ''));
        $aiProv = $ai->provider();
        $model = $aiProv === AiSettingsService::PROVIDER_ANTHROPIC ? $ai->anthropicModel() : $ai->openaiModel();
        $bundle = $this->buildPagesSeoAiBundle(
            $uid,
            $pid,
            $preset,
            $rangeStart,
            $rangeEnd,
            $device,
            $country,
            $repo,
            $pgRepo,
            $uiLocale,
            $aiProv,
            $model,
            $_GET,
        );

        $state['insight_ready'] = $bundle['insight']['ready'];
        if (!$state['insight_ready']) {
            return $state;
        }

        $cache = new DashboardCacheRepository($this->pdo(), $this->tablePrefix);
        $cacheTtl = SeoAiAssistantCacheKey::cacheTtlSeconds($this->config);
        $cached = $cache->getPayload($uid, $pid, $preset, $bundle['group_key'], $rangeEnd);
        if ($this->seoAiAssistantHasFreshCachedSuccess($cached, $cacheTtl)) {
            $state['has_cached'] = true;
            $extracted = $this->seoAiAssistantExtractSuccessBodyFromCacheRow($cached);
            if (is_array($extracted)) {
                $state['cached_body'] = $extracted;
            }
        }

        return $state;
    }

    private function handleCannibalizationSeoAiAssistantPost(): void
    {
        $this->assertPostCsrf();
        header('Content-Type: application/json; charset=utf-8');
        header('Cache-Control: no-store, no-cache, must-revalidate');

        $uid = $this->currentUserId();
        $settingsRepo = new SettingsRepository($this->pdo(), $this->tablePrefix, $uid);
        $ai = new AiSettingsService($settingsRepo);
        if (!$ai->isEnabled()) {
            echo json_encode([
                'ok' => false,
                'error_code' => 'not_enabled',
                'message' => __('seo_ai.completion.not_enabled'),
            ], JSON_UNESCAPED_UNICODE);
            exit;
        }

        $propRepo = new GooglePropertyRepository($this->pdo(), $this->tablePrefix, $uid);
        $filters = $this->resolveGlobalFilters($propRepo);
        $activeProp = $filters['activeProp'];
        if (!is_array($activeProp) || empty($activeProp['id'])) {
            echo json_encode([
                'ok' => false,
                'error_code' => 'no_property',
                'message' => __('seo_ai.dashboard.no_property'),
            ], JSON_UNESCAPED_UNICODE);
            exit;
        }

        $pid = (int) $activeProp['id'];
        $preset = (string) $filters['preset'];
        $device = (string) ($filters['device'] ?? 'all');
        $country = (string) ($filters['country'] ?? 'all');
        ['start' => $rangeStart, 'end' => $rangeEnd] = $this->rangeFromPreset($preset);

        $canRepo = new GscCannibalizationRepository($this->pdo(), $this->tablePrefix);
        $uiLocale = SupportedLocales::normalize((string) ($settingsRepo->get(SupportedLocales::SETTING_KEY) ?? ''));
        $aiProv = $ai->provider();
        $model = $aiProv === AiSettingsService::PROVIDER_ANTHROPIC ? $ai->anthropicModel() : $ai->openaiModel();

        $bundle = $this->buildCannibalizationSeoAiBundle(
            $pid,
            $preset,
            $rangeStart,
            $rangeEnd,
            $device,
            $country,
            $canRepo,
            $uiLocale,
            $aiProv,
            $model,
            array_merge($_GET, $_POST),
        );

        $insight = $bundle['insight'];
        if (!$insight['ready']) {
            echo json_encode([
                'ok' => false,
                'error_code' => 'not_ready',
                'message' => $bundle['not_ready_message'],
            ], JSON_UNESCAPED_UNICODE);
            exit;
        }

        $cache = new DashboardCacheRepository($this->pdo(), $this->tablePrefix);
        $cacheTtl = SeoAiAssistantCacheKey::cacheTtlSeconds($this->config);
        $groupKey = $bundle['group_key'];
        $regenerate = isset($_POST['regenerate']) && (string) $_POST['regenerate'] === '1';

        if (!$regenerate) {
            $cached = $cache->getPayload($uid, $pid, $preset, $groupKey, $rangeEnd);
            if ($this->seoAiAssistantTryServeCachedResponse($cached, $cacheTtl)) {
                exit;
            }
        }

        if (!$ai->isReady()) {
            echo json_encode([
                'ok' => false,
                'error_code' => 'ai_not_configured',
                'message' => __('seo_ai.dashboard.ai_not_configured'),
            ], JSON_UNESCAPED_UNICODE);
            exit;
        }

        $tokenUsageRepo = new SeoAiTokenUsageRepository($this->pdo(), $this->tablePrefix);
        $monthStart = SeoAiTokenUsageRepository::calendarMonthStart();
        $limit = $aiProv === AiSettingsService::PROVIDER_ANTHROPIC
            ? $ai->anthropicMonthlyTokenLimit()
            : $ai->openaiMonthlyTokenLimit();
        if ($limit > 0) {
            $usedMonth = $tokenUsageRepo->sumTotalTokens($uid, $aiProv, $monthStart);
            if ($usedMonth >= $limit) {
                echo json_encode([
                    'ok' => false,
                    'error_code' => 'token_limit_reached',
                    'message' => __('seo_ai.completion.token_limit_reached'),
                ], JSON_UNESCAPED_UNICODE);
                exit;
            }
        }

        $pepper = (string) (($this->config['app'] ?? [])['key'] ?? '');
        $limiter = new SeoAiAssistantRateLimiter(
            $this->config,
            $this->root . '/storage/rate_limit',
            $pepper,
        );
        if (!$limiter->tryConsume($uid, $this->seoAiAssistantClientIp())) {
            $lim = SeoAiAssistantRateLimiter::resolvedLimits($this->config);
            $windows = [];
            if ($lim['session_enabled']) {
                $windows[] = $lim['session_window'];
            }
            if ($lim['ip_enabled']) {
                $windows[] = $lim['ip_window'];
            }
            $retryAfter = $windows === [] ? 60 : max(60, min(3600, min($windows)));
            header('Retry-After: ' . (string) $retryAfter);
            http_response_code(429);
            echo json_encode([
                'ok' => false,
                'error_code' => 'rate_limited',
                'message' => __('seo_ai.completion.rate_limited'),
                'retry_after_seconds' => $retryAfter,
            ], JSON_UNESCAPED_UNICODE);
            exit;
        }

        $locale = SupportedLocales::normalize($uiLocale);
        $label = SupportedLocales::label($locale);
        $completion = new SeoAiCompletionService($ai);
        $promptOverrides = [
            'system_prompt' => SeoAiCannibalizationPrompts::systemPrompt($locale, $label),
            'user_json' => SeoAiCannibalizationPrompts::userEnvelope($insight['payload']),
            'prompt_version' => SeoAiCannibalizationPrompts::PROMPT_VERSION,
        ];
        $result = $completion->completeStructuredInsight($insight['payload'], $uiLocale, $promptOverrides);
        if (empty($result['ok'])) {
            echo json_encode([
                'ok' => false,
                'error_code' => isset($result['error_code']) && is_string($result['error_code']) ? $result['error_code'] : 'llm_error',
                'message' => isset($result['message']) && is_string($result['message']) ? $result['message'] : __('seo_ai.completion.parse_failed'),
            ], JSON_UNESCAPED_UNICODE);
            exit;
        }

        if (isset($result['usage']) && is_array($result['usage'])) {
            $u = $result['usage'];
            $tokenUsageRepo->record(
                $uid,
                (string) ($u['provider'] ?? $aiProv),
                (int) ($u['input_tokens'] ?? 0),
                (int) ($u['output_tokens'] ?? 0),
                (int) ($u['total_tokens'] ?? 0),
            );
        }

        $out = [
            'ok' => true,
            'executive_summary' => $result['executive_summary'] ?? '',
            'seo_interpretation' => $result['seo_interpretation'] ?? '',
            'priority_recommendation' => $result['priority_recommendation'] ?? '',
            'prompt_version' => (int) ($result['prompt_version'] ?? $bundle['prompt_version']),
            'provider' => (string) ($result['provider'] ?? ''),
            'model' => (string) ($result['model'] ?? ''),
            'cached' => false,
            'cache_ttl_seconds' => $cacheTtl,
        ];

        $cache->putPayload($uid, $pid, $preset, $groupKey, $rangeEnd, SeoAiAssistantDashboardCacheEnvelope::wrapStoredPayload($out));

        echo json_encode($out, JSON_UNESCAPED_UNICODE);
        exit;
    }

    /**
     * @param array<string, mixed> $params GET/POST merge
     *
     * @return array{
     *   insight: array{ready:bool,payload:array<string,mixed>},
     *   group_key: string,
     *   not_ready_message: string,
     *   prompt_version: int,
     * }
     */
    private function buildCannibalizationSeoAiBundle(
        int $pid,
        string $preset,
        string $rangeStart,
        string $rangeEnd,
        string $device,
        string $country,
        GscCannibalizationRepository $canRepo,
        string $uiLocale,
        string $aiProvider,
        string $model,
        array $params,
    ): array {
        $max = SeoAiCannibalizationPayloadService::MAX_CASES;
        $minImpr = max(0, (int) ($params['min_impr'] ?? 500));
        $minPages = max(2, (int) ($params['min_pages'] ?? 2));
        $sort = (string) ($params['sort'] ?? 'impressions');
        $dir = (string) ($params['dir'] ?? 'DESC');

        $aiRows = $canRepo->listForRange($pid, $rangeStart, $rangeEnd, $device, $country, $minImpr, $minPages, $sort, $dir, 1, $max);
        $rows = is_array($aiRows['rows'] ?? null) ? $aiRows['rows'] : [];

        $insight = (new SeoAiCannibalizationPayloadService())->build($rows, [
            'preset' => $preset,
            'device' => $device,
            'country' => $country,
            'range_start' => $rangeStart,
            'range_end' => $rangeEnd,
            'min_impressions' => $minImpr,
            'min_pages' => $minPages,
            'sort' => $sort,
            'dir' => $dir,
            'total_cases' => (int) ($aiRows['total'] ?? 0),
        ]);

        $groupKey = SeoAiAssistantCacheKey::cannibalizationCacheGroupKey32(
            $pid,
            $preset,
            $rangeStart,
            $rangeEnd,
            $device,
            $country,
            $uiLocale,
            $aiProvider,
            $model,
            $minImpr,
            $minPages,
            $sort,
            $dir,
            $insight['payload'],
        );

        return [
            'insight' => $insight,
            'group_key' => $groupKey,
            'not_ready_message' => __('seo_ai.cannibalization.not_ready'),
            'prompt_version' => SeoAiCannibalizationPrompts::PROMPT_VERSION,
        ];
    }

    /**
     * @return array{
     *   ai_ready: bool,
     *   insight_ready: bool,
     *   has_cached: bool,
     *   cached_body: ?array{executive_summary:string,seo_interpretation:string,priority_recommendation:string},
     * }
     */
    private function cannibalizationSeoAiViewState(
        int $uid,
        int $pid,
        string $preset,
        string $rangeStart,
        string $rangeEnd,
        string $device,
        string $country,
        GscCannibalizationRepository $canRepo,
        AiSettingsService $ai,
        SettingsRepository $repo,
    ): array {
        $state = [
            'ai_ready' => $ai->isReady(),
            'insight_ready' => false,
            'has_cached' => false,
            'cached_body' => null,
        ];

        $uiLocale = SupportedLocales::normalize((string) ($repo->get(SupportedLocales::SETTING_KEY) ?? ''));
        $aiProv = $ai->provider();
        $model = $aiProv === AiSettingsService::PROVIDER_ANTHROPIC ? $ai->anthropicModel() : $ai->openaiModel();
        $bundle = $this->buildCannibalizationSeoAiBundle(
            $pid,
            $preset,
            $rangeStart,
            $rangeEnd,
            $device,
            $country,
            $canRepo,
            $uiLocale,
            $aiProv,
            $model,
            $_GET,
        );

        $state['insight_ready'] = $bundle['insight']['ready'];
        if (!$state['insight_ready']) {
            return $state;
        }

        $cache = new DashboardCacheRepository($this->pdo(), $this->tablePrefix);
        $cacheTtl = SeoAiAssistantCacheKey::cacheTtlSeconds($this->config);
        $cached = $cache->getPayload($uid, $pid, $preset, $bundle['group_key'], $rangeEnd);
        if ($this->seoAiAssistantHasFreshCachedSuccess($cached, $cacheTtl)) {
            $state['has_cached'] = true;
            $extracted = $this->seoAiAssistantExtractSuccessBodyFromCacheRow($cached);
            if (is_array($extracted)) {
                $state['cached_body'] = $extracted;
            }
        }

        return $state;
    }

    private function handleLongTailSeoAiAssistantPost(): void
    {
        $this->assertPostCsrf();
        header('Content-Type: application/json; charset=utf-8');
        header('Cache-Control: no-store, no-cache, must-revalidate');

        $uid = $this->currentUserId();
        $settingsRepo = new SettingsRepository($this->pdo(), $this->tablePrefix, $uid);
        $ai = new AiSettingsService($settingsRepo);
        if (!$ai->isEnabled()) {
            echo json_encode([
                'ok' => false,
                'error_code' => 'not_enabled',
                'message' => __('seo_ai.completion.not_enabled'),
            ], JSON_UNESCAPED_UNICODE);
            exit;
        }

        $propRepo = new GooglePropertyRepository($this->pdo(), $this->tablePrefix, $uid);
        $filters = $this->resolveGlobalFilters($propRepo);
        $activeProp = $filters['activeProp'];
        if (!is_array($activeProp) || empty($activeProp['id'])) {
            echo json_encode([
                'ok' => false,
                'error_code' => 'no_property',
                'message' => __('seo_ai.dashboard.no_property'),
            ], JSON_UNESCAPED_UNICODE);
            exit;
        }

        $pid = (int) $activeProp['id'];
        $preset = (string) $filters['preset'];
        $device = (string) ($filters['device'] ?? 'all');
        $country = (string) ($filters['country'] ?? 'all');
        ['start' => $rangeStart, 'end' => $rangeEnd] = $this->rangeFromPreset($preset);

        $ltRepo = new GscLongTailRepository($this->pdo(), $this->tablePrefix);
        $uiLocale = SupportedLocales::normalize((string) ($settingsRepo->get(SupportedLocales::SETTING_KEY) ?? ''));
        $aiProv = $ai->provider();
        $model = $aiProv === AiSettingsService::PROVIDER_ANTHROPIC ? $ai->anthropicModel() : $ai->openaiModel();

        $bundle = $this->buildLongTailSeoAiBundle(
            $pid,
            $preset,
            $rangeStart,
            $rangeEnd,
            $device,
            $country,
            $ltRepo,
            $uiLocale,
            $aiProv,
            $model,
            array_merge($_GET, $_POST),
        );

        $insight = $bundle['insight'];
        if (!$insight['ready']) {
            echo json_encode([
                'ok' => false,
                'error_code' => 'not_ready',
                'message' => $bundle['not_ready_message'],
            ], JSON_UNESCAPED_UNICODE);
            exit;
        }

        $cache = new DashboardCacheRepository($this->pdo(), $this->tablePrefix);
        $cacheTtl = SeoAiAssistantCacheKey::cacheTtlSeconds($this->config);
        $groupKey = $bundle['group_key'];
        $regenerate = isset($_POST['regenerate']) && (string) $_POST['regenerate'] === '1';

        if (!$regenerate) {
            $cached = $cache->getPayload($uid, $pid, $preset, $groupKey, $rangeEnd);
            if ($this->seoAiAssistantTryServeCachedResponse($cached, $cacheTtl)) {
                exit;
            }
        }

        if (!$ai->isReady()) {
            echo json_encode([
                'ok' => false,
                'error_code' => 'ai_not_configured',
                'message' => __('seo_ai.dashboard.ai_not_configured'),
            ], JSON_UNESCAPED_UNICODE);
            exit;
        }

        $tokenUsageRepo = new SeoAiTokenUsageRepository($this->pdo(), $this->tablePrefix);
        $monthStart = SeoAiTokenUsageRepository::calendarMonthStart();
        $limit = $aiProv === AiSettingsService::PROVIDER_ANTHROPIC
            ? $ai->anthropicMonthlyTokenLimit()
            : $ai->openaiMonthlyTokenLimit();
        if ($limit > 0) {
            $usedMonth = $tokenUsageRepo->sumTotalTokens($uid, $aiProv, $monthStart);
            if ($usedMonth >= $limit) {
                echo json_encode([
                    'ok' => false,
                    'error_code' => 'token_limit_reached',
                    'message' => __('seo_ai.completion.token_limit_reached'),
                ], JSON_UNESCAPED_UNICODE);
                exit;
            }
        }

        $pepper = (string) (($this->config['app'] ?? [])['key'] ?? '');
        $limiter = new SeoAiAssistantRateLimiter(
            $this->config,
            $this->root . '/storage/rate_limit',
            $pepper,
        );
        if (!$limiter->tryConsume($uid, $this->seoAiAssistantClientIp())) {
            $lim = SeoAiAssistantRateLimiter::resolvedLimits($this->config);
            $windows = [];
            if ($lim['session_enabled']) {
                $windows[] = $lim['session_window'];
            }
            if ($lim['ip_enabled']) {
                $windows[] = $lim['ip_window'];
            }
            $retryAfter = $windows === [] ? 60 : max(60, min(3600, min($windows)));
            header('Retry-After: ' . (string) $retryAfter);
            http_response_code(429);
            echo json_encode([
                'ok' => false,
                'error_code' => 'rate_limited',
                'message' => __('seo_ai.completion.rate_limited'),
                'retry_after_seconds' => $retryAfter,
            ], JSON_UNESCAPED_UNICODE);
            exit;
        }

        $locale = SupportedLocales::normalize($uiLocale);
        $label = SupportedLocales::label($locale);
        $completion = new SeoAiCompletionService($ai);
        $promptOverrides = [
            'system_prompt' => SeoAiLongTailPrompts::systemPrompt($locale, $label),
            'user_json' => SeoAiLongTailPrompts::userEnvelope($insight['payload']),
            'prompt_version' => SeoAiLongTailPrompts::PROMPT_VERSION,
        ];
        $result = $completion->completeStructuredInsight($insight['payload'], $uiLocale, $promptOverrides);
        if (empty($result['ok'])) {
            echo json_encode([
                'ok' => false,
                'error_code' => isset($result['error_code']) && is_string($result['error_code']) ? $result['error_code'] : 'llm_error',
                'message' => isset($result['message']) && is_string($result['message']) ? $result['message'] : __('seo_ai.completion.parse_failed'),
            ], JSON_UNESCAPED_UNICODE);
            exit;
        }

        if (isset($result['usage']) && is_array($result['usage'])) {
            $u = $result['usage'];
            $tokenUsageRepo->record(
                $uid,
                (string) ($u['provider'] ?? $aiProv),
                (int) ($u['input_tokens'] ?? 0),
                (int) ($u['output_tokens'] ?? 0),
                (int) ($u['total_tokens'] ?? 0),
            );
        }

        $out = [
            'ok' => true,
            'executive_summary' => $result['executive_summary'] ?? '',
            'seo_interpretation' => $result['seo_interpretation'] ?? '',
            'priority_recommendation' => $result['priority_recommendation'] ?? '',
            'prompt_version' => (int) ($result['prompt_version'] ?? $bundle['prompt_version']),
            'provider' => (string) ($result['provider'] ?? ''),
            'model' => (string) ($result['model'] ?? ''),
            'cached' => false,
            'cache_ttl_seconds' => $cacheTtl,
        ];

        $cache->putPayload($uid, $pid, $preset, $groupKey, $rangeEnd, SeoAiAssistantDashboardCacheEnvelope::wrapStoredPayload($out));

        echo json_encode($out, JSON_UNESCAPED_UNICODE);
        exit;
    }

    private function handleQuestionQueriesSeoAiAssistantPost(): void
    {
        $this->assertPostCsrf();
        header('Content-Type: application/json; charset=utf-8');
        header('Cache-Control: no-store, no-cache, must-revalidate');

        $uid = $this->currentUserId();
        $settingsRepo = new SettingsRepository($this->pdo(), $this->tablePrefix, $uid);
        $ai = new AiSettingsService($settingsRepo);
        if (!$ai->isEnabled()) {
            echo json_encode([
                'ok' => false,
                'error_code' => 'not_enabled',
                'message' => __('seo_ai.completion.not_enabled'),
            ], JSON_UNESCAPED_UNICODE);
            exit;
        }

        $propRepo = new GooglePropertyRepository($this->pdo(), $this->tablePrefix, $uid);
        $filters = $this->resolveGlobalFilters($propRepo);
        $activeProp = $filters['activeProp'];
        if (!is_array($activeProp) || empty($activeProp['id'])) {
            echo json_encode([
                'ok' => false,
                'error_code' => 'no_property',
                'message' => __('seo_ai.dashboard.no_property'),
            ], JSON_UNESCAPED_UNICODE);
            exit;
        }

        $pid = (int) $activeProp['id'];
        $preset = (string) $filters['preset'];
        $device = (string) ($filters['device'] ?? 'all');
        $country = (string) ($filters['country'] ?? 'all');
        ['start' => $rangeStart, 'end' => $rangeEnd] = $this->rangeFromPreset($preset);

        $qqRepo = new GscQuestionQueriesRepository($this->pdo(), $this->tablePrefix);
        $uiLocale = SupportedLocales::normalize((string) ($settingsRepo->get(SupportedLocales::SETTING_KEY) ?? ''));
        $aiProv = $ai->provider();
        $model = $aiProv === AiSettingsService::PROVIDER_ANTHROPIC ? $ai->anthropicModel() : $ai->openaiModel();

        $bundle = $this->buildQuestionQueriesSeoAiBundle(
            $pid,
            $preset,
            $rangeStart,
            $rangeEnd,
            $device,
            $country,
            $qqRepo,
            $settingsRepo,
            $uiLocale,
            $aiProv,
            $model,
            array_merge($_GET, $_POST),
        );

        $insight = $bundle['insight'];
        if (!$insight['ready']) {
            echo json_encode([
                'ok' => false,
                'error_code' => 'not_ready',
                'message' => $bundle['not_ready_message'],
            ], JSON_UNESCAPED_UNICODE);
            exit;
        }

        $cache = new DashboardCacheRepository($this->pdo(), $this->tablePrefix);
        $cacheTtl = SeoAiAssistantCacheKey::cacheTtlSeconds($this->config);
        $groupKey = $bundle['group_key'];
        $regenerate = isset($_POST['regenerate']) && (string) $_POST['regenerate'] === '1';

        if (!$regenerate) {
            $cached = $cache->getPayload($uid, $pid, $preset, $groupKey, $rangeEnd);
            if ($this->seoAiAssistantTryServeCachedResponse($cached, $cacheTtl)) {
                exit;
            }
        }

        if (!$ai->isReady()) {
            echo json_encode([
                'ok' => false,
                'error_code' => 'ai_not_configured',
                'message' => __('seo_ai.dashboard.ai_not_configured'),
            ], JSON_UNESCAPED_UNICODE);
            exit;
        }

        $tokenUsageRepo = new SeoAiTokenUsageRepository($this->pdo(), $this->tablePrefix);
        $monthStart = SeoAiTokenUsageRepository::calendarMonthStart();
        $limit = $aiProv === AiSettingsService::PROVIDER_ANTHROPIC
            ? $ai->anthropicMonthlyTokenLimit()
            : $ai->openaiMonthlyTokenLimit();
        if ($limit > 0) {
            $usedMonth = $tokenUsageRepo->sumTotalTokens($uid, $aiProv, $monthStart);
            if ($usedMonth >= $limit) {
                echo json_encode([
                    'ok' => false,
                    'error_code' => 'token_limit_reached',
                    'message' => __('seo_ai.completion.token_limit_reached'),
                ], JSON_UNESCAPED_UNICODE);
                exit;
            }
        }

        $pepper = (string) (($this->config['app'] ?? [])['key'] ?? '');
        $limiter = new SeoAiAssistantRateLimiter(
            $this->config,
            $this->root . '/storage/rate_limit',
            $pepper,
        );
        if (!$limiter->tryConsume($uid, $this->seoAiAssistantClientIp())) {
            $lim = SeoAiAssistantRateLimiter::resolvedLimits($this->config);
            $windows = [];
            if ($lim['session_enabled']) {
                $windows[] = $lim['session_window'];
            }
            if ($lim['ip_enabled']) {
                $windows[] = $lim['ip_window'];
            }
            $retryAfter = $windows === [] ? 60 : max(60, min(3600, min($windows)));
            header('Retry-After: ' . (string) $retryAfter);
            http_response_code(429);
            echo json_encode([
                'ok' => false,
                'error_code' => 'rate_limited',
                'message' => __('seo_ai.completion.rate_limited'),
                'retry_after_seconds' => $retryAfter,
            ], JSON_UNESCAPED_UNICODE);
            exit;
        }

        $locale = SupportedLocales::normalize($uiLocale);
        $label = SupportedLocales::label($locale);
        $completion = new SeoAiCompletionService($ai);
        $promptOverrides = [
            'system_prompt' => SeoAiQuestionQueriesPrompts::systemPrompt($locale, $label),
            'user_json' => SeoAiQuestionQueriesPrompts::userEnvelope($insight['payload']),
            'prompt_version' => SeoAiQuestionQueriesPrompts::PROMPT_VERSION,
        ];
        $result = $completion->completeStructuredInsight($insight['payload'], $uiLocale, $promptOverrides);
        if (empty($result['ok'])) {
            echo json_encode([
                'ok' => false,
                'error_code' => isset($result['error_code']) && is_string($result['error_code']) ? $result['error_code'] : 'llm_error',
                'message' => isset($result['message']) && is_string($result['message']) ? $result['message'] : __('seo_ai.completion.parse_failed'),
            ], JSON_UNESCAPED_UNICODE);
            exit;
        }

        if (isset($result['usage']) && is_array($result['usage'])) {
            $u = $result['usage'];
            $tokenUsageRepo->record(
                $uid,
                (string) ($u['provider'] ?? $aiProv),
                (int) ($u['input_tokens'] ?? 0),
                (int) ($u['output_tokens'] ?? 0),
                (int) ($u['total_tokens'] ?? 0),
            );
        }

        $out = [
            'ok' => true,
            'executive_summary' => $result['executive_summary'] ?? '',
            'seo_interpretation' => $result['seo_interpretation'] ?? '',
            'priority_recommendation' => $result['priority_recommendation'] ?? '',
            'prompt_version' => (int) ($result['prompt_version'] ?? $bundle['prompt_version']),
            'provider' => (string) ($result['provider'] ?? ''),
            'model' => (string) ($result['model'] ?? ''),
            'cached' => false,
            'cache_ttl_seconds' => $cacheTtl,
        ];

        $cache->putPayload($uid, $pid, $preset, $groupKey, $rangeEnd, SeoAiAssistantDashboardCacheEnvelope::wrapStoredPayload($out));

        echo json_encode($out, JSON_UNESCAPED_UNICODE);
        exit;
    }

    private function handleDecaySeoAiAssistantPost(): void
    {
        $this->assertPostCsrf();
        header('Content-Type: application/json; charset=utf-8');
        header('Cache-Control: no-store, no-cache, must-revalidate');

        $uid = $this->currentUserId();
        $settingsRepo = new SettingsRepository($this->pdo(), $this->tablePrefix, $uid);
        $ai = new AiSettingsService($settingsRepo);
        if (!$ai->isEnabled()) {
            echo json_encode([
                'ok' => false,
                'error_code' => 'not_enabled',
                'message' => __('seo_ai.completion.not_enabled'),
            ], JSON_UNESCAPED_UNICODE);
            exit;
        }

        $propRepo = new GooglePropertyRepository($this->pdo(), $this->tablePrefix, $uid);
        $filters = $this->resolveGlobalFilters($propRepo);
        $activeProp = $filters['activeProp'];
        if (!is_array($activeProp) || empty($activeProp['id'])) {
            echo json_encode([
                'ok' => false,
                'error_code' => 'no_property',
                'message' => __('seo_ai.dashboard.no_property'),
            ], JSON_UNESCAPED_UNICODE);
            exit;
        }

        $pid = (int) $activeProp['id'];
        $preset = (string) $filters['preset'];
        $device = (string) ($filters['device'] ?? 'all');
        $country = (string) ($filters['country'] ?? 'all');
        ['start' => $curStart, 'end' => $curEnd] = $this->rangeFromPreset($preset);
        $periods = $this->resolveDecayComparisonRanges($curStart, $curEnd);

        $decayRepo = new GscDecayRepository($this->pdo(), $this->tablePrefix);
        $uiLocale = SupportedLocales::normalize((string) ($settingsRepo->get(SupportedLocales::SETTING_KEY) ?? ''));
        $aiProv = $ai->provider();
        $model = $aiProv === AiSettingsService::PROVIDER_ANTHROPIC ? $ai->anthropicModel() : $ai->openaiModel();

        $bundle = $this->buildDecaySeoAiBundle(
            $pid,
            $preset,
            $periods['cur_start'],
            $periods['cur_end'],
            $periods['prev_start'],
            $periods['prev_end'],
            $device,
            $country,
            $decayRepo,
            $settingsRepo,
            $uiLocale,
            $aiProv,
            $model,
            array_merge($_GET, $_POST),
        );

        $insight = $bundle['insight'];
        if (!$insight['ready']) {
            echo json_encode([
                'ok' => false,
                'error_code' => 'not_ready',
                'message' => $bundle['not_ready_message'],
            ], JSON_UNESCAPED_UNICODE);
            exit;
        }

        $cache = new DashboardCacheRepository($this->pdo(), $this->tablePrefix);
        $cacheTtl = SeoAiAssistantCacheKey::cacheTtlSeconds($this->config);
        $groupKey = $bundle['group_key'];
        $regenerate = isset($_POST['regenerate']) && (string) $_POST['regenerate'] === '1';

        if (!$regenerate) {
            $cached = $cache->getPayload($uid, $pid, $preset, $groupKey, $periods['cur_end']);
            if ($this->seoAiAssistantTryServeCachedResponse($cached, $cacheTtl)) {
                exit;
            }
        }

        if (!$ai->isReady()) {
            echo json_encode([
                'ok' => false,
                'error_code' => 'ai_not_configured',
                'message' => __('seo_ai.dashboard.ai_not_configured'),
            ], JSON_UNESCAPED_UNICODE);
            exit;
        }

        $tokenUsageRepo = new SeoAiTokenUsageRepository($this->pdo(), $this->tablePrefix);
        $monthStart = SeoAiTokenUsageRepository::calendarMonthStart();
        $limit = $aiProv === AiSettingsService::PROVIDER_ANTHROPIC
            ? $ai->anthropicMonthlyTokenLimit()
            : $ai->openaiMonthlyTokenLimit();
        if ($limit > 0) {
            $usedMonth = $tokenUsageRepo->sumTotalTokens($uid, $aiProv, $monthStart);
            if ($usedMonth >= $limit) {
                echo json_encode([
                    'ok' => false,
                    'error_code' => 'token_limit_reached',
                    'message' => __('seo_ai.completion.token_limit_reached'),
                ], JSON_UNESCAPED_UNICODE);
                exit;
            }
        }

        $pepper = (string) (($this->config['app'] ?? [])['key'] ?? '');
        $limiter = new SeoAiAssistantRateLimiter(
            $this->config,
            $this->root . '/storage/rate_limit',
            $pepper,
        );
        if (!$limiter->tryConsume($uid, $this->seoAiAssistantClientIp())) {
            $lim = SeoAiAssistantRateLimiter::resolvedLimits($this->config);
            $windows = [];
            if ($lim['session_enabled']) {
                $windows[] = $lim['session_window'];
            }
            if ($lim['ip_enabled']) {
                $windows[] = $lim['ip_window'];
            }
            $retryAfter = $windows === [] ? 60 : max(60, min(3600, min($windows)));
            header('Retry-After: ' . (string) $retryAfter);
            http_response_code(429);
            echo json_encode([
                'ok' => false,
                'error_code' => 'rate_limited',
                'message' => __('seo_ai.completion.rate_limited'),
                'retry_after_seconds' => $retryAfter,
            ], JSON_UNESCAPED_UNICODE);
            exit;
        }

        $locale = SupportedLocales::normalize($uiLocale);
        $label = SupportedLocales::label($locale);
        $completion = new SeoAiCompletionService($ai);
        $promptOverrides = [
            'system_prompt' => SeoAiDecayPrompts::systemPrompt($locale, $label),
            'user_json' => SeoAiDecayPrompts::userEnvelope($insight['payload']),
            'prompt_version' => SeoAiDecayPrompts::PROMPT_VERSION,
        ];
        $result = $completion->completeStructuredInsight($insight['payload'], $uiLocale, $promptOverrides);
        if (empty($result['ok'])) {
            echo json_encode([
                'ok' => false,
                'error_code' => isset($result['error_code']) && is_string($result['error_code']) ? $result['error_code'] : 'llm_error',
                'message' => isset($result['message']) && is_string($result['message']) ? $result['message'] : __('seo_ai.completion.parse_failed'),
            ], JSON_UNESCAPED_UNICODE);
            exit;
        }

        if (isset($result['usage']) && is_array($result['usage'])) {
            $u = $result['usage'];
            $tokenUsageRepo->record(
                $uid,
                (string) ($u['provider'] ?? $aiProv),
                (int) ($u['input_tokens'] ?? 0),
                (int) ($u['output_tokens'] ?? 0),
                (int) ($u['total_tokens'] ?? 0),
            );
        }

        $out = [
            'ok' => true,
            'executive_summary' => $result['executive_summary'] ?? '',
            'seo_interpretation' => $result['seo_interpretation'] ?? '',
            'priority_recommendation' => $result['priority_recommendation'] ?? '',
            'prompt_version' => (int) ($result['prompt_version'] ?? $bundle['prompt_version']),
            'provider' => (string) ($result['provider'] ?? ''),
            'model' => (string) ($result['model'] ?? ''),
            'cached' => false,
            'cache_ttl_seconds' => $cacheTtl,
        ];

        $cache->putPayload($uid, $pid, $preset, $groupKey, $periods['cur_end'], SeoAiAssistantDashboardCacheEnvelope::wrapStoredPayload($out));

        echo json_encode($out, JSON_UNESCAPED_UNICODE);
        exit;
    }

    /**
     * @return array{min_impressions:int,clicks_drop_pct:float,impr_drop_pct:float,position_worsen:float}
     */
    private function resolveDecayConfig(SettingsRepository $settings, array $params): array
    {
        $cfg = [
            'min_impressions' => (int) ($settings->get('decay.min_impressions') ?? '500'),
            'clicks_drop_pct' => (float) ($settings->get('decay.clicks_drop_pct') ?? '0.2'),
            'impr_drop_pct' => (float) ($settings->get('decay.impr_drop_pct') ?? '0.2'),
            'position_worsen' => (float) ($settings->get('decay.position_worsen') ?? '1.0'),
        ];

        if (array_key_exists('min_impressions', $params)) {
            $cfg['min_impressions'] = max(0, (int) $params['min_impressions']);
        }
        if (array_key_exists('clicks_drop_pct', $params)) {
            $cfg['clicks_drop_pct'] = max(0.0, min(1.0, ((float) $params['clicks_drop_pct']) / 100.0));
        }
        if (array_key_exists('impr_drop_pct', $params)) {
            $cfg['impr_drop_pct'] = max(0.0, min(1.0, ((float) $params['impr_drop_pct']) / 100.0));
        }
        if (array_key_exists('position_worsen', $params)) {
            $cfg['position_worsen'] = max(0.0, (float) $params['position_worsen']);
        }

        return $cfg;
    }

    /**
     * @return array{cur_start:string,cur_end:string,prev_start:string,prev_end:string}
     */
    private function resolveDecayComparisonRanges(string $curStart, string $curEnd): array
    {
        $lenDays = max(1, (int) ((strtotime($curEnd) - strtotime($curStart)) / 86400) + 1);
        $prevEnd = date('Y-m-d', strtotime($curStart . ' -1 day'));
        $prevStart = date('Y-m-d', strtotime($prevEnd . ' -' . ($lenDays - 1) . ' days'));

        return [
            'cur_start' => $curStart,
            'cur_end' => $curEnd,
            'prev_start' => $prevStart,
            'prev_end' => $prevEnd,
        ];
    }

    /**
     * @param array<string, mixed> $params GET/POST merge
     *
     * @return array{
     *   insight: array{ready:bool,payload:array<string,mixed>},
     *   group_key: string,
     *   not_ready_message: string,
     *   prompt_version: int,
     * }
     */
    private function buildDecaySeoAiBundle(
        int $pid,
        string $preset,
        string $curStart,
        string $curEnd,
        string $prevStart,
        string $prevEnd,
        string $device,
        string $country,
        GscDecayRepository $decayRepo,
        SettingsRepository $settings,
        string $uiLocale,
        string $aiProvider,
        string $model,
        array $params,
    ): array {
        $max = SeoAiDecayPayloadService::MAX_QUERIES;
        $cfg = $this->resolveDecayConfig($settings, $params);
        $sort = (string) ($params['sort'] ?? 'd_clicks');
        $dir = (string) ($params['dir'] ?? 'ASC');

        $aiRows = $decayRepo->listForRange(
            $pid,
            $curStart,
            $curEnd,
            $prevStart,
            $prevEnd,
            $device,
            $country,
            $cfg,
            $sort,
            $dir,
            1,
            $max,
        );
        $rows = is_array($aiRows['rows'] ?? null) ? $aiRows['rows'] : [];

        $insight = (new SeoAiDecayPayloadService())->build($rows, [
            'preset' => $preset,
            'device' => $device,
            'country' => $country,
            'cur_start' => $curStart,
            'cur_end' => $curEnd,
            'prev_start' => $prevStart,
            'prev_end' => $prevEnd,
            'min_impressions' => (int) $cfg['min_impressions'],
            'clicks_drop_pct' => (float) $cfg['clicks_drop_pct'],
            'impr_drop_pct' => (float) $cfg['impr_drop_pct'],
            'position_worsen' => (float) $cfg['position_worsen'],
            'sort' => $sort,
            'dir' => $dir,
            'total_queries' => (int) ($aiRows['total'] ?? 0),
        ]);

        $groupKey = SeoAiAssistantCacheKey::decayCacheGroupKey32(
            $pid,
            $preset,
            $curStart,
            $curEnd,
            $prevStart,
            $prevEnd,
            $device,
            $country,
            $uiLocale,
            $aiProvider,
            $model,
            (int) $cfg['min_impressions'],
            (float) $cfg['clicks_drop_pct'],
            (float) $cfg['impr_drop_pct'],
            (float) $cfg['position_worsen'],
            $sort,
            $dir,
            $insight['payload'],
        );

        return [
            'insight' => $insight,
            'group_key' => $groupKey,
            'not_ready_message' => __('seo_ai.decay.not_ready'),
            'prompt_version' => SeoAiDecayPrompts::PROMPT_VERSION,
        ];
    }

    /**
     * @return array{
     *   ai_ready: bool,
     *   insight_ready: bool,
     *   has_cached: bool,
     *   cached_body: ?array{executive_summary:string,seo_interpretation:string,priority_recommendation:string},
     * }
     */
    private function decaySeoAiViewState(
        int $uid,
        int $pid,
        string $preset,
        string $curStart,
        string $curEnd,
        string $prevStart,
        string $prevEnd,
        string $device,
        string $country,
        GscDecayRepository $decayRepo,
        AiSettingsService $ai,
        SettingsRepository $repo,
    ): array {
        $state = [
            'ai_ready' => $ai->isReady(),
            'insight_ready' => false,
            'has_cached' => false,
            'cached_body' => null,
        ];

        $uiLocale = SupportedLocales::normalize((string) ($repo->get(SupportedLocales::SETTING_KEY) ?? ''));
        $aiProv = $ai->provider();
        $model = $aiProv === AiSettingsService::PROVIDER_ANTHROPIC ? $ai->anthropicModel() : $ai->openaiModel();
        $bundle = $this->buildDecaySeoAiBundle(
            $pid,
            $preset,
            $curStart,
            $curEnd,
            $prevStart,
            $prevEnd,
            $device,
            $country,
            $decayRepo,
            $repo,
            $uiLocale,
            $aiProv,
            $model,
            $_GET,
        );

        $state['insight_ready'] = $bundle['insight']['ready'];
        if (!$state['insight_ready']) {
            return $state;
        }

        $cache = new DashboardCacheRepository($this->pdo(), $this->tablePrefix);
        $cacheTtl = SeoAiAssistantCacheKey::cacheTtlSeconds($this->config);
        $cached = $cache->getPayload($uid, $pid, $preset, $bundle['group_key'], $curEnd);
        if ($this->seoAiAssistantHasFreshCachedSuccess($cached, $cacheTtl)) {
            $state['has_cached'] = true;
            $extracted = $this->seoAiAssistantExtractSuccessBodyFromCacheRow($cached);
            if (is_array($extracted)) {
                $state['cached_body'] = $extracted;
            }
        }

        return $state;
    }

    /**
     * @return list<string>
     */
    private function resolveQuestionQueriesTerms(SettingsRepository $settings): array
    {
        $defaultTerms = "what\nwhere\nwhen\nwhy\nhow\nwho\nwhich\nwhose\nwhom";
        $stored = $settings->get('question_queries.terms');
        $termsRaw = is_string($stored) ? $stored : $defaultTerms;

        return array_values(array_filter(
            array_map('trim', preg_split('/[\r\n,]+/', $termsRaw) ?: []),
            static fn ($s) => $s !== '',
        ));
    }

    /**
     * @param array<string, mixed> $params GET/POST merge
     *
     * @return array{
     *   insight: array{ready:bool,payload:array<string,mixed>},
     *   group_key: string,
     *   not_ready_message: string,
     *   prompt_version: int,
     * }
     */
    private function buildQuestionQueriesSeoAiBundle(
        int $pid,
        string $preset,
        string $rangeStart,
        string $rangeEnd,
        string $device,
        string $country,
        GscQuestionQueriesRepository $qqRepo,
        SettingsRepository $settings,
        string $uiLocale,
        string $aiProvider,
        string $model,
        array $params,
    ): array {
        $max = SeoAiQuestionQueriesPayloadService::MAX_QUERIES;
        $terms = $this->resolveQuestionQueriesTerms($settings);
        $sort = (string) ($params['sort'] ?? 'clicks');
        $dir = (string) ($params['dir'] ?? 'DESC');

        $aiRows = $qqRepo->listForRange($pid, $rangeStart, $rangeEnd, $terms, $device, $country, $sort, $dir, 1, $max);
        $rows = is_array($aiRows['rows'] ?? null) ? $aiRows['rows'] : [];

        $insight = (new SeoAiQuestionQueriesPayloadService())->build($rows, [
            'preset' => $preset,
            'device' => $device,
            'country' => $country,
            'range_start' => $rangeStart,
            'range_end' => $rangeEnd,
            'question_terms' => $terms,
            'sort' => $sort,
            'dir' => $dir,
            'total_queries' => (int) ($aiRows['total'] ?? 0),
        ]);

        $groupKey = SeoAiAssistantCacheKey::questionQueriesCacheGroupKey32(
            $pid,
            $preset,
            $rangeStart,
            $rangeEnd,
            $device,
            $country,
            $uiLocale,
            $aiProvider,
            $model,
            $terms,
            $sort,
            $dir,
            $insight['payload'],
        );

        return [
            'insight' => $insight,
            'group_key' => $groupKey,
            'not_ready_message' => __('seo_ai.question_queries.not_ready'),
            'prompt_version' => SeoAiQuestionQueriesPrompts::PROMPT_VERSION,
        ];
    }

    /**
     * @return array{
     *   ai_ready: bool,
     *   insight_ready: bool,
     *   has_cached: bool,
     *   cached_body: ?array{executive_summary:string,seo_interpretation:string,priority_recommendation:string},
     * }
     */
    private function questionQueriesSeoAiViewState(
        int $uid,
        int $pid,
        string $preset,
        string $rangeStart,
        string $rangeEnd,
        string $device,
        string $country,
        GscQuestionQueriesRepository $qqRepo,
        AiSettingsService $ai,
        SettingsRepository $repo,
    ): array {
        $state = [
            'ai_ready' => $ai->isReady(),
            'insight_ready' => false,
            'has_cached' => false,
            'cached_body' => null,
        ];

        $uiLocale = SupportedLocales::normalize((string) ($repo->get(SupportedLocales::SETTING_KEY) ?? ''));
        $aiProv = $ai->provider();
        $model = $aiProv === AiSettingsService::PROVIDER_ANTHROPIC ? $ai->anthropicModel() : $ai->openaiModel();
        $bundle = $this->buildQuestionQueriesSeoAiBundle(
            $pid,
            $preset,
            $rangeStart,
            $rangeEnd,
            $device,
            $country,
            $qqRepo,
            $repo,
            $uiLocale,
            $aiProv,
            $model,
            $_GET,
        );

        $state['insight_ready'] = $bundle['insight']['ready'];
        if (!$state['insight_ready']) {
            return $state;
        }

        $cache = new DashboardCacheRepository($this->pdo(), $this->tablePrefix);
        $cacheTtl = SeoAiAssistantCacheKey::cacheTtlSeconds($this->config);
        $cached = $cache->getPayload($uid, $pid, $preset, $bundle['group_key'], $rangeEnd);
        if ($this->seoAiAssistantHasFreshCachedSuccess($cached, $cacheTtl)) {
            $state['has_cached'] = true;
            $extracted = $this->seoAiAssistantExtractSuccessBodyFromCacheRow($cached);
            if (is_array($extracted)) {
                $state['cached_body'] = $extracted;
            }
        }

        return $state;
    }

    /**
     * @param array<string, mixed> $params GET/POST merge
     *
     * @return array{
     *   insight: array{ready:bool,payload:array<string,mixed>},
     *   group_key: string,
     *   not_ready_message: string,
     *   prompt_version: int,
     * }
     */
    private function buildLongTailSeoAiBundle(
        int $pid,
        string $preset,
        string $rangeStart,
        string $rangeEnd,
        string $device,
        string $country,
        GscLongTailRepository $ltRepo,
        string $uiLocale,
        string $aiProvider,
        string $model,
        array $params,
    ): array {
        $max = SeoAiLongTailPayloadService::MAX_QUERIES;
        $minTerms = max(1, (int) ($params['min_terms'] ?? 4));
        $sort = (string) ($params['sort'] ?? 'clicks');
        $dir = (string) ($params['dir'] ?? 'DESC');

        $aiRows = $ltRepo->listForRange($pid, $rangeStart, $rangeEnd, $minTerms, $device, $country, $sort, $dir, 1, $max);
        $rows = is_array($aiRows['rows'] ?? null) ? $aiRows['rows'] : [];

        $insight = (new SeoAiLongTailPayloadService())->build($rows, [
            'preset' => $preset,
            'device' => $device,
            'country' => $country,
            'range_start' => $rangeStart,
            'range_end' => $rangeEnd,
            'min_terms' => $minTerms,
            'sort' => $sort,
            'dir' => $dir,
            'total_queries' => (int) ($aiRows['total'] ?? 0),
        ]);

        $groupKey = SeoAiAssistantCacheKey::longTailCacheGroupKey32(
            $pid,
            $preset,
            $rangeStart,
            $rangeEnd,
            $device,
            $country,
            $uiLocale,
            $aiProvider,
            $model,
            $minTerms,
            $sort,
            $dir,
            $insight['payload'],
        );

        return [
            'insight' => $insight,
            'group_key' => $groupKey,
            'not_ready_message' => __('seo_ai.long_tail.not_ready'),
            'prompt_version' => SeoAiLongTailPrompts::PROMPT_VERSION,
        ];
    }

    /**
     * @return array{
     *   ai_ready: bool,
     *   insight_ready: bool,
     *   has_cached: bool,
     *   cached_body: ?array{executive_summary:string,seo_interpretation:string,priority_recommendation:string},
     * }
     */
    private function longTailSeoAiViewState(
        int $uid,
        int $pid,
        string $preset,
        string $rangeStart,
        string $rangeEnd,
        string $device,
        string $country,
        GscLongTailRepository $ltRepo,
        AiSettingsService $ai,
        SettingsRepository $repo,
    ): array {
        $state = [
            'ai_ready' => $ai->isReady(),
            'insight_ready' => false,
            'has_cached' => false,
            'cached_body' => null,
        ];

        $uiLocale = SupportedLocales::normalize((string) ($repo->get(SupportedLocales::SETTING_KEY) ?? ''));
        $aiProv = $ai->provider();
        $model = $aiProv === AiSettingsService::PROVIDER_ANTHROPIC ? $ai->anthropicModel() : $ai->openaiModel();
        $bundle = $this->buildLongTailSeoAiBundle(
            $pid,
            $preset,
            $rangeStart,
            $rangeEnd,
            $device,
            $country,
            $ltRepo,
            $uiLocale,
            $aiProv,
            $model,
            $_GET,
        );

        $state['insight_ready'] = $bundle['insight']['ready'];
        if (!$state['insight_ready']) {
            return $state;
        }

        $cache = new DashboardCacheRepository($this->pdo(), $this->tablePrefix);
        $cacheTtl = SeoAiAssistantCacheKey::cacheTtlSeconds($this->config);
        $cached = $cache->getPayload($uid, $pid, $preset, $bundle['group_key'], $rangeEnd);
        if ($this->seoAiAssistantHasFreshCachedSuccess($cached, $cacheTtl)) {
            $state['has_cached'] = true;
            $extracted = $this->seoAiAssistantExtractSuccessBodyFromCacheRow($cached);
            if (is_array($extracted)) {
                $state['cached_body'] = $extracted;
            }
        }

        return $state;
    }

    /**
     * @param list<array{id:int,name:string}> $categories
     * @param array<string, mixed> $params
     */
    private function parseTrackCategoryFilterFromParams(array $categories, array $params): ?int
    {
        $raw = trim((string) ($params['track_cat'] ?? ''));
        if ($raw === '') {
            return null;
        }
        if ($raw === '0') {
            return 0;
        }
        if (!ctype_digit($raw)) {
            return null;
        }
        $id = (int) $raw;
        if ($id < 1) {
            return null;
        }
        foreach ($categories as $c) {
            if (is_array($c) && (int) ($c['id'] ?? 0) === $id) {
                return $id;
            }
        }

        return null;
    }

    /**
     * @param array<string, mixed> $params GET/POST merge
     *
     * @return array{
     *   insight: array{ready:bool,payload:array<string,mixed>},
     *   group_key: string,
     *   not_ready_message: string,
     *   prompt_version: int,
     * }
     */
    private function buildQuickWinSeoAiBundle(
        string $tab,
        int $pid,
        string $preset,
        string $rangeStart,
        string $rangeEnd,
        string $device,
        string $country,
        SettingsRepository $repo,
        GscQuickWinRepository $qwRepo,
        array $ctrCurve,
        string $uiLocale,
        string $aiProvider,
        string $model,
        array $params,
    ): array {
        $max = SeoAiIqqiPayloadService::MAX_OPPORTUNITIES;

        if ($tab === 'low_ctr_pages') {
            $thresh = $this->resolveQuickWinLowCtrPagesThresholds($repo, $params);
            $aiRows = $qwRepo->listLowCtrPages(
                $pid,
                $rangeStart,
                $rangeEnd,
                $device,
                $country,
                $thresh['min_impr'],
                $thresh['coeff'],
                $ctrCurve,
                1,
                $max,
                'score',
                'DESC',
            );
            $insight = (new SeoAiLowCtrPagesPayloadService())->build(
                is_array($aiRows['rows'] ?? null) ? $aiRows['rows'] : [],
                [
                    'preset' => $preset,
                    'device' => $device,
                    'country' => $country,
                    'range_start' => $rangeStart,
                    'range_end' => $rangeEnd,
                    'lowctr_pages_min_impr' => $thresh['min_impr'],
                    'lowctr_coeff' => $thresh['coeff'],
                    'total_opportunities' => (int) ($aiRows['total'] ?? 0),
                ],
            );
            $groupKey = SeoAiAssistantCacheKey::lowCtrPagesCacheGroupKey32(
                $pid,
                $preset,
                $rangeStart,
                $rangeEnd,
                $device,
                $country,
                $uiLocale,
                $aiProvider,
                $model,
                $thresh['min_impr'],
                $thresh['coeff'],
                $insight['payload'],
            );

            return [
                'insight' => $insight,
                'group_key' => $groupKey,
                'not_ready_message' => __('seo_ai.low_ctr_pages.not_ready'),
                'prompt_version' => SeoAiLowCtrPagesPrompts::PROMPT_VERSION,
            ];
        }

        if ($tab === 'ctr_opportunity') {
            $thresh = $this->resolveQuickWinCtrThresholds($repo, $params);
            $aiRows = $qwRepo->listCtrOpportunity(
                $pid,
                $rangeStart,
                $rangeEnd,
                $device,
                $country,
                $thresh['min_impr'],
                $ctrCurve,
                1,
                $max,
                'score',
                'DESC',
            );
            $insight = (new SeoAiCtrOpportunityPayloadService())->build(
                is_array($aiRows['rows'] ?? null) ? $aiRows['rows'] : [],
                [
                    'preset' => $preset,
                    'device' => $device,
                    'country' => $country,
                    'range_start' => $rangeStart,
                    'range_end' => $rangeEnd,
                    'ctr_min_impr' => $thresh['min_impr'],
                    'total_opportunities' => (int) ($aiRows['total'] ?? 0),
                ],
            );
            $groupKey = SeoAiAssistantCacheKey::ctrOpportunityCacheGroupKey32(
                $pid,
                $preset,
                $rangeStart,
                $rangeEnd,
                $device,
                $country,
                $uiLocale,
                $aiProvider,
                $model,
                $thresh['min_impr'],
                $insight['payload'],
            );

            return [
                'insight' => $insight,
                'group_key' => $groupKey,
                'not_ready_message' => __('seo_ai.ctr.not_ready'),
                'prompt_version' => SeoAiCtrOpportunityPrompts::PROMPT_VERSION,
            ];
        }

        if ($tab === 'topical_gaps') {
            $thresh = $this->resolveQuickWinTopicalGapsThresholds($repo, $params);
            $aiRows = $qwRepo->listTopicalGaps(
                $pid,
                $rangeStart,
                $rangeEnd,
                $device,
                $country,
                $thresh['min_impr'],
                $thresh['pos_min'],
                $thresh['max_clicks'],
                $ctrCurve,
                1,
                $max,
                'score',
                'DESC',
            );
            $insight = (new SeoAiTopicalGapsPayloadService())->build(
                is_array($aiRows['rows'] ?? null) ? $aiRows['rows'] : [],
                [
                    'preset' => $preset,
                    'device' => $device,
                    'country' => $country,
                    'range_start' => $rangeStart,
                    'range_end' => $rangeEnd,
                    'topical_min_impr' => $thresh['min_impr'],
                    'topical_pos_min' => $thresh['pos_min'],
                    'topical_max_clicks' => $thresh['max_clicks'],
                    'total_opportunities' => (int) ($aiRows['total'] ?? 0),
                ],
            );
            $groupKey = SeoAiAssistantCacheKey::topicalGapsCacheGroupKey32(
                $pid,
                $preset,
                $rangeStart,
                $rangeEnd,
                $device,
                $country,
                $uiLocale,
                $aiProvider,
                $model,
                $thresh['min_impr'],
                $thresh['pos_min'],
                $thresh['max_clicks'],
                $insight['payload'],
            );

            return [
                'insight' => $insight,
                'group_key' => $groupKey,
                'not_ready_message' => __('seo_ai.topical_gaps.not_ready'),
                'prompt_version' => SeoAiTopicalGapsPrompts::PROMPT_VERSION,
            ];
        }

        if ($tab === 'iqqi') {
            $thresh = $this->resolveQuickWinIqqiThresholds($repo, $params);
            $aiRows = $qwRepo->listIqqi(
                $pid,
                $rangeStart,
                $rangeEnd,
                $device,
                $country,
                $thresh['min_impr'],
                $thresh['pos_min'],
                $thresh['pos_max'],
                $ctrCurve,
                1,
                $max,
                'score',
                'DESC',
            );
            $insight = (new SeoAiIqqiPayloadService())->build(
                is_array($aiRows['rows'] ?? null) ? $aiRows['rows'] : [],
                [
                    'preset' => $preset,
                    'device' => $device,
                    'country' => $country,
                    'range_start' => $rangeStart,
                    'range_end' => $rangeEnd,
                    'iqqi_min_impr' => $thresh['min_impr'],
                    'iqqi_pos_min' => $thresh['pos_min'],
                    'iqqi_pos_max' => $thresh['pos_max'],
                    'total_opportunities' => (int) ($aiRows['total'] ?? 0),
                ],
            );
            $groupKey = SeoAiAssistantCacheKey::iqqiCacheGroupKey32(
                $pid,
                $preset,
                $rangeStart,
                $rangeEnd,
                $device,
                $country,
                $uiLocale,
                $aiProvider,
                $model,
                $thresh['min_impr'],
                $thresh['pos_min'],
                $thresh['pos_max'],
                $insight['payload'],
            );

            return [
                'insight' => $insight,
                'group_key' => $groupKey,
                'not_ready_message' => __('seo_ai.iqqi.not_ready'),
                'prompt_version' => SeoAiIqqiPrompts::PROMPT_VERSION,
            ];
        }

        throw new \InvalidArgumentException('Unsupported Quick Win SEO AI tab: ' . $tab);
    }

    /**
     * @return array{
     *   ai_ready: bool,
     *   insight_ready: bool,
     *   has_cached: bool,
     *   cached_body: ?array{executive_summary:string,seo_interpretation:string,priority_recommendation:string},
     * }
     */
    private function quickWinSeoAiViewState(
        string $tab,
        int $uid,
        int $pid,
        string $preset,
        string $rangeStart,
        string $rangeEnd,
        string $device,
        string $country,
        SettingsRepository $repo,
        GscQuickWinRepository $qwRepo,
        array $ctrCurve,
        AiSettingsService $ai,
    ): array {
        $state = [
            'ai_ready' => $ai->isReady(),
            'insight_ready' => false,
            'has_cached' => false,
            'cached_body' => null,
        ];

        try {
            $uiLocale = SupportedLocales::normalize((string) ($repo->get(SupportedLocales::SETTING_KEY) ?? ''));
            $aiProv = $ai->provider();
            $model = $aiProv === AiSettingsService::PROVIDER_ANTHROPIC ? $ai->anthropicModel() : $ai->openaiModel();
            $bundle = $this->buildQuickWinSeoAiBundle(
                $tab,
                $pid,
                $preset,
                $rangeStart,
                $rangeEnd,
                $device,
                $country,
                $repo,
                $qwRepo,
                $ctrCurve,
                $uiLocale,
                $aiProv,
                $model,
                $_GET,
            );
        } catch (\InvalidArgumentException) {
            return $state;
        }

        $state['insight_ready'] = $bundle['insight']['ready'];
        if (!$state['insight_ready']) {
            return $state;
        }

        $cache = new DashboardCacheRepository($this->pdo(), $this->tablePrefix);
        $cacheTtl = SeoAiAssistantCacheKey::cacheTtlSeconds($this->config);
        $cached = $cache->getPayload($uid, $pid, $preset, $bundle['group_key'], $rangeEnd);
        if ($this->seoAiAssistantHasFreshCachedSuccess($cached, $cacheTtl)) {
            $state['has_cached'] = true;
            $extracted = $this->seoAiAssistantExtractSuccessBodyFromCacheRow($cached);
            if (is_array($extracted)) {
                $state['cached_body'] = $extracted;
            }
        }

        return $state;
    }

    /**
     * @param array<string, mixed> $params GET/POST merge
     *
     * @return array{min_impr:int}
     */
    private function resolveQuickWinCtrThresholds(SettingsRepository $repo, array $params): array
    {
        $minCtrImpr = (int) ($repo->get('quickwin.ctr_gap.min_impr') ?? '200');
        $reset = (string) ($params['reset'] ?? '') === '1';
        if (!$reset) {
            $minCtrImpr = max(0, (int) ($params['ctr_min_impr'] ?? $minCtrImpr));
        }

        return ['min_impr' => $minCtrImpr];
    }

    /**
     * @param array<string, mixed> $params GET/POST merge
     *
     * @return array{min_impr:int, coeff:float}
     */
    private function resolveQuickWinLowCtrPagesThresholds(SettingsRepository $repo, array $params): array
    {
        $minImpr = (int) ($repo->get('quickwin.lowctr_pages.min_impr') ?? '1000');
        $coeff = (float) ($repo->get('quickwin.lowctr.coeff') ?? '0.5');
        $reset = (string) ($params['reset'] ?? '') === '1';
        if (!$reset) {
            $minImpr = max(0, (int) ($params['lowctr_pages_min_impr'] ?? $minImpr));
            $coeff = max(0.05, (float) ($params['lowctr_coeff'] ?? $coeff));
        }

        return ['min_impr' => $minImpr, 'coeff' => $coeff];
    }

    /**
     * @param array<string, mixed> $params GET/POST merge
     *
     * @return array{min_impr:int, pos_min:float, max_clicks:int}
     */
    private function resolveQuickWinTopicalGapsThresholds(SettingsRepository $repo, array $params): array
    {
        $minImpr = (int) ($repo->get('quickwin.topical.min_impr') ?? '200');
        $posMin = (float) ($repo->get('quickwin.topical.pos_min') ?? '15');
        $maxClicks = (int) ($repo->get('quickwin.topical.max_clicks') ?? '2');
        $reset = (string) ($params['reset'] ?? '') === '1';
        if (!$reset) {
            $minImpr = max(0, (int) ($params['topical_min_impr'] ?? $minImpr));
            $posMin = max(1.0, (float) ($params['topical_pos_min'] ?? $posMin));
            $maxClicks = max(0, (int) ($params['topical_max_clicks'] ?? $maxClicks));
        }

        return ['min_impr' => $minImpr, 'pos_min' => $posMin, 'max_clicks' => $maxClicks];
    }

    /**
     * @param array<string, mixed> $payload
     *
     * @return array{system_prompt:string,user_json:string,prompt_version:int}
     */
    private function quickWinSeoAiPromptOverrides(string $tab, string $locale, string $label, array $payload): array
    {
        if ($tab === 'ctr_opportunity') {
            return [
                'system_prompt' => SeoAiCtrOpportunityPrompts::systemPrompt($locale, $label),
                'user_json' => SeoAiCtrOpportunityPrompts::userEnvelope($payload),
                'prompt_version' => SeoAiCtrOpportunityPrompts::PROMPT_VERSION,
            ];
        }
        if ($tab === 'low_ctr_pages') {
            return [
                'system_prompt' => SeoAiLowCtrPagesPrompts::systemPrompt($locale, $label),
                'user_json' => SeoAiLowCtrPagesPrompts::userEnvelope($payload),
                'prompt_version' => SeoAiLowCtrPagesPrompts::PROMPT_VERSION,
            ];
        }
        if ($tab === 'topical_gaps') {
            return [
                'system_prompt' => SeoAiTopicalGapsPrompts::systemPrompt($locale, $label),
                'user_json' => SeoAiTopicalGapsPrompts::userEnvelope($payload),
                'prompt_version' => SeoAiTopicalGapsPrompts::PROMPT_VERSION,
            ];
        }

        return [
            'system_prompt' => SeoAiIqqiPrompts::systemPrompt($locale, $label),
            'user_json' => SeoAiIqqiPrompts::userEnvelope($payload),
            'prompt_version' => SeoAiIqqiPrompts::PROMPT_VERSION,
        ];
    }

    /**
     * @return ?array{
     *   slug:string,
     *   tab:string,
     *   session_storage_key:string,
     *   url_query_params:list<string>,
     *   intro_hint:string,
     *   not_ready:string,
     *   generate_tooltip:string,
     * }
     */
    private function quickWinSeoAiCardPreset(string $tab): ?array
    {
        return match ($tab) {
            'iqqi' => [
                'slug' => 'iqqi',
                'tab' => 'iqqi',
                'session_storage_key' => 'ss_seo_ai_iqqi_has_result',
                'url_query_params' => ['prop', 'range', 'compare', 'device', 'country', 'tab', 'iqqi_min_impr', 'iqqi_pos_min', 'iqqi_pos_max'],
                'intro_hint' => __('seo_ai.iqqi.intro_hint'),
                'not_ready' => __('seo_ai.iqqi.not_ready'),
                'generate_tooltip' => __('seo_ai.iqqi.generate_tooltip'),
            ],
            'ctr_opportunity' => [
                'slug' => 'ctr',
                'tab' => 'ctr_opportunity',
                'session_storage_key' => 'ss_seo_ai_ctr_has_result',
                'url_query_params' => ['prop', 'range', 'compare', 'device', 'country', 'tab', 'ctr_min_impr'],
                'intro_hint' => __('seo_ai.ctr.intro_hint'),
                'not_ready' => __('seo_ai.ctr.not_ready'),
                'generate_tooltip' => __('seo_ai.ctr.generate_tooltip'),
            ],
            'low_ctr_pages' => [
                'slug' => 'lowctr',
                'tab' => 'low_ctr_pages',
                'session_storage_key' => 'ss_seo_ai_lowctr_has_result',
                'url_query_params' => ['prop', 'range', 'compare', 'device', 'country', 'tab', 'lowctr_pages_min_impr', 'lowctr_coeff'],
                'intro_hint' => __('seo_ai.low_ctr_pages.intro_hint'),
                'not_ready' => __('seo_ai.low_ctr_pages.not_ready'),
                'generate_tooltip' => __('seo_ai.low_ctr_pages.generate_tooltip'),
            ],
            'topical_gaps' => [
                'slug' => 'topical',
                'tab' => 'topical_gaps',
                'session_storage_key' => 'ss_seo_ai_topical_has_result',
                'url_query_params' => ['prop', 'range', 'compare', 'device', 'country', 'tab', 'topical_min_impr', 'topical_pos_min', 'topical_max_clicks'],
                'intro_hint' => __('seo_ai.topical_gaps.intro_hint'),
                'not_ready' => __('seo_ai.topical_gaps.not_ready'),
                'generate_tooltip' => __('seo_ai.topical_gaps.generate_tooltip'),
            ],
            default => null,
        };
    }

    /**
     * @param array<string, mixed> $params GET/POST merge
     *
     * @return array{min_impr:int, pos_min:float, pos_max:float}
     */
    private function resolveQuickWinIqqiThresholds(SettingsRepository $repo, array $params): array
    {
        $minIqqiImpr = (int) ($repo->get('quickwin.iqqi.min_impr') ?? '200');
        $iqqiPosMin = (float) ($repo->get('quickwin.iqqi.pos_min') ?? '4');
        $iqqiPosMax = (float) ($repo->get('quickwin.iqqi.pos_max') ?? '10');
        $reset = (string) ($params['reset'] ?? '') === '1';
        if (!$reset) {
            $minIqqiImpr = max(0, (int) ($params['iqqi_min_impr'] ?? $minIqqiImpr));
            $iqqiPosMin = max(1.0, (float) ($params['iqqi_pos_min'] ?? $iqqiPosMin));
            $iqqiPosMax = max($iqqiPosMin, (float) ($params['iqqi_pos_max'] ?? $iqqiPosMax));
        }

        return [
            'min_impr' => $minIqqiImpr,
            'pos_min' => $iqqiPosMin,
            'pos_max' => $iqqiPosMax,
        ];
    }

    private function handleDashboardWidgetsGet(): void
    {
        $uid = $this->currentUserId();
        $propRepo = new GooglePropertyRepository($this->pdo(), $this->tablePrefix, $uid);
        $widgets = new GscDashboardWidgetsRepository($this->pdo(), $this->tablePrefix);
        $cache = new DashboardCacheRepository($this->pdo(), $this->tablePrefix);

        $filters = $this->resolveGlobalFilters($propRepo);
        $activeProp = $filters['activeProp'];
        $preset = (string) ($filters['preset'] ?? 'last_28_days');
        $device = (string) ($filters['device'] ?? 'all');
        $country = (string) ($filters['country'] ?? 'all');

        ['start' => $kpiStart, 'end' => $kpiEnd] = $this->rangeFromPreset($preset);
        $rangeDays = max(1, (int) ((strtotime($kpiEnd) - strtotime($kpiStart)) / 86400) + 1);

        $group = strtolower(trim((string) ($_GET['group'] ?? '')));
        if (!in_array($group, ['trend', 'traffic'], true)) {
            http_response_code(400);
            header('Content-Type: application/json; charset=utf-8');
            echo json_encode(['ok' => false, 'error' => 'bad_group'], JSON_UNESCAPED_SLASHES);
            return;
        }

        if ($rangeDays <= 90) {
            http_response_code(400);
            header('Content-Type: application/json; charset=utf-8');
            echo json_encode(['ok' => false, 'error' => 'range_not_deferred'], JSON_UNESCAPED_SLASHES);
            return;
        }

        if (!is_array($activeProp) || empty($activeProp['id'])) {
            http_response_code(400);
            header('Content-Type: application/json; charset=utf-8');
            echo json_encode(['ok' => false, 'error' => 'no_property'], JSON_UNESCAPED_SLASHES);
            return;
        }

        $pid = (int) $activeProp['id'];
        $computedForEnd = $kpiEnd; // yesterday

        $cached = $cache->getPayload($uid, $pid, $preset, $group, $computedForEnd);
        if (is_array($cached)) {
            header('Content-Type: application/json; charset=utf-8');
            header('Cache-Control: no-store, no-cache, must-revalidate, max-age=0');
            header('Pragma: no-cache');
            echo json_encode(['ok' => true, 'group' => $group, 'cached' => true, 'payload' => $cached], JSON_UNESCAPED_SLASHES);
            return;
        }

        $payload = [];
        if ($group === 'trend') {
            $prevEnd = date('Y-m-d', strtotime($kpiStart . ' -1 day'));
            $prevStart = date('Y-m-d', strtotime($prevEnd . ' -' . ($rangeDays - 1) . ' days'));
            $rankTrend = $widgets->rankingTrend($pid, $kpiStart, $kpiEnd, $prevStart, $prevEnd, $device, $country, 10);
            $payload = [
                'rankTrend' => $rankTrend,
            ];
        } else {
            $deviceClicks = $widgets->clicksByDevice($pid, $kpiStart, $kpiEnd);
            $topCountries = $widgets->topCountriesByClicks($pid, $kpiStart, $kpiEnd, 5);
            $kwDist = $widgets->keywordDistribution($pid, $kpiStart, $kpiEnd);
            $payload = [
                'deviceClicks' => $deviceClicks,
                'topCountries' => $topCountries,
                'kwDist' => $kwDist,
            ];
        }

        $cache->putPayload($uid, $pid, $preset, $group, $computedForEnd, $payload);

        header('Content-Type: application/json; charset=utf-8');
        header('Cache-Control: no-store, no-cache, must-revalidate, max-age=0');
        header('Pragma: no-cache');
        echo json_encode(['ok' => true, 'group' => $group, 'cached' => false, 'payload' => $payload], JSON_UNESCAPED_SLASHES);
    }

    private function showSettings(): void
    {
        $flash = $this->pullFlash();
        $uid = $this->currentUserId();
        $repo = new SettingsRepository($this->pdo(), $this->tablePrefix, $uid);
        $cronToken = (string) ($repo->get('cron.token') ?? '');
        if ($cronToken === '') {
            // Ensure a token exists so the Automation tab can always show a usable cron URL.
            try {
                $cronToken = bin2hex(random_bytes(16));
                $repo->set('cron.token', $cronToken);
            } catch (\Throwable) {
                $cronToken = '';
            }
        }
        $cronPath = '/cron/sync?t=' . rawurlencode($cronToken !== '' ? $cronToken : '');
        $cronUrl = seoscope_absolute_app_url($cronPath);
        $logs = new SyncLogRepository($this->pdo(), $this->tablePrefix);
        $diag = $this->buildDiagnosticsPayload($repo, $logs);
        $jobRepo = new SyncJobRepository($this->pdo(), $this->tablePrefix);
        $diag['sync_jobs_recent'] = $jobRepo->recentJobsForUser($uid, 15);
        $propRepo = new GooglePropertyRepository($this->pdo(), $this->tablePrefix, $uid);

        $googleDiag = $_SESSION['_google_diag'] ?? null;
        unset($_SESSION['_google_diag']);

        $clientId = $repo->get('google.client_id') ?? '';
        $hasClientSecret = ($repo->get('google.client_secret') ?? '') !== '';
        $hasOauthJson = ($repo->get('google.oauth_json') ?? '') !== '';
        $isConnected = ($repo->get('google.connected_at') ?? '') !== '';
        $reconnectRequired = ($repo->get('google.reconnect_required') ?? '') === '1';
        $tokenError = $repo->get('google.token_last_error');
        $properties = $propRepo->all();
        $resetPropertyIdsWithData = [];
        try {
            $pids = [];
            foreach ($properties as $p) {
                if (!is_array($p)) continue;
                $pid = (int) ($p['id'] ?? 0);
                if ($pid > 0) $pids[] = $pid;
            }
            $pids = array_values(array_unique($pids));
            if ($pids !== []) {
                $tAgg = '`' . str_replace('`', '``', $this->tablePrefix . 'gsc_daily_property_agg') . '`';
                $ph = implode(',', array_fill(0, count($pids), '?'));
                $stmt = $this->pdo()->prepare("SELECT DISTINCT property_id FROM {$tAgg} WHERE property_id IN ({$ph})");
                $stmt->execute($pids);
                $rows = $stmt->fetchAll(\PDO::FETCH_COLUMN) ?: [];
                foreach ($rows as $v) {
                    $id = is_numeric($v) ? (int) $v : 0;
                    if ($id > 0) $resetPropertyIdsWithData[$id] = true;
                }
            }
        } catch (\Throwable) {
        }

        $syncMode = $repo->get('gsc.sync.mode') ?? 'last_30_days';
        $syncRowLimit = $repo->get('gsc.sync.row_limit') ?? '25000';
        $syncSleepMs = $repo->get('gsc.sync.sleep_ms') ?? '100';
        $breakdownsPolicy = $repo->get('gsc.sync.breakdowns_policy') ?? 'off';
        $quickWinCfg = [
            'iqqi_min_impr' => (int) ($repo->get('quickwin.iqqi.min_impr') ?? '200'),
            'iqqi_pos_min' => (float) ($repo->get('quickwin.iqqi.pos_min') ?? '4'),
            'iqqi_pos_max' => (float) ($repo->get('quickwin.iqqi.pos_max') ?? '10'),
            'ctr_min_impr' => (int) ($repo->get('quickwin.ctr_gap.min_impr') ?? '200'),
            'lowctr_pages_min_impr' => (int) ($repo->get('quickwin.lowctr_pages.min_impr') ?? '1000'),
            'lowctr_coeff' => (float) ($repo->get('quickwin.lowctr.coeff') ?? '0.5'),
            'topical_min_impr' => (int) ($repo->get('quickwin.topical.min_impr') ?? '200'),
            'topical_pos_min' => (float) ($repo->get('quickwin.topical.pos_min') ?? '15'),
            'topical_max_clicks' => (int) ($repo->get('quickwin.topical.max_clicks') ?? '2'),
        ];
        $lastSyncSummary = $repo->get('gsc.last_sync_summary');

        $hasClientId = trim((string) $clientId) !== '';
        $setupCredsOk = $hasOauthJson || ($hasClientId && $hasClientSecret);
        $propsCount = is_array($properties) ? count($properties) : 0;
        $enabledCount = 0;
        foreach ($properties as $p) {
            if (is_array($p) && !empty($p['is_enabled'])) {
                $enabledCount++;
            }
        }
        $cronLastRunAt = (string) ($repo->get('cron.last_run_at') ?? '');
        $cronIsStale = true;
        if ($cronLastRunAt !== '') {
            $ts = strtotime($cronLastRunAt);
            if ($ts !== false) {
                $cronIsStale = (time() - $ts) > (15 * 60);
            }
        }

        $setup = [
            'credentials' => [
                'ok' => $setupCredsOk,
                'has_oauth_json' => $hasOauthJson,
                'has_client_id' => $hasClientId,
                'has_client_secret' => $hasClientSecret,
            ],
            'google_connection' => [
                'connected' => (bool) $isConnected,
                'reconnect_required' => (bool) $reconnectRequired,
                'token_error' => is_string($tokenError) ? $tokenError : null,
            ],
            'properties' => [
                'count' => $propsCount,
                'enabled_count' => $enabledCount,
            ],
            'cron' => [
                'token_present' => $cronToken !== '',
                'url' => $cronUrl,
                'last_run_at' => $cronLastRunAt !== '' ? $cronLastRunAt : null,
                'is_stale' => $cronIsStale,
            ],
        ];

        // Never repopulate secrets in cleartext in the UI.
        $clientSecret = $hasClientSecret ? str_repeat('•', 24) : '';
        $oauthJson = $hasOauthJson ? str_repeat('•', 96) : '';

        // Keep name consistent with /settings/google view.
        $diagGoogle = $googleDiag;
        $uiLocale = SupportedLocales::normalize($repo->get(SupportedLocales::SETTING_KEY));

        $updateReleases = null;
        $updateReleasesError = null;
        $updateUploadMax = (string) ini_get('upload_max_filesize');
        $updatePostMax = (string) ini_get('post_max_size');
        if (trim((string) ($_GET['tab'] ?? '')) === 'update') {
            $url = $this->releasesJsonUrl();
            if ($url !== '') {
                $fr = new \SeoScope\Updates\ReleaseInfoFetcher($url);
                $r = $fr->fetch();
                if ($r['ok']) {
                    $updateReleases = $r['info'];
                } else {
                    $updateReleasesError = (string) $r['error'];
                }
            }
        }
        $currentSeoscopeVersion = \SeoScope\Version::VERSION;

        $filters = $this->resolveGlobalFilters($propRepo);
        $globalFilters = $filters['globalFilters'];
        $globalFilters['current_path'] = '/settings';
        $settingsTab = trim((string) ($_GET['tab'] ?? 'google'));
        if ($settingsTab === 'tracked_categories') {
            $settingsTab = 'categories';
        }
        $settingsTabAllowed = ['google', 'automation', 'interface', 'ai', 'categories', 'diagnostics', 'reset', 'update', 'tracked_categories'];
        if (!in_array($settingsTab, $settingsTabAllowed, true)) {
            $settingsTab = 'google';
        }
        $globalFilters['settings_tab'] = $settingsTab;

        $settingsTrackedCategoriesList = [];
        $settingsTrackedCatActiveProp = $filters['activeProp'];
        if ($settingsTab === 'categories' && is_array($settingsTrackedCatActiveProp) && !empty($settingsTrackedCatActiveProp['id'])) {
            $catRepoSt = new TrackedCategoryRepository($this->pdo(), $this->tablePrefix);
            $settingsTrackedCategoriesList = $catRepoSt->listForProperty($uid, (int) $settingsTrackedCatActiveProp['id']);
        }

        $aiSettings = (new AiSettingsService($repo))->formSnapshot();
        $openaiModelPicker = ['models' => [], 'from_api' => false];
        $aiTokenUsage = [
            'openai' => ['all_time' => 0, 'last_30_days' => 0, 'calendar_month' => 0],
            'anthropic' => ['all_time' => 0, 'last_30_days' => 0, 'calendar_month' => 0],
        ];
        if ($settingsTab === 'ai') {
            $aiSvc = new AiSettingsService($repo);
            $openaiModelPicker = (new OpenAiModelCatalog($repo, $aiSvc))->choicesForSettings($aiSvc->openaiModel());
            $tokenRepo = new SeoAiTokenUsageRepository($this->pdo(), $this->tablePrefix);
            $since30 = date('Y-m-d H:i:s', strtotime('-30 days'));
            $ms = SeoAiTokenUsageRepository::calendarMonthStart();
            foreach (['openai', 'anthropic'] as $p) {
                $aiTokenUsage[$p] = [
                    'all_time' => $tokenRepo->sumTotalTokens($uid, $p, null),
                    'last_30_days' => $tokenRepo->sumTotalTokens($uid, $p, $since30),
                    'calendar_month' => $tokenRepo->sumTotalTokens($uid, $p, $ms),
                ];
            }
        }

        require $this->root . '/app/Views/settings.php';
    }

    private function releasesJsonUrl(): string
    {
        $u = $this->config['update']['releases_url'] ?? null;
        if (is_string($u) && trim($u) !== '') {
            return trim($u);
        }

        return 'https://seoscope.cloud/releases.json';
    }

    private function handleSettingsUpdateUploadPost(): void
    {
        $this->assertPostCsrf();
        $redir = seoscope_url('/settings?' . http_build_query(['tab' => 'update']));

        $f = $_FILES['update_package'] ?? null;
        if (!is_array($f) || (int) ($f['error'] ?? 0) !== \UPLOAD_ERR_OK) {
            $code = is_array($f) ? (string) ($f['error'] ?? '0') : '0';
            $_SESSION['_flash_error'] = (int) $code === \UPLOAD_ERR_INI_SIZE || (int) $code === \UPLOAD_ERR_FORM_SIZE
                ? __('settings.update.error_too_big')
                : __('settings.update.error_upload', ['code' => $code]);
            $this->redirect($redir);

            return;
        }

        $tmp = (string) ($f['tmp_name'] ?? '');
        if ($tmp === '' || !is_uploaded_file($tmp)) {
            $_SESSION['_flash_error'] = __('settings.update.error_no_file');
            $this->redirect($redir);

            return;
        }

        if (!\class_exists(\ZipArchive::class)) {
            $_SESSION['_flash_error'] = __('settings.update.error_no_zip');
            $this->redirect($redir);

            return;
        }

        $current = \SeoScope\Version::VERSION;
        $installer = new \SeoScope\Updates\PackageInstaller($this->root);

        try {
            $r = $installer->applyFromZipPath($tmp, $current);
        } catch (\Throwable $e) {
            $_SESSION['_flash_error'] = __('settings.update.error_apply', ['message' => $e->getMessage()]);
            $this->redirect($redir);

            return;
        }

        if (empty($r['ok'])) {
            $_SESSION['_flash_error'] = $this->mapPackageInstallFailure($r);
            $this->redirect($redir);

            return;
        }

        $this->runMigrations();
        $_SESSION['_flash_ok'] = __(
            'settings.update.success',
            ['version' => (string) ($r['new_version'] ?? ''), 'files' => (string) (int) ($r['files'] ?? 0)]
        );
        $this->redirect($redir);
    }

    /**
     * @param array{ok: false, error: string, detail?: string} $r
     */
    private function mapPackageInstallFailure(array $r): string
    {
        $e = (string) ($r['error'] ?? '');
        $d = isset($r['detail']) && is_string($r['detail']) ? $r['detail'] : '';

        return match ($e) {
            \SeoScope\Updates\PackageInstaller::ERR_NO_ZIP_EXT => __('settings.update.error_no_zip'),
            \SeoScope\Updates\PackageInstaller::ERR_INVALID_ZIP => __('settings.update.error_invalid_zip', ['detail' => $d]),
            \SeoScope\Updates\PackageInstaller::ERR_NO_PACKAGE => __('settings.update.error_not_package'),
            \SeoScope\Updates\PackageInstaller::ERR_NO_VERSION => __('settings.update.error_no_version_file'),
            \SeoScope\Updates\PackageInstaller::ERR_NOT_NEWER => __('settings.update.error_not_newer', ['version' => $d]),
            \SeoScope\Updates\PackageInstaller::ERR_WRITE, \SeoScope\Updates\PackageInstaller::ERR_PATH
                => __('settings.update.error_write', ['message' => $d]),
            default => __('settings.update.error_apply', ['message' => $e]),
        };
    }

    private function handleSettingsAiPost(): void
    {
        $this->assertPostCsrf();
        $uid = $this->currentUserId();
        $repo = new SettingsRepository($this->pdo(), $this->tablePrefix, $uid);
        $ai = new AiSettingsService($repo);
        $ai->saveFromPost($_POST);
        $repo->set(OpenAiModelCatalog::CACHE_SETTING, null);

        $snap = $ai->formSnapshot();
        if ($snap['enabled'] && !$snap['active_provider_configured']) {
            $_SESSION['_flash_error'] = __('settings.ai.saved_missing_key');
        } else {
            $_SESSION['_flash_ok'] = __('settings.ai.saved');
        }
        $this->redirect(seoscope_url('/settings?' . http_build_query(['tab' => 'ai'])));
    }

    private function handleSettingsAiEnabledPost(): void
    {
        $this->assertPostCsrf();
        header('Content-Type: application/json; charset=utf-8');
        header('Cache-Control: no-store, no-cache, must-revalidate');

        $uid = $this->currentUserId();
        $repo = new SettingsRepository($this->pdo(), $this->tablePrefix, $uid);
        $ai = new AiSettingsService($repo);
        $enabled = !empty($_POST['ai_enabled']);
        $ai->setEnabled($enabled);

        echo json_encode([
            'ok' => true,
            'enabled' => $enabled,
            'message' => $enabled
                ? __('settings.ai.enabled_on')
                : __('settings.ai.enabled_off'),
        ], JSON_UNESCAPED_UNICODE);
        exit;
    }

    private function handleSettingsAiTestPost(): void
    {
        $this->assertPostCsrf();
        $uid = $this->currentUserId();
        $repo = new SettingsRepository($this->pdo(), $this->tablePrefix, $uid);
        $ai = new AiSettingsService($repo);
        $result = (new AiConnectionTestService($ai))->test($_POST);

        header('Content-Type: application/json; charset=utf-8');
        header('Cache-Control: no-store, no-cache, must-revalidate');
        echo json_encode($result, JSON_UNESCAPED_UNICODE);
        exit;
    }

    private function handleSettingsAiModelsRefreshPost(): void
    {
        $this->assertPostCsrf();
        $uid = $this->currentUserId();
        $repo = new SettingsRepository($this->pdo(), $this->tablePrefix, $uid);
        $ai = new AiSettingsService($repo);
        $result = (new OpenAiModelCatalog($repo, $ai))->refresh();

        header('Content-Type: application/json; charset=utf-8');
        header('Cache-Control: no-store, no-cache, must-revalidate');
        echo json_encode($result, JSON_UNESCAPED_UNICODE);
        exit;
    }

    private function handleSettingsInterfacePost(): void
    {
        $this->assertPostCsrf();
        $raw = (string) ($_POST['locale'] ?? '');
        if (!SupportedLocales::isSupported($raw)) {
            $_SESSION['_flash_error'] = __('settings.interface.error_invalid');
            $this->redirect(seoscope_url('/settings?' . http_build_query(['tab' => 'interface'])));

            return;
        }
        $locale = SupportedLocales::normalize($raw);
        $uid = $this->currentUserId();
        $repo = new SettingsRepository($this->pdo(), $this->tablePrefix, $uid);
        $repo->set(SupportedLocales::SETTING_KEY, $locale);
        SupportedLocales::setUiLocaleCookie($locale, $this->config);
        $_SESSION['_flash_ok'] = __('settings.interface.saved');
        $this->redirect(seoscope_url('/settings?' . http_build_query(['tab' => 'interface'])));
    }

    private function handleSettingsInterfaceLanguageCreatePost(): void
    {
        $this->assertPostCsrf();
        $code = $this->canonLocale((string) ($_POST['code'] ?? ''));
        $label = trim((string) ($_POST['label'] ?? ''));
        if ($code === null || $label === '') {
            $_SESSION['_flash_error'] = __('settings.interface.langs.error_invalid');
            $this->redirect(seoscope_url('/settings?' . http_build_query(['tab' => 'interface'])));
            return;
        }
        if (mb_strlen($label) > 48) {
            $_SESSION['_flash_error'] = __('settings.interface.langs.error_invalid');
            $this->redirect(seoscope_url('/settings?' . http_build_query(['tab' => 'interface'])));
            return;
        }
        if (SupportedLocales::isSupported($code)) {
            $_SESSION['_flash_error'] = __('settings.interface.langs.error_exists');
            $this->redirect(seoscope_url('/settings?' . http_build_query(['tab' => 'interface'])));
            return;
        }

        $dir = $this->ensureLangStorageDir();
        if ($dir === null) {
            $_SESSION['_flash_error'] = __('settings.interface.langs.error_storage');
            $this->redirect(seoscope_url('/settings?' . http_build_query(['tab' => 'interface'])));
            return;
        }

        $localesPath = $dir . '/locales.json';
        $existing = $this->readLocalesJson($localesPath);
        $existing[$code] = $label;
        ksort($existing, SORT_STRING);
        if (!$this->writeJsonFile($localesPath, $existing)) {
            $_SESSION['_flash_error'] = __('settings.interface.langs.error_storage');
            $this->redirect(seoscope_url('/settings?' . http_build_query(['tab' => 'interface'])));
            return;
        }

        $_SESSION['_flash_ok'] = __('settings.interface.langs.created');
        $this->redirect(seoscope_url('/settings?' . http_build_query(['tab' => 'interface'])));
    }

    private function handleSettingsInterfaceLanguageDownloadGet(): void
    {
        $raw = (string) ($_GET['code'] ?? '');
        $code = $this->canonLocale($raw);
        $custom = SupportedLocales::customAll();
        if ($code === null || !array_key_exists($code, $custom)) {
            http_response_code(404);
            echo 'Not found';
            return;
        }
        $enFile = $this->root . '/lang/en/messages.php';
        $en = is_readable($enFile) ? require $enFile : [];
        $en = is_array($en) ? $en : [];

        $json = json_encode($en, JSON_PRETTY_PRINT | JSON_UNESCAPED_UNICODE | JSON_UNESCAPED_SLASHES);
        if (!is_string($json)) {
            http_response_code(500);
            echo 'Failed to encode';
            return;
        }
        $filename = 'messages.' . $code . '.json';
        header('Content-Type: application/json; charset=utf-8');
        header('Content-Disposition: attachment; filename="' . $filename . '"');
        echo $json;
        exit;
    }

    private function handleSettingsInterfaceLanguageUploadPost(): void
    {
        $this->assertPostCsrf();
        $raw = (string) ($_GET['code'] ?? $_POST['code'] ?? '');
        $code = $this->canonLocale($raw);
        $custom = SupportedLocales::customAll();
        if ($code === null || !array_key_exists($code, $custom)) {
            $_SESSION['_flash_error'] = __('settings.interface.langs.error_invalid');
            $this->redirect(seoscope_url('/settings?' . http_build_query(['tab' => 'interface'])));
            return;
        }

        $file = $_FILES['messages_file'] ?? null;
        if (!is_array($file) || !isset($file['tmp_name'], $file['error'])) {
            $_SESSION['_flash_error'] = __('settings.interface.langs.error_upload');
            $this->redirect(seoscope_url('/settings?' . http_build_query(['tab' => 'interface'])));
            return;
        }
        if ((int) $file['error'] !== UPLOAD_ERR_OK) {
            $_SESSION['_flash_error'] = __('settings.interface.langs.error_upload');
            $this->redirect(seoscope_url('/settings?' . http_build_query(['tab' => 'interface'])));
            return;
        }
        $size = isset($file['size']) && is_numeric($file['size']) ? (int) $file['size'] : 0;
        if ($size <= 0 || $size > 2_000_000) {
            $_SESSION['_flash_error'] = __('settings.interface.langs.error_upload');
            $this->redirect(seoscope_url('/settings?' . http_build_query(['tab' => 'interface'])));
            return;
        }
        $tmp = (string) $file['tmp_name'];
        $contents = @file_get_contents($tmp);
        if (!is_string($contents) || $contents === '') {
            $_SESSION['_flash_error'] = __('settings.interface.langs.error_upload');
            $this->redirect(seoscope_url('/settings?' . http_build_query(['tab' => 'interface'])));
            return;
        }
        $decoded = json_decode($contents, true);
        if (!is_array($decoded)) {
            $_SESSION['_flash_error'] = __('settings.interface.langs.error_json');
            $this->redirect(seoscope_url('/settings?' . http_build_query(['tab' => 'interface'])));
            return;
        }

        $enFile = $this->root . '/lang/en/messages.php';
        $en = is_readable($enFile) ? require $enFile : [];
        $en = is_array($en) ? $en : [];
        $enKeys = array_fill_keys(array_keys($en), true);

        $out = [];
        foreach ($decoded as $k => $v) {
            if (!is_string($k)) {
                continue;
            }
            // Ignore keys that no longer exist in the EN source (e.g. removed UI features).
            if (!isset($enKeys[$k])) {
                continue;
            }
            if (!is_string($v)) {
                continue;
            }
            $out[$k] = $v;
        }

        $dir = $this->ensureLangStorageDir();
        if ($dir === null) {
            $_SESSION['_flash_error'] = __('settings.interface.langs.error_storage');
            $this->redirect(seoscope_url('/settings?' . http_build_query(['tab' => 'interface'])));
            return;
        }
        $target = $dir . '/' . $code . '.json';
        if (!$this->writeJsonFile($target, $out)) {
            $_SESSION['_flash_error'] = __('settings.interface.langs.error_storage');
            $this->redirect(seoscope_url('/settings?' . http_build_query(['tab' => 'interface'])));
            return;
        }

        $_SESSION['_flash_ok'] = __('settings.interface.langs.uploaded');
        $this->redirect(seoscope_url('/settings?' . http_build_query(['tab' => 'interface'])));
    }

    private function handleSettingsInterfaceLanguageDeletePost(): void
    {
        $this->assertPostCsrf();
        $raw = (string) ($_GET['code'] ?? $_POST['code'] ?? '');
        $code = $this->canonLocale($raw);
        $custom = SupportedLocales::customAll();
        if ($code === null || !array_key_exists($code, $custom)) {
            $_SESSION['_flash_error'] = __('settings.interface.langs.error_invalid');
            $this->redirect(seoscope_url('/settings?' . http_build_query(['tab' => 'interface'])));
            return;
        }

        $dir = $this->ensureLangStorageDir();
        if ($dir === null) {
            $_SESSION['_flash_error'] = __('settings.interface.langs.error_storage');
            $this->redirect(seoscope_url('/settings?' . http_build_query(['tab' => 'interface'])));
            return;
        }

        // Remove code from locales.json
        $localesPath = $dir . '/locales.json';
        $existing = $this->readLocalesJson($localesPath);
        unset($existing[$code]);
        if (!$this->writeJsonFile($localesPath, $existing)) {
            $_SESSION['_flash_error'] = __('settings.interface.langs.error_storage');
            $this->redirect(seoscope_url('/settings?' . http_build_query(['tab' => 'interface'])));
            return;
        }

        // Remove translations file if present
        $jsonFile = $dir . '/' . $code . '.json';
        if (is_file($jsonFile)) {
            @unlink($jsonFile);
        }

        // If user currently uses this locale, move them back to English.
        $uid = $this->currentUserId();
        $repo = new SettingsRepository($this->pdo(), $this->tablePrefix, $uid);
        $current = SupportedLocales::normalize($repo->get(SupportedLocales::SETTING_KEY));
        if ($current === $code) {
            $repo->set(SupportedLocales::SETTING_KEY, SupportedLocales::DEFAULT);
            SupportedLocales::setUiLocaleCookie(SupportedLocales::DEFAULT, $this->config);
        }

        $_SESSION['_flash_ok'] = __('settings.interface.langs.deleted');
        $this->redirect(seoscope_url('/settings?' . http_build_query(['tab' => 'interface'])));
    }

    private function showLicense(): void
    {
        $storage = new LicenseStorage($this->root);
        $state = $storage->load();
        $flash = $this->pullFlash();
        require $this->root . '/app/Views/license.php';
    }

    private function handleLicensePost(): void
    {
        $this->assertPostCsrf();
        $purchaseCode = trim((string) ($_POST['purchase_code'] ?? ''));
        if ($purchaseCode === '') {
            $_SESSION['_flash_error'] = __('license.error_empty');
            $this->redirect(seoscope_url('/license'));
            return;
        }
        $storage = new LicenseStorage($this->root);
        $installId = $storage->installId();
        $domain = strtolower(trim((string) ($_SERVER['HTTP_HOST'] ?? '')));
        $domain = preg_replace('~:\\d+$~', '', $domain) ?? $domain;
        if ($domain === '') {
            $domain = 'localhost';
        }

        $devSecret = null;
        $licCfg = $this->config['licensing'] ?? null;
        if (is_array($licCfg) && is_string($licCfg['dev_secret'] ?? null) && (string) $licCfg['dev_secret'] !== '') {
            $devSecret = (string) $licCfg['dev_secret'];
        }
        $client = new LicenseClient($this->licensingBaseUrl(), $devSecret);
        $res = $client->activate($purchaseCode, $domain, $installId);
        if ($res['ok'] && is_array($res['data'])) {
            $token = is_string($res['data']['token'] ?? null) ? (string) $res['data']['token'] : '';
            $next = is_numeric($res['data']['next_check_at'] ?? null) ? (int) $res['data']['next_check_at'] : (time() + 86400);
            if ($token !== '') {
                $storage->save(new LicenseState('active', $token, time(), $next, null));
                $_SESSION['_flash_ok'] = __('license.activated');
                $this->redirect(seoscope_url('/login'));
                return;
            }
        }

        if ((int) $res['status'] === 409 && is_array($res['data']) && (string) ($res['data']['error'] ?? '') === 'in_use') {
            $bound = is_string($res['data']['bound_domain'] ?? null) ? (string) $res['data']['bound_domain'] : '';
            $_SESSION['_flash_error'] = str_replace(':domain', $bound, __('license.in_use'));
            $this->redirect(seoscope_url('/license'));
            return;
        }

        $_SESSION['_flash_error'] = __('license.error_invalid');
        $this->redirect(seoscope_url('/license'));
    }

    private function licensingBaseUrl(): string
    {
        $lic = $this->config['licensing'] ?? [];
        if (is_array($lic) && is_string($lic['url'] ?? null) && (string) $lic['url'] !== '') {
            return (string) $lic['url'];
        }
        return 'https://licensing.seoscope.cloud';
    }

    private function showSettingsDiagnostics(): void
    {
        $flash = $this->pullFlash();
        $uid = $this->currentUserId();
        $repo = new SettingsRepository($this->pdo(), $this->tablePrefix, $uid);
        $logs = new SyncLogRepository($this->pdo(), $this->tablePrefix);
        $diag = $this->buildDiagnosticsPayload($repo, $logs);
        $jobRepo = new SyncJobRepository($this->pdo(), $this->tablePrefix);
        $diag['sync_jobs_recent'] = $jobRepo->recentJobsForUser($uid, 15);
        require $this->root . '/app/Views/settings_diagnostics.php';
    }

    private function handleDiagnosticsExportPost(): void
    {
        $this->assertPostCsrf();
        $uid = $this->currentUserId();
        $repo = new SettingsRepository($this->pdo(), $this->tablePrefix, $uid);
        $logs = new SyncLogRepository($this->pdo(), $this->tablePrefix);
        $payload = $this->buildDiagnosticsPayload($repo, $logs);
        $jobRepo = new SyncJobRepository($this->pdo(), $this->tablePrefix);
        $payload['sync_jobs_recent'] = $jobRepo->recentJobsForUser($uid, 30);

        if (!class_exists(\ZipArchive::class)) {
            $_SESSION['_flash_error'] = __('settings.diagnostics.export_failed', ['message' => 'ZipArchive not available']);
            $this->redirect(seoscope_url('/settings/diagnostics'));
            return;
        }

        $outDir = $this->root . '/storage/app';
        if (!is_dir($outDir)) {
            @mkdir($outDir, 0775, true);
        }
        $file = $outDir . '/diagnostics-' . date('Ymd-His') . '.zip';

        $zip = new \ZipArchive();
        @unlink($file);
        if ($zip->open($file, \ZipArchive::CREATE) !== true) {
            $_SESSION['_flash_error'] = __('settings.diagnostics.export_failed', ['message' => 'Cannot create zip']);
            $this->redirect(seoscope_url('/settings/diagnostics'));
            return;
        }

        $zip->addFromString('diagnostics.json', json_encode($payload, JSON_PRETTY_PRINT | JSON_UNESCAPED_SLASHES));
        $zip->addFromString('README.txt', "SeoScope diagnostics bundle (no secrets)\nGenerated at: " . ($payload['generated_at'] ?? '') . "\n");
        $zip->close();

        header('Content-Type: application/zip');
        header('Content-Disposition: attachment; filename="' . basename($file) . '"');
        header('Content-Length: ' . (string) filesize($file));
        readfile($file);
        @unlink($file);
        exit;
    }

    private function showSettingsAutomation(): void
    {
        $flash = $this->pullFlash();
        $uid = $this->currentUserId();
        $repo = new SettingsRepository($this->pdo(), $this->tablePrefix, $uid);
        $cronToken = (string) ($repo->get('cron.token') ?? '');
        $cronPath = '/cron/sync?t=' . rawurlencode($cronToken !== '' ? $cronToken : '');
        $cronUrl = seoscope_absolute_app_url($cronPath);
        require $this->root . '/app/Views/settings_automation.php';
    }

    private function handleSettingsAutomationTokenPost(): void
    {
        $this->assertPostCsrf();
        $uid = $this->currentUserId();
        $repo = new SettingsRepository($this->pdo(), $this->tablePrefix, $uid);
        $token = bin2hex(random_bytes(16));
        $repo->set('cron.token', $token);
        $_SESSION['_flash_ok'] = __('settings.automation.token_regenerated');
        $this->redirect(seoscope_url('/settings') . '?tab=automation');
    }

    private function handleCronSyncGet(): void
    {
        header('Content-Type: application/json; charset=utf-8');

        $t = (string) ($_GET['t'] ?? '');
        $cronUid = SettingsRepository::findUserIdByCronToken($this->pdo(), $this->tablePrefix, $t);
        if ($cronUid === null) {
            http_response_code(403);
            echo json_encode(['ok' => false, 'error' => 'forbidden'], JSON_UNESCAPED_SLASHES);
            return;
        }

        TranslatorBootstrap::registerForUserId($this->root, $this->config, (int) $cronUid);

        $lockDir = $this->root . '/storage/locks';
        if (!is_dir($lockDir)) {
            @mkdir($lockDir, 0775, true);
        }
        // Tenant-scoped lock: in future SaaS, different tenants can run cron concurrently.
        $lockPath = $lockDir . '/cron-sync-user-' . (int) $cronUid . '.lock';
        $fh = @fopen($lockPath, 'c+');
        if ($fh === false) {
            http_response_code(500);
            echo json_encode(['ok' => false, 'error' => 'lock_open_failed'], JSON_UNESCAPED_SLASHES);
            return;
        }
        if (!flock($fh, LOCK_EX | LOCK_NB)) {
            fclose($fh);
            http_response_code(409);
            echo json_encode(['ok' => false, 'error' => 'locked'], JSON_UNESCAPED_SLASHES);
            return;
        }

        // Process more work per cron call by default; still protected by lock (409 on overlap).
        // Users can override with &loops=1..10 if needed.
        $loops = max(1, min(10, (int) ($_GET['loops'] ?? 4)));

        // Use the shared runner (also used by web kickoffs).
        $r = $this->runSyncWorkForUser((int) $cronUid, (int) $loops, null, 'cron');

        fflush($fh);
        flock($fh, LOCK_UN);
        fclose($fh);

        echo json_encode([
            'ok' => !empty($r['ok']),
            'status' => (string) ($r['status'] ?? 'error'),
            'run_id' => (int) ($r['run_id'] ?? 0),
            'loops' => (int) ($r['loops'] ?? $loops),
            'result' => (array) ($r['result'] ?? []),
            'error' => $r['error'] ?? null,
        ], JSON_UNESCAPED_SLASHES);
        return;

        $settings = new SettingsRepository($this->pdo(), $this->tablePrefix, $cronUid);
        $props = new GooglePropertyRepository($this->pdo(), $this->tablePrefix, $cronUid);
        $dq = new GscDailyQueryRepository($this->pdo(), $this->tablePrefix);
        $dp = new GscDailyPageRepository($this->pdo(), $this->tablePrefix);
        $dpd = new GscDailyPropertyDeviceRepository($this->pdo(), $this->tablePrefix);
        $dpc = new GscDailyPropertyCountryRepository($this->pdo(), $this->tablePrefix);
        $dqp = new GscDailyQueryPageRepository($this->pdo(), $this->tablePrefix);
        $logs = new SyncLogRepository($this->pdo(), $this->tablePrefix);
        $jobRepo = new SyncJobRepository($this->pdo(), $this->tablePrefix);
        $aggRepo = new GscDailyPropertyAggRepository($this->pdo(), $this->tablePrefix);
        $agg = new GscAggregationService($this->pdo(), $aggRepo, $this->tablePrefix);

        // Observability: record last successful cron hit (best-effort).
        try {
            $settings->set('cron.last_run_at', date('Y-m-d H:i:s'));
        } catch (\Throwable) {
        }

        // Robustness: recover stale RUNNING jobs (cron timeout/killed).
        try {
            $jobRepo->recoverStaleRunningJobsForUser((int) $cronUid, 15 * 60);
        } catch (\Throwable) {
        }

        $mode = $settings->get('gsc.sync.mode') ?? 'last_30_days';
        $endDt = new \DateTimeImmutable('yesterday');
        $rangeEnd = $endDt->format('Y-m-d');
        $rangeStart = match ($mode) {
            'last_3_months' => $endDt->modify('-3 months')->format('Y-m-d'),
            'last_12_months' => $endDt->modify('-12 months')->format('Y-m-d'),
            'last_16_months' => $endDt->modify('-16 months')->format('Y-m-d'),
            default => $endDt->modify('-29 days')->format('Y-m-d'),
        };
        $runId = $logs->startRun('cron:' . $mode, $rangeStart, $rangeEnd);

        $svc = new GscSyncService($settings, $props, $dq, $dp, $dpd, $dpc, $dqp, $logs, $runId, $agg);
        $total = ['properties_processed' => 0, 'properties_failed' => 0, 'days_processed' => 0, 'query_rows_upserted' => 0, 'page_rows_upserted' => 0];
        $status = 'ok';
        $err = null;

        try {
            for ($i = 0; $i < $loops; $i++) {
                $job = null;
                try {
                    $job = $jobRepo->claimNextForUser($cronUid);
                } catch (\Throwable) {
                    $job = null;
                }

                if (is_array($job)) {
                    $jobIdSelfCheck = (int) ($job['id'] ?? 0);
                    $sd0 = (string) ($job['start_date'] ?? '');
                    $ed0 = (string) ($job['end_date'] ?? '');
                    if (
                        $jobIdSelfCheck > 0
                        && preg_match('/^\d{4}-\d{2}-\d{2}$/', $sd0)
                        && preg_match('/^\d{4}-\d{2}-\d{2}$/', $ed0)
                        && $sd0 > $ed0
                    ) {
                        // Cursor moved past the intended end (e.g. stuck RUNNING at 100% in UI); close the job.
                        $jobRepo->markDone($jobIdSelfCheck);
                        $res = [
                            'properties_processed' => 0,
                            'properties_failed' => 0,
                            'days_processed' => 0,
                            'query_rows_upserted' => 0,
                            'page_rows_upserted' => 0,
                        ];
                    } else {
                    $propId = (int) ($job['property_id'] ?? 0);
                    $prop = $propId > 0 ? $props->findById($propId) : null;
                    if (!is_array($prop) || trim((string) ($prop['site_url'] ?? '')) === '') {
                        $jobRepo->markFailed((int) $job['id'], 'property_not_found');
                        $res = ['properties_processed' => 0, 'properties_failed' => 1, 'days_processed' => 0, 'query_rows_upserted' => 0, 'page_rows_upserted' => 0];
                    } else {
                        $siteUrl = (string) ($prop['site_url'] ?? '');
                        $start = (string) ($job['start_date'] ?? '');
                        $end = (string) ($job['end_date'] ?? '');

                        $jobType = (string) ($job['job_type'] ?? '');
                        // Import everything needed by current features for both FAST and BACKFILL.
                        $includeQueryPage = ($jobType === 'bootstrap_28' || $jobType === 'full_backfill');
                        // Keep each cron HTTP request bounded: large properties can time out if we process too many days in one go.
                        // Full backfills were too slow at 3 days/call; use 7 days per loop for ~10x speed with loops=4.
                        $chunkDays = 7;
                        $jr = $svc->runPropertyRange($propId, $siteUrl, $start, $end, $chunkDays, $includeQueryPage);
                        $lastOk = is_string($jr['last_ok_date'] ?? null) ? (string) $jr['last_ok_date'] : null;
                        $errMsg = is_string($jr['error'] ?? null) ? (string) $jr['error'] : null;
                        $jobHadError = $errMsg !== null && $errMsg !== '';
                        $isTransient = false;
                        $jobId = (int) ($job['id'] ?? 0);
                        // If user cancelled while running, stop cleanly and do not reschedule.
                        if ($jobId > 0 && !$jobRepo->isActive($jobId)) {
                            $res = [
                                'properties_processed' => 1,
                                'properties_failed' => 0,
                                'days_processed' => (int) ($jr['days_processed'] ?? 0),
                                'query_rows_upserted' => (int) ($jr['query_rows_upserted'] ?? 0),
                                'page_rows_upserted' => (int) ($jr['page_rows_upserted'] ?? 0),
                            ];
                        } elseif ($jobId > 0 && $jobRepo->isCancelRequested($jobId)) {
                            $jobRepo->markCancelled($jobId);
                            $res = [
                                'properties_processed' => 1,
                                'properties_failed' => 0,
                                'days_processed' => (int) ($jr['days_processed'] ?? 0),
                                'query_rows_upserted' => (int) ($jr['query_rows_upserted'] ?? 0),
                                'page_rows_upserted' => (int) ($jr['page_rows_upserted'] ?? 0),
                            ];
                        } else {

                        if ($jobHadError) {
                            $attempts = (int) ($job['attempts'] ?? 1);
                            $lower = strtolower($errMsg);
                            $isTransient = str_contains($lower, 'google api request failed')
                                || str_contains($lower, 'google api response was not valid json')
                                || str_contains($lower, 'backend error')
                                || str_contains($lower, 'quota')
                                || str_contains($lower, 'rate')
                                || str_contains($lower, '429')
                                || str_contains($lower, '503')
                                || str_contains($lower, '500');

                            if ($isTransient) {
                                $delay = min(3600, 60 * (2 ** max(0, $attempts - 1)));
                                $next = date('Y-m-d H:i:s', time() + $delay);
                                $jobRepo->markRetry((int) $job['id'], $errMsg, $next);
                            } else {
                                $jobRepo->markFailed((int) $job['id'], $errMsg);
                            }
                        } else {
                            if ($lastOk !== null && preg_match('/^\d{4}-\d{2}-\d{2}$/', $lastOk)) {
                                if ($lastOk < $end) {
                                    $nextStart = date('Y-m-d', strtotime($lastOk . ' +1 day'));
                                    $jobRepo->rescheduleFromDate((int) $job['id'], $nextStart);
                                } else {
                                    $jobRepo->markDone((int) $job['id']);
                                    if ($jobType === 'bootstrap_28') {
                                        $endDt = new \DateTimeImmutable('yesterday');
                                        $start = $endDt->modify('-16 months')->format('Y-m-d');
                                        // Backfill excludes the most recent 28 days (handled by bootstrap_28).
                                        $endBackfill = $endDt->modify('-28 days')->format('Y-m-d');
                                        if ($start <= $endBackfill) {
                                            $jobRepo->enqueueFullBackfill((int) $cronUid, (int) $propId, $start, $endBackfill);
                                        }
                                    }
                                }
                            } else {
                                // Nothing processed but no error: treat as done to avoid infinite loops.
                                $jobRepo->markDone((int) $job['id']);
                                if ($jobType === 'bootstrap_28') {
                                    $endDt = new \DateTimeImmutable('yesterday');
                                    $start = $endDt->modify('-16 months')->format('Y-m-d');
                                    $endBackfill = $endDt->modify('-28 days')->format('Y-m-d');
                                    if ($start <= $endBackfill) {
                                        $jobRepo->enqueueFullBackfill((int) $cronUid, (int) $propId, $start, $endBackfill);
                                    }
                                }
                            }
                        }

                        $res = [
                            'properties_processed' => 1,
                            'properties_failed' => $jobHadError ? 1 : 0,
                            'days_processed' => (int) ($jr['days_processed'] ?? 0),
                            'query_rows_upserted' => (int) ($jr['query_rows_upserted'] ?? 0),
                            'page_rows_upserted' => (int) ($jr['page_rows_upserted'] ?? 0),
                        ];
                        }
                    }
                    }
                } else {
                    $res = $svc->runOnce();
                }
                foreach ($total as $k => $v) {
                    $total[$k] = (int) $total[$k] + (int) ($res[$k] ?? 0);
                }
                if ((int) ($res['days_processed'] ?? 0) === 0) {
                    break;
                }
            }
        } catch (\Throwable $e) {
            $status = 'error';
            $err = mb_substr($e->getMessage(), 0, 250);
        }

        $failed = (int) ($total['properties_failed'] ?? 0);
        if ($status === 'error') {
            $logs->finishRun($runId, 'error', (int) ($total['properties_processed'] ?? 0), $failed, (int) ($total['days_processed'] ?? 0), (int) ($total['query_rows_upserted'] ?? 0), (int) ($total['page_rows_upserted'] ?? 0), $err);
        } else {
            $logs->finishRun($runId, $failed > 0 ? 'partial' : 'ok', (int) ($total['properties_processed'] ?? 0), $failed, (int) ($total['days_processed'] ?? 0), (int) ($total['query_rows_upserted'] ?? 0), (int) ($total['page_rows_upserted'] ?? 0), $failed > 0 ? 'Some properties failed' : null);
        }

        fflush($fh);
        flock($fh, LOCK_UN);
        fclose($fh);

        echo json_encode([
            'ok' => $status === 'ok' || $status === 'partial',
            'status' => $status,
            'run_id' => $runId,
            'loops' => $loops,
            'result' => $total,
            'error' => $err,
        ], JSON_UNESCAPED_SLASHES);
    }

    /**
     * Run a bounded amount of sync work for a user (used by both cron and web kickoffs).
     *
     * @return array{ok:bool,status:string,run_id:int,loops:int,result:array<string,int>,error:null|string,elapsed_ms:int}
     */
    private function runSyncWorkForUser(int $userId, int $loops, ?int $maxSeconds, string $source): array
    {
        $t0 = microtime(true);
        $hardStopAt = null;
        if ($maxSeconds !== null && $maxSeconds > 0) {
            // Stop slightly before the cap to reduce timeout risk on shared hosting.
            $hardStopAt = $t0 + max(1.0, ((float) $maxSeconds) - 1.5);
        }

        $settings = new SettingsRepository($this->pdo(), $this->tablePrefix, $userId);
        $props = new GooglePropertyRepository($this->pdo(), $this->tablePrefix, $userId);
        $dq = new GscDailyQueryRepository($this->pdo(), $this->tablePrefix);
        $dp = new GscDailyPageRepository($this->pdo(), $this->tablePrefix);
        $dpd = new GscDailyPropertyDeviceRepository($this->pdo(), $this->tablePrefix);
        $dpc = new GscDailyPropertyCountryRepository($this->pdo(), $this->tablePrefix);
        $dqp = new GscDailyQueryPageRepository($this->pdo(), $this->tablePrefix);
        $logs = new SyncLogRepository($this->pdo(), $this->tablePrefix);
        $jobRepo = new SyncJobRepository($this->pdo(), $this->tablePrefix);
        $aggRepo = new GscDailyPropertyAggRepository($this->pdo(), $this->tablePrefix);
        $agg = new GscAggregationService($this->pdo(), $aggRepo, $this->tablePrefix);

        if ($source === 'cron') {
            // Observability: record last successful cron hit (best-effort).
            try {
                $settings->set('cron.last_run_at', date('Y-m-d H:i:s'));
            } catch (\Throwable) {
            }
        }

        // Robustness: recover stale RUNNING jobs (cron timeout/killed).
        try {
            $jobRepo->recoverStaleRunningJobsForUser((int) $userId, 15 * 60);
        } catch (\Throwable) {
        }

        $mode = $settings->get('gsc.sync.mode') ?? 'last_30_days';
        $endDt = new \DateTimeImmutable('yesterday');
        $rangeEnd = $endDt->format('Y-m-d');
        $rangeStart = match ($mode) {
            'last_3_months' => $endDt->modify('-3 months')->format('Y-m-d'),
            'last_12_months' => $endDt->modify('-12 months')->format('Y-m-d'),
            'last_16_months' => $endDt->modify('-16 months')->format('Y-m-d'),
            default => $endDt->modify('-29 days')->format('Y-m-d'),
        };
        $runId = $logs->startRun($source . ':' . $mode, $rangeStart, $rangeEnd);

        $svc = new GscSyncService($settings, $props, $dq, $dp, $dpd, $dpc, $dqp, $logs, $runId, $agg);
        $total = ['properties_processed' => 0, 'properties_failed' => 0, 'days_processed' => 0, 'query_rows_upserted' => 0, 'page_rows_upserted' => 0];
        $status = 'ok';
        $err = null;

        try {
            for ($i = 0; $i < $loops; $i++) {
                if ($hardStopAt !== null && microtime(true) >= $hardStopAt) {
                    break;
                }

                $job = null;
                try {
                    $job = $jobRepo->claimNextForUser($userId);
                } catch (\Throwable) {
                    $job = null;
                }

                if (is_array($job)) {
                    $jobIdSelfCheck = (int) ($job['id'] ?? 0);
                    $sd0 = (string) ($job['start_date'] ?? '');
                    $ed0 = (string) ($job['end_date'] ?? '');
                    if (
                        $jobIdSelfCheck > 0
                        && preg_match('/^\\d{4}-\\d{2}-\\d{2}$/', $sd0)
                        && preg_match('/^\\d{4}-\\d{2}-\\d{2}$/', $ed0)
                        && $sd0 > $ed0
                    ) {
                        // Cursor moved past the intended end (e.g. stuck RUNNING at 100% in UI); close the job.
                        $jobRepo->markDone($jobIdSelfCheck);
                        $res = [
                            'properties_processed' => 0,
                            'properties_failed' => 0,
                            'days_processed' => 0,
                            'query_rows_upserted' => 0,
                            'page_rows_upserted' => 0,
                        ];
                    } else {
                        $propId = (int) ($job['property_id'] ?? 0);
                        $prop = $propId > 0 ? $props->findById($propId) : null;
                        if (!is_array($prop) || trim((string) ($prop['site_url'] ?? '')) === '') {
                            $jobRepo->markFailed((int) $job['id'], 'property_not_found');
                            $res = ['properties_processed' => 0, 'properties_failed' => 1, 'days_processed' => 0, 'query_rows_upserted' => 0, 'page_rows_upserted' => 0];
                        } else {
                            $siteUrl = (string) ($prop['site_url'] ?? '');
                            $start = (string) ($job['start_date'] ?? '');
                            $end = (string) ($job['end_date'] ?? '');

                            $jobType = (string) ($job['job_type'] ?? '');
                            // Import everything needed by current features for both FAST and BACKFILL.
                            $includeQueryPage = ($jobType === 'bootstrap_28' || $jobType === 'full_backfill');
                            // Keep each HTTP request bounded: large properties can time out if we process too many days in one go.
                            $chunkDays = $source === 'kickoff' ? 3 : 7;
                            $chunkStart = $start;
                            $chunkEnd = $end;
                            $reverseBackfill = strtolower($jobType) === 'full_backfill';
                            $reverseBootstrap = strtolower($jobType) === 'bootstrap_28';
                            if ($reverseBackfill || $reverseBootstrap) {
                                // Process most recent missing days first: take the tail of the remaining [start..end] window.
                                $chunkEnd = $end;
                                $chunkStart = date('Y-m-d', max(strtotime($start . ' 12:00:00') ?: 0, (strtotime($end . ' 12:00:00') ?: 0) - (86400 * max(0, $chunkDays - 1))));
                                if ($chunkStart < $start) {
                                    $chunkStart = $start;
                                }
                                if ($chunkStart > $chunkEnd) {
                                    $chunkStart = $chunkEnd;
                                }
                            }
                            $jr = $svc->runPropertyRange($propId, $siteUrl, $chunkStart, $chunkEnd, $chunkDays, $includeQueryPage);
                            $lastOk = is_string($jr['last_ok_date'] ?? null) ? (string) $jr['last_ok_date'] : null;
                            $errMsg = is_string($jr['error'] ?? null) ? (string) $jr['error'] : null;
                            $jobHadError = $errMsg !== null && $errMsg !== '';
                            $isTransient = false;
                            $jobId = (int) ($job['id'] ?? 0);

                            // If user cancelled while running, stop cleanly and do not reschedule.
                            if ($jobId > 0 && !$jobRepo->isActive($jobId)) {
                                $res = [
                                    'properties_processed' => 1,
                                    'properties_failed' => 0,
                                    'days_processed' => (int) ($jr['days_processed'] ?? 0),
                                    'query_rows_upserted' => (int) ($jr['query_rows_upserted'] ?? 0),
                                    'page_rows_upserted' => (int) ($jr['page_rows_upserted'] ?? 0),
                                ];
                            } elseif ($jobId > 0 && $jobRepo->isCancelRequested($jobId)) {
                                $jobRepo->markCancelled($jobId);
                                $res = [
                                    'properties_processed' => 1,
                                    'properties_failed' => 0,
                                    'days_processed' => (int) ($jr['days_processed'] ?? 0),
                                    'query_rows_upserted' => (int) ($jr['query_rows_upserted'] ?? 0),
                                    'page_rows_upserted' => (int) ($jr['page_rows_upserted'] ?? 0),
                                ];
                            } else {
                                if ($jobHadError) {
                                    $attempts = (int) ($job['attempts'] ?? 1);
                                    $lower = strtolower($errMsg);
                                    $isTransient = str_contains($lower, 'google api request failed')
                                        || str_contains($lower, 'google api response was not valid json')
                                        || str_contains($lower, 'backend error')
                                        || str_contains($lower, 'quota')
                                        || str_contains($lower, 'rate')
                                        || str_contains($lower, '429')
                                        || str_contains($lower, '503')
                                        || str_contains($lower, '500');

                                    if ($isTransient) {
                                        $delay = min(3600, 60 * (2 ** max(0, $attempts - 1)));
                                        $next = date('Y-m-d H:i:s', time() + $delay);
                                        $jobRepo->markRetry((int) $job['id'], $errMsg, $next);
                                    } else {
                                        $jobRepo->markFailed((int) $job['id'], $errMsg);
                                    }
                                } else {
                                    if ($lastOk !== null && preg_match('/^\\d{4}-\\d{2}-\\d{2}$/', $lastOk)) {
                                        if ($reverseBackfill || $reverseBootstrap) {
                                            // Move the end cursor backwards (next chunk ends before the current chunkStart).
                                            if ($chunkStart > $start) {
                                                $nextEnd = date('Y-m-d', strtotime($chunkStart . ' -1 day'));
                                                $jobRepo->rescheduleToDate((int) $job['id'], $nextEnd);
                                            } else {
                                                $jobRepo->markDone((int) $job['id']);
                                                if ($jobType === 'bootstrap_28') {
                                                    $endDt = new \DateTimeImmutable('yesterday');
                                                    $start = $endDt->modify('-16 months')->format('Y-m-d');
                                                    // Backfill excludes the most recent 28 days (handled by bootstrap_28).
                                                    $endBackfill = $endDt->modify('-28 days')->format('Y-m-d');
                                                    if ($start <= $endBackfill) {
                                                        $jobRepo->enqueueFullBackfill((int) $userId, (int) $propId, $start, $endBackfill);
                                                    }
                                                }
                                            }
                                        } elseif ($lastOk < $end) {
                                            $nextStart = date('Y-m-d', strtotime($lastOk . ' +1 day'));
                                            $jobRepo->rescheduleFromDate((int) $job['id'], $nextStart);
                                        } else {
                                            $jobRepo->markDone((int) $job['id']);
                                            if ($jobType === 'bootstrap_28') {
                                                $endDt = new \DateTimeImmutable('yesterday');
                                                $start = $endDt->modify('-16 months')->format('Y-m-d');
                                                // Backfill excludes the most recent 28 days (handled by bootstrap_28).
                                                $endBackfill = $endDt->modify('-28 days')->format('Y-m-d');
                                                if ($start <= $endBackfill) {
                                                    $jobRepo->enqueueFullBackfill((int) $userId, (int) $propId, $start, $endBackfill);
                                                }
                                            }
                                        }
                                    } else {
                                        // Nothing processed but no error: treat as done to avoid infinite loops.
                                        $jobRepo->markDone((int) $job['id']);
                                        if ($jobType === 'bootstrap_28') {
                                            $endDt = new \DateTimeImmutable('yesterday');
                                            $start = $endDt->modify('-16 months')->format('Y-m-d');
                                            $endBackfill = $endDt->modify('-28 days')->format('Y-m-d');
                                            if ($start <= $endBackfill) {
                                                $jobRepo->enqueueFullBackfill((int) $userId, (int) $propId, $start, $endBackfill);
                                            }
                                        }
                                    }
                                }

                                $res = [
                                    'properties_processed' => 1,
                                    'properties_failed' => $jobHadError ? 1 : 0,
                                    'days_processed' => (int) ($jr['days_processed'] ?? 0),
                                    'query_rows_upserted' => (int) ($jr['query_rows_upserted'] ?? 0),
                                    'page_rows_upserted' => (int) ($jr['page_rows_upserted'] ?? 0),
                                ];
                            }
                        }
                    }
                } else {
                    $res = $svc->runOnce();
                }

                foreach ($total as $k => $v) {
                    $total[$k] = (int) $total[$k] + (int) ($res[$k] ?? 0);
                }
                if ((int) ($res['days_processed'] ?? 0) === 0) {
                    break;
                }
            }
        } catch (\Throwable $e) {
            $status = 'error';
            $err = mb_substr($e->getMessage(), 0, 250);
        }

        $failed = (int) ($total['properties_failed'] ?? 0);
        if ($status === 'error') {
            $logs->finishRun($runId, 'error', (int) ($total['properties_processed'] ?? 0), $failed, (int) ($total['days_processed'] ?? 0), (int) ($total['query_rows_upserted'] ?? 0), (int) ($total['page_rows_upserted'] ?? 0), $err);
        } else {
            $logs->finishRun($runId, $failed > 0 ? 'partial' : 'ok', (int) ($total['properties_processed'] ?? 0), $failed, (int) ($total['days_processed'] ?? 0), (int) ($total['query_rows_upserted'] ?? 0), (int) ($total['page_rows_upserted'] ?? 0), $failed > 0 ? 'Some properties failed' : null);
        }

        $elapsedMs = (int) round((microtime(true) - $t0) * 1000);
        $ok = $status === 'ok' || $status === 'partial';

        return [
            'ok' => $ok,
            'status' => $status,
            'run_id' => $runId,
            'loops' => $loops,
            'result' => $total,
            'error' => $err,
            'elapsed_ms' => $elapsedMs,
        ];
    }

    /**
     * Best-effort kickoff: run a small amount of sync work now, using the same per-user lock as cron.
     *
     * @return array{locked:bool,ran:bool,days_processed:int,error:null|string}
     */
    private function kickoffSyncNow(int $userId, int $maxSeconds): array
    {
        $lockDir = $this->root . '/storage/locks';
        if (!is_dir($lockDir)) {
            @mkdir($lockDir, 0775, true);
        }
        $lockPath = $lockDir . '/cron-sync-user-' . (int) $userId . '.lock';
        $fh = @fopen($lockPath, 'c+');
        if ($fh === false) {
            return ['locked' => true, 'ran' => false, 'days_processed' => 0, 'error' => 'lock_open_failed'];
        }
        if (!flock($fh, LOCK_EX | LOCK_NB)) {
            fclose($fh);
            return ['locked' => true, 'ran' => false, 'days_processed' => 0, 'error' => null];
        }

        try {
            $r = $this->runSyncWorkForUser((int) $userId, 1, $maxSeconds, 'kickoff');
        } catch (\Throwable $e) {
            $r = ['result' => ['days_processed' => 0], 'error' => mb_substr($e->getMessage(), 0, 200)];
        } finally {
            fflush($fh);
            flock($fh, LOCK_UN);
            fclose($fh);
        }

        $days = 0;
        if (is_array($r) && is_array($r['result'] ?? null)) {
            $days = (int) (($r['result']['days_processed'] ?? 0));
        }

        return [
            'locked' => false,
            'ran' => $days > 0,
            'days_processed' => $days,
            'error' => is_string($r['error'] ?? null) ? (string) $r['error'] : null,
        ];
    }

    private function showSettingsDecay(): void
    {
        $flash = $this->pullFlash();
        $uid = $this->currentUserId();
        $repo = new SettingsRepository($this->pdo(), $this->tablePrefix, $uid);
        $cfg = $this->resolveDecayConfig($repo, $_GET);
        require $this->root . '/app/Views/settings_decay.php';
    }

    private function handleSettingsDecayPost(): void
    {
        $this->assertPostCsrf();
        $uid = $this->currentUserId();
        $repo = new SettingsRepository($this->pdo(), $this->tablePrefix, $uid);

        $minImpr = max(0, (int) ($_POST['min_impressions'] ?? 0));
        $clicksDropPct = (int) ($_POST['clicks_drop_pct'] ?? 20);
        $imprDropPct = (int) ($_POST['impr_drop_pct'] ?? 20);
        $posWorsen = (float) ($_POST['position_worsen'] ?? 1.0);

        if ($clicksDropPct < 0 || $clicksDropPct > 100 || $imprDropPct < 0 || $imprDropPct > 100 || $posWorsen < 0) {
            $_SESSION['_flash_error'] = __('settings.decay.invalid');
            $this->redirect(seoscope_url('/settings/decay'));
            return;
        }

        $repo->set('decay.min_impressions', (string) $minImpr);
        $repo->set('decay.clicks_drop_pct', (string) ($clicksDropPct / 100));
        $repo->set('decay.impr_drop_pct', (string) ($imprDropPct / 100));
        $repo->set('decay.position_worsen', (string) $posWorsen);

        $_SESSION['_flash_ok'] = __('settings.decay.saved');
        $this->redirect(seoscope_url('/settings/decay'));
    }

    private function showKeywords(): void
    {
        $flash = $this->pullFlash();
        $uid = $this->currentUserId();
        $propRepo = new GooglePropertyRepository($this->pdo(), $this->tablePrefix, $uid);
        $kwRepo = new GscKeywordsRepository($this->pdo(), $this->tablePrefix);

        $filters = $this->resolveGlobalFilters($propRepo);
        $activeProp = $filters['activeProp'];
        $preset = $filters['preset'];
        $device = (string) ($filters['device'] ?? 'all');
        $country = (string) ($filters['country'] ?? 'all');
        $q = trim((string) ($_GET['q'] ?? ''));
        $sort = (string) ($_GET['sort'] ?? 'clicks');
        $dir = (string) ($_GET['dir'] ?? 'DESC');
        $compare = (string) ($_GET['compare'] ?? '') === '1';
        $page = max(1, (int) ($_GET['page'] ?? 1));
        $perPage = max(10, min(100, (int) ($_GET['per_page'] ?? 10)));

        $keywordsShow = strtolower(trim((string) ($_GET['show'] ?? 'all')));
        if (!in_array($keywordsShow, ['all', 'tracked'], true)) {
            $keywordsShow = 'all';
        }
        $trackedQueriesList = [];
        $trackedCategoriesList = [];
        $trackedQueryCategoryMap = [];
        $keywordsTrackCategoryFilter = null;
        if (is_array($activeProp) && !empty($activeProp['id'])) {
            $pidKw = (int) $activeProp['id'];
            $catRepoKw = new TrackedCategoryRepository($this->pdo(), $this->tablePrefix);
            $trackedCategoriesList = $catRepoKw->listForProperty($uid, $pidKw);
            $keywordsTrackCategoryFilter = $this->parseTrackCategoryFilterFromGet($trackedCategoriesList);
            $tqRepo = new TrackedQueryRepository($this->pdo(), $this->tablePrefix);
            $trackedQueriesList = $tqRepo->listQueries($uid, $pidKw);
            $trackedQueryCategoryMap = $tqRepo->queryCategoryMap($uid, $pidKw);
            $onlyQueries = $keywordsShow === 'tracked'
                ? $tqRepo->listQueries($uid, $pidKw, $keywordsTrackCategoryFilter)
                : null;
        } else {
            $onlyQueries = null;
        }

        ['start' => $rangeStart, 'end' => $rangeEnd] = $this->rangeFromPreset($preset);

        $data = null;
        if (is_array($activeProp) && !empty($activeProp['id'])) {
            $data = $kwRepo->listForRange((int) $activeProp['id'], $rangeStart, $rangeEnd, $q, $device, $country, $sort, $dir, $page, $perPage, $onlyQueries);
            if ($compare && is_array($data) && !empty($data['rows'])) {
                $queries = [];
                foreach ($data['rows'] as $r) {
                    if (is_array($r) && !empty($r['query'])) {
                        $queries[] = (string) $r['query'];
                    }
                }
                $lenDays = max(1, (int) ((strtotime($rangeEnd) - strtotime($rangeStart)) / 86400) + 1);
                $prevEnd = date('Y-m-d', strtotime($rangeStart . ' -1 day'));
                $prevStart = date('Y-m-d', strtotime($prevEnd . ' -' . ($lenDays - 1) . ' days'));
                $prevMap = $kwRepo->totalsForQueries((int) $activeProp['id'], $prevStart, $prevEnd, $device, $country, $queries);
                // Attach prev metrics to current rows.
                $enriched = [];
                foreach ($data['rows'] as $r) {
                    if (!is_array($r)) {
                        continue;
                    }
                    $pq = $prevMap[(string) ($r['query'] ?? '')] ?? ['clicks' => 0, 'impressions' => 0, 'ctr' => 0.0, 'position' => 0.0];
                    $r['prev'] = $pq;
                    $enriched[] = $r;
                }
                $data['rows'] = $enriched;
                $data['prev_range'] = ['start' => $prevStart, 'end' => $prevEnd];
            }
        }

        $globalFilters = $filters['globalFilters'];
        $keywordsTrackMax = TrackedQueryRepository::MAX_TRACKED_PER_PROPERTY;
        $keywordsTrackAtLimit = is_array($activeProp) && !empty($activeProp['id'])
            && count($trackedQueriesList) >= $keywordsTrackMax;

        $repo = new SettingsRepository($this->pdo(), $this->tablePrefix, $uid);
        $seoAiInsightsEnabled = (new AiSettingsService($repo))->isEnabled();
        $seoAiKwCard = null;
        if ($seoAiInsightsEnabled) {
            $seoAiKwCard = [
                'slug' => 'keywords',
                'tab' => '',
                'endpoint' => '/keywords/seo-ai-assistant',
                'append_tab_param' => false,
                'csrf' => AdminCsrf::token(),
                'cached_body' => null,
                'has_cached' => false,
                'no_property' => true,
                'no_rows' => false,
                'configure' => false,
                'idle' => false,
                'show_cached' => false,
                'session_storage_key' => 'ss_seo_ai_keywords_has_result',
                'url_query_params' => ['prop', 'range', 'compare', 'device', 'country', 'show', 'q', 'sort', 'dir', 'track_cat'],
                'intro_hint' => __('seo_ai.keywords.intro_hint'),
                'not_ready' => __('seo_ai.keywords.not_ready'),
                'generate_tooltip' => __('seo_ai.keywords.generate_tooltip'),
            ];
            if (is_array($activeProp) && !empty($activeProp['id'])) {
                $aiKw = new AiSettingsService($repo);
                $kwState = $this->keywordsSeoAiViewState(
                    $uid,
                    (int) $activeProp['id'],
                    $preset,
                    $rangeStart,
                    $rangeEnd,
                    $device,
                    $country,
                    $repo,
                    $kwRepo,
                    $aiKw,
                );
                $seoAiKwCard['no_property'] = false;
                $seoAiKwCard['cached_body'] = $kwState['cached_body'];
                $seoAiKwCard['has_cached'] = $kwState['has_cached'];
                $seoAiKwCard['no_rows'] = !$kwState['insight_ready'];
                $seoAiKwCard['configure'] = $kwState['insight_ready'] && !$kwState['ai_ready'];
                $seoAiKwCard['idle'] = $kwState['insight_ready'] && $kwState['ai_ready'];
                $seoAiKwCard['show_cached'] = $kwState['has_cached'];
            }
        }

        require $this->root . '/app/Views/keywords.php';
    }

    private function keywordsRedirectUrlFromPost(): string
    {
        $qs = [];
        $map = [
            'ret_prop' => 'prop',
            'ret_range' => 'range',
            'ret_show' => 'show',
            'ret_q' => 'q',
            'ret_sort' => 'sort',
            'ret_dir' => 'dir',
            'ret_page' => 'page',
            'ret_per_page' => 'per_page',
        ];
        foreach ($map as $postKey => $queryKey) {
            $v = trim((string) ($_POST[$postKey] ?? ''));
            if ($v === '') {
                continue;
            }
            if ($queryKey === 'show' && $v !== 'tracked') {
                continue;
            }
            if ($queryKey === 'page') {
                $qs[$queryKey] = (string) max(1, (int) $v);
                continue;
            }
            if ($queryKey === 'per_page') {
                $qs[$queryKey] = (string) max(10, min(100, (int) $v));
                continue;
            }
            $qs[$queryKey] = $v;
        }
        if ((string) ($_POST['ret_compare'] ?? '') === '1') {
            $qs['compare'] = '1';
        }
        $tc = trim((string) ($_POST['ret_track_cat'] ?? ''));
        if ($tc !== '' && ($tc === '0' || ctype_digit($tc))) {
            $qs['track_cat'] = $tc;
        }

        return seoscope_url('/keywords' . ($qs !== [] ? ('?' . http_build_query($qs)) : ''));
    }

    private function pagesRedirectUrlFromPost(): string
    {
        $qs = [];
        $map = [
            'ret_prop' => 'prop',
            'ret_range' => 'range',
            'ret_show' => 'show',
            'ret_q' => 'q',
            'ret_sort' => 'sort',
            'ret_dir' => 'dir',
            'ret_page' => 'page',
            'ret_per_page' => 'per_page',
        ];
        foreach ($map as $postKey => $queryKey) {
            $v = trim((string) ($_POST[$postKey] ?? ''));
            if ($v === '') {
                continue;
            }
            if ($queryKey === 'show' && $v !== 'tracked') {
                continue;
            }
            if ($queryKey === 'page') {
                $qs[$queryKey] = (string) max(1, (int) $v);
                continue;
            }
            if ($queryKey === 'per_page') {
                $qs[$queryKey] = (string) max(10, min(100, (int) $v));
                continue;
            }
            $qs[$queryKey] = $v;
        }
        if ((string) ($_POST['ret_compare'] ?? '') === '1') {
            $qs['compare'] = '1';
        }
        $tc = trim((string) ($_POST['ret_track_cat'] ?? ''));
        if ($tc !== '' && ($tc === '0' || ctype_digit($tc))) {
            $qs['track_cat'] = $tc;
        }

        return seoscope_url('/pages' . ($qs !== [] ? ('?' . http_build_query($qs)) : ''));
    }

    private function wantsAsyncTrackRequest(): bool
    {
        $h = strtolower(trim((string) ($_SERVER['HTTP_X_SEOSCOPE_ASYNC'] ?? '')));
        if ($h === '1') {
            return true;
        }

        return str_contains(strtolower((string) ($_SERVER['HTTP_ACCEPT'] ?? '')), 'application/json');
    }

    /**
     * @param array<string, mixed> $extra
     */
    private function finishTrackAction(bool $ok, string $message, string $redirect, array $extra = []): never
    {
        if ($this->wantsAsyncTrackRequest()) {
            header('Content-Type: application/json; charset=utf-8');
            header('Cache-Control: no-store, no-cache, must-revalidate');
            echo json_encode(array_merge([
                'ok' => $ok,
                'message' => $message,
                'level' => $ok ? 'ok' : 'error',
            ], $extra), JSON_UNESCAPED_UNICODE);
            exit;
        }
        if ($ok) {
            $_SESSION['_flash_ok'] = $message;
        } else {
            $_SESSION['_flash_error'] = $message;
        }
        $this->redirect($redirect);
    }

    /**
     * @param list<array{id:int,name:string}> $categories
     */
    private function parseTrackCategoryFilterFromGet(array $categories): ?int
    {
        $raw = trim((string) ($_GET['track_cat'] ?? ''));
        if ($raw === '') {
            return null;
        }
        if ($raw === '0') {
            return 0;
        }
        if (!ctype_digit($raw)) {
            return null;
        }
        $id = (int) $raw;
        if ($id < 1) {
            return null;
        }
        foreach ($categories as $c) {
            if (is_array($c) && (int) ($c['id'] ?? 0) === $id) {
                return $id;
            }
        }

        return null;
    }

    private function trackedCategoryRedirectFromPost(): string
    {
        $rb = trim((string) ($_POST['ret_base'] ?? 'kw'));
        if ($rb === 'settings') {
            $pid = max(0, (int) ($_POST['property_id'] ?? 0));
            $qs = ['tab' => 'categories'];
            if ($pid > 0) {
                $qs['prop'] = (string) $pid;
            }

            return seoscope_url('/settings?' . http_build_query($qs));
        }

        return $rb === 'pg' ? $this->pagesRedirectUrlFromPost() : $this->keywordsRedirectUrlFromPost();
    }

    private function handleTrackedCategoryCreatePost(): void
    {
        $this->assertPostCsrf();
        $uid = $this->currentUserId();
        $propRepo = new GooglePropertyRepository($this->pdo(), $this->tablePrefix, $uid);
        $redirect = $this->trackedCategoryRedirectFromPost();

        $pid = (int) ($_POST['property_id'] ?? 0);
        $prop = $propRepo->findById($pid);
        if (!is_array($prop) || empty($prop['id'])) {
            $_SESSION['_flash_error'] = __('tracked_cat.property_error');
            $this->redirect($redirect);
            return;
        }

        $name = (string) ($_POST['name'] ?? '');
        $repo = new TrackedCategoryRepository($this->pdo(), $this->tablePrefix);
        $res = $repo->create($uid, $pid, $name);
        if ($res === 'invalid') {
            $_SESSION['_flash_error'] = __('tracked_cat.create_invalid');
        } elseif ($res === 'at_limit') {
            $_SESSION['_flash_error'] = __('tracked_cat.create_at_limit', ['max' => (string) TrackedCategoryRepository::MAX_CATEGORIES_PER_PROPERTY]);
        } elseif ($res === 'duplicate') {
            $_SESSION['_flash_error'] = __('tracked_cat.create_duplicate');
        } else {
            $_SESSION['_flash_ok'] = __('tracked_cat.create_ok');
        }
        $this->redirect($redirect);
    }

    private function handleTrackedCategoryDeletePost(): void
    {
        $this->assertPostCsrf();
        $uid = $this->currentUserId();
        $propRepo = new GooglePropertyRepository($this->pdo(), $this->tablePrefix, $uid);
        $redirect = $this->trackedCategoryRedirectFromPost();

        $pid = (int) ($_POST['property_id'] ?? 0);
        $prop = $propRepo->findById($pid);
        if (!is_array($prop) || empty($prop['id'])) {
            $_SESSION['_flash_error'] = __('tracked_cat.property_error');
            $this->redirect($redirect);
            return;
        }

        $cid = (int) ($_POST['category_id'] ?? 0);
        if ($cid < 1) {
            $_SESSION['_flash_error'] = __('tracked_cat.delete_invalid');
            $this->redirect($redirect);
            return;
        }

        $repo = new TrackedCategoryRepository($this->pdo(), $this->tablePrefix);
        if ($repo->findOwned($uid, $pid, $cid) === null) {
            $_SESSION['_flash_error'] = __('tracked_cat.delete_not_found');
            $this->redirect($redirect);
            return;
        }
        if (!$repo->delete($uid, $pid, $cid)) {
            $_SESSION['_flash_error'] = __('tracked_cat.delete_error');
        } else {
            $_SESSION['_flash_ok'] = __('tracked_cat.delete_ok');
        }
        $this->redirect($redirect);
    }

    private function handleTrackedCategoryRenamePost(): void
    {
        $this->assertPostCsrf();
        $uid = $this->currentUserId();
        $propRepo = new GooglePropertyRepository($this->pdo(), $this->tablePrefix, $uid);
        $redirect = $this->trackedCategoryRedirectFromPost();

        $pid = (int) ($_POST['property_id'] ?? 0);
        $prop = $propRepo->findById($pid);
        if (!is_array($prop) || empty($prop['id'])) {
            $_SESSION['_flash_error'] = __('tracked_cat.property_error');
            $this->redirect($redirect);
            return;
        }

        $cid = (int) ($_POST['category_id'] ?? 0);
        if ($cid < 1) {
            $_SESSION['_flash_error'] = __('tracked_cat.rename_invalid');
            $this->redirect($redirect);
            return;
        }

        $name = (string) ($_POST['name'] ?? '');
        $repo = new TrackedCategoryRepository($this->pdo(), $this->tablePrefix);
        $res = $repo->rename($uid, $pid, $cid, $name);
        if ($res === 'invalid') {
            $_SESSION['_flash_error'] = __('tracked_cat.rename_invalid');
        } elseif ($res === 'not_found') {
            $_SESSION['_flash_error'] = __('tracked_cat.rename_not_found');
        } elseif ($res === 'duplicate') {
            $_SESSION['_flash_error'] = __('tracked_cat.rename_duplicate');
        } elseif ($res === 'unchanged') {
            $_SESSION['_flash_ok'] = __('tracked_cat.rename_unchanged');
        } else {
            $_SESSION['_flash_ok'] = __('tracked_cat.rename_ok');
        }
        $this->redirect($redirect);
    }

    private function handleKeywordsCategoryAssignPost(): void
    {
        $this->assertPostCsrf();
        $uid = $this->currentUserId();
        $propRepo = new GooglePropertyRepository($this->pdo(), $this->tablePrefix, $uid);
        $redirect = $this->keywordsRedirectUrlFromPost();
        $action = 'assign';

        $pid = (int) ($_POST['property_id'] ?? 0);
        $prop = $propRepo->findById($pid);
        if (!is_array($prop) || empty($prop['id'])) {
            $this->finishTrackAction(false, __('tracked_cat.property_error'), $redirect, ['action' => $action]);
        }

        $query = trim((string) ($_POST['query'] ?? ''));
        if ($query === '' || mb_strlen($query) > TrackedQueryRepository::MAX_QUERY_LENGTH) {
            $this->finishTrackAction(false, __('tracked_cat.assign_invalid_query'), $redirect, ['action' => $action]);
        }

        $tqRepo = new TrackedQueryRepository($this->pdo(), $this->tablePrefix);
        if (!$tqRepo->isTracked($uid, $pid, $query)) {
            $this->finishTrackAction(false, __('tracked_cat.assign_not_tracked'), $redirect, ['action' => $action]);
        }

        $rawCat = trim((string) ($_POST['category_id'] ?? ''));
        $newCat = null;
        if ($rawCat !== '' && $rawCat !== '0' && ctype_digit($rawCat)) {
            $newCat = (int) $rawCat;
        }
        if ($newCat !== null) {
            $catRepo = new TrackedCategoryRepository($this->pdo(), $this->tablePrefix);
            if ($catRepo->findOwned($uid, $pid, $newCat) === null) {
                $this->finishTrackAction(false, __('tracked_cat.assign_invalid_category'), $redirect, ['action' => $action]);
            }
        }

        if (!$tqRepo->setCategory($uid, $pid, $query, $newCat)) {
            $this->finishTrackAction(false, __('tracked_cat.assign_error'), $redirect, ['action' => $action]);
        }
        $this->finishTrackAction(true, __('tracked_cat.assign_ok'), $redirect, ['action' => $action]);
    }

    private function handlePagesCategoryAssignPost(): void
    {
        $this->assertPostCsrf();
        $uid = $this->currentUserId();
        $propRepo = new GooglePropertyRepository($this->pdo(), $this->tablePrefix, $uid);
        $redirect = $this->pagesRedirectUrlFromPost();
        $action = 'assign';

        $pid = (int) ($_POST['property_id'] ?? 0);
        $prop = $propRepo->findById($pid);
        if (!is_array($prop) || empty($prop['id'])) {
            $this->finishTrackAction(false, __('tracked_cat.property_error'), $redirect, ['action' => $action]);
        }

        $pageUrl = trim((string) ($_POST['page_url'] ?? ''));
        if ($pageUrl === '' || mb_strlen($pageUrl) > TrackedPageRepository::MAX_PAGE_LENGTH) {
            $this->finishTrackAction(false, __('tracked_cat.assign_invalid_page'), $redirect, ['action' => $action]);
        }

        $tpRepo = new TrackedPageRepository($this->pdo(), $this->tablePrefix);
        if (!$tpRepo->isTracked($uid, $pid, $pageUrl)) {
            $this->finishTrackAction(false, __('tracked_cat.assign_not_tracked_page'), $redirect, ['action' => $action]);
        }

        $rawCat = trim((string) ($_POST['category_id'] ?? ''));
        $newCat = null;
        if ($rawCat !== '' && $rawCat !== '0' && ctype_digit($rawCat)) {
            $newCat = (int) $rawCat;
        }
        if ($newCat !== null) {
            $catRepo = new TrackedCategoryRepository($this->pdo(), $this->tablePrefix);
            if ($catRepo->findOwned($uid, $pid, $newCat) === null) {
                $this->finishTrackAction(false, __('tracked_cat.assign_invalid_category'), $redirect, ['action' => $action]);
            }
        }

        if (!$tpRepo->setCategory($uid, $pid, $pageUrl, $newCat)) {
            $this->finishTrackAction(false, __('tracked_cat.assign_error'), $redirect, ['action' => $action]);
        }
        $this->finishTrackAction(true, __('tracked_cat.assign_ok'), $redirect, ['action' => $action]);
    }

    private function handleKeywordsTrackPost(): void
    {
        $this->assertPostCsrf();
        $uid = $this->currentUserId();
        $propRepo = new GooglePropertyRepository($this->pdo(), $this->tablePrefix, $uid);
        $redirect = $this->keywordsRedirectUrlFromPost();
        $action = 'track';

        $pid = (int) ($_POST['property_id'] ?? 0);
        $prop = $propRepo->findById($pid);
        if (!is_array($prop) || empty($prop['id'])) {
            $this->finishTrackAction(false, __('keywords.track_property_error'), $redirect, ['action' => $action]);
        }

        $queryInput = (string) ($_POST['query'] ?? '');
        $qKey = trim($queryInput);

        $rawTrackCat = trim((string) ($_POST['track_category_id'] ?? '0'));
        $wantCatId = null;
        if ($rawTrackCat !== '' && $rawTrackCat !== '0' && ctype_digit($rawTrackCat)) {
            $tmpC = (int) $rawTrackCat;
            if ($tmpC > 0) {
                $wantCatId = $tmpC;
            }
        }

        $tqRepo = new TrackedQueryRepository($this->pdo(), $this->tablePrefix);
        $res = $tqRepo->add($uid, $pid, $queryInput);
        if ($res === 'invalid') {
            $this->finishTrackAction(false, __('keywords.track_invalid'), $redirect, ['action' => $action]);
        }
        if ($res === 'at_limit') {
            $this->finishTrackAction(false, __('keywords.track_limit_reached', ['max' => (string) TrackedQueryRepository::MAX_TRACKED_PER_PROPERTY]), $redirect, ['action' => $action]);
        }
        if ($res === 'duplicate') {
            $this->finishTrackAction(true, __('keywords.track_duplicate'), $redirect, ['action' => $action, 'tracked' => true]);
        }
        if ($wantCatId !== null) {
            $catRepo = new TrackedCategoryRepository($this->pdo(), $this->tablePrefix);
            if ($catRepo->findOwned($uid, $pid, $wantCatId) === null) {
                $this->finishTrackAction(true, __('keywords.track_added_category_invalid'), $redirect, ['action' => $action, 'tracked' => true]);
            }
            if (!$tqRepo->setCategory($uid, $pid, $qKey, $wantCatId)) {
                $this->finishTrackAction(false, __('keywords.track_added_category_failed'), $redirect, ['action' => $action, 'tracked' => true]);
            }
            $this->finishTrackAction(true, __('keywords.track_added_with_category'), $redirect, ['action' => $action, 'tracked' => true]);
        }
        $this->finishTrackAction(true, __('keywords.track_added'), $redirect, ['action' => $action, 'tracked' => true]);
    }

    private function handleKeywordsUntrackPost(): void
    {
        $this->assertPostCsrf();
        $uid = $this->currentUserId();
        $propRepo = new GooglePropertyRepository($this->pdo(), $this->tablePrefix, $uid);
        $redirect = $this->keywordsRedirectUrlFromPost();
        $action = 'untrack';

        $pid = (int) ($_POST['property_id'] ?? 0);
        $prop = $propRepo->findById($pid);
        if (!is_array($prop) || empty($prop['id'])) {
            $this->finishTrackAction(false, __('keywords.track_property_error'), $redirect, ['action' => $action]);
        }

        $query = trim((string) ($_POST['query'] ?? ''));
        if ($query === '' || mb_strlen($query) > TrackedQueryRepository::MAX_QUERY_LENGTH) {
            $this->finishTrackAction(false, __('keywords.untrack_invalid'), $redirect, ['action' => $action]);
        }

        $tqRepo = new TrackedQueryRepository($this->pdo(), $this->tablePrefix);
        if (!$tqRepo->remove($uid, $pid, $query)) {
            $this->finishTrackAction(false, __('keywords.untrack_not_found'), $redirect, ['action' => $action]);
        }
        $this->finishTrackAction(true, __('keywords.untrack_removed'), $redirect, ['action' => $action, 'tracked' => false]);
    }

    private function handlePagesTrackPost(): void
    {
        $this->assertPostCsrf();
        $uid = $this->currentUserId();
        $propRepo = new GooglePropertyRepository($this->pdo(), $this->tablePrefix, $uid);
        $redirect = $this->pagesRedirectUrlFromPost();
        $action = 'track';

        $pid = (int) ($_POST['property_id'] ?? 0);
        $prop = $propRepo->findById($pid);
        if (!is_array($prop) || empty($prop['id'])) {
            $this->finishTrackAction(false, __('pages.track_property_error'), $redirect, ['action' => $action]);
        }

        $pageInput = (string) ($_POST['page_url'] ?? '');
        $pKey = trim($pageInput);

        $rawTrackCat = trim((string) ($_POST['track_category_id'] ?? '0'));
        $wantCatId = null;
        if ($rawTrackCat !== '' && $rawTrackCat !== '0' && ctype_digit($rawTrackCat)) {
            $tmpC = (int) $rawTrackCat;
            if ($tmpC > 0) {
                $wantCatId = $tmpC;
            }
        }

        $tpRepo = new TrackedPageRepository($this->pdo(), $this->tablePrefix);
        $res = $tpRepo->add($uid, $pid, $pageInput);
        if ($res === 'invalid') {
            $this->finishTrackAction(false, __('pages.track_invalid'), $redirect, ['action' => $action]);
        }
        if ($res === 'at_limit') {
            $this->finishTrackAction(false, __('pages.track_limit_reached', ['max' => (string) TrackedPageRepository::MAX_TRACKED_PER_PROPERTY]), $redirect, ['action' => $action]);
        }
        if ($res === 'duplicate') {
            $this->finishTrackAction(true, __('pages.track_duplicate'), $redirect, ['action' => $action, 'tracked' => true]);
        }
        if ($wantCatId !== null) {
            $catRepo = new TrackedCategoryRepository($this->pdo(), $this->tablePrefix);
            if ($catRepo->findOwned($uid, $pid, $wantCatId) === null) {
                $this->finishTrackAction(true, __('pages.track_added_category_invalid'), $redirect, ['action' => $action, 'tracked' => true]);
            }
            if (!$tpRepo->setCategory($uid, $pid, $pKey, $wantCatId)) {
                $this->finishTrackAction(false, __('pages.track_added_category_failed'), $redirect, ['action' => $action, 'tracked' => true]);
            }
            $this->finishTrackAction(true, __('pages.track_added_with_category'), $redirect, ['action' => $action, 'tracked' => true]);
        }
        $this->finishTrackAction(true, __('pages.track_added'), $redirect, ['action' => $action, 'tracked' => true]);
    }

    private function handlePagesUntrackPost(): void
    {
        $this->assertPostCsrf();
        $uid = $this->currentUserId();
        $propRepo = new GooglePropertyRepository($this->pdo(), $this->tablePrefix, $uid);
        $redirect = $this->pagesRedirectUrlFromPost();
        $action = 'untrack';

        $pid = (int) ($_POST['property_id'] ?? 0);
        $prop = $propRepo->findById($pid);
        if (!is_array($prop) || empty($prop['id'])) {
            $this->finishTrackAction(false, __('pages.track_property_error'), $redirect, ['action' => $action]);
        }

        $pageUrl = trim((string) ($_POST['page_url'] ?? ''));
        if ($pageUrl === '' || mb_strlen($pageUrl) > TrackedPageRepository::MAX_PAGE_LENGTH) {
            $this->finishTrackAction(false, __('pages.untrack_invalid'), $redirect, ['action' => $action]);
        }

        $tpRepo = new TrackedPageRepository($this->pdo(), $this->tablePrefix);
        if (!$tpRepo->remove($uid, $pid, $pageUrl)) {
            $this->finishTrackAction(false, __('pages.untrack_not_found'), $redirect, ['action' => $action]);
        }
        $this->finishTrackAction(true, __('pages.untrack_removed'), $redirect, ['action' => $action, 'tracked' => false]);
    }

    private function handleKeywordPagesJson(): void
    {
        $uid = $this->currentUserId();
        $propRepo = new GooglePropertyRepository($this->pdo(), $this->tablePrefix, $uid);
        $qpRepo = new GscDailyQueryPageRepository($this->pdo(), $this->tablePrefix);

        $filters = $this->resolveGlobalFilters($propRepo);
        $activeProp = $filters['activeProp'];
        $preset = $filters['preset'];
        $device = (string) ($filters['device'] ?? 'all');
        $country = (string) ($filters['country'] ?? 'all');
        ['start' => $rangeStart, 'end' => $rangeEnd] = $this->rangeFromPreset($preset);

        $q = trim((string) ($_GET['query'] ?? ''));
        $limit = max(1, min(25, (int) ($_GET['limit'] ?? 10)));

        $rows = [];
        if (is_array($activeProp) && !empty($activeProp['id']) && $q !== '') {
            $rows = $qpRepo->topPagesForQuery((int) $activeProp['id'], $rangeStart, $rangeEnd, $q, $device, $country, $limit);
        }

        header('Content-Type: application/json; charset=utf-8');
        header('Cache-Control: no-store, no-cache, must-revalidate, max-age=0');
        header('Pragma: no-cache');
        echo json_encode(['ok' => true, 'query' => $q, 'rows' => $rows], JSON_UNESCAPED_SLASHES);
    }

    private function handleQuickWinPagesJson(): void
    {
        $uid = $this->currentUserId();
        $propRepo = new GooglePropertyRepository($this->pdo(), $this->tablePrefix, $uid);
        $qpRepo = new GscDailyQueryPageRepository($this->pdo(), $this->tablePrefix);

        $filters = $this->resolveGlobalFilters($propRepo);
        $activeProp = $filters['activeProp'];
        $preset = $filters['preset'];
        $device = (string) ($filters['device'] ?? 'all');
        $country = (string) ($filters['country'] ?? 'all');
        ['start' => $rangeStart, 'end' => $rangeEnd] = $this->rangeFromPreset($preset);

        $q = trim((string) ($_GET['query'] ?? ''));
        $limit = max(1, min(25, (int) ($_GET['limit'] ?? 10)));

        $rows = [];
        if (is_array($activeProp) && !empty($activeProp['id']) && $q !== '') {
            $rows = $qpRepo->topPagesForQuery((int) $activeProp['id'], $rangeStart, $rangeEnd, $q, $device, $country, $limit);
        }

        header('Content-Type: application/json; charset=utf-8');
        header('Cache-Control: no-store, no-cache, must-revalidate, max-age=0');
        header('Pragma: no-cache');
        echo json_encode(['ok' => true, 'query' => $q, 'rows' => $rows], JSON_UNESCAPED_SLASHES);
    }

    private function showPages(): void
    {
        $flash = $this->pullFlash();
        $uid = $this->currentUserId();
        $propRepo = new GooglePropertyRepository($this->pdo(), $this->tablePrefix, $uid);
        $pgRepo = new GscPagesRepository($this->pdo(), $this->tablePrefix);

        $filters = $this->resolveGlobalFilters($propRepo);
        $activeProp = $filters['activeProp'];
        $preset = $filters['preset'];
        $device = (string) ($filters['device'] ?? 'all');
        $country = (string) ($filters['country'] ?? 'all');
        $q = trim((string) ($_GET['q'] ?? ''));
        $sort = (string) ($_GET['sort'] ?? 'clicks');
        $dir = (string) ($_GET['dir'] ?? 'DESC');
        $compare = (string) ($_GET['compare'] ?? '') === '1';
        $page = max(1, (int) ($_GET['page'] ?? 1));
        $perPage = max(10, min(100, (int) ($_GET['per_page'] ?? 10)));

        $pagesShow = strtolower(trim((string) ($_GET['show'] ?? 'all')));
        if (!in_array($pagesShow, ['all', 'tracked'], true)) {
            $pagesShow = 'all';
        }
        $trackedPagesList = [];
        $pagesTrackedCategoriesList = [];
        $trackedPageCategoryMap = [];
        $pagesTrackCategoryFilter = null;
        if (is_array($activeProp) && !empty($activeProp['id'])) {
            $pidPg = (int) $activeProp['id'];
            $catRepoPg = new TrackedCategoryRepository($this->pdo(), $this->tablePrefix);
            $pagesTrackedCategoriesList = $catRepoPg->listForProperty($uid, $pidPg);
            $pagesTrackCategoryFilter = $this->parseTrackCategoryFilterFromGet($pagesTrackedCategoriesList);
            $tpRepo = new TrackedPageRepository($this->pdo(), $this->tablePrefix);
            $trackedPagesList = $tpRepo->listPages($uid, $pidPg);
            $trackedPageCategoryMap = $tpRepo->pageCategoryMap($uid, $pidPg);
            $onlyPages = $pagesShow === 'tracked'
                ? $tpRepo->listPages($uid, $pidPg, $pagesTrackCategoryFilter)
                : null;
        } else {
            $onlyPages = null;
        }

        ['start' => $rangeStart, 'end' => $rangeEnd] = $this->rangeFromPreset($preset);

        $data = null;
        if (is_array($activeProp) && !empty($activeProp['id'])) {
            $data = $pgRepo->listForRange((int) $activeProp['id'], $rangeStart, $rangeEnd, $q, $device, $country, $sort, $dir, $page, $perPage, $onlyPages);
            if ($compare && is_array($data) && !empty($data['rows'])) {
                $pagesList = [];
                foreach ($data['rows'] as $r) {
                    if (is_array($r) && !empty($r['page'])) {
                        $pagesList[] = (string) $r['page'];
                    }
                }
                $lenDays = max(1, (int) ((strtotime($rangeEnd) - strtotime($rangeStart)) / 86400) + 1);
                $prevEnd = date('Y-m-d', strtotime($rangeStart . ' -1 day'));
                $prevStart = date('Y-m-d', strtotime($prevEnd . ' -' . ($lenDays - 1) . ' days'));
                $prevMap = $pgRepo->totalsForPages((int) $activeProp['id'], $prevStart, $prevEnd, $device, $country, $pagesList);
                $enriched = [];
                foreach ($data['rows'] as $r) {
                    if (!is_array($r)) {
                        continue;
                    }
                    $pp = $prevMap[(string) ($r['page'] ?? '')] ?? ['clicks' => 0, 'impressions' => 0, 'ctr' => 0.0, 'position' => 0.0];
                    $r['prev'] = $pp;
                    $enriched[] = $r;
                }
                $data['rows'] = $enriched;
                $data['prev_range'] = ['start' => $prevStart, 'end' => $prevEnd];
            }
        }

        $globalFilters = $filters['globalFilters'];
        $pagesTrackMax = TrackedPageRepository::MAX_TRACKED_PER_PROPERTY;
        $pagesTrackAtLimit = is_array($activeProp) && !empty($activeProp['id'])
            && count($trackedPagesList) >= $pagesTrackMax;

        $repo = new SettingsRepository($this->pdo(), $this->tablePrefix, $uid);
        $seoAiInsightsEnabled = (new AiSettingsService($repo))->isEnabled();
        $seoAiPgCard = null;
        if ($seoAiInsightsEnabled) {
            $seoAiPgCard = [
                'slug' => 'pages',
                'tab' => '',
                'endpoint' => '/pages/seo-ai-assistant',
                'append_tab_param' => false,
                'csrf' => AdminCsrf::token(),
                'cached_body' => null,
                'has_cached' => false,
                'no_property' => true,
                'no_rows' => false,
                'configure' => false,
                'idle' => false,
                'show_cached' => false,
                'session_storage_key' => 'ss_seo_ai_pages_has_result',
                'url_query_params' => ['prop', 'range', 'compare', 'device', 'country', 'show', 'q', 'sort', 'dir', 'track_cat'],
                'intro_hint' => __('seo_ai.pages.intro_hint'),
                'not_ready' => __('seo_ai.pages.not_ready'),
                'generate_tooltip' => __('seo_ai.pages.generate_tooltip'),
            ];
            if (is_array($activeProp) && !empty($activeProp['id'])) {
                $aiPg = new AiSettingsService($repo);
                $pgState = $this->pagesSeoAiViewState(
                    $uid,
                    (int) $activeProp['id'],
                    $preset,
                    $rangeStart,
                    $rangeEnd,
                    $device,
                    $country,
                    $repo,
                    $pgRepo,
                    $aiPg,
                );
                $seoAiPgCard['no_property'] = false;
                $seoAiPgCard['cached_body'] = $pgState['cached_body'];
                $seoAiPgCard['has_cached'] = $pgState['has_cached'];
                $seoAiPgCard['no_rows'] = !$pgState['insight_ready'];
                $seoAiPgCard['configure'] = $pgState['insight_ready'] && !$pgState['ai_ready'];
                $seoAiPgCard['idle'] = $pgState['insight_ready'] && $pgState['ai_ready'];
                $seoAiPgCard['show_cached'] = $pgState['has_cached'];
            }
        }

        require $this->root . '/app/Views/pages.php';
    }

    private function handlePageKeywordsJson(): void
    {
        $uid = $this->currentUserId();
        $propRepo = new GooglePropertyRepository($this->pdo(), $this->tablePrefix, $uid);
        $qpRepo = new GscDailyQueryPageRepository($this->pdo(), $this->tablePrefix);

        $filters = $this->resolveGlobalFilters($propRepo);
        $activeProp = $filters['activeProp'];
        $preset = $filters['preset'];
        $device = (string) ($filters['device'] ?? 'all');
        $country = (string) ($filters['country'] ?? 'all');
        ['start' => $rangeStart, 'end' => $rangeEnd] = $this->rangeFromPreset($preset);

        $pageUrl = trim((string) ($_GET['page'] ?? ''));
        $limit = max(1, min(100, (int) ($_GET['limit'] ?? 25)));

        $rows = [];
        if (is_array($activeProp) && !empty($activeProp['id']) && $pageUrl !== '') {
            $rows = $qpRepo->topQueriesForPage((int) $activeProp['id'], $rangeStart, $rangeEnd, $pageUrl, $device, $country, $limit);
        }

        header('Content-Type: application/json; charset=utf-8');
        header('Cache-Control: no-store, no-cache, must-revalidate, max-age=0');
        header('Pragma: no-cache');
        echo json_encode(['ok' => true, 'page' => $pageUrl, 'rows' => $rows], JSON_UNESCAPED_SLASHES);
    }

    private function showOpportunities(): void
    {
        $flash = $this->pullFlash();
        $uid = $this->currentUserId();
        $propRepo = new GooglePropertyRepository($this->pdo(), $this->tablePrefix, $uid);
        $repo = new SettingsRepository($this->pdo(), $this->tablePrefix, $uid);
        $ctrRepo = new GscCtrBenchmarkRepository($this->pdo(), $this->tablePrefix);
        $qwRepo = new GscQuickWinRepository($this->pdo(), $this->tablePrefix);

        $filters = $this->resolveGlobalFilters($propRepo);
        $activeProp = $filters['activeProp'];
        $preset = $filters['preset'];
        $device = (string) ($filters['device'] ?? 'all');
        $country = (string) ($filters['country'] ?? 'all');
        ['start' => $rangeStart, 'end' => $rangeEnd] = $this->rangeFromPreset($preset);

        $tab = (string) ($_GET['tab'] ?? 'iqqi');
        $allowedTabs = ['iqqi', 'ctr_opportunity', 'low_ctr_pages', 'topical_gaps'];
        if (!in_array($tab, $allowedTabs, true)) {
            $tab = 'iqqi';
        }

        $sort = (string) ($_GET['sort'] ?? 'score');
        $dir = (string) ($_GET['dir'] ?? 'DESC');
        $page = max(1, (int) ($_GET['page'] ?? 1));
        $perPage = max(10, min(100, (int) ($_GET['per_page'] ?? 10)));

        $data = null;
        if (is_array($activeProp) && !empty($activeProp['id'])) {
            $pid = (int) $activeProp['id'];
            $ctrCurve = $ctrRepo->curveForRange($pid, $rangeStart, $rangeEnd, $device, $country);

            $minIqqiImpr = (int) ($repo->get('quickwin.iqqi.min_impr') ?? '200');
            $iqqiPosMin = (float) ($repo->get('quickwin.iqqi.pos_min') ?? '4');
            $iqqiPosMax = (float) ($repo->get('quickwin.iqqi.pos_max') ?? '10');
            $minCtrImpr = (int) ($repo->get('quickwin.ctr_gap.min_impr') ?? '200');
            $minLowCtrPagesImpr = (int) ($repo->get('quickwin.lowctr_pages.min_impr') ?? '1000');
            $lowCtrCoeff = (float) ($repo->get('quickwin.lowctr.coeff') ?? '0.5');
            $minTopicalImpr = (int) ($repo->get('quickwin.topical.min_impr') ?? '200');
            $topicalPosMin = (float) ($repo->get('quickwin.topical.pos_min') ?? '15');
            $topicalMaxClicks = (int) ($repo->get('quickwin.topical.max_clicks') ?? '2');

            // Optional URL overrides (non-persistent) + reset UX support.
            $reset = (string) ($_GET['reset'] ?? '') === '1';
            if (!$reset) {
                $minIqqiImpr = max(0, (int) ($_GET['iqqi_min_impr'] ?? $minIqqiImpr));
                $iqqiPosMin = max(1.0, (float) ($_GET['iqqi_pos_min'] ?? $iqqiPosMin));
                $iqqiPosMax = max($iqqiPosMin, (float) ($_GET['iqqi_pos_max'] ?? $iqqiPosMax));
                $minCtrImpr = max(0, (int) ($_GET['ctr_min_impr'] ?? $minCtrImpr));
                $minLowCtrPagesImpr = max(0, (int) ($_GET['lowctr_pages_min_impr'] ?? $minLowCtrPagesImpr));
                $lowCtrCoeff = max(0.05, (float) ($_GET['lowctr_coeff'] ?? $lowCtrCoeff));
                $minTopicalImpr = max(0, (int) ($_GET['topical_min_impr'] ?? $minTopicalImpr));
                $topicalPosMin = max(1.0, (float) ($_GET['topical_pos_min'] ?? $topicalPosMin));
                $topicalMaxClicks = max(0, (int) ($_GET['topical_max_clicks'] ?? $topicalMaxClicks));
            }

            $data = match ($tab) {
                'ctr_opportunity' => $qwRepo->listCtrOpportunity($pid, $rangeStart, $rangeEnd, $device, $country, $minCtrImpr, $ctrCurve, $page, $perPage, $sort, $dir),
                'low_ctr_pages' => $qwRepo->listLowCtrPages($pid, $rangeStart, $rangeEnd, $device, $country, $minLowCtrPagesImpr, $lowCtrCoeff, $ctrCurve, $page, $perPage, $sort, $dir),
                'topical_gaps' => $qwRepo->listTopicalGaps($pid, $rangeStart, $rangeEnd, $device, $country, $minTopicalImpr, $topicalPosMin, $topicalMaxClicks, $ctrCurve, $page, $perPage, $sort, $dir),
                default => $qwRepo->listIqqi($pid, $rangeStart, $rangeEnd, $device, $country, $minIqqiImpr, $iqqiPosMin, $iqqiPosMax, $ctrCurve, $page, $perPage, $sort, $dir),
            };

            if (is_array($data)) {
                $data['thresholds'] = [
                    'iqqi_min_impr' => $minIqqiImpr,
                    'iqqi_pos_min' => $iqqiPosMin,
                    'iqqi_pos_max' => $iqqiPosMax,
                    'ctr_min_impr' => $minCtrImpr,
                    'lowctr_pages_min_impr' => $minLowCtrPagesImpr,
                    'lowctr_coeff' => $lowCtrCoeff,
                    'topical_min_impr' => $minTopicalImpr,
                    'topical_pos_min' => $topicalPosMin,
                    'topical_max_clicks' => $topicalMaxClicks,
                ];
            }
        }

        $globalFilters = $filters['globalFilters'];

        $seoAiInsightsEnabled = (new AiSettingsService($repo))->isEnabled();
        $seoAiQwCsrf = AdminCsrf::token();
        $seoAiQwCard = null;
        $qwAiPreset = $seoAiInsightsEnabled ? $this->quickWinSeoAiCardPreset($tab) : null;
        if (is_array($qwAiPreset)) {
            $seoAiQwCard = array_merge($qwAiPreset, [
                'csrf' => $seoAiQwCsrf,
                'cached_body' => null,
                'has_cached' => false,
                'no_property' => true,
                'no_rows' => false,
                'configure' => false,
                'idle' => false,
                'show_cached' => false,
            ]);
            if (is_array($activeProp) && !empty($activeProp['id'])) {
                $aiQw = new AiSettingsService($repo);
                $pidQw = (int) $activeProp['id'];
                $ctrCurveQw = $ctrRepo->curveForRange($pidQw, $rangeStart, $rangeEnd, $device, $country);
                $qwState = $this->quickWinSeoAiViewState(
                    $tab,
                    $uid,
                    $pidQw,
                    $preset,
                    $rangeStart,
                    $rangeEnd,
                    $device,
                    $country,
                    $repo,
                    $qwRepo,
                    $ctrCurveQw,
                    $aiQw,
                );
                $seoAiQwCard['no_property'] = false;
                $seoAiQwCard['cached_body'] = $qwState['cached_body'];
                $seoAiQwCard['has_cached'] = $qwState['has_cached'];
                $seoAiQwCard['no_rows'] = !$qwState['insight_ready'];
                $seoAiQwCard['configure'] = $qwState['insight_ready'] && !$qwState['ai_ready'];
                $seoAiQwCard['idle'] = $qwState['insight_ready'] && $qwState['ai_ready'];
                $seoAiQwCard['show_cached'] = $qwState['has_cached'];
            }
        }

        require $this->root . '/app/Views/opportunities.php';
    }

    private function showDecay(): void
    {
        $flash = $this->pullFlash();
        $uid = $this->currentUserId();
        $propRepo = new GooglePropertyRepository($this->pdo(), $this->tablePrefix, $uid);
        $repo = new SettingsRepository($this->pdo(), $this->tablePrefix, $uid);
        $decayRepo = new GscDecayRepository($this->pdo(), $this->tablePrefix);

        $filters = $this->resolveGlobalFilters($propRepo);
        $activeProp = $filters['activeProp'];
        $preset = $filters['preset'];
        $device = (string) ($filters['device'] ?? 'all');
        $country = (string) ($filters['country'] ?? 'all');
        ['start' => $curStart, 'end' => $curEnd] = $this->rangeFromPreset($preset);
        $periods = $this->resolveDecayComparisonRanges($curStart, $curEnd);
        $prevStart = $periods['prev_start'];
        $prevEnd = $periods['prev_end'];
        $cfg = $this->resolveDecayConfig($repo, $_GET);

        $sort = (string) ($_GET['sort'] ?? 'd_clicks');
        $dir = (string) ($_GET['dir'] ?? 'ASC'); // drops: more negative first
        $page = max(1, (int) ($_GET['page'] ?? 1));
        $perPage = max(10, min(100, (int) ($_GET['per_page'] ?? 10)));

        $data = null;
        if (is_array($activeProp) && !empty($activeProp['id'])) {
            $data = $decayRepo->listForRange((int) $activeProp['id'], $curStart, $curEnd, $prevStart, $prevEnd, $device, $country, $cfg, $sort, $dir, $page, $perPage);
            if (is_array($data)) {
                $data['prev_range'] = ['start' => $prevStart, 'end' => $prevEnd];
            }
        }

        $rangeStart = $curStart;
        $rangeEnd = $curEnd;
        $globalFilters = $filters['globalFilters'];

        $seoAiInsightsEnabled = (new AiSettingsService($repo))->isEnabled();
        $seoAiDecayCard = null;
        if ($seoAiInsightsEnabled) {
            $seoAiDecayCard = [
                'slug' => 'decay',
                'tab' => '',
                'endpoint' => '/decay/seo-ai-assistant',
                'append_tab_param' => false,
                'csrf' => AdminCsrf::token(),
                'cached_body' => null,
                'has_cached' => false,
                'no_property' => true,
                'no_rows' => false,
                'configure' => false,
                'idle' => false,
                'show_cached' => false,
                'session_storage_key' => 'ss_seo_ai_decay_has_result',
                'url_query_params' => ['prop', 'range', 'device', 'country', 'min_impressions', 'clicks_drop_pct', 'impr_drop_pct', 'position_worsen', 'sort', 'dir'],
                'intro_hint' => __('seo_ai.decay.intro_hint'),
                'not_ready' => __('seo_ai.decay.not_ready'),
                'generate_tooltip' => __('seo_ai.decay.generate_tooltip'),
            ];
            if (is_array($activeProp) && !empty($activeProp['id'])) {
                $aiDecay = new AiSettingsService($repo);
                $decayState = $this->decaySeoAiViewState(
                    $uid,
                    (int) $activeProp['id'],
                    $preset,
                    $curStart,
                    $curEnd,
                    $prevStart,
                    $prevEnd,
                    $device,
                    $country,
                    $decayRepo,
                    $aiDecay,
                    $repo,
                );
                $seoAiDecayCard['no_property'] = false;
                $seoAiDecayCard['cached_body'] = $decayState['cached_body'];
                $seoAiDecayCard['has_cached'] = $decayState['has_cached'];
                $seoAiDecayCard['no_rows'] = !$decayState['insight_ready'];
                $seoAiDecayCard['configure'] = $decayState['insight_ready'] && !$decayState['ai_ready'];
                $seoAiDecayCard['idle'] = $decayState['insight_ready'] && $decayState['ai_ready'];
                $seoAiDecayCard['show_cached'] = $decayState['has_cached'];
            }
        }

        require $this->root . '/app/Views/decay.php';
    }

    private function showCannibalization(): void
    {
        $flash = $this->pullFlash();
        $uid = $this->currentUserId();
        $propRepo = new GooglePropertyRepository($this->pdo(), $this->tablePrefix, $uid);
        $canRepo = new GscCannibalizationRepository($this->pdo(), $this->tablePrefix);

        $filters = $this->resolveGlobalFilters($propRepo);
        $activeProp = $filters['activeProp'];
        $preset = $filters['preset'];
        $device = (string) ($filters['device'] ?? 'all');
        $country = (string) ($filters['country'] ?? 'all');
        ['start' => $rangeStart, 'end' => $rangeEnd] = $this->rangeFromPreset($preset);

        $minImpr = max(0, (int) ($_GET['min_impr'] ?? 500));
        $minPages = max(2, (int) ($_GET['min_pages'] ?? 2));
        $sort = (string) ($_GET['sort'] ?? 'impressions');
        $dir = (string) ($_GET['dir'] ?? 'DESC');
        $page = max(1, (int) ($_GET['page'] ?? 1));
        $perPage = max(10, min(100, (int) ($_GET['per_page'] ?? 10)));

        $data = null;
        if (is_array($activeProp) && !empty($activeProp['id'])) {
            $data = $canRepo->listForRange((int) $activeProp['id'], $rangeStart, $rangeEnd, $device, $country, $minImpr, $minPages, $sort, $dir, $page, $perPage);
        }

        $globalFilters = $filters['globalFilters'];

        $repo = new SettingsRepository($this->pdo(), $this->tablePrefix, $uid);
        $seoAiInsightsEnabled = (new AiSettingsService($repo))->isEnabled();
        $seoAiCanCard = null;
        if ($seoAiInsightsEnabled) {
            $seoAiCanCard = [
                'slug' => 'cannibalization',
                'tab' => '',
                'endpoint' => '/cannibalization/seo-ai-assistant',
                'append_tab_param' => false,
                'csrf' => AdminCsrf::token(),
                'cached_body' => null,
                'has_cached' => false,
                'no_property' => true,
                'no_rows' => false,
                'configure' => false,
                'idle' => false,
                'show_cached' => false,
                'session_storage_key' => 'ss_seo_ai_cannibalization_has_result',
                'url_query_params' => ['prop', 'range', 'device', 'country', 'min_impr', 'min_pages', 'sort', 'dir'],
                'intro_hint' => __('seo_ai.cannibalization.intro_hint'),
                'not_ready' => __('seo_ai.cannibalization.not_ready'),
                'generate_tooltip' => __('seo_ai.cannibalization.generate_tooltip'),
            ];
            if (is_array($activeProp) && !empty($activeProp['id'])) {
                $aiCan = new AiSettingsService($repo);
                $canState = $this->cannibalizationSeoAiViewState(
                    $uid,
                    (int) $activeProp['id'],
                    $preset,
                    $rangeStart,
                    $rangeEnd,
                    $device,
                    $country,
                    $canRepo,
                    $aiCan,
                    $repo,
                );
                $seoAiCanCard['no_property'] = false;
                $seoAiCanCard['cached_body'] = $canState['cached_body'];
                $seoAiCanCard['has_cached'] = $canState['has_cached'];
                $seoAiCanCard['no_rows'] = !$canState['insight_ready'];
                $seoAiCanCard['configure'] = $canState['insight_ready'] && !$canState['ai_ready'];
                $seoAiCanCard['idle'] = $canState['insight_ready'] && $canState['ai_ready'];
                $seoAiCanCard['show_cached'] = $canState['has_cached'];
            }
        }

        require $this->root . '/app/Views/cannibalization.php';
    }

    private function showQuickWin(): void
    {
        // Quick Win is the Opportunities route renamed.
        $this->showOpportunities();
    }

    private function showLongTail(): void
    {
        $flash = $this->pullFlash();
        $uid = $this->currentUserId();
        $propRepo = new GooglePropertyRepository($this->pdo(), $this->tablePrefix, $uid);
        $filters = $this->resolveGlobalFilters($propRepo);

        $activeProp = $filters['activeProp'];
        $preset = $filters['preset'];
        $device = (string) ($filters['device'] ?? 'all');
        $country = (string) ($filters['country'] ?? 'all');
        ['start' => $rangeStart, 'end' => $rangeEnd] = $this->rangeFromPreset($preset);

        $minTerms = max(1, (int) ($_GET['min_terms'] ?? 4));
        $sort = (string) ($_GET['sort'] ?? 'clicks');
        $dir = (string) ($_GET['dir'] ?? 'DESC');
        $page = max(1, (int) ($_GET['page'] ?? 1));
        $perPage = max(10, min(100, (int) ($_GET['per_page'] ?? 10)));

        $data = null;
        if (is_array($activeProp) && !empty($activeProp['id'])) {
            $repo = new GscLongTailRepository($this->pdo(), $this->tablePrefix);
            $data = $repo->listForRange((int) $activeProp['id'], $rangeStart, $rangeEnd, $minTerms, $device, $country, $sort, $dir, $page, $perPage);
        }

        $globalFilters = $filters['globalFilters'];

        $settingsRepo = new SettingsRepository($this->pdo(), $this->tablePrefix, $uid);
        $seoAiInsightsEnabled = (new AiSettingsService($settingsRepo))->isEnabled();
        $seoAiLtCard = null;
        if ($seoAiInsightsEnabled) {
            $ltRepoAi = new GscLongTailRepository($this->pdo(), $this->tablePrefix);
            $seoAiLtCard = [
                'slug' => 'long-tail',
                'tab' => '',
                'endpoint' => '/long-tail/seo-ai-assistant',
                'append_tab_param' => false,
                'csrf' => AdminCsrf::token(),
                'cached_body' => null,
                'has_cached' => false,
                'no_property' => true,
                'no_rows' => false,
                'configure' => false,
                'idle' => false,
                'show_cached' => false,
                'session_storage_key' => 'ss_seo_ai_long_tail_has_result',
                'url_query_params' => ['prop', 'range', 'device', 'country', 'min_terms', 'sort', 'dir'],
                'intro_hint' => __('seo_ai.long_tail.intro_hint'),
                'not_ready' => __('seo_ai.long_tail.not_ready'),
                'generate_tooltip' => __('seo_ai.long_tail.generate_tooltip'),
            ];
            if (is_array($activeProp) && !empty($activeProp['id'])) {
                $aiLt = new AiSettingsService($settingsRepo);
                $ltState = $this->longTailSeoAiViewState(
                    $uid,
                    (int) $activeProp['id'],
                    $preset,
                    $rangeStart,
                    $rangeEnd,
                    $device,
                    $country,
                    $ltRepoAi,
                    $aiLt,
                    $settingsRepo,
                );
                $seoAiLtCard['no_property'] = false;
                $seoAiLtCard['cached_body'] = $ltState['cached_body'];
                $seoAiLtCard['has_cached'] = $ltState['has_cached'];
                $seoAiLtCard['no_rows'] = !$ltState['insight_ready'];
                $seoAiLtCard['configure'] = $ltState['insight_ready'] && !$ltState['ai_ready'];
                $seoAiLtCard['idle'] = $ltState['insight_ready'] && $ltState['ai_ready'];
                $seoAiLtCard['show_cached'] = $ltState['has_cached'];
            }
        }

        require $this->root . '/app/Views/long_tail.php';
    }

    private function showQuestionQueries(): void
    {
        $flash = $this->pullFlash();
        $uid = $this->currentUserId();
        $propRepo = new GooglePropertyRepository($this->pdo(), $this->tablePrefix, $uid);
        $settings = new SettingsRepository($this->pdo(), $this->tablePrefix, $uid);
        $repo = new GscQuestionQueriesRepository($this->pdo(), $this->tablePrefix);

        $filters = $this->resolveGlobalFilters($propRepo);
        $activeProp = $filters['activeProp'];
        $preset = $filters['preset'];
        $device = (string) ($filters['device'] ?? 'all');
        $country = (string) ($filters['country'] ?? 'all');
        ['start' => $rangeStart, 'end' => $rangeEnd] = $this->rangeFromPreset($preset);

        $sort = (string) ($_GET['sort'] ?? 'clicks');
        $dir = (string) ($_GET['dir'] ?? 'DESC');
        $page = max(1, (int) ($_GET['page'] ?? 1));
        $perPage = max(10, min(100, (int) ($_GET['per_page'] ?? 10)));

        $stored = $settings->get('question_queries.terms');
        $terms = $this->resolveQuestionQueriesTerms($settings);

        $data = null;
        if (is_array($activeProp) && !empty($activeProp['id'])) {
            $data = $repo->listForRange((int) $activeProp['id'], $rangeStart, $rangeEnd, $terms, $device, $country, $sort, $dir, $page, $perPage);
        }

        $csrf = AdminCsrf::token();
        // UI requirement: textarea starts empty until user customizes.
        $questionTermsRaw = is_string($stored) ? (string) $stored : '';
        $globalFilters = $filters['globalFilters'];

        $seoAiInsightsEnabled = (new AiSettingsService($settings))->isEnabled();
        $seoAiQqCard = null;
        if ($seoAiInsightsEnabled) {
            $qqRepoAi = new GscQuestionQueriesRepository($this->pdo(), $this->tablePrefix);
            $seoAiQqCard = [
                'slug' => 'question-queries',
                'tab' => '',
                'endpoint' => '/question-queries/seo-ai-assistant',
                'append_tab_param' => false,
                'csrf' => AdminCsrf::token(),
                'cached_body' => null,
                'has_cached' => false,
                'no_property' => true,
                'no_rows' => false,
                'configure' => false,
                'idle' => false,
                'show_cached' => false,
                'session_storage_key' => 'ss_seo_ai_question_queries_has_result',
                'url_query_params' => ['prop', 'range', 'device', 'country', 'sort', 'dir'],
                'intro_hint' => __('seo_ai.question_queries.intro_hint'),
                'not_ready' => __('seo_ai.question_queries.not_ready'),
                'generate_tooltip' => __('seo_ai.question_queries.generate_tooltip'),
            ];
            if (is_array($activeProp) && !empty($activeProp['id'])) {
                $aiQq = new AiSettingsService($settings);
                $qqState = $this->questionQueriesSeoAiViewState(
                    $uid,
                    (int) $activeProp['id'],
                    $preset,
                    $rangeStart,
                    $rangeEnd,
                    $device,
                    $country,
                    $qqRepoAi,
                    $aiQq,
                    $settings,
                );
                $seoAiQqCard['no_property'] = false;
                $seoAiQqCard['cached_body'] = $qqState['cached_body'];
                $seoAiQqCard['has_cached'] = $qqState['has_cached'];
                $seoAiQqCard['no_rows'] = !$qqState['insight_ready'];
                $seoAiQqCard['configure'] = $qqState['insight_ready'] && !$qqState['ai_ready'];
                $seoAiQqCard['idle'] = $qqState['insight_ready'] && $qqState['ai_ready'];
                $seoAiQqCard['show_cached'] = $qqState['has_cached'];
            }
        }

        require $this->root . '/app/Views/question_queries.php';
    }

    private function handleQuestionQueriesTermsPost(): void
    {
        $this->assertPostCsrf();

        $uid = $this->currentUserId();
        $repo = new SettingsRepository($this->pdo(), $this->tablePrefix, $uid);

        $defaultTerms = "what\nwhere\nwhen\nwhy\nhow\nwho\nwhich\nwhose\nwhom";
        $reset = (string) ($_POST['reset'] ?? '') === '1';
        $raw = $reset ? $defaultTerms : (string) ($_POST['question_terms'] ?? '');
        $raw = trim($raw);
        if ($raw === '') {
            // Empty means: keep using built-in defaults, and keep the textarea empty.
            $repo->set('question_queries.terms', null);
            $_SESSION['_flash_ok'] = __('question_queries.terms_saved');
            $qs = [];
            $prop = trim((string) ($_POST['prop'] ?? ''));
            $range = trim((string) ($_POST['range'] ?? ''));
            if ($prop !== '') $qs['prop'] = $prop;
            if ($range !== '') $qs['range'] = $range;
            $this->redirect(seoscope_url('/question-queries' . ($qs ? ('?' . http_build_query($qs)) : '')));
            return;
        }

        // Normalize: one term per line.
        $terms = array_values(array_filter(array_map('trim', preg_split('/[\r\n,]+/', $raw) ?: []), static fn ($s) => $s !== ''));
        $terms = array_slice($terms, 0, 50);
        $stored = implode("\n", $terms);
        $repo->set('question_queries.terms', $stored);

        $_SESSION['_flash_ok'] = __('question_queries.terms_saved');
        $qs = [];
        $prop = trim((string) ($_POST['prop'] ?? ''));
        $range = trim((string) ($_POST['range'] ?? ''));
        if ($prop !== '') $qs['prop'] = $prop;
        if ($range !== '') $qs['range'] = $range;
        $this->redirect(seoscope_url('/question-queries' . ($qs ? ('?' . http_build_query($qs)) : '')));
    }

    /**
     * @return array{start:string,end:string}
     */
    private function rangeFromPreset(string $preset): array
    {
        $endDt = new \DateTimeImmutable('yesterday');
        $end = $endDt->format('Y-m-d');
        $preset = strtolower(trim($preset));
        if ($preset === 'last_30_days') {
            $preset = 'last_28_days';
        }
        $start = match ($preset) {
            'last_7_days' => $endDt->modify('-6 days')->format('Y-m-d'),
            'last_28_days' => $endDt->modify('-27 days')->format('Y-m-d'),
            'last_3_months' => $endDt->modify('-3 months')->format('Y-m-d'),
            'last_6_months' => $endDt->modify('-6 months')->format('Y-m-d'),
            'last_12_months' => $endDt->modify('-12 months')->format('Y-m-d'),
            'last_16_months' => $endDt->modify('-16 months')->format('Y-m-d'),
            default => $endDt->modify('-29 days')->format('Y-m-d'), // last_30_days
        };
        return ['start' => $start, 'end' => $end];
    }

    /**
     * @return array{
     *   preset:string,
     *   compare:bool,
     *   device:string,
     *   country:string,
     *   activeProp:null|array,
     *   globalFilters:array{
     *     current_path:string,
     *     preset:string,
     *     compare:bool,
     *     device:string,
     *     country:string,
     *     prop_id:string,
     *     properties:list<array{id:int,site_url:string}>,
     *     countries:list<string>
     *   }
     * }
     */
    private function resolveGlobalFilters(GooglePropertyRepository $propRepo): array
    {
        $all = $propRepo->all();
        $enabled = array_values(array_filter($all, static fn ($p) => is_array($p) && !empty($p['is_enabled']) && !empty($p['id'])));
        $options = [];
        foreach ($enabled as $p) {
            $options[] = ['id' => (int) $p['id'], 'site_url' => (string) ($p['site_url'] ?? '')];
        }

        $preset = (string) ($_GET['range'] ?? 'last_28_days');
        if ($preset === 'last_30_days') {
            $preset = 'last_28_days';
        }
        $allowed = ['last_7_days', 'last_28_days', 'last_3_months', 'last_6_months', 'last_12_months', 'last_16_months'];
        if (!in_array($preset, $allowed, true)) {
            $preset = 'last_28_days';
        }

        $compare = (string) ($_GET['compare'] ?? '') === '1';
        // Device/Country breakdown filters are no longer exposed in UX. Keep params for backward-compatibility but ignore them.
        $device = 'all';
        $country = 'all';
        $uid = $this->currentUserId();
        $settings = new SettingsRepository($this->pdo(), $this->tablePrefix, $uid);
        $breakdownsEnabled = false;

        $requestedId = (int) ($_GET['prop'] ?? 0);
        $activeProp = null;
        if ($requestedId > 0) {
            foreach ($enabled as $p) {
                if ((int) ($p['id'] ?? 0) === $requestedId) {
                    $activeProp = $p;
                    break;
                }
            }
        }
        if ($activeProp === null) {
            foreach ($enabled as $p) {
                $activeProp = $p;
                break;
            }
        }

        $pathOnly = parse_url((string) ($_SERVER['REQUEST_URI'] ?? '/'), PHP_URL_PATH);
        $currentPath = is_string($pathOnly) && $pathOnly !== '' ? $pathOnly : '/';

        $countries = [];
        // No country list needed: country selector removed.

        return [
            'preset' => $preset,
            'compare' => $compare,
            'device' => $device,
            'country' => $country,
            'activeProp' => $activeProp,
            'globalFilters' => [
                'current_path' => $currentPath,
                'preset' => $preset,
                'compare' => $compare,
                'device' => $device,
                'country' => $country,
                'breakdowns_enabled' => $breakdownsEnabled,
                'prop_id' => is_array($activeProp) ? (string) ((int) ($activeProp['id'] ?? 0)) : '',
                'properties' => $options,
                'countries' => $countries,
            ],
        ];
    }

    /**
     * @return list<string> lowercased 2-3 char country codes
     */
    private function listCountriesForProperty(int $propertyId): array
    {
        $t = '`' . str_replace('`', '``', $this->tablePrefix . 'gsc_daily_query') . '`';
        $stmt = $this->pdo()->prepare(
            "SELECT country, SUM(impressions) AS impr
             FROM {$t}
             WHERE property_id = ? AND country <> 'all'
             GROUP BY country
             ORDER BY impr DESC
             LIMIT 80"
        );
        $stmt->execute([$propertyId]);
        $rows = $stmt->fetchAll(\PDO::FETCH_ASSOC) ?: [];
        $out = [];
        foreach ($rows as $r) {
            if (!is_array($r)) continue;
            $c = strtolower(trim((string) ($r['country'] ?? '')));
            if ($c !== '') $out[] = $c;
        }
        return $out;
    }

    private function showSettingsGoogle(): void
    {
        $uid = $this->currentUserId();
        $repo = new SettingsRepository($this->pdo(), $this->tablePrefix, $uid);
        $propRepo = new GooglePropertyRepository($this->pdo(), $this->tablePrefix, $uid);
        $flash = $this->pullFlash();
        $diag = $_SESSION['_google_diag'] ?? null;
        unset($_SESSION['_google_diag']);

        $clientId = $repo->get('google.client_id') ?? '';
        $hasClientSecret = ($repo->get('google.client_secret') ?? '') !== '';
        $hasOauthJson = ($repo->get('google.oauth_json') ?? '') !== '';
        $isConnected = ($repo->get('google.connected_at') ?? '') !== '';
        $reconnectRequired = ($repo->get('google.reconnect_required') ?? '') === '1';
        $tokenError = $repo->get('google.token_last_error');
        $properties = $propRepo->all();

        $syncMode = $repo->get('gsc.sync.mode') ?? 'last_30_days';
        $syncRowLimit = $repo->get('gsc.sync.row_limit') ?? '25000';
        $syncSleepMs = $repo->get('gsc.sync.sleep_ms') ?? '100';
        $breakdownsPolicy = $repo->get('gsc.sync.breakdowns_policy') ?? 'off';
        $quickWinCfg = [
            'iqqi_min_impr' => (int) ($repo->get('quickwin.iqqi.min_impr') ?? '200'),
            'iqqi_pos_min' => (float) ($repo->get('quickwin.iqqi.pos_min') ?? '4'),
            'iqqi_pos_max' => (float) ($repo->get('quickwin.iqqi.pos_max') ?? '10'),
            'ctr_min_impr' => (int) ($repo->get('quickwin.ctr_gap.min_impr') ?? '200'),
            'lowctr_pages_min_impr' => (int) ($repo->get('quickwin.lowctr_pages.min_impr') ?? '1000'),
            'lowctr_coeff' => (float) ($repo->get('quickwin.lowctr.coeff') ?? '0.5'),
            'topical_min_impr' => (int) ($repo->get('quickwin.topical.min_impr') ?? '200'),
            'topical_pos_min' => (float) ($repo->get('quickwin.topical.pos_min') ?? '15'),
            'topical_max_clicks' => (int) ($repo->get('quickwin.topical.max_clicks') ?? '2'),
        ];
        $lastSyncSummary = $repo->get('gsc.last_sync_summary');

        // Never repopulate secrets in cleartext in the UI.
        $clientSecret = $hasClientSecret ? str_repeat('•', 24) : '';
        $oauthJson = $hasOauthJson ? str_repeat('•', 96) : '';
        require $this->root . '/app/Views/settings_google.php';
    }

    private function handleSettingsGoogleDiagnosticsPost(): void
    {
        $this->assertPostCsrf();
        $to = $this->postRedirectToLocalPath();

        $uid = $this->currentUserId();
        $repo = new SettingsRepository($this->pdo(), $this->tablePrefix, $uid);
        $_SESSION['_google_diag'] = $this->runGoogleDiagnostics($repo);
        $this->redirect(seoscope_url($to));
    }

    private function handleSettingsGooglePost(): void
    {
        $this->assertPostCsrf();
        $to = $this->postRedirectToLocalPath();

        $uid = $this->currentUserId();
        $repo = new SettingsRepository($this->pdo(), $this->tablePrefix, $uid);

        $maskedSecret = str_repeat('•', 24);
        $maskedJson = str_repeat('•', 96);
        $hasExistingSecret = ($repo->get('google.client_secret') ?? '') !== '';

        $clientId = trim((string) ($_POST['client_id'] ?? ''));
        $clientSecret = trim((string) ($_POST['client_secret'] ?? ''));
        $jsonText = trim((string) ($_POST['oauth_json'] ?? ''));

        // Treat masked fields as "unchanged".
        if ($clientSecret === $maskedSecret) {
            $clientSecret = '';
        }
        if ($jsonText === $maskedJson) {
            $jsonText = '';
        }

        // If a file was uploaded, it takes precedence over textarea.
        if (isset($_FILES['oauth_json_file']) && is_array($_FILES['oauth_json_file'])) {
            $tmp = $_FILES['oauth_json_file']['tmp_name'] ?? '';
            $err = (int) ($_FILES['oauth_json_file']['error'] ?? 0);
            if ($err === UPLOAD_ERR_OK && is_string($tmp) && $tmp !== '' && is_readable($tmp)) {
                $fileContents = (string) file_get_contents($tmp);
                if (trim($fileContents) !== '') {
                    $jsonText = trim($fileContents);
                }
            }
        }

        if ($jsonText !== '') {
            $decoded = json_decode($jsonText, true);
            if (!is_array($decoded)) {
                $_SESSION['_flash_error'] = __('settings.google.creds.invalid_json');
                $this->redirect(seoscope_url($to));
                return;
            }
            $block = null;
            if (isset($decoded['web']) && is_array($decoded['web'])) {
                $block = $decoded['web'];
            } elseif (isset($decoded['installed']) && is_array($decoded['installed'])) {
                $block = $decoded['installed'];
            }
            $cid = is_array($block) ? trim((string) ($block['client_id'] ?? '')) : '';
            $csec = is_array($block) ? trim((string) ($block['client_secret'] ?? '')) : '';
            if ($cid === '' || $csec === '') {
                $_SESSION['_flash_error'] = __('settings.google.creds.json_missing_fields');
                $this->redirect(seoscope_url($to));
                return;
            }

            $repo->setSecret('google.oauth_json', $jsonText);
            $repo->set('google.client_id', $cid);
            $repo->setSecret('google.client_secret', $csec);

            $_SESSION['_flash_ok'] = __('settings.google.creds.saved');
            $this->redirect(seoscope_url($to));
            return;
        }

        if ($clientId === '' || ($clientSecret === '' && !$hasExistingSecret)) {
            $_SESSION['_flash_error'] = __('settings.google.creds.missing_manual');
            $this->redirect(seoscope_url($to));
            return;
        }

        $repo->setSecret('google.oauth_json', null);
        $repo->set('google.client_id', $clientId);
        if ($clientSecret !== '') {
            $repo->setSecret('google.client_secret', $clientSecret);
        }

        $_SESSION['_flash_ok'] = __('settings.google.creds.saved');
        $this->redirect(seoscope_url($to));
    }

    private function handleSettingsResetPropertiesPost(): void
    {
        $this->assertPostCsrf();
        $uid = $this->currentUserId();

        $confirm = strtoupper(trim((string) ($_POST['confirm_text'] ?? '')));
        if ($confirm !== 'RESET') {
            $_SESSION['_flash_error'] = __('settings.reset.invalid_confirm');
            $this->redirect(seoscope_url('/settings?' . http_build_query(['tab' => 'reset'])));
            return;
        }

        $raw = $_POST['property_ids'] ?? [];
        $ids = [];
        if (is_array($raw)) {
            foreach ($raw as $v) {
                $i = (int) $v;
                if ($i > 0) $ids[] = $i;
            }
        }
        $ids = array_values(array_unique($ids));
        if ($ids === []) {
            $_SESSION['_flash_error'] = __('settings.reset.no_selection');
            $this->redirect(seoscope_url('/settings?' . http_build_query(['tab' => 'reset'])));
            return;
        }

        // Validate ownership via account scoping (findById uses current user's google_account_id).
        $propRepo = new GooglePropertyRepository($this->pdo(), $this->tablePrefix, $uid);
        $owned = [];
        foreach ($ids as $pid) {
            if (is_array($propRepo->findById($pid))) {
                $owned[] = $pid;
            }
        }
        if ($owned === []) {
            $_SESSION['_flash_error'] = __('settings.reset.no_selection');
            $this->redirect(seoscope_url('/settings?' . http_build_query(['tab' => 'reset'])));
            return;
        }

        // If any selected property has a running/queued sync, cancel jobs first and ask the user to retry.
        $jobRepo = new SyncJobRepository($this->pdo(), $this->tablePrefix);
        $active = [];
        foreach ($owned as $pid) {
            if ($jobRepo->hasActiveJobForProperty($uid, $pid)) {
                $active[] = $pid;
            }
        }
        if ($active !== []) {
            foreach ($active as $pid) {
                try { $jobRepo->cancelActiveJobsForProperty($uid, $pid); } catch (\Throwable) {}
            }
            $_SESSION['_flash_error'] = __('settings.reset.jobs_running');
            $this->redirect(seoscope_url('/settings?' . http_build_query(['tab' => 'reset'])));
            return;
        }

        try {
            $svc = new ResetService($this->pdo(), $this->tablePrefix);
            $svc->resetProperties($uid, $owned);
            $_SESSION['_flash_ok'] = __('settings.reset.props_done');
        } catch (\Throwable $e) {
            $_SESSION['_flash_error'] = __('settings.reset.failed', ['message' => $e->getMessage()]);
        }
        $this->redirect(seoscope_url('/settings?' . http_build_query(['tab' => 'reset'])));
    }

    private function handleSettingsResetAllPost(): void
    {
        $this->assertPostCsrf();
        $confirm = strtoupper(trim((string) ($_POST['confirm_text'] ?? '')));
        if ($confirm !== 'DELETE ALL') {
            $_SESSION['_flash_error'] = __('settings.reset.invalid_confirm_all');
            $this->redirect(seoscope_url('/settings?' . http_build_query(['tab' => 'reset'])));
            return;
        }

        try {
            $svc = new ResetService($this->pdo(), $this->tablePrefix);
            $svc->resetAllData();
        } catch (\Throwable $e) {
            $_SESSION['_flash_error'] = __('settings.reset.failed', ['message' => $e->getMessage()]);
            $this->redirect(seoscope_url('/settings?' . http_build_query(['tab' => 'reset'])));
            return;
        }

        // After deleting users, force logout and clear session.
        try {
            $_SESSION = [];
            if (session_status() === PHP_SESSION_ACTIVE) {
                session_destroy();
            }
        } catch (\Throwable) {
        }
        $this->redirect(seoscope_url('/login'));
    }

    private function handleSetup(string $method): void
    {
        SessionAuth::start();
        if (SessionAuth::check()) {
            $this->redirect(seoscope_url('/'));
            return;
        }

        // Setup is only allowed when there are no users.
        $table = '`' . str_replace('`', '``', $this->tablePrefix . 'users') . '`';
        $cnt = 0;
        try {
            $cnt = (int) (($this->pdo()->query("SELECT COUNT(*) FROM {$table}")?->fetchColumn()) ?: 0);
        } catch (\Throwable) {
            $cnt = 0;
        }
        if ($cnt > 0) {
            $this->redirect(seoscope_url('/login'));
            return;
        }

        if ($method === 'GET') {
            $flash = $this->pullFlash();
            require $this->root . '/app/Views/setup.php';
            return;
        }

        if ($method !== 'POST') {
            http_response_code(405);
            return;
        }

        if (!AdminCsrf::validate($_POST['_csrf'] ?? null)) {
            AdminCsrf::refresh();
            $_SESSION['_flash_error'] = __('auth.csrf_invalid');
            $this->redirect(seoscope_url('/setup'));
            return;
        }

        $email = strtolower(trim((string) ($_POST['email'] ?? '')));
        $password = (string) ($_POST['password'] ?? '');
        if ($email === '' || !str_contains($email, '@') || strlen($password) < 8) {
            $_SESSION['_flash_error'] = __('setup.invalid');
            $this->redirect(seoscope_url('/setup'));
            return;
        }

        $hash = password_hash($password, PASSWORD_DEFAULT);
        $now = date('Y-m-d H:i:s');
        $stmt = $this->pdo()->prepare(
            "INSERT INTO {$table} (email, password_hash, name, role, is_active, is_owner, created_at, updated_at)
             VALUES (?, ?, NULL, 'admin', 1, 1, ?, ?)"
        );
        $stmt->execute([$email, $hash, $now, $now]);

        $_SESSION['_flash_ok'] = __('setup.created');
        $this->redirect(seoscope_url('/license'));
    }

    private function handleGoogleConnectPost(): void
    {
        $this->assertPostCsrf();
        $to = $this->postRedirectToLocalPath();
        $this->rememberGoogleOauthReturnPath($to);

        $uid = $this->currentUserId();
        $repo = new SettingsRepository($this->pdo(), $this->tablePrefix, $uid);
        $clientId = trim((string) ($repo->get('google.client_id') ?? ''));
        $clientSecret = trim((string) ($repo->getSecret('google.client_secret') ?? ''));
        if ($clientId === '' || $clientSecret === '') {
            $_SESSION['_flash_error'] = __('settings.google.oauth.missing_creds');
            $this->redirect(seoscope_url($to));
            return;
        }

        $state = bin2hex(random_bytes(16));
        $_SESSION['_google_oauth_state'] = $state;

        $redirectUri = $this->googleRedirectUri();
        $scopes = [
            'https://www.googleapis.com/auth/webmasters.readonly',
        ];

        $url = GoogleOAuth::buildAuthUrl($clientId, $redirectUri, $state, $scopes);
        $this->redirect($url);
    }

    private function handleGoogleCallbackGet(): void
    {
        $uid = $this->currentUserId();
        $repo = new SettingsRepository($this->pdo(), $this->tablePrefix, $uid);

        $state = (string) ($_GET['state'] ?? '');
        $expected = (string) ($_SESSION['_google_oauth_state'] ?? '');
        unset($_SESSION['_google_oauth_state']);

        if ($state === '' || $expected === '' || !hash_equals($expected, $state)) {
            unset($_SESSION['_google_oauth_return']);
            $_SESSION['_flash_error'] = __('settings.google.oauth.bad_state');
            $this->redirect(seoscope_url($this->defaultGoogleSettingsRedirectPath()));
            return;
        }

        $to = $this->takeGoogleOauthReturnPath();

        $err = trim((string) ($_GET['error'] ?? ''));
        if ($err !== '') {
            $_SESSION['_flash_error'] = __('settings.google.oauth.denied', ['error' => $err]);
            $this->redirect(seoscope_url($to));
            return;
        }

        $code = trim((string) ($_GET['code'] ?? ''));
        if ($code === '') {
            $_SESSION['_flash_error'] = __('settings.google.oauth.missing_code');
            $this->redirect(seoscope_url($to));
            return;
        }

        $clientId = trim((string) ($repo->get('google.client_id') ?? ''));
        $clientSecret = trim((string) ($repo->getSecret('google.client_secret') ?? ''));
        if ($clientId === '' || $clientSecret === '') {
            $_SESSION['_flash_error'] = __('settings.google.oauth.missing_creds');
            $this->redirect(seoscope_url($to));
            return;
        }

        try {
            $token = GoogleOAuth::exchangeCodeForToken($code, $clientId, $clientSecret, $this->googleRedirectUri());
        } catch (Throwable $e) {
            $_SESSION['_flash_error'] = __('settings.google.oauth.token_failed', ['message' => $e->getMessage()]);
            $this->redirect(seoscope_url($to));
            return;
        }

        // Preserve existing refresh_token if Google does not return it on subsequent connects.
        $existingRaw = $repo->getSecret('google.tokens') ?? '';
        $existing = $existingRaw !== '' ? json_decode($existingRaw, true) : null;
        if (is_array($existing) && empty($token['refresh_token']) && !empty($existing['refresh_token'])) {
            $token['refresh_token'] = $existing['refresh_token'];
        }

        $token['obtained_at'] = date('c');
        GoogleTokenManager::computeExpiresAtFromExchange($token);
        $repo->setSecret('google.tokens', json_encode($token, JSON_UNESCAPED_SLASHES));
        $repo->set('google.connected_at', date('c'));
        $repo->set('google.reconnect_required', '0');
        $repo->set('google.token_last_error', null);

        $_SESSION['_flash_ok'] = __('settings.google.oauth.connected');
        $this->redirect(seoscope_url($to));
    }

    private function handleGoogleRefreshPost(): void
    {
        $this->assertPostCsrf();
        $to = $this->postRedirectToLocalPath();

        $uid = $this->currentUserId();
        $repo = new SettingsRepository($this->pdo(), $this->tablePrefix, $uid);
        $clientId = trim((string) ($repo->get('google.client_id') ?? ''));
        $clientSecret = trim((string) ($repo->getSecret('google.client_secret') ?? ''));
        if ($clientId === '' || $clientSecret === '') {
            $_SESSION['_flash_error'] = __('settings.google.oauth.missing_creds');
            $this->redirect(seoscope_url($to));
            return;
        }

        try {
            GoogleTokenManager::ensureFreshTokens($repo, $clientId, $clientSecret);
        } catch (Throwable $e) {
            $msg = $e->getMessage();
            if (GoogleOAuth::isInvalidGrantError($msg)) {
                $_SESSION['_flash_error'] = __('settings.google.oauth.reconnect_required');
            } else {
                $_SESSION['_flash_error'] = __('settings.google.oauth.refresh_failed', ['message' => $msg]);
            }
            $this->redirect(seoscope_url($to));
            return;
        }

        $_SESSION['_flash_ok'] = __('settings.google.oauth.refreshed');
        $this->redirect(seoscope_url($to));
    }

    private function handleGoogleDisconnectPost(): void
    {
        $this->assertPostCsrf();
        $to = $this->postRedirectToLocalPath();

        $uid = $this->currentUserId();
        $repo = new SettingsRepository($this->pdo(), $this->tablePrefix, $uid);
        $raw = (string) ($repo->getSecret('google.tokens') ?? '');
        $tokens = $raw !== '' ? json_decode($raw, true) : null;
        $accessToken = is_array($tokens) ? (string) ($tokens['access_token'] ?? '') : '';
        $refreshToken = is_array($tokens) ? (string) ($tokens['refresh_token'] ?? '') : '';

        // Best effort: revoke tokens with Google (do not block disconnect on failure).
        if ($refreshToken !== '') {
            GoogleOAuth::revokeToken($refreshToken);
        } elseif ($accessToken !== '') {
            GoogleOAuth::revokeToken($accessToken);
        }

        // Clear connection state and sensitive data.
        $repo->setSecret('google.tokens', null);
        $repo->set('google.connected_at', null);
        $repo->set('google.reconnect_required', '0');
        $repo->set('google.token_last_error', null);

        $_SESSION['_flash_ok'] = __('settings.google.oauth.disconnected');
        $this->redirect(seoscope_url($to));
    }

    private function handleGoogleFetchPropertiesPost(): void
    {
        $this->assertPostCsrf();
        $to = $this->postRedirectToLocalPath();

        $uid = $this->currentUserId();
        $repo = new SettingsRepository($this->pdo(), $this->tablePrefix, $uid);
        $propRepo = new GooglePropertyRepository($this->pdo(), $this->tablePrefix, $uid);

        $clientId = trim((string) ($repo->get('google.client_id') ?? ''));
        $clientSecret = trim((string) ($repo->getSecret('google.client_secret') ?? ''));
        if ($clientId === '' || $clientSecret === '') {
            $_SESSION['_flash_error'] = __('settings.google.oauth.missing_creds');
            $this->redirect(seoscope_url($to));
            return;
        }

        try {
            $tokens = GoogleTokenManager::ensureFreshTokens($repo, $clientId, $clientSecret);
            $accessToken = (string) ($tokens['access_token'] ?? '');
            $sites = GoogleSearchConsoleClient::listSites($accessToken);
        } catch (Throwable $e) {
            $_SESSION['_flash_error'] = __('settings.google.props.fetch_failed', ['message' => $e->getMessage()]);
            $this->redirect(seoscope_url($to));
            return;
        }

        $count = $propRepo->upsertMany($sites);
        $_SESSION['_flash_ok'] = __('settings.google.props.fetched', ['count' => (string) $count]);
        $this->redirect(seoscope_url($to));
    }

    private function handleGoogleTogglePropertyPost(): void
    {
        $this->assertPostCsrf();

        $siteUrl = trim((string) ($_POST['site_url'] ?? ''));
        $enabled = (string) ($_POST['enabled'] ?? '') === '1';
        $redirect = (string) ($_POST['redirect'] ?? '');
        $redirect = $this->safeRedirectPath($redirect) ?? '/settings?tab=google';
        if ($siteUrl === '') {
            $this->redirect(seoscope_url($redirect));
            return;
        }

        $uid = $this->currentUserId();
        $propRepo = new GooglePropertyRepository($this->pdo(), $this->tablePrefix, $uid);
        $jobRepo = new SyncJobRepository($this->pdo(), $this->tablePrefix);
        if ($enabled) {
            $permRow = $propRepo->findPermission($siteUrl);
            $perm = strtolower((string) ($permRow['permission_level'] ?? ''));
            if ($perm === 'siteunverifieduser') {
                $_SESSION['_flash_error'] = __('settings.google.props.enable_blocked_unverified');
                $this->redirect(seoscope_url($redirect));
                return;
            }
        }
        $propRepo->setEnabled($siteUrl, $enabled);

        // If enabling, immediately bootstrap last 28 days (queued; processed by cron).
        $kickoff = null;
        if ($enabled) {
            // Best-effort resolve property id for this site_url.
            $props = $propRepo->all();
            foreach ($props as $p) {
                if (is_array($p) && (string) ($p['site_url'] ?? '') === $siteUrl) {
                    $pid = (int) ($p['id'] ?? 0);
                    if ($pid > 0) {
                        $jobRepo->enqueueLast28Days($uid, $pid);
                        $kickoff = $this->kickoffSyncNow($uid, 20);
                    }
                    break;
                }
            }
        }

        if ($enabled) {
            $msg = __('settings.google.props.enabled');
            if (is_array($kickoff) && !empty($kickoff['ran'])) {
                $msg .= ' ' . __('projects.sync_kickoff_started');
            } elseif (is_array($kickoff) && !empty($kickoff['locked'])) {
                $msg .= ' ' . __('projects.sync_kickoff_queued_locked');
            }
            $_SESSION['_flash_ok'] = $msg;
        } else {
            $_SESSION['_flash_ok'] = __('settings.google.props.disabled');
        }
        $this->redirect(seoscope_url($redirect));
    }

    private function safeRedirectPath(string $path): ?string
    {
        $path = trim($path);
        if ($path === '' || !str_starts_with($path, '/')) {
            return null;
        }
        // Prevent open redirect; allow only local paths without scheme/host.
        if (str_contains($path, '://')) {
            return null;
        }
        return $path;
    }

    private function defaultGoogleSettingsRedirectPath(): string
    {
        return '/settings?tab=google';
    }

    private function postRedirectToLocalPath(): string
    {
        $raw = (string) ($_POST['redirect'] ?? '');

        return $this->safeRedirectPath($raw) ?? $this->defaultGoogleSettingsRedirectPath();
    }

    private function rememberGoogleOauthReturnPath(string $path): void
    {
        $_SESSION['_google_oauth_return'] = $path;
    }

    private function takeGoogleOauthReturnPath(): string
    {
        $p = (string) ($_SESSION['_google_oauth_return'] ?? '');
        unset($_SESSION['_google_oauth_return']);

        return $this->safeRedirectPath($p) ?? $this->defaultGoogleSettingsRedirectPath();
    }

    private function handleGoogleSyncConfigPost(): void
    {
        $this->assertPostCsrf();
        $to = $this->postRedirectToLocalPath();

        $uid = $this->currentUserId();
        $repo = new SettingsRepository($this->pdo(), $this->tablePrefix, $uid);

        $mode = trim((string) ($_POST['sync_mode'] ?? 'last_30_days'));
        $allowed = ['last_30_days', 'last_3_months', 'last_12_months', 'last_16_months'];
        $mode = in_array($mode, $allowed, true) ? $mode : 'last_30_days';

        $rowLimitRaw = trim((string) ($_POST['sync_row_limit'] ?? '25000'));
        $rowLimit = (int) $rowLimitRaw;

        // Global toggle (product default: OFF for performance). When enabled, we allow breakdowns for last 28 days.
        $enableBreakdowns = (string) ($_POST['enable_breakdowns'] ?? '') === '1';
        $breakdownsPolicy = $enableBreakdowns ? 'last_28_only' : 'off';

        if ($rowLimit < 1 || $rowLimit > 25000) {
            $_SESSION['_flash_error'] = __('settings.google.sync.error_row_limit');
            $this->redirect(seoscope_url($to));
            return;
        }

        $repo->set('gsc.sync.mode', $mode);
        $repo->set('gsc.sync.row_limit', (string) $rowLimit);
        $repo->set('gsc.sync.breakdowns_policy', $breakdownsPolicy);

        $_SESSION['_flash_ok'] = __('settings.google.sync.saved');
        $this->redirect(seoscope_url($to));
    }

    private function handleGoogleSyncSafetyPost(): void
    {
        $this->assertPostCsrf();
        $to = $this->postRedirectToLocalPath();

        $uid = $this->currentUserId();
        $repo = new SettingsRepository($this->pdo(), $this->tablePrefix, $uid);

        $sleepMs = (int) ($_POST['sync_sleep_ms'] ?? 0);
        $sleepMs = max(0, min(2000, $sleepMs));

        $repo->set('gsc.sync.sleep_ms', (string) $sleepMs);
        $_SESSION['_flash_ok'] = __('settings.google.sync.safety_saved');
        $this->redirect(seoscope_url($to));
    }

    private function handleGoogleSyncRunPost(): void
    {
        $this->assertPostCsrf();
        $to = $this->postRedirectToLocalPath();

        $uid = $this->currentUserId();
        $settings = new SettingsRepository($this->pdo(), $this->tablePrefix, $uid);
        $props = new GooglePropertyRepository($this->pdo(), $this->tablePrefix, $uid);
        $dq = new GscDailyQueryRepository($this->pdo(), $this->tablePrefix);
        $dp = new GscDailyPageRepository($this->pdo(), $this->tablePrefix);
        $dpd = new GscDailyPropertyDeviceRepository($this->pdo(), $this->tablePrefix);
        $dpc = new GscDailyPropertyCountryRepository($this->pdo(), $this->tablePrefix);
        $dqp = new GscDailyQueryPageRepository($this->pdo(), $this->tablePrefix);
        $logs = new SyncLogRepository($this->pdo(), $this->tablePrefix);
        $aggRepo = new GscDailyPropertyAggRepository($this->pdo(), $this->tablePrefix);
        $agg = new GscAggregationService($this->pdo(), $aggRepo, $this->tablePrefix);

        $mode = $settings->get('gsc.sync.mode') ?? 'last_30_days';
        $endDt = new \DateTimeImmutable('yesterday');
        $rangeEnd = $endDt->format('Y-m-d');
        $rangeStart = match ($mode) {
            'last_3_months' => $endDt->modify('-3 months')->format('Y-m-d'),
            'last_12_months' => $endDt->modify('-12 months')->format('Y-m-d'),
            'last_16_months' => $endDt->modify('-16 months')->format('Y-m-d'),
            default => $endDt->modify('-29 days')->format('Y-m-d'), // last_30_days
        };
        $runId = $logs->startRun($mode, $rangeStart, $rangeEnd);

        $svc = new GscSyncService($settings, $props, $dq, $dp, $dpd, $dpc, $dqp, $logs, $runId, $agg);

        try {
            $res = $svc->runOnce();
        } catch (Throwable $e) {
            $settings->set('gsc.last_sync_summary', null);
            $logs->finishRun($runId, 'error', 0, 0, 0, 0, 0, mb_substr($e->getMessage(), 0, 250));
            $_SESSION['_flash_error'] = __('settings.google.sync.run_failed', ['message' => $e->getMessage()]);
            $this->redirect(seoscope_url($to));
            return;
        }

        $summary = sprintf(
            'properties=%d failed=%d days=%d query_upserts=%d page_upserts=%d',
            (int) ($res['properties_processed'] ?? 0),
            (int) ($res['properties_failed'] ?? 0),
            (int) ($res['days_processed'] ?? 0),
            (int) ($res['query_rows_upserted'] ?? 0),
            (int) ($res['page_rows_upserted'] ?? 0)
        );
        $settings->set('gsc.last_sync_summary', $summary);
        $failed = (int) ($res['properties_failed'] ?? 0);
        $_SESSION['_flash_ok'] = $failed > 0 ? __('settings.google.sync.run_ok_partial', ['count' => (string) $failed]) : __('settings.google.sync.run_ok');
        $logs->finishRun(
            $runId,
            $failed > 0 ? 'partial' : 'ok',
            (int) ($res['properties_processed'] ?? 0),
            $failed,
            (int) ($res['days_processed'] ?? 0),
            (int) ($res['query_rows_upserted'] ?? 0),
            (int) ($res['page_rows_upserted'] ?? 0),
            $failed > 0 ? 'Some properties failed' : null
        );
        $this->redirect(seoscope_url($to));
    }

    private function handleGoogleQuickWinPost(): void
    {
        $this->assertPostCsrf();
        $uid = $this->currentUserId();
        $repo = new SettingsRepository($this->pdo(), $this->tablePrefix, $uid);
        $redirect = (string) ($_POST['redirect'] ?? '');
        $redirect = $this->safeRedirectPath($redirect) ?? $this->defaultGoogleSettingsRedirectPath();

        $int = static fn (string $k, int $min, int $max, int $def): int => max($min, min($max, (int) ($_POST[$k] ?? $def)));
        $float = static fn (string $k, float $min, float $max, float $def): float => max($min, min($max, (float) ($_POST[$k] ?? $def)));

        $repo->set('quickwin.iqqi.min_impr', (string) $int('qw_iqqi_min_impr', 0, 10000000, 200));
        $repo->set('quickwin.iqqi.pos_min', (string) $float('qw_iqqi_pos_min', 1.0, 100.0, 4.0));
        $repo->set('quickwin.iqqi.pos_max', (string) $float('qw_iqqi_pos_max', 1.0, 100.0, 10.0));
        $repo->set('quickwin.ctr_gap.min_impr', (string) $int('qw_ctr_min_impr', 0, 10000000, 200));
        $repo->set('quickwin.lowctr_pages.min_impr', (string) $int('qw_lowctr_pages_min_impr', 0, 10000000, 1000));
        $repo->set('quickwin.lowctr.coeff', (string) $float('qw_lowctr_coeff', 0.05, 2.0, 0.5));
        $repo->set('quickwin.topical.min_impr', (string) $int('qw_topical_min_impr', 0, 10000000, 200));
        $repo->set('quickwin.topical.pos_min', (string) $float('qw_topical_pos_min', 1.0, 100.0, 15.0));
        $repo->set('quickwin.topical.max_clicks', (string) $int('qw_topical_max_clicks', 0, 10000000, 2));

        $_SESSION['_flash_ok'] = __('settings.google.quickwin.saved');
        $this->redirect(seoscope_url($redirect));
    }

    /**
     * @return array{
     *   overall_ok: bool,
     *   checked_at: string,
     *   redirect_uri: string,
     *   checks: list<array{ok: bool, label: string, hint: string}>
     * }
     */
    private function runGoogleDiagnostics(SettingsRepository $repo): array
    {
        $checks = [];

        $app = is_array($this->config['app'] ?? null) ? $this->config['app'] : [];
        $appUrl = trim((string) ($app['url'] ?? ''));
        $basePath = (string) ($app['base_path'] ?? '');
        $basePath = $basePath === '' ? '' : '/' . trim($basePath, '/');

        $parsed = $appUrl !== '' ? parse_url($appUrl) : false;
        $appUrlOk = is_array($parsed) && !empty($parsed['scheme']) && !empty($parsed['host']);
        $checks[] = [
            'ok' => $appUrlOk,
            'label' => __('settings.google.diag.app_url.label'),
            'hint' => $appUrlOk ? __('settings.google.diag.app_url.ok') : __('settings.google.diag.app_url.fail'),
        ];

        $httpsOk = $appUrlOk && strtolower((string) ($parsed['scheme'] ?? '')) === 'https';
        $checks[] = [
            'ok' => $httpsOk,
            'label' => __('settings.google.diag.https.label'),
            'hint' => $httpsOk ? __('settings.google.diag.https.ok') : __('settings.google.diag.https.fail'),
        ];

        $basePathOk = ($basePath === '' || ($basePath[0] === '/' && !str_ends_with($basePath, '/')));
        $checks[] = [
            'ok' => $basePathOk,
            'label' => __('settings.google.diag.base_path.label'),
            'hint' => $basePathOk ? __('settings.google.diag.base_path.ok') : __('settings.google.diag.base_path.fail'),
        ];

        $redirectUri = ($appUrlOk ? rtrim($appUrl, '/') : '') . $basePath . '/oauth/google/callback';

        $opensslOk = extension_loaded('openssl') && function_exists('openssl_encrypt');
        $checks[] = [
            'ok' => $opensslOk,
            'label' => __('settings.google.diag.ext_openssl.label'),
            'hint' => $opensslOk ? __('settings.google.diag.ext_openssl.ok') : __('settings.google.diag.ext_openssl.fail'),
        ];

        $curlOk = extension_loaded('curl');
        $checks[] = [
            'ok' => $curlOk,
            'label' => __('settings.google.diag.ext_curl.label'),
            'hint' => $curlOk ? __('settings.google.diag.ext_curl.ok') : __('settings.google.diag.ext_curl.fail'),
        ];

        $appKey = (string) (($app['key'] ?? '') ?: '');
        $keyOk = $appKey !== '';
        $checks[] = [
            'ok' => $keyOk,
            'label' => __('settings.google.diag.app_key.label'),
            'hint' => $keyOk ? __('settings.google.diag.app_key.ok') : __('settings.google.diag.app_key.fail'),
        ];

        if (defined('SEOSCOPE_ROOT')) {
            $paths = [
                SEOSCOPE_ROOT . '/storage',
                SEOSCOPE_ROOT . '/storage/app',
                SEOSCOPE_ROOT . '/storage/cache',
                SEOSCOPE_ROOT . '/storage/logs',
                SEOSCOPE_ROOT . '/storage/sessions',
            ];
            $allWritable = true;
            foreach ($paths as $p) {
                if (!is_dir($p) || !is_writable($p)) {
                    $allWritable = false;
                    break;
                }
            }
            $checks[] = [
                'ok' => $allWritable,
                'label' => __('settings.google.diag.storage.label'),
                'hint' => $allWritable ? __('settings.google.diag.storage.ok') : __('settings.google.diag.storage.fail'),
            ];
        } else {
            $checks[] = [
                'ok' => false,
                'label' => __('settings.google.diag.storage.label'),
                'hint' => __('settings.google.diag.storage.unknown'),
            ];
        }

        $settingsTableOk = true;
        try {
            $repo->get('google.client_id');
        } catch (Throwable) {
            $settingsTableOk = false;
        }
        $checks[] = [
            'ok' => $settingsTableOk,
            'label' => __('settings.google.diag.db.label'),
            'hint' => $settingsTableOk ? __('settings.google.diag.db.ok') : __('settings.google.diag.db.fail'),
        ];

        $hasClientId = trim((string) ($repo->get('google.client_id') ?? '')) !== '';
        $hasSecret = trim((string) ($repo->get('google.client_secret') ?? '')) !== '';
        $hasJson = trim((string) ($repo->get('google.oauth_json') ?? '')) !== '';
        $credsOk = $hasClientId && ($hasSecret || $hasJson);
        $checks[] = [
            'ok' => $credsOk,
            'label' => __('settings.google.diag.creds.label'),
            'hint' => $credsOk ? __('settings.google.diag.creds.ok') : __('settings.google.diag.creds.fail'),
        ];

        $overallOk = true;
        foreach ($checks as $c) {
            if (!$c['ok']) {
                $overallOk = false;
                break;
            }
        }

        return [
            'overall_ok' => $overallOk,
            'checked_at' => date('c'),
            'redirect_uri' => $redirectUri,
            'checks' => $checks,
        ];
    }

    /**
     * Build a support-friendly bundle without secrets.
     *
     * @return array<string,mixed>
     */
    private function buildDiagnosticsPayload(SettingsRepository $repo, SyncLogRepository $logs): array
    {
        $googleDiag = $this->runGoogleDiagnostics($repo);

        $safeSettings = [
            'gsc.sync.mode' => $repo->get('gsc.sync.mode'),
            'gsc.sync.row_limit' => $repo->get('gsc.sync.row_limit'),
            'gsc.sync.breakdowns_policy' => $repo->get('gsc.sync.breakdowns_policy'),
            'gsc.last_sync_summary' => $repo->get('gsc.last_sync_summary'),
            'cron.last_run_at' => $repo->get('cron.last_run_at'),
            'decay.min_impressions' => $repo->get('decay.min_impressions'),
            'decay.clicks_drop_pct' => $repo->get('decay.clicks_drop_pct'),
            'decay.impr_drop_pct' => $repo->get('decay.impr_drop_pct'),
            'decay.position_worsen' => $repo->get('decay.position_worsen'),
        ];

        $googleStatus = [
            'connected_at' => $repo->get('google.connected_at'),
            'reconnect_required' => $repo->get('google.reconnect_required'),
            'token_last_error' => $repo->get('google.token_last_error'),
        ];

        $env = [
            'php_version' => PHP_VERSION,
            'extensions' => [
                'openssl' => extension_loaded('openssl'),
                'curl' => extension_loaded('curl'),
                'zip' => class_exists(\ZipArchive::class),
                'pdo_mysql' => extension_loaded('pdo_mysql'),
            ],
        ];

        $paths = [];
        if (defined('SEOSCOPE_ROOT')) {
            foreach (['storage', 'storage/app', 'storage/cache', 'storage/logs', 'storage/sessions', 'storage/locks'] as $p) {
                $abs = SEOSCOPE_ROOT . '/' . $p;
                $paths[] = [
                    'path' => $p,
                    'is_dir' => is_dir($abs),
                    'is_writable' => is_writable($abs),
                ];
            }
        }

        return [
            'generated_at' => date('c'),
            'version' => defined('SEOSCOPE_VERSION') ? SEOSCOPE_VERSION : null,
            'app' => [
                'url' => (string) (($this->config['app']['url'] ?? '') ?: ''),
                'base_path' => (string) (($this->config['app']['base_path'] ?? '') ?: ''),
            ],
            'environment' => $env,
            'storage_paths' => $paths,
            'google_diagnostics' => $googleDiag,
            'google_status' => $googleStatus,
            'settings' => $safeSettings,
            'sync_runs_recent' => $logs->recentRuns(10),
            'sync_items_recent' => $logs->recentItems(30),
        ];
    }

    private function googleRedirectUri(): string
    {
        return seoscope_google_oauth_redirect_uri();
    }

    private function csvFilename(string $prefix, string $siteUrl, string $start, string $end): string
    {
        $slug = preg_replace('/[^a-zA-Z0-9]+/', '-', $siteUrl);
        $slug = is_string($slug) ? trim($slug, '-') : 'property';
        if ($slug === '') {
            $slug = 'property';
        }
        return $prefix . '-' . $slug . '-' . $start . '_to_' . $end . '.csv';
    }

    private function sendCsvHeaders(string $filename): void
    {
        header('Content-Type: text/csv; charset=utf-8');
        header('Content-Disposition: attachment; filename="' . $filename . '"');
        header('X-Content-Type-Options: nosniff');
        header('Cache-Control: no-store, no-cache, must-revalidate, max-age=0');
        header('Pragma: no-cache');
    }

    private function handleExportKeywordsCsv(): void
    {
        $uid = $this->currentUserId();
        $propRepo = new GooglePropertyRepository($this->pdo(), $this->tablePrefix, $uid);
        $kwRepo = new GscKeywordsRepository($this->pdo(), $this->tablePrefix);

        $filters = $this->resolveGlobalFilters($propRepo);
        $activeProp = $filters['activeProp'];
        if (!is_array($activeProp) || empty($activeProp['id'])) {
            http_response_code(400);
            echo 'No active property';
            return;
        }

        $preset = (string) ($filters['preset'] ?? 'last_30_days');
        $device = (string) ($filters['device'] ?? 'all');
        $country = (string) ($filters['country'] ?? 'all');
        ['start' => $rangeStart, 'end' => $rangeEnd] = $this->rangeFromPreset($preset);

        $sort = (string) ($_GET['sort'] ?? 'clicks');
        $dir = (string) ($_GET['dir'] ?? 'DESC');
        $compare = (string) ($_GET['compare'] ?? '') === '1';

        $keywordsShow = strtolower(trim((string) ($_GET['show'] ?? 'all')));
        if (!in_array($keywordsShow, ['all', 'tracked'], true)) {
            $keywordsShow = 'all';
        }
        $trackedQueriesList = [];
        $onlyQueries = null;
        if ($keywordsShow === 'tracked') {
            $pidEx = (int) $activeProp['id'];
            $catRepoEx = new TrackedCategoryRepository($this->pdo(), $this->tablePrefix);
            $catsEx = $catRepoEx->listForProperty($uid, $pidEx);
            $catFilterEx = $this->parseTrackCategoryFilterFromGet($catsEx);
            $tqRepo = new TrackedQueryRepository($this->pdo(), $this->tablePrefix);
            $trackedQueriesList = $tqRepo->listQueries($uid, $pidEx, $catFilterEx);
            $onlyQueries = $trackedQueriesList;
        }

        $prevStart = null;
        $prevEnd = null;
        if ($compare) {
            $lenDays = max(1, (int) ((strtotime($rangeEnd) - strtotime($rangeStart)) / 86400) + 1);
            $prevEnd = date('Y-m-d', strtotime($rangeStart . ' -1 day'));
            $prevStart = date('Y-m-d', strtotime($prevEnd . ' -' . ($lenDays - 1) . ' days'));
        }

        $filename = $this->csvFilename('keywords', (string) ($activeProp['site_url'] ?? ''), $rangeStart, $rangeEnd);
        $this->sendCsvHeaders($filename);

        $out = fopen('php://output', 'wb');
        if ($out === false) {
            http_response_code(500);
            echo 'Cannot open output';
            return;
        }

        $header = ['query', 'clicks', 'impressions', 'ctr', 'position'];
        if ($compare) {
            $header = array_merge($header, ['prev_clicks', 'prev_impressions', 'prev_ctr', 'prev_position', 'delta_clicks_pct', 'delta_impr_pct', 'delta_ctr_pct', 'delta_pos']);
        }
        fputcsv($out, $header);

        $exportSearch = trim((string) ($_GET['q'] ?? ''));

        $page = 1;
        $perPage = 100;
        while (true) {
            $data = $kwRepo->listForRange((int) $activeProp['id'], $rangeStart, $rangeEnd, $exportSearch, $device, $country, $sort, $dir, $page, $perPage, $onlyQueries);
            $rows = is_array($data) ? ($data['rows'] ?? []) : [];
            if (!is_array($rows) || $rows === []) {
                break;
            }

            $prevMap = [];
            if ($compare && $prevStart !== null && $prevEnd !== null) {
                $queries = [];
                foreach ($rows as $r) {
                    if (is_array($r) && !empty($r['query'])) {
                        $queries[] = (string) $r['query'];
                    }
                }
                $prevMap = $kwRepo->totalsForQueries((int) $activeProp['id'], $prevStart, $prevEnd, $device, $country, $queries);
            }

            foreach ($rows as $r) {
                if (!is_array($r)) {
                    continue;
                }
                $q = (string) ($r['query'] ?? '');
                $clicks = (int) ($r['clicks'] ?? 0);
                $impr = (int) ($r['impressions'] ?? 0);
                $ctr = (float) ($r['ctr'] ?? 0);
                $pos = (float) ($r['position'] ?? 0);

                $line = [$q, $clicks, $impr, $ctr, $pos];
                if ($compare) {
                    $p = $prevMap[$q] ?? ['clicks' => 0, 'impressions' => 0, 'ctr' => 0.0, 'position' => 0.0];
                    $pc = (float) ($p['clicks'] ?? 0);
                    $pi = (float) ($p['impressions'] ?? 0);
                    $pctr = (float) ($p['ctr'] ?? 0);
                    $ppos = (float) ($p['position'] ?? 0);
                    $dc = $pc > 0 ? (($clicks - $pc) / $pc) : ($clicks > 0 ? 1.0 : 0.0);
                    $di = $pi > 0 ? (($impr - $pi) / $pi) : ($impr > 0 ? 1.0 : 0.0);
                    $dctr = $pctr > 0 ? (($ctr - $pctr) / $pctr) : ($ctr > 0 ? 1.0 : 0.0);
                    $dpos = $pos - $ppos;
                    $line = array_merge($line, [(int) $pc, (int) $pi, $pctr, $ppos, $dc, $di, $dctr, $dpos]);
                }
                fputcsv($out, $line);
            }

            $page++;
            if (function_exists('flush')) {
                flush();
            }
        }
        fclose($out);
        exit;
    }

    private function handleExportPagesCsv(): void
    {
        $uid = $this->currentUserId();
        $propRepo = new GooglePropertyRepository($this->pdo(), $this->tablePrefix, $uid);
        $pgRepo = new GscPagesRepository($this->pdo(), $this->tablePrefix);

        $filters = $this->resolveGlobalFilters($propRepo);
        $activeProp = $filters['activeProp'];
        if (!is_array($activeProp) || empty($activeProp['id'])) {
            http_response_code(400);
            echo 'No active property';
            return;
        }

        $preset = (string) ($filters['preset'] ?? 'last_30_days');
        $device = (string) ($filters['device'] ?? 'all');
        $country = (string) ($filters['country'] ?? 'all');
        ['start' => $rangeStart, 'end' => $rangeEnd] = $this->rangeFromPreset($preset);

        $sort = (string) ($_GET['sort'] ?? 'clicks');
        $dir = (string) ($_GET['dir'] ?? 'DESC');
        $compare = (string) ($_GET['compare'] ?? '') === '1';

        $pagesShow = strtolower(trim((string) ($_GET['show'] ?? 'all')));
        if (!in_array($pagesShow, ['all', 'tracked'], true)) {
            $pagesShow = 'all';
        }
        $trackedPagesList = [];
        $onlyPages = null;
        if ($pagesShow === 'tracked') {
            $pidExP = (int) $activeProp['id'];
            $catRepoExP = new TrackedCategoryRepository($this->pdo(), $this->tablePrefix);
            $catsExP = $catRepoExP->listForProperty($uid, $pidExP);
            $catFilterExP = $this->parseTrackCategoryFilterFromGet($catsExP);
            $tpRepo = new TrackedPageRepository($this->pdo(), $this->tablePrefix);
            $trackedPagesList = $tpRepo->listPages($uid, $pidExP, $catFilterExP);
            $onlyPages = $trackedPagesList;
        }

        $prevStart = null;
        $prevEnd = null;
        if ($compare) {
            $lenDays = max(1, (int) ((strtotime($rangeEnd) - strtotime($rangeStart)) / 86400) + 1);
            $prevEnd = date('Y-m-d', strtotime($rangeStart . ' -1 day'));
            $prevStart = date('Y-m-d', strtotime($prevEnd . ' -' . ($lenDays - 1) . ' days'));
        }

        $filename = $this->csvFilename('pages', (string) ($activeProp['site_url'] ?? ''), $rangeStart, $rangeEnd);
        $this->sendCsvHeaders($filename);

        $out = fopen('php://output', 'wb');
        if ($out === false) {
            http_response_code(500);
            echo 'Cannot open output';
            return;
        }

        $header = ['page', 'clicks', 'impressions', 'ctr', 'position'];
        if ($compare) {
            $header = array_merge($header, ['prev_clicks', 'prev_impressions', 'prev_ctr', 'prev_position', 'delta_clicks_pct', 'delta_impr_pct', 'delta_ctr_pct', 'delta_pos']);
        }
        fputcsv($out, $header);

        $exportSearch = trim((string) ($_GET['q'] ?? ''));

        $page = 1;
        $perPage = 100;
        while (true) {
            $data = $pgRepo->listForRange((int) $activeProp['id'], $rangeStart, $rangeEnd, $exportSearch, $device, $country, $sort, $dir, $page, $perPage, $onlyPages);
            $rows = is_array($data) ? ($data['rows'] ?? []) : [];
            if (!is_array($rows) || $rows === []) {
                break;
            }

            $prevMap = [];
            if ($compare && $prevStart !== null && $prevEnd !== null) {
                $pagesList = [];
                foreach ($rows as $r) {
                    if (is_array($r) && !empty($r['page'])) {
                        $pagesList[] = (string) $r['page'];
                    }
                }
                $prevMap = $pgRepo->totalsForPages((int) $activeProp['id'], $prevStart, $prevEnd, $device, $country, $pagesList);
            }

            foreach ($rows as $r) {
                if (!is_array($r)) {
                    continue;
                }
                $p = (string) ($r['page'] ?? '');
                $clicks = (int) ($r['clicks'] ?? 0);
                $impr = (int) ($r['impressions'] ?? 0);
                $ctr = (float) ($r['ctr'] ?? 0);
                $pos = (float) ($r['position'] ?? 0);
                $line = [$p, $clicks, $impr, $ctr, $pos];

                if ($compare) {
                    $pr = $prevMap[$p] ?? ['clicks' => 0, 'impressions' => 0, 'ctr' => 0.0, 'position' => 0.0];
                    $pc = (float) ($pr['clicks'] ?? 0);
                    $pi = (float) ($pr['impressions'] ?? 0);
                    $pctr = (float) ($pr['ctr'] ?? 0);
                    $ppos = (float) ($pr['position'] ?? 0);
                    $dc = $pc > 0 ? (($clicks - $pc) / $pc) : ($clicks > 0 ? 1.0 : 0.0);
                    $di = $pi > 0 ? (($impr - $pi) / $pi) : ($impr > 0 ? 1.0 : 0.0);
                    $dctr = $pctr > 0 ? (($ctr - $pctr) / $pctr) : ($ctr > 0 ? 1.0 : 0.0);
                    $dpos = $pos - $ppos;
                    $line = array_merge($line, [(int) $pc, (int) $pi, $pctr, $ppos, $dc, $di, $dctr, $dpos]);
                }

                fputcsv($out, $line);
            }

            $page++;
            if (function_exists('flush')) {
                flush();
            }
        }
        fclose($out);
        exit;
    }

    private function handleExportOpportunitiesCsv(): void
    {
        $uid = $this->currentUserId();
        $propRepo = new GooglePropertyRepository($this->pdo(), $this->tablePrefix, $uid);
        $oppRepo = new GscOpportunitiesRepository($this->pdo(), $this->tablePrefix);

        $filters = $this->resolveGlobalFilters($propRepo);
        $activeProp = $filters['activeProp'];
        if (!is_array($activeProp) || empty($activeProp['id'])) {
            http_response_code(400);
            echo 'No active property';
            return;
        }

        $preset = (string) ($filters['preset'] ?? 'last_30_days');
        $device = (string) ($filters['device'] ?? 'all');
        $country = (string) ($filters['country'] ?? 'all');
        ['start' => $rangeStart, 'end' => $rangeEnd] = $this->rangeFromPreset($preset);

        $sort = (string) ($_GET['sort'] ?? 'score');
        $dir = (string) ($_GET['dir'] ?? 'DESC');

        $filename = $this->csvFilename('opportunities', (string) ($activeProp['site_url'] ?? ''), $rangeStart, $rangeEnd);
        $this->sendCsvHeaders($filename);

        $out = fopen('php://output', 'wb');
        if ($out === false) {
            http_response_code(500);
            echo 'Cannot open output';
            return;
        }
        fputcsv($out, ['query', 'score', 'impressions', 'ctr', 'position', 'clicks', 'rules']);

        $page = 1;
        $perPage = 100;
        while (true) {
            $data = $oppRepo->listForRange((int) $activeProp['id'], $rangeStart, $rangeEnd, $device, $country, $sort, $dir, $page, $perPage);
            $rows = is_array($data) ? ($data['rows'] ?? []) : [];
            if (!is_array($rows) || $rows === []) {
                break;
            }
            foreach ($rows as $r) {
                if (!is_array($r)) {
                    continue;
                }
                $rules = isset($r['rules']) && is_array($r['rules']) ? implode('|', array_map('strval', $r['rules'])) : '';
                fputcsv($out, [
                    (string) ($r['query'] ?? ''),
                    (float) ($r['score'] ?? 0),
                    (int) ($r['impressions'] ?? 0),
                    (float) ($r['ctr'] ?? 0),
                    (float) ($r['position'] ?? 0),
                    (int) ($r['clicks'] ?? 0),
                    $rules,
                ]);
            }
            $page++;
            if (function_exists('flush')) {
                flush();
            }
        }

        fclose($out);
        exit;
    }

    /**
     * @param list<string> $parts
     */
    private function handleProjects(string $method, array $parts): void
    {
        $uid = $this->currentUserId();
        $repo = new ProjectRepository($this->pdo(), $this->tablePrefix, $uid);
        $propRepo = new GooglePropertyRepository($this->pdo(), $this->tablePrefix, $uid);
        $jobRepo = new SyncJobRepository($this->pdo(), $this->tablePrefix);
        $sub = $parts[1] ?? '';

        if ($sub === 'new') {
            if (strtoupper($method) === 'GET') {
                $flash = $this->pullFlash();
                $properties = $propRepo->siteUrls();
                $project = null;
                require $this->root . '/app/Views/project_form.php';
                return;
            }
            if (strtoupper($method) === 'POST') {
                $this->assertPostCsrf();
                $name = trim((string) ($_POST['name'] ?? ''));
                $siteUrl = trim((string) ($_POST['site_url'] ?? ''));
                $notes = trim((string) ($_POST['notes'] ?? ''));
                $err = $this->validateProjectInput($name, $siteUrl);
                if ($err !== null) {
                    $_SESSION['_flash_error'] = $err;
                    $this->redirect(seoscope_url('/projects/new'));
                    return;
                }
                $repo->create($name, $siteUrl, $notes === '' ? null : $notes);
                $_SESSION['_flash_ok'] = __('projects.saved');
                $this->redirect(seoscope_url('/projects'));
                return;
            }
            http_response_code(405);
            return;
        }

        if ($sub === 'edit') {
            $id = (int) ($_GET['id'] ?? 0);
            $project = $id > 0 ? $repo->find($id) : null;
            if ($project === null) {
                http_response_code(404);
                echo 'Not Found';
                return;
            }
            if (strtoupper($method) === 'GET') {
                $flash = $this->pullFlash();
                $properties = $propRepo->siteUrls();
                require $this->root . '/app/Views/project_form.php';
                return;
            }
            if (strtoupper($method) === 'POST') {
                $this->assertPostCsrf();
                $pid = (int) ($_POST['id'] ?? 0);
                if ($pid !== $project['id']) {
                    http_response_code(400);
                    return;
                }
                $name = trim((string) ($_POST['name'] ?? ''));
                $siteUrl = trim((string) ($_POST['site_url'] ?? ''));
                $notes = trim((string) ($_POST['notes'] ?? ''));
                $gscProperty = trim((string) ($_POST['gsc_property'] ?? ''));
                $gscProperty = $gscProperty === '' ? null : $gscProperty;
                $err = $this->validateProjectInput($name, $siteUrl);
                if ($err !== null) {
                    $_SESSION['_flash_error'] = $err;
                    $this->redirect(seoscope_url('/projects/edit?id=' . $pid));
                    return;
                }
                if ($gscProperty !== null) {
                    $allowed = $propRepo->siteUrls();
                    if (!in_array($gscProperty, $allowed, true)) {
                        $_SESSION['_flash_error'] = __('projects.error_gsc_invalid');
                        $this->redirect(seoscope_url('/projects/edit?id=' . $pid));
                        return;
                    }
                }
                $repo->update($pid, $name, $siteUrl, $notes === '' ? null : $notes, $gscProperty);
                $_SESSION['_flash_ok'] = __('projects.updated');
                $this->redirect(seoscope_url('/projects'));
                return;
            }
            http_response_code(405);
            return;
        }

        if (strtoupper($method) === 'POST' && $sub === 'delete') {
            $this->assertPostCsrf();
            $id = (int) ($_POST['id'] ?? 0);
            if ($id < 1) {
                http_response_code(400);
                return;
            }
            if ($repo->find($id) !== null) {
                $repo->delete($id);
                $_SESSION['_flash_ok'] = __('projects.deleted');
            }
            $this->redirect(seoscope_url('/projects'));
            return;
        }

        if (strtoupper($method) === 'POST' && $sub === 'full-sync') {
            $this->assertPostCsrf();
            $propertyId = (int) ($_POST['property_id'] ?? 0);
            $refresh = !empty($_POST['refresh']);
            $missingOnly = !empty($_POST['missing_only']);
            if ($propertyId < 1) {
                http_response_code(400);
                return;
            }
            $prop = $propRepo->findById($propertyId);
            if (!is_array($prop)) {
                http_response_code(404);
                return;
            }
            $perm = strtolower((string) ($prop['permission_level'] ?? ''));
            if ($perm === 'siteunverifieduser') {
                $_SESSION['_flash_error'] = __('projects.gsc_enable_blocked_hint');
                $this->redirect(seoscope_url('/projects'));
                return;
            }

            $endDt = new \DateTimeImmutable('yesterday');
            $start = $endDt->modify('-16 months')->format('Y-m-d');
            // Backfill "the rest": exclude the most recent 28 days (handled on Enable).
            $endBackfill = $endDt->modify('-28 days')->format('Y-m-d');

            $created = false;
            if ($start <= $endBackfill) {
                if ($missingOnly) {
                    $tAgg = '`' . str_replace('`', '``', $this->tablePrefix . 'gsc_daily_property_agg') . '`';
                    $stmt = $this->pdo()->prepare("SELECT MIN(date) AS min_date, MAX(date) AS max_date FROM {$tAgg} WHERE property_id = ? LIMIT 1");
                    $stmt->execute([$propertyId]);
                    $rr = $stmt->fetch(\PDO::FETCH_ASSOC) ?: null;
                    $min = is_array($rr) && is_string($rr['min_date'] ?? null) ? (string) $rr['min_date'] : null;
                    $max = is_array($rr) && is_string($rr['max_date'] ?? null) ? (string) $rr['max_date'] : null;

                    $missingStart = null;
                    $missingEnd = null;
                    if ($min === null || $max === null) {
                        $missingStart = $start;
                        $missingEnd = $endBackfill;
                    } elseif ($min > $start) {
                        $missingStart = $start;
                        $missingEnd = (new \DateTimeImmutable($min))->modify('-1 day')->format('Y-m-d');
                    } elseif ($max < $endBackfill) {
                        $missingStart = (new \DateTimeImmutable($max))->modify('+1 day')->format('Y-m-d');
                        $missingEnd = $endBackfill;
                    }

                    if ($missingStart !== null && $missingEnd !== null && $missingStart <= $missingEnd) {
                        $created = $jobRepo->enqueueFullBackfill($uid, $propertyId, $missingStart, $missingEnd);
                    } else {
                        $_SESSION['_flash_ok'] = __('projects.full_sync_missing_nothing');
                        $this->redirect(seoscope_url('/projects'));
                        return;
                    }
                } else {
                    $created = $jobRepo->enqueueFullBackfill($uid, $propertyId, $start, $endBackfill);
                }
            }

            $kickoff = null;
            if ($created) {
                $kickoff = $this->kickoffSyncNow($uid, 20);
            }
            if ($refresh && !$created) {
                $_SESSION['_flash_error'] = __('projects.full_sync_already_queued');
            } else {
                if ($created) {
                    $msg = __('projects.full_sync_queued');
                    if (is_array($kickoff) && !empty($kickoff['ran'])) {
                        $msg .= ' ' . __('projects.sync_kickoff_started');
                    } elseif (is_array($kickoff) && !empty($kickoff['locked'])) {
                        $msg .= ' ' . __('projects.sync_kickoff_queued_locked');
                    }
                    $_SESSION['_flash_ok'] = $msg;
                } else {
                    $_SESSION['_flash_ok'] = __('projects.full_sync_already_queued');
                }
            }
            $this->redirect(seoscope_url('/projects'));
            return;
        }

        if (strtoupper($method) === 'POST' && $sub === 'queue-action') {
            $this->assertPostCsrf();
            $propertyId = (int) ($_POST['property_id'] ?? 0);
            $action = (string) ($_POST['action'] ?? '');
            if ($propertyId < 1) {
                http_response_code(400);
                return;
            }
            try {
                if ($action === 'pause') {
                    $jobRepo->pauseActiveJobsForProperty($uid, $propertyId);
                    $_SESSION['_flash_ok'] = __('projects.queue_paused');
                } elseif ($action === 'resume') {
                    $jobRepo->resumePausedJobsForProperty($uid, $propertyId);
                    $_SESSION['_flash_ok'] = __('projects.queue_resumed');
                } elseif ($action === 'cancel') {
                    $jobRepo->cancelActiveJobsForProperty($uid, $propertyId);
                    $_SESSION['_flash_ok'] = __('projects.queue_cancelled');
                }
            } catch (\Throwable $e) {
                $_SESSION['_flash_error'] = __('projects.queue_action_failed', ['message' => mb_substr($e->getMessage(), 0, 200)]);
            }
            $this->redirect(seoscope_url('/projects'));
            return;
        }

        if ($sub === '' && strtoupper($method) === 'GET') {
            $projects = $repo->all();
            $flash = $this->pullFlash();
            $propertiesRows = $propRepo->all();
            $propertiesWithJobs = [];
            $propertiesHasActiveJob = [];
            $importedRanges = [];
            $propertiesBackfillDone = [];
            $settings = new SettingsRepository($this->pdo(), $this->tablePrefix, $uid);
            $cronLastRunAt = $settings->get('cron.last_run_at');
            $recentJobs = $jobRepo->recentJobsForUser($uid, 15);
            foreach ($propertiesRows as $r) {
                if (!is_array($r)) {
                    continue;
                }
                $pid = (int) ($r['id'] ?? 0);
                if ($pid < 1) {
                    continue;
                }
                $st = $jobRepo->activeJobStatusForProperty($uid, $pid);
                if (is_array($st)) {
                    $propertiesWithJobs[$pid] = $st;
                }
                if ($jobRepo->hasActiveJobForProperty($uid, $pid)) {
                    $propertiesHasActiveJob[$pid] = true;
                }
                $bf = $jobRepo->lastCompletedFullBackfillForProperty($uid, $pid);
                if (is_array($bf) && ($bf['completed_at'] ?? '') !== '') {
                    $propertiesBackfillDone[$pid] = $bf;
                }
            }

            // Imported range per property (min/max date from daily aggregates) in one query.
            $pids = [];
            foreach ($propertiesRows as $r) {
                if (!is_array($r)) {
                    continue;
                }
                $pid = (int) ($r['id'] ?? 0);
                if ($pid > 0) {
                    $pids[] = $pid;
                }
            }
            $pids = array_values(array_unique($pids));
            if ($pids !== []) {
                $t = '`' . str_replace('`', '``', $this->tablePrefix . 'gsc_daily_property_agg') . '`';
                $ph = implode(',', array_fill(0, count($pids), '?'));
                $stmt = $this->pdo()->prepare(
                    "SELECT
                        property_id,
                        MIN(CASE WHEN (clicks > 0 OR impressions > 0) THEN date ELSE NULL END) AS min_date,
                        MAX(CASE WHEN (clicks > 0 OR impressions > 0) THEN date ELSE NULL END) AS max_date
                     FROM {$t}
                     WHERE property_id IN ({$ph})
                     GROUP BY property_id"
                );
                $stmt->execute($pids);
                $rows = $stmt->fetchAll(\PDO::FETCH_ASSOC) ?: [];
                foreach ($rows as $rr) {
                    if (!is_array($rr)) {
                        continue;
                    }
                    $pid = (int) ($rr['property_id'] ?? 0);
                    if ($pid < 1) {
                        continue;
                    }
                    $min = is_string($rr['min_date'] ?? null) ? (string) $rr['min_date'] : null;
                    $max = is_string($rr['max_date'] ?? null) ? (string) $rr['max_date'] : null;
                    $importedRanges[$pid] = ['min' => $min, 'max' => $max];
                }
            }
            require $this->root . '/app/Views/projects_index.php';
            return;
        }

        http_response_code(404);
    }

    private function assertPostCsrf(): void
    {
        if (!AdminCsrf::validate($_POST['_csrf'] ?? null)) {
            http_response_code(403);
            echo 'Invalid CSRF';
            exit;
        }
    }

    private function canonLocale(string $code): ?string
    {
        $t = trim($code);
        if ($t === '') {
            return null;
        }
        $t = strtolower($t);
        if (!preg_match('/^[a-z]{2}$/', $t)) {
            return null;
        }
        return $t;
    }

    private function ensureLangStorageDir(): ?string
    {
        $dir = $this->root . '/storage/lang';
        if (!is_dir($dir)) {
            @mkdir($dir, 0775, true);
        }
        if (!is_dir($dir) || !is_writable($dir)) {
            return null;
        }
        return $dir;
    }

    /**
     * @return array<string,string>
     */
    private function readLocalesJson(string $path): array
    {
        if (!is_readable($path)) {
            return [];
        }
        $raw = @file_get_contents($path);
        if (!is_string($raw) || $raw === '') {
            return [];
        }
        $decoded = json_decode($raw, true);
        if (!is_array($decoded)) {
            return [];
        }
        $out = [];
        foreach ($decoded as $code => $label) {
            if (!is_string($code) || !is_string($label)) {
                continue;
            }
            $c = $this->canonLocale($code);
            if ($c === null) {
                continue;
            }
            $lbl = trim($label);
            if ($lbl === '') {
                continue;
            }
            $out[$c] = $lbl;
        }
        return $out;
    }

    /**
     * @param array<string,mixed> $data
     */
    private function writeJsonFile(string $path, array $data): bool
    {
        $json = json_encode($data, JSON_PRETTY_PRINT | JSON_UNESCAPED_UNICODE | JSON_UNESCAPED_SLASHES);
        if (!is_string($json)) {
            return false;
        }
        return @file_put_contents($path, $json) !== false;
    }

    private function validateProjectInput(string $name, string $siteUrl): ?string
    {
        if ($name === '') {
            return __('projects.error_name');
        }
        if ($siteUrl === '') {
            return __('projects.error_url');
        }
        if (!filter_var($siteUrl, FILTER_VALIDATE_URL)) {
            return __('projects.error_url_invalid');
        }
        return null;
    }

    /** @return array{ok:?string,err:?string} */
    private function pullFlash(): array
    {
        $ok = $_SESSION['_flash_ok'] ?? null;
        $err = $_SESSION['_flash_error'] ?? null;
        unset($_SESSION['_flash_ok'], $_SESSION['_flash_error']);
        return [
            'ok' => is_string($ok) ? $ok : null,
            'err' => is_string($err) ? $err : null,
        ];
    }
}
