<?php
declare(strict_types=1);

namespace Ekesto\CacheWarmup\Settings;

use Ekesto\CacheWarmup\Core\TriggerReasonResolver;
use Ekesto\CacheWarmup\Logger;
use Ekesto\CacheWarmup\Multilingual\AdapterFactory;
use Ekesto\CacheWarmup\Multilingual\LanguageContextSwitcherInterface;
use Ekesto\CacheWarmup\WarmupRuntime;
use Ekesto\CacheWarmup\Queue;
use Ekesto\CacheWarmup\Settings;
use Ekesto\CacheWarmup\Settings\Actions\RunNowAction;
use Ekesto\CacheWarmup\Sitemap;
use Ekesto\CacheWarmup\Support\Access;
use Ekesto\CacheWarmup\TuningStats;
use Ekesto\CacheWarmup\Triggers;
use Ekesto\CacheWarmup\Warmup\TargetedBrotliTransport;

/**
 * REST controller for settings/admin app endpoints.
 */
final class RestController
{
    /** Default recent log rows returned by /logs. */
    private const RECENT_LOGS_DEFAULT_LIMIT = 10;

    /** Hard cap of additional rows when /logs is requested with all=1. */
    private const RECENT_LOGS_ALL_ADDITIONAL_LIMIT = 200;

    /** Show all URL rows when run size is at or below this threshold. */
    private const LOG_EVENT_SMALL_RUN_MAX = 25;

    /** Preview row cap for large runs. */
    private const LOG_EVENT_PREVIEW_CAP = 20;

    /**
     * Mark plugin REST responses as non-cacheable for LiteSpeed.
     *
     * @param mixed $result
     * @param mixed $server REST server instance.
     * @param mixed $request Current REST request.
     * @return mixed Original pre-dispatch result.
     */
    public static function preventLscCaching($result, $server, $request)
    {
        $route = $request->get_route();
        if (str_starts_with($route, '/' . Settings::REST_NAMESPACE)) {
            // phpcs:ignore WordPress.NamingConventions.PrefixAllGlobals.NonPrefixedHooknameFound -- LiteSpeed Cache third-party control hook.
            do_action('litespeed_control_set_nocache', 'ekesto-cache-warmup REST API');
        }
        return $result;
    }

    /**
     * Register all REST routes used by the settings application.
     */
    public static function registerRestEndpoints(): void
    {
        add_filter('rest_pre_dispatch', [self::class, 'preventLscCaching'], 10, 3);

        register_rest_route(Settings::REST_NAMESPACE, '/settings', [
            'methods' => 'GET',
            'callback' => [self::class, 'restGetSettings'],
            'permission_callback' => static fn(): bool => Access::userCanManage(),
        ]);

        register_rest_route(Settings::REST_NAMESPACE, '/settings', [
            'methods' => 'POST',
            'callback' => [self::class, 'restSaveSettings'],
            'permission_callback' => static fn(): bool => Access::userCanManage(),
        ]);

        register_rest_route(Settings::REST_NAMESPACE, '/logs', [
            'methods' => 'GET',
            'callback' => [self::class, 'restGetLogs'],
            'permission_callback' => static fn(): bool => Access::userCanManage(),
        ]);

        register_rest_route(Settings::REST_NAMESPACE, '/logs/(?P<id>\d+)/details', [
            'methods' => 'GET',
            'callback' => [self::class, 'restGetLogDetails'],
            'permission_callback' => static fn(): bool => Access::userCanManage(),
        ]);

        register_rest_route(Settings::REST_NAMESPACE, '/server-time', [
            'methods' => 'GET',
            'callback' => [self::class, 'restGetServerTime'],
            'permission_callback' => static fn(): bool => Access::userCanManage(),
        ]);

        register_rest_route(Settings::REST_NAMESPACE, '/run-now', [
            'methods' => 'POST',
            'callback' => [self::class, 'restRunNow'],
            'permission_callback' => static fn(): bool => Access::userCanManage(),
        ]);

        register_rest_route(Settings::REST_NAMESPACE, '/run-full', [
            'methods' => 'POST',
            'callback' => [self::class, 'restRunFull'],
            'permission_callback' => static fn(): bool => Access::userCanManage(),
        ]);

        register_rest_route(Settings::REST_NAMESPACE, '/diagnostics', [
            'methods' => 'GET',
            'callback' => [self::class, 'restGetDiagnostics'],
            'permission_callback' => static fn(): bool => Access::userCanManage(),
        ]);

        register_rest_route(Settings::REST_NAMESPACE, '/post-types', [
            'methods' => 'GET',
            'callback' => [self::class, 'restGetPostTypes'],
            'permission_callback' => static fn(): bool => Access::userCanManage(),
        ]);

        register_rest_route(Settings::REST_NAMESPACE, '/pages', [
            'methods' => 'GET',
            'callback' => [self::class, 'restGetPages'],
            'permission_callback' => static fn(): bool => Access::userCanManage(),
            'args' => [
                'refresh' => [
                    'default' => false,
                    'sanitize_callback' => 'rest_sanitize_boolean',
                ],
            ],
        ]);

        register_rest_route(Settings::REST_NAMESPACE, '/post-types-from-sitemap', [
            'methods' => 'GET',
            'callback' => [self::class, 'restGetPostTypesFromSitemap'],
            'permission_callback' => static fn(): bool => Access::userCanManage(),
        ]);

        register_rest_route(Settings::REST_NAMESPACE, '/post-types-from-sitemap/refresh', [
            'methods' => 'POST',
            'callback' => [self::class, 'restRefreshPostTypesFromSitemap'],
            'permission_callback' => static fn(): bool => Access::userCanManage(),
        ]);

        register_rest_route(Settings::REST_NAMESPACE, '/warmup-probe', [
            'methods' => 'POST',
            'callback' => [self::class, 'restRunWarmupProbe'],
            'permission_callback' => static fn(): bool => Access::userCanManage(),
        ]);

        register_rest_route(Settings::REST_NAMESPACE, '/http-self-check', [
            'methods' => 'POST',
            'callback' => [self::class, 'restHttpSelfCheck'],
            'permission_callback' => static fn(): bool => Access::userCanManage(),
        ]);

        register_rest_route(Settings::REST_NAMESPACE, '/tuning-stats/reset', [
            'methods' => 'POST',
            'callback' => [self::class, 'restResetTuningStats'],
            'permission_callback' => static fn(): bool => Access::userCanManage(),
        ]);
    }

    /**
     * Return merged settings payload.
     *
     * @param mixed $request REST request object.
     * @return \WP_REST_Response Settings response.
     */
    public static function restGetSettings($request)
    {
        $settings = Repository::all();
        $response = new \WP_REST_Response([Settings::OPTION => $settings], 200);
        return self::applyRestNoCache($response);
    }

    /**
     * Persist sanitized settings payload.
     *
     * @param mixed $request REST request object.
     * @return \WP_REST_Response Settings response.
     */
    public static function restSaveSettings($request)
    {
        $params = $request->get_json_params();
        $raw = $params[Settings::OPTION] ?? [];
        $beforeSitemaps = Repository::getSitemaps();

        $sanitized = Repository::sanitizeOption($raw);
        update_option(Settings::OPTION, $sanitized);

        $afterSitemaps = Repository::extractSitemapsFromOption($sanitized);
        $beforeHash = SitemapSnapshotService::hashSitemapUrls($beforeSitemaps);
        $afterHash = SitemapSnapshotService::hashSitemapUrls($afterSitemaps);

        if ($beforeHash !== $afterHash) {
            SitemapSnapshotService::markOutdated();
            Sitemap::scheduleMembershipRebuild(true);
        }

        $settings = Repository::all();
        $response = new \WP_REST_Response([Settings::OPTION => $settings], 200);
        return self::applyRestNoCache($response);
    }

    /**
     * Return the latest warmup logs.
     *
     * @param mixed $request REST request object.
     * @return \WP_REST_Response Logs response.
     */
    public static function restGetLogs($request)
    {
        global $wpdb;

        $table = Logger::getTableName();
        if ($table === '') {
            $response = new \WP_REST_Response([
                'logs' => [],
                'logs_total' => 0,
                'logs_shown' => 0,
                'logs_overflow' => 0,
                'logs_truncated' => false,
            ], 200);
            return self::applyRestNoCache($response);
        }

        $showAll = rest_sanitize_boolean($request->get_param('all'));
        $limit = $showAll
            ? (self::RECENT_LOGS_DEFAULT_LIMIT + self::RECENT_LOGS_ALL_ADDITIONAL_LIMIT)
            : self::RECENT_LOGS_DEFAULT_LIMIT;

        // phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery,WordPress.DB.DirectDatabaseQuery.NoCaching -- Authenticated admin REST read from the plugin-owned warmup log table.
        $totalRows = (int) $wpdb->get_var(
            $wpdb->prepare('SELECT COUNT(*) FROM %i', $table)
        );
        if ($totalRows < 0) {
            $totalRows = 0;
        }

        // phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery,WordPress.DB.DirectDatabaseQuery.NoCaching -- Authenticated admin REST read from the plugin-owned warmup log table, bounded by a hard limit.
        $rows = $wpdb->get_results(
            $wpdb->prepare(
                'SELECT id, run_id, started_at, finished_at, trigger_key, mode, status, total_urls, warmed_urls, failed_urls, details
                 FROM %i
                 ORDER BY id DESC
                 LIMIT %d',
                $table,
                $limit
            ),
            ARRAY_A
        );

        if (!is_array($rows)) {
            $rows = [];
        }

        $rows = self::decorateLogRows($rows);
        $shownRows = count($rows);
        $overflowRows = max(0, $totalRows - $shownRows);
        $isTruncated = $showAll && $totalRows > $limit;

        $response = new \WP_REST_Response([
            'logs' => $rows,
            'logs_total' => $totalRows,
            'logs_shown' => $shownRows,
            'logs_overflow' => $overflowRows,
            'logs_truncated' => $isTruncated,
        ], 200);
        return self::applyRestNoCache($response);
    }

    /**
     * Return URL detail rows for a specific warmup log run.
     *
     * @param mixed $request REST request object.
     * @return \WP_REST_Response|\WP_Error Log details response.
     */
    public static function restGetLogDetails($request)
    {
        global $wpdb;

        $logId = absint($request->get_param('id'));
        if ($logId < 1) {
            return new \WP_Error(
                'ecw_invalid_log_id',
                __('Invalid log ID.', 'cache-autopilot'),
                ['status' => 400]
            );
        }

        $table = Logger::getTableName();
        if ($table === '') {
            return new \WP_Error(
                'ecw_log_not_found',
                __('Run not found.', 'cache-autopilot'),
                ['status' => 404]
            );
        }

        // phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery,WordPress.DB.DirectDatabaseQuery.NoCaching -- Authenticated admin REST read of one plugin-owned warmup log row.
        $row = $wpdb->get_row(
            $wpdb->prepare(
                'SELECT id, run_id, started_at, finished_at, trigger_key, mode, status, total_urls, warmed_urls, failed_urls, details
                 FROM %i
                 WHERE id = %d
                 LIMIT 1',
                $table,
                $logId
            ),
            ARRAY_A
        );

        if (!is_array($row)) {
            return new \WP_Error(
                'ecw_log_not_found',
                __('Run not found.', 'cache-autopilot'),
                ['status' => 404]
            );
        }

        $showAllRows = rest_sanitize_boolean($request->get_param('all'));
        $rows = self::decorateLogRows([$row], $showAllRows);
        $decoratedRow = isset($rows[0]) && is_array($rows[0]) ? $rows[0] : [];

        $response = new \WP_REST_Response(['log' => $decoratedRow], 200);
        return self::applyRestNoCache($response);
    }

    /**
     * Return current server time and timezone.
     *
     * @param mixed $request REST request object.
     * @return \WP_REST_Response Server time response.
     */
    public static function restGetServerTime($request)
    {
        $tz = wp_timezone();
        $now = new \DateTimeImmutable('now', $tz);

        $response = new \WP_REST_Response([
            'epoch' => $now->getTimestamp(),
            'tz' => $tz->getName(),
            'formatted' => wp_date('Y-m-d H:i:s', $now->getTimestamp()),
        ], 200);

        return self::applyRestNoCache($response);
    }

    /**
     * Execute the next queued warmup batch immediately.
     *
     * @param mixed $request REST request object.
     * @return \WP_REST_Response Run-now response.
     */
    public static function restRunNow($request)
    {
        $result = (defined('DISABLE_WP_CRON') && DISABLE_WP_CRON)
            ? RunNowAction::runNextBatchNow()
            : RunNowAction::requestNextBatchSoon();

        $httpStatus = isset($result['http_status']) ? (int) $result['http_status'] : 200;
        unset($result['http_status']);

        $response = new \WP_REST_Response($result, $httpStatus);
        return self::applyRestNoCache($response);
    }

    /**
     * Schedule a full warmup run through the existing trigger/queue flow.
     *
     * @param mixed $request REST request object.
     * @return \WP_REST_Response Run-full response.
     */
    public static function restRunFull($request)
    {
        if (!Repository::isEnabled('general.enabled')) {
            $response = new \WP_REST_Response([
                'success' => false,
                'code' => 'warmup_paused',
                'message' => __('Warmup is paused. Enable warmup before scheduling a full warmup.', 'cache-autopilot'),
            ], 409);

            return self::applyRestNoCache($response);
        }

        if (!Triggers::enqueueManualFullWarmup('manual:settings_general')) {
            $response = new \WP_REST_Response([
                'success' => false,
                'code' => 'eci_unavailable',
                'message' => __('Cache Invalidator authority is unavailable. Full refresh request was not dispatched.', 'cache-autopilot'),
            ], 409);

            return self::applyRestNoCache($response);
        }

        $response = new \WP_REST_Response([
            'success' => true,
            'message' => __('Full refresh requested via Cache Invalidator.', 'cache-autopilot'),
        ], 200);

        return self::applyRestNoCache($response);
    }

    /**
     * Return environment diagnostics for WP-Cron configuration.
     *
     * @param mixed $request REST request object.
     * @return \WP_REST_Response Diagnostics response.
     */
    public static function restGetDiagnostics($request)
    {
        $disableWpCron = (defined('DISABLE_WP_CRON') && DISABLE_WP_CRON);
        $alternateWpCron = (defined('ALTERNATE_WP_CRON') && ALTERNATE_WP_CRON);

        $state = Queue::get();
        $status = sanitize_key((string) ($state['status'] ?? 'idle'));
        $runId = sanitize_text_field((string) ($state['run_id'] ?? ''));

        if (!Queue::hasActiveRun($state)) {
            $status = 'idle';
            $runId = '';
        }

        $lastActivityTs = isset($state['last_activity']) ? (int) $state['last_activity'] : 0;
        $lastActivityFormatted = $lastActivityTs > 0 ? wp_date('Y-m-d H:i:s', $lastActivityTs) : null;

        $response = new \WP_REST_Response([
            'disable_wp_cron' => $disableWpCron,
            'alternate_wp_cron' => $alternateWpCron,
            'last_activity' => $lastActivityFormatted,
            'last_activity_ts' => $lastActivityTs,
            'queue_active' => Queue::hasActiveRun($state),
            'run_now_actionable' => Queue::hasPendingWork($state)
                || (Queue::isPreparingFullState($state) && !Queue::isRunLocked()),
            'queue_status' => $status,
            'run_id' => $runId,
        ], 200);

        return self::applyRestNoCache($response);
    }

    /**
     * Run a WordPress HTTP API self-check against the site home URL.
     *
     * @param mixed $request REST request object.
     * @return \WP_REST_Response Self-check result.
     */
    public static function restHttpSelfCheck($request)
    {
        $targetUrl = home_url('/');
        $t0 = microtime(true);
        $resp = wp_remote_get($targetUrl, [
            'timeout' => 5,
            'redirection' => 2,
            'sslverify' => true,
            'headers' => ['Cache-Control' => 'no-cache'],
            'user-agent' => 'Ekesto-Cache-Warmup/self-check',
        ]);
        $elapsedMs = (int) round((microtime(true) - $t0) * 1000);

        $success = false;
        $httpCode = null;
        $message = '';
        if (is_wp_error($resp)) {
            $message = $resp->get_error_message();
        } else {
            $httpCode = (int) wp_remote_retrieve_response_code($resp);
            $success = ($httpCode >= 200 && $httpCode < 400);
            $message = 'HTTP ' . $httpCode;
        }

        $response = new \WP_REST_Response([
            'wp_http_self_check' => [
                'success' => $success,
                'http_code' => $httpCode,
                'message' => $message,
                'elapsed_ms' => $elapsedMs,
                'checked_at' => (new \DateTimeImmutable('now', new \DateTimeZone('UTC')))->format(\DateTime::ATOM),
                'target_url' => $targetUrl,
                'transport_used' => 'wp_remote_get',
            ],
        ], 200);

        return self::applyRestNoCache($response);
    }

    /**
     * Return public post types with published counts.
     *
     * @param mixed $request REST request object.
     * @return \WP_REST_Response Post-type response.
     */
    public static function restGetPostTypes($request)
    {
        $excludedPostTypes = ['attachment'];

        $postTypes = get_post_types(['public' => true], 'objects');
        $result = [];

        foreach ($postTypes as $postType) {
            if (in_array($postType->name, $excludedPostTypes, true)) {
                continue;
            }

            $count = wp_count_posts($postType->name);
            $publishedCount = isset($count->publish) ? (int) $count->publish : 0;

            $result[] = [
                'name' => $postType->name,
                'label' => $postType->label ?? $postType->name,
                'count' => $publishedCount,
            ];
        }

        $response = new \WP_REST_Response(['post_types' => $result], 200);
        return self::applyRestNoCache($response);
    }

    /**
     * Return all published pages for priority configuration.
     *
     * @param mixed $request REST request object.
     * @return \WP_REST_Response Page list response.
     */
    public static function restGetPages($request)
    {
        $startedAt = microtime(true);
        $forceRefresh = rest_sanitize_boolean($request->get_param('refresh'));
        if ($forceRefresh) {
            PriorityPagesCache::invalidate();
        }

        $cacheSource = $forceRefresh ? 'regenerated' : 'cache_hit';
        $result = PriorityPagesCache::get();

        if (!is_array($result)) {
            if (!$forceRefresh) {
                $cacheSource = 'cache_miss';
            }

            $result = self::queryPriorityPagesInDefaultLanguage();
            PriorityPagesCache::set($result);
        }

        $durationMs = (int) round((microtime(true) - $startedAt) * 1000);
        $response = new \WP_REST_Response(['pages' => $result], 200);
        $response->header('X-ECW-Pages-Source', $cacheSource);
        $response->header('X-ECW-Pages-Duration-Ms', (string) $durationMs);
        return self::applyRestNoCache($response);
    }

    /**
     * Query published pages in the multilingual system's default language.
     *
     * @return array<int,array<string,mixed>> Priority page rows.
     */
    private static function queryPriorityPagesInDefaultLanguage(): array
    {
        $languageAdapter = AdapterFactory::create();
        $defaultLanguage = $languageAdapter?->getDefaultLanguage();
        $defaultLanguage = is_string($defaultLanguage) ? trim($defaultLanguage) : '';
        $canSwitchLanguage = $languageAdapter instanceof LanguageContextSwitcherInterface
            && $defaultLanguage !== '';
        $languageSwitched = false;

        try {
            if ($canSwitchLanguage) {
                $languageAdapter->switchLanguage($defaultLanguage);
                $languageSwitched = true;
            }

            $query = new \WP_Query([
                'post_type' => 'page',
                'post_status' => 'publish',
                'posts_per_page' => -1,
                'orderby' => 'title',
                'order' => 'ASC',
                'fields' => 'ids',
                'no_found_rows' => true,
                'cache_results' => false,
                'update_post_meta_cache' => false,
                'update_post_term_cache' => false,
            ]);

            $homeUrl = home_url();
            $frontPageId = get_option('show_on_front') === 'page'
                ? absint(get_option('page_on_front'))
                : 0;
            $result = [];

            foreach ($query->posts as $postId) {
                $postId = (int) $postId;
                if ($postId <= 0) {
                    continue;
                }

                $permalink = get_permalink($postId);
                $uri = '';
                if (is_string($permalink) && $permalink !== '') {
                    $uri = str_replace($homeUrl, '', $permalink);
                }

                $result[] = [
                    'id' => $postId,
                    'title' => (string) get_the_title($postId),
                    'uri' => $uri,
                    'status' => 'publish',
                    'is_front_page' => $frontPageId > 0 && $postId === $frontPageId,
                ];
            }

            return $result;
        } finally {
            if ($languageSwitched) {
                $languageAdapter->switchLanguage(null);
            }
        }
    }

    /**
     * Return snapshot-derived post-type data from sitemap URLs.
     *
     * @param mixed $request REST request object.
     * @return \WP_REST_Response Snapshot payload response.
     */
    public static function restGetPostTypesFromSitemap($request)
    {
        $sitemaps = Repository::getSitemaps();
        $hasSitemap = !empty($sitemaps);
        $snapshot = SitemapSnapshotService::getSnapshot();
        $payload = SitemapSnapshotService::buildEndpointPayload($snapshot, $sitemaps, $hasSitemap);
        $response = new \WP_REST_Response($payload, 200);

        return self::applyRestNoCache($response);
    }

    /**
     * Refresh sitemap snapshot by resolving sitemap XML once on demand.
     *
     * @param mixed $request REST request object.
     * @return \WP_REST_Response Snapshot payload response.
     */
    public static function restRefreshPostTypesFromSitemap($request)
    {
        if (get_transient(Settings::SITEMAP_REFRESH_LOCK)) {
            $response = new \WP_REST_Response([
                'code' => 'refresh_in_progress',
                'message' => __('A sitemap refresh is already running. Please wait a moment and try again.', 'cache-autopilot'),
            ], 409);
            return self::applyRestNoCache($response);
        }

        set_transient(Settings::SITEMAP_REFRESH_LOCK, '1', 30);

        try {
            $sitemaps = Repository::getSitemaps();
            $hasSitemap = !empty($sitemaps);
            $currentHash = SitemapSnapshotService::hashSitemapUrls($sitemaps);

            if (!$hasSitemap) {
                $snapshot = SitemapSnapshotService::getSnapshot();
                $payload = SitemapSnapshotService::buildEndpointPayload($snapshot, $sitemaps, false);
                $response = new \WP_REST_Response($payload, 200);
                return self::applyRestNoCache($response);
            }

            $urls = Sitemap::resolveUrls($sitemaps);
            $warning = null;
            $lastError = null;

            if (!empty($urls)) {
                Sitemap::primeSitemapUrlSetCache($urls);
            }

            if (empty($urls)) {
                if (Sitemap::hadFetchErrors()) {
                    $lastError = __('Could not fetch sitemap XML right now. Please try again in a moment.', 'cache-autopilot');
                } else {
                    $warning = __('Sitemap configured but no URLs found. Check your sitemap URLs.', 'cache-autopilot');
                }
            }

            if ($lastError !== null) {
                $snapshot = SitemapSnapshotService::getSnapshot();
                $snapshot['last_error'] = $lastError;
                SitemapSnapshotService::saveSnapshot($snapshot);

                $payload = SitemapSnapshotService::buildEndpointPayload($snapshot, $sitemaps, true);
                $response = new \WP_REST_Response($payload, 200);
                return self::applyRestNoCache($response);
            }

            $snapshot = SitemapSnapshotService::defaultSnapshot();
            $snapshot['sitemaps_hash'] = $currentHash;
            $snapshot['generated_at'] = gmdate('c');
            $snapshot['post_types'] = SitemapSnapshotService::classifyPostTypesFromUrls($urls);
            $snapshot['warning'] = $warning;
            $snapshot['last_error'] = null;
            $snapshot['source'] = 'manual_refresh';

            $existing = SitemapSnapshotService::getSnapshot();
            $snapshot['last_full_warm_hash'] = $existing['last_full_warm_hash'];
            $snapshot['last_full_warm_at'] = $existing['last_full_warm_at'];
            $snapshot['last_full_warm_url_count'] = $existing['last_full_warm_url_count'];

            SitemapSnapshotService::saveSnapshot($snapshot);

            $payload = SitemapSnapshotService::buildEndpointPayload($snapshot, $sitemaps, true);
            $response = new \WP_REST_Response($payload, 200);
            return self::applyRestNoCache($response);
        } finally {
            delete_transient(Settings::SITEMAP_REFRESH_LOCK);
        }
    }

    /**
     * Return warmup transport diagnostics and store the latest snapshot.
     *
     * @param mixed $request REST request object.
     * @return \WP_REST_Response Transport diagnostics response.
     */
    public static function restRunWarmupProbe($request)
    {
        $brotliSupport = TargetedBrotliTransport::supportStatus();
        $stats = TuningStats::get();
        $transportSamples = is_array($stats['transport_samples'] ?? null)
            ? $stats['transport_samples']
            : [];
        $effectiveSamples = [];
        $brotliSamples = [];
        $fallbackSamples = [];

        foreach ($transportSamples as $sample) {
            if (!is_array($sample) || !is_numeric($sample['effective_ms'] ?? null)) {
                continue;
            }

            $effectiveSamples[] = (int) $sample['effective_ms'];

            if (is_numeric($sample['brotli_ms'] ?? null)) {
                $brotliSamples[] = (int) $sample['brotli_ms'];
            }

            if (is_numeric($sample['fallback_ms'] ?? null)) {
                $fallbackSamples[] = (int) $sample['fallback_ms'];
            }
        }

        $sampleCount = count($effectiveSamples);
        $fallbackCount = count($fallbackSamples);
        $lastFallbackAt = (int) ($stats['last_fallback_at'] ?? 0);
        $strategy = !empty($brotliSupport['supported'])
            ? 'brotli_profile_with_fallback'
            : 'standard_request_only';

        $results = [
            'strategy' => $strategy,
            'brotli_profile_available' => !empty($brotliSupport['supported']),
            'brotli_profile_unavailable_reason' => !empty($brotliSupport['supported'])
                ? ''
                : (string) ($brotliSupport['reason'] ?? 'unknown'),
            'sample_count' => $sampleCount,
            'fallback_rate_pct' => $sampleCount > 0 ? round(($fallbackCount / $sampleCount) * 100, 1) : 0.0,
            'p90_effective_ms' => self::percentileMs($effectiveSamples, 0.90),
            'p90_brotli_ms' => self::percentileMs($brotliSamples, 0.90),
            'p90_fallback_ms' => self::percentileMs($fallbackSamples, 0.90),
            'last_fallback_reason' => $lastFallbackAt > 0
                ? (string) ($stats['last_fallback_reason'] ?? '')
                : '',
            'last_fallback_at_ts' => $lastFallbackAt,
            'tested_at' => (new \DateTimeImmutable('now', new \DateTimeZone('UTC')))->format(\DateTime::ATOM),
        ];

        $saved = get_option(Settings::OPTION, []);
        $saved = is_array($saved) ? $saved : [];
        if (!isset($saved['diagnostics']) || !is_array($saved['diagnostics'])) {
            $saved['diagnostics'] = [];
        }
        $saved['diagnostics']['probe_results'] = Repository::sanitizeDiagnosticsPayload($results);
        update_option(Settings::OPTION, $saved);

        $response = new \WP_REST_Response($results, 200);
        return self::applyRestNoCache($response);
    }

    /**
     * Calculate a percentile value from a numeric sample list.
     *
     * @param mixed $samples Sample list.
     * @param float $percentile Percentile between 0 and 1.
     * @return int|null Percentile value or null when insufficient data.
     */
    private static function percentileMs($samples, float $percentile): ?int
    {
        if (!is_array($samples) || empty($samples)) {
            return null;
        }

        $values = array_values(array_filter($samples, 'is_numeric'));
        if (count($values) < 10) {
            return null;
        }

        sort($values, SORT_NUMERIC);
        $index = (int) ceil($percentile * count($values)) - 1;
        $index = max(0, min($index, count($values) - 1));

        return (int) $values[$index];
    }

    /**
     * Clear stored tuning telemetry.
     *
     * @param mixed $request REST request object.
     * @return \WP_REST_Response Reset confirmation response.
     */
    public static function restResetTuningStats($request)
    {
        TuningStats::clear();
        $response = new \WP_REST_Response(['success' => true], 200);
        return self::applyRestNoCache($response);
    }

    /**
     * Add UI-friendly derived display fields to raw log rows.
     *
     * @param array<int,array<string,mixed>> $rows Raw rows from database query.
     * @return array<int,array<string,mixed>> Decorated rows.
     */
    private static function decorateLogRows(array $rows, bool $showAllRows = false): array
    {
        $activeQueue = self::getActiveQueueSnapshot();

        foreach ($rows as $index => $row) {
            $runId = isset($row['run_id']) ? (string) $row['run_id'] : '';
            $triggerKey = isset($row['trigger_key']) ? (string) $row['trigger_key'] : '';
            $mode = isset($row['mode']) ? (string) $row['mode'] : '';
            $totalUrls = isset($row['total_urls']) ? (int) $row['total_urls'] : 0;
            $queueRows = [];
            $activeQueueForRow = [];

            $details = self::decodeLogDetails($row['details'] ?? null);

            if (
                $runId !== ''
                && !empty($activeQueue)
                && $runId === (string) ($activeQueue['run_id'] ?? '')
            ) {
                $row['total_urls'] = max(
                    (int) ($row['total_urls'] ?? 0),
                    (int) ($activeQueue['total_urls'] ?? 0)
                );
                $row['warmed_urls'] = max(
                    (int) ($row['warmed_urls'] ?? 0),
                    (int) ($activeQueue['warmed_urls'] ?? 0)
                );
                $row['failed_urls'] = max(
                    (int) ($row['failed_urls'] ?? 0),
                    (int) ($activeQueue['failed_urls'] ?? 0)
                );

                $totalUrls = (int) $row['total_urls'];
                $queueRows = self::buildQueueEventRows($activeQueue);
                $activeQueueForRow = $activeQueue;

                $storedStatus = sanitize_key((string) ($row['status'] ?? ''));
                if (in_array($storedStatus, ['pending', 'running'], true)) {
                    $row['status'] = !empty($activeQueue['runner_locked']) ? 'running' : 'pending';
                }
            }

            $triggerDisplay = TriggerReasonResolver::resolve($triggerKey, $details, $mode);

            $modeDisplayKey = $mode === 'single' ? 'targeted' : $mode;
            $incomingUrlCount = (int) ($details['incoming_url_count'] ?? 0);
            $isMerged = ($mode === 'single' && $totalUrls > 1 && ($incomingUrlCount === 0 || $totalUrls > $incomingUrlCount));
            $storedUpdateCount = max(0, (int) ($details['targeted_update_count'] ?? 0));
            $activeUpdateCount = max(0, (int) ($activeQueueForRow['targeted_update_count'] ?? 0));
            $isUpdated = $mode === 'single' && max($storedUpdateCount, $activeUpdateCount) > 0;

            $row['trigger_display'] = $triggerDisplay;
            $row['mode_display_key'] = $modeDisplayKey;
            $row['is_merged'] = $isMerged;
            $row['is_updated'] = $isUpdated;
            $row = array_merge(
                $row,
                self::buildLogEventPreview($details, $totalUrls, $queueRows, $activeQueueForRow, $showAllRows)
            );

            $rows[$index] = $row;
        }

        return $rows;
    }

    /**
     * Build additive UI event fields for log rows from details.events.
     *
     * @param array<string,mixed> $details Decoded details payload.
     * @param int                 $totalUrls Run-level total URL count.
     * @return array<string,mixed> Event preview payload.
     */
    private static function buildLogEventPreview(
        array $details,
        int $totalUrls,
        array $queueRows = [],
        array $activeQueue = [],
        bool $showAllRows = false
    ): array {
        $eventRows = self::extractLogEventRows($details);
        $progressRows = self::buildLiveProgressRows($activeQueue, $eventRows);

        if ($eventRows === [] && $queueRows === [] && $progressRows === []) {
            return [
                'event_rows' => [],
                'event_counts' => [
                    'warmed' => 0,
                    'failed' => 0,
                    'other' => 0,
                ],
                'event_rows_total' => 0,
                'event_rows_shown' => 0,
                'event_rows_overflow' => 0,
                'event_details_available' => false,
            ];
        }

        $counts = [
            'warmed' => 0,
            'failed' => 0,
            'other' => 0,
        ];
        $failedRows = [];
        $warmedRows = [];
        $progressOnlyRows = [];
        $queuedRowsNormalized = [];

        foreach ($eventRows as $eventRow) {
            $bucket = self::classifyLogEventResult((string) ($eventRow['result'] ?? ''));
            $counts[$bucket]++;

            if ($bucket === 'failed') {
                $failedRows[] = $eventRow;
            } elseif ($bucket === 'warmed') {
                $warmedRows[] = $eventRow;
            }
        }

        foreach ($progressRows as $progressRow) {
            if (!is_array($progressRow)) {
                continue;
            }

            $progressBucket = self::classifyLogEventResult((string) ($progressRow['result'] ?? ''));
            if ($progressBucket === 'failed') {
                $failedRows[] = $progressRow;
                $counts['failed']++;
                continue;
            }

            if ($progressBucket === 'warmed') {
                $warmedRows[] = $progressRow;
                $counts['warmed']++;
                continue;
            }

            $progressOnlyRows[] = $progressRow;
            $counts['other']++;
        }

        foreach ($queueRows as $queueRow) {
            if (!is_array($queueRow)) {
                continue;
            }

            $queuedRowsNormalized[] = $queueRow;
            $counts['other']++;
        }

        $allRows = array_merge($eventRows, $progressOnlyRows, $queuedRowsNormalized);
        $previewCandidates = $allRows;
        $previewRows = $allRows;
        if (!$showAllRows && $totalUrls > self::LOG_EVENT_SMALL_RUN_MAX) {
            $previewCandidates = array_merge($failedRows, $warmedRows, $progressOnlyRows, $queuedRowsNormalized);
            $previewRows = array_slice(
                $previewCandidates,
                0,
                self::LOG_EVENT_PREVIEW_CAP
            );

            if ($previewRows === []) {
                $previewCandidates = $allRows;
                $previewRows = array_slice($allRows, 0, self::LOG_EVENT_PREVIEW_CAP);
            }
        }

        $canonicalPreviewSet = $previewCandidates;
        $totalRows = count($canonicalPreviewSet);
        $shownRows = count($previewRows);
        if ($shownRows > $totalRows) {
            $shownRows = $totalRows;
        }
        $overflowRows = max(0, $totalRows - $shownRows);

        return [
            'event_rows' => $previewRows,
            'event_counts' => $counts,
            'event_rows_total' => $totalRows,
            'event_rows_shown' => $shownRows,
            'event_rows_overflow' => $overflowRows,
            'event_details_available' => true,
        ];
    }

    /**
     * Normalize event rows from details.events into a stable UI shape.
     *
     * @param array<string,mixed> $details Decoded details payload.
     * @return array<int,array<string,mixed>> Normalized event rows.
     */
    private static function extractLogEventRows(array $details): array
    {
        $rawEvents = $details['events'] ?? null;
        if (!is_array($rawEvents)) {
            return [];
        }

        $rows = [];

        foreach ($rawEvents as $event) {
            if (!is_array($event)) {
                continue;
            }

            $url = isset($event['url']) ? esc_url_raw((string) $event['url']) : '';
            if ($url === '') {
                continue;
            }

            $result = self::normalizeLogEventResult($event);
            if (str_starts_with($result, 'queued')) {
                continue;
            }

            $statusCode = 0;
            if (isset($event['status_code']) && is_numeric($event['status_code'])) {
                $statusCode = max(0, (int) $event['status_code']);
            } elseif (isset($event['code']) && is_numeric($event['code'])) {
                $statusCode = max(0, (int) $event['code']);
            }

            $durationMs = 0;
            if (isset($event['duration_ms']) && is_numeric($event['duration_ms'])) {
                $durationMs = max(0, (int) $event['duration_ms']);
            } elseif (isset($event['ms']) && is_numeric($event['ms'])) {
                $durationMs = max(0, (int) $event['ms']);
            }

            $fallbackReason = isset($event['fallback_reason'])
                ? sanitize_text_field((string) $event['fallback_reason'])
                : '';
            $brotliError = isset($event['brotli_error'])
                ? sanitize_text_field((string) $event['brotli_error'])
                : '';
            $brotliCode = isset($event['brotli_code']) && is_numeric($event['brotli_code'])
                ? max(0, (int) $event['brotli_code'])
                : 0;
            $brotliSkipReason = isset($event['brotli_skip_reason'])
                ? sanitize_text_field((string) $event['brotli_skip_reason'])
                : '';

            $rows[] = [
                'ts' => isset($event['ts']) ? sanitize_text_field((string) $event['ts']) : '',
                'result' => $result,
                'url' => $url,
                'status_code' => $statusCode,
                'duration_ms' => $durationMs,
                'error' => isset($event['error']) ? sanitize_textarea_field((string) $event['error']) : '',
                'transport_used' => isset($event['transport_used']) ? sanitize_key((string) $event['transport_used']) : '',
                'fallback_used' => !empty($event['fallback_used']),
                'fallback_reason' => $fallbackReason,
                'brotli_attempted' => !empty($event['brotli_attempted']),
                'brotli_code' => $brotliCode,
                'brotli_error' => $brotliError,
                'brotli_skip_reason' => $brotliSkipReason,
            ];
        }

        return $rows;
    }

    /**
     * Return a snapshot of the currently active queue (if any).
     *
     * @return array<string,mixed> Snapshot keyed by run_id or empty array when no active run exists.
     */
    private static function getActiveQueueSnapshot(): array
    {
        $state = Queue::get();

        if (!Queue::hasActiveRun($state)) {
            return [];
        }

        $runId = isset($state['run_id']) ? trim((string) $state['run_id']) : '';
        if ($runId === '') {
            return [];
        }

        $status = (string) ($state['status'] ?? '');
        $urls = isset($state['urls']) && is_array($state['urls']) ? $state['urls'] : [];
        $normalizedUrls = array_values(array_filter(array_map(
            static fn($url): string => is_string($url) ? trim($url) : '',
            $urls
        ), static fn(string $url): bool => $url !== ''));
        $pendingPriorityUrls = Queue::getPreparingPendingUrls($state);
        $targetedBufferedUrls = Queue::getTargetedBufferedUrls($state);
        $targetedUpdateMeta = Queue::getTargetedUpdateMeta($state);

        $totalUrls = Queue::getTotalUrls($state);
        $cursor = isset($state['cursor']) ? (int) $state['cursor'] : 0;
        $cursor = max(0, min($cursor, count($normalizedUrls)));

        return [
            'run_id' => $runId,
            'status' => $status,
            'total_urls' => $totalUrls,
            'warmed_urls' => max(0, (int) ($state['warmed'] ?? 0)),
            'failed_urls' => max(0, (int) ($state['failed'] ?? 0)),
            'processed_urls' => array_slice($normalizedUrls, 0, $cursor),
            'pending_urls' => $status === 'preparing'
                ? $pendingPriorityUrls
                : array_values(array_merge(array_slice($normalizedUrls, $cursor), $targetedBufferedUrls)),
            'prepare_source' => sanitize_key((string) ($state['prepare_source'] ?? '')),
            'targeted_update_count' => $targetedUpdateMeta['update_count'],
            'correlation_history_saturated' => $targetedUpdateMeta['history_saturated'],
            'runner_locked' => Queue::isRunLocked(),
        ];
    }

    /**
     * Convert live pending queue URLs into UI-compatible event rows.
     *
     * @param array<string,mixed> $activeQueue Active queue snapshot from getActiveQueueSnapshot().
     * @return array<int,array<string,mixed>> Queue event rows.
     */
    private static function buildQueueEventRows(array $activeQueue): array
    {
        $status = sanitize_key((string) ($activeQueue['status'] ?? ''));
        $pendingUrls = isset($activeQueue['pending_urls']) && is_array($activeQueue['pending_urls'])
            ? $activeQueue['pending_urls']
            : [];

        if ($pendingUrls === []) {
            return [];
        }

        $rows = [];
        foreach ($pendingUrls as $url) {
            if (!is_string($url) || trim($url) === '') {
                continue;
            }

            $rows[] = [
                'ts' => '',
                'result' => $status === 'preparing' ? 'queued_buffered' : 'queued',
                'url' => esc_url_raw($url),
                'status_code' => 0,
                'duration_ms' => 0,
                'error' => '',
                'transport_used' => '',
                'fallback_used' => false,
                'fallback_reason' => '',
                'brotli_attempted' => false,
                'brotli_error' => '',
                'brotli_skip_reason' => '',
            ];
        }

        return $rows;
    }

    /**
     * Build synthetic rows for processed URLs not yet flushed into details.events.
     *
     * This bridges the temporary gap between per-URL queue counter updates and
     * per-batch detail event persistence.
     *
     * @param array<string,mixed>            $activeQueue Active queue snapshot for this run.
     * @param array<int,array<string,mixed>> $eventRows Persisted event rows.
     * @return array<int,array<string,mixed>> Synthetic progress rows.
     */
    private static function buildLiveProgressRows(array $activeQueue, array $eventRows): array
    {
        if ($activeQueue === []) {
            return [];
        }

        $processedUrls = isset($activeQueue['processed_urls']) && is_array($activeQueue['processed_urls'])
            ? $activeQueue['processed_urls']
            : [];

        if ($processedUrls === []) {
            return [];
        }

        $eventUrlMap = [];
        $eventWarmedCount = 0;
        $eventFailedCount = 0;

        foreach ($eventRows as $eventRow) {
            if (!is_array($eventRow)) {
                continue;
            }

            $eventUrl = isset($eventRow['url']) ? trim((string) $eventRow['url']) : '';
            if ($eventUrl !== '') {
                $eventUrlMap[$eventUrl] = true;
            }

            $bucket = self::classifyLogEventResult((string) ($eventRow['result'] ?? ''));
            if ($bucket === 'warmed') {
                $eventWarmedCount++;
            } elseif ($bucket === 'failed') {
                $eventFailedCount++;
            }
        }

        $missingProcessedUrls = [];
        foreach ($processedUrls as $processedUrl) {
            if (!is_string($processedUrl)) {
                continue;
            }

            $processedUrl = trim($processedUrl);
            if ($processedUrl === '') {
                continue;
            }

            if (!isset($eventUrlMap[$processedUrl])) {
                $missingProcessedUrls[] = $processedUrl;
            }
        }

        if ($missingProcessedUrls === []) {
            return [];
        }

        $queueWarmedCount = max(0, (int) ($activeQueue['warmed_urls'] ?? 0));
        $queueFailedCount = max(0, (int) ($activeQueue['failed_urls'] ?? 0));

        $deltaWarmed = max(0, $queueWarmedCount - $eventWarmedCount);
        $deltaFailed = max(0, $queueFailedCount - $eventFailedCount);
        $maxRows = min(count($missingProcessedUrls), $deltaWarmed + $deltaFailed);

        if ($maxRows <= 0) {
            return [];
        }

        $rows = [];
        for ($i = 0; $i < $maxRows; $i++) {
            $result = 'processed';
            if ($deltaFailed === 0 && $deltaWarmed > 0) {
                $result = 'warmed';
            } elseif ($deltaWarmed === 0 && $deltaFailed > 0) {
                $result = 'failed';
            }

            $rows[] = [
                'ts' => '',
                'result' => $result,
                'url' => esc_url_raw((string) $missingProcessedUrls[$i]),
                'status_code' => 0,
                'duration_ms' => 0,
                'error' => '',
                'transport_used' => '',
                'fallback_used' => false,
                'fallback_reason' => '',
                'brotli_attempted' => false,
                'brotli_error' => '',
                'brotli_skip_reason' => '',
            ];
        }

        return $rows;
    }

    /**
     * Normalize mixed event result fields into stable UI keys.
     *
     * @param array<string,mixed> $event Raw event row.
     * @return string Stable result key.
     */
    private static function normalizeLogEventResult(array $event): string
    {
        $result = isset($event['result']) ? sanitize_key((string) $event['result']) : '';
        if ($result !== '') {
            return $result;
        }

        if (array_key_exists('ok', $event)) {
            return !empty($event['ok']) ? 'warmed' : 'failed';
        }

        return 'unknown';
    }

    /**
     * Classify normalized result keys for summary counters and preview rules.
     *
     * @param string $result Normalized result key.
     * @return string One of 'warmed', 'failed', or 'other'.
     */
    private static function classifyLogEventResult(string $result): string
    {
        if ($result === 'warmed') {
            return 'warmed';
        }

        if ($result === 'failed') {
            return 'failed';
        }

        return 'other';
    }

    /**
     * Decode JSON details payload from a log row.
     *
     * @param mixed $rawDetails Raw database value for details.
     * @return array<string,mixed> Decoded details array or empty array on failure.
     */
    private static function decodeLogDetails($rawDetails): array
    {
        if (!is_string($rawDetails) || trim($rawDetails) === '') {
            return [];
        }

        $decoded = json_decode($rawDetails, true);
        if (!is_array($decoded)) {
            return [];
        }

        return $decoded;
    }

    /**
     * Apply no-cache headers to REST responses.
     *
     * @param \WP_REST_Response $response Response instance.
     * @return \WP_REST_Response Decorated response instance.
     */
    private static function applyRestNoCache(\WP_REST_Response $response): \WP_REST_Response
    {
        if (!defined('DONOTCACHEPAGE')) {
            // phpcs:ignore WordPress.NamingConventions.PrefixAllGlobals.NonPrefixedConstantFound -- WordPress/cache-plugin global constant used to prevent caching of authenticated REST responses.
            define('DONOTCACHEPAGE', true);
        }

        add_filter('rest_send_nocache_headers', '__return_true', PHP_INT_MAX);

        $response->header('Cache-Control', 'no-store, no-cache, must-revalidate, max-age=0, private');
        $response->header('Pragma', 'no-cache');
        $response->header('Expires', '0');

        $headers = $response->get_headers();
        $vary = isset($headers['Vary']) ? (string) $headers['Vary'] : '';
        if ($vary === '') {
            $response->header('Vary', 'Cookie');
        } elseif (stripos($vary, 'Cookie') === false) {
            $response->header('Vary', $vary . ', Cookie');
        }

        return $response;
    }
}
