<?php
declare(strict_types=1);

namespace Ekesto\CacheWarmup;

use Ekesto\CacheWarmup\Debug;
use Ekesto\CacheWarmup\Settings\Repository;

/**
 * Persistent warmup run logging.
 * Stores run summaries and details in a dedicated database table.
 */
final class Logger
{
    /**
     * Database table name (with prefix).
     *
     * @return string Validated database table name, or an empty string when invalid.
     */
    public static function getTableName(): string
    {
        global $wpdb;
        return self::validateTableName($wpdb->prefix . 'ekesto_cache_warmup_log');
    }

    /**
     * Validate a custom table identifier before it is used in SQL.
     *
     * @param string $tableName Table identifier.
     * @return string Validated identifier, or an empty string when invalid.
     */
    private static function validateTableName(string $tableName): string
    {
        return preg_match('/^[A-Za-z0-9_]+$/', $tableName) === 1 ? $tableName : '';
    }

    /**
     * Drop the dedicated warmup log table.
     *
     * @return void
     */
    public static function dropTable(): void
    {
        global $wpdb;

        $table = self::getTableName();
        if ($table === '') {
            return;
        }

        // phpcs:disable WordPress.DB.DirectDatabaseQuery.DirectQuery,WordPress.DB.DirectDatabaseQuery.NoCaching,WordPress.DB.DirectDatabaseQuery.SchemaChange -- Operational uninstall cleanup for the plugin-owned custom log table.
        $wpdb->query(
            $wpdb->prepare('DROP TABLE IF EXISTS %i', $table)
        );
        // phpcs:enable WordPress.DB.DirectDatabaseQuery.DirectQuery,WordPress.DB.DirectDatabaseQuery.NoCaching,WordPress.DB.DirectDatabaseQuery.SchemaChange
    }

    /**
     * Create a new run log entry.
     *
     * @param string $runId   UUID v4.
     * @param string $triggerKey Trigger identifier.
     * @param string $mode    'full' or 'single'.
     * @param int    $total   Total URLs planned.
     * @param array  $meta    Optional metadata for details.
     * @param string $status  Initial non-terminal status (`pending`, `running`, or `preparing`).
     */
    public static function startRun(
        string $runId,
        string $triggerKey,
        string $mode,
        int $total,
        array $meta = [],
        string $status = 'running'
    ): void
    {
        global $wpdb;

        $table = self::getTableName();
        if ($table === '') {
            return;
        }

        $siteId = (int) (function_exists('get_current_blog_id') ? get_current_blog_id() : 0);
        $status = sanitize_key($status);
        if (!in_array($status, ['pending', 'running', 'preparing'], true)) {
            $status = 'running';
        }

        // phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery,WordPress.DB.DirectDatabaseQuery.NoCaching -- Operational write to the plugin-owned warmup log table.
        $inserted = $wpdb->insert(
            $table,
            [
                'run_id' => $runId,
                'site_id' => $siteId,
                'trigger_key' => $triggerKey,
                'mode' => $mode,
                'status' => $status,
                'started_at' => gmdate('Y-m-d H:i:s'),
                'updated_at' => gmdate('Y-m-d H:i:s'),
                'total_urls' => max(0, $total),
                'warmed_urls' => 0,
                'failed_urls' => 0,
                'message' => null,
                'details' => !empty($meta) ? wp_json_encode($meta) : null,
            ],
            ['%s', '%d', '%s', '%s', '%s', '%s', '%s', '%d', '%d', '%d', '%s', '%s']
        );

        if ($inserted !== false) {
            Debug::info('Logger::startRun inserted', [
                'run_id' => $runId,
                'total' => $total,
            ]);

            if (!empty($meta['label_locked'])) {
                Debug::verbose('[ECW] primary identity locked', [
                    'run_id' => $runId,
                    'key' => $meta['primary_trigger_key'] ?? null,
                    'class' => $meta['primary_trigger_class'] ?? null,
                    'event' => $meta['primary_trigger_event'] ?? null,
                    'post_type' => $meta['primary_trigger_post_type'] ?? null,
                ]);
            }
        } else {
            Debug::error('Logger::startRun insert failed', [
                'table' => $table,
                'error' => (string) $wpdb->last_error,
            ]);
        }
    }

    /**
     * Transition a provisional preparing row to running without creating a new row.
     *
     * @param string $runId Run identifier.
     * @param int $totalUrls Materialized URL count.
     * @param array<string,mixed> $meta Additional preparation metadata to merge into details.
     * @return void
     */
    public static function transitionPreparingToRunning(string $runId, int $totalUrls, array $meta = []): void
    {
        $runId = trim($runId);
        if ($runId === '') {
            return;
        }

        global $wpdb;

        $table = self::getTableName();
        if ($table === '') {
            return;
        }

        // phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery,WordPress.DB.DirectDatabaseQuery.NoCaching -- Operational read from the plugin-owned warmup log table.
        $existingDetails = $wpdb->get_var(
            $wpdb->prepare('SELECT details FROM %i WHERE run_id = %s LIMIT 1', $table, $runId)
        );

        $details = [];
        if (is_string($existingDetails) && $existingDetails !== '') {
            $decoded = json_decode($existingDetails, true);
            if (is_array($decoded)) {
                $details = $decoded;
            }
        }

        foreach ($meta as $key => $value) {
            if (!is_string($key) || $key === '') {
                continue;
            }

            $details[$key] = $value;
        }

        // phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery,WordPress.DB.DirectDatabaseQuery.NoCaching -- Operational status write to the plugin-owned warmup log table.
        $updated = $wpdb->update(
            $table,
            [
                'status' => 'running',
                'updated_at' => gmdate('Y-m-d H:i:s'),
                'total_urls' => max(0, $totalUrls),
                'details' => wp_json_encode($details),
            ],
            [
                'run_id' => $runId,
                'status' => 'preparing',
            ],
            ['%s', '%s', '%d', '%s'],
            ['%s', '%s']
        );

        if ($updated === false) {
            Debug::error('Logger::transitionPreparingToRunning failed', [
                'run_id' => $runId,
                'error' => (string) $wpdb->last_error,
            ]);
            return;
        }

        if ($updated === 0) {
            // Best-effort fallback for repeated calls after a successful transition.
            self::updateTotalUrls($runId, $totalUrls);
        }
    }

    /**
     * Mark a queued run as actively executing after the runner lock is acquired.
     *
     * @param string $runId Run identifier.
     * @return void
     */
    public static function transitionPendingToRunning(string $runId): void
    {
        self::transitionNonTerminalStatus($runId, 'pending', 'running');
    }

    /**
     * Mark an incomplete run as queued after its runner lock is released.
     *
     * @param string $runId Run identifier.
     * @return void
     */
    public static function transitionRunningToPending(string $runId): void
    {
        self::transitionNonTerminalStatus($runId, 'running', 'pending');
    }

    /**
     * Synchronize queue-authoritative targeted update metadata into log details.
     *
     * @param string $runId Run identifier.
     * @param int $updateCount Count of later distinct content correlations.
     * @param bool $historySaturated Whether exact correlation tracking reached its safety limit.
     * @return void
     */
    public static function syncTargetedUpdateMeta(
        string $runId,
        int $updateCount,
        bool $historySaturated
    ): void {
        $runId = trim($runId);
        if ($runId === '') {
            return;
        }

        global $wpdb;

        $table = self::getTableName();
        if ($table === '') {
            return;
        }

        // phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery,WordPress.DB.DirectDatabaseQuery.NoCaching -- Runner-owned metadata synchronization for the plugin log table.
        $existingJson = $wpdb->get_var(
            $wpdb->prepare('SELECT details FROM %i WHERE run_id = %s LIMIT 1', $table, $runId)
        );
        $details = [];
        if (is_string($existingJson) && $existingJson !== '') {
            $decoded = json_decode($existingJson, true);
            if (is_array($decoded)) {
                $details = $decoded;
            }
        }

        $updateCount = max(0, $updateCount);
        $existingCount = max(0, (int) ($details['targeted_update_count'] ?? 0));
        $nextCount = max($existingCount, $updateCount);
        $nextSaturated = !empty($details['correlation_history_saturated']) || $historySaturated;

        if ($nextCount === $existingCount && $nextSaturated === !empty($details['correlation_history_saturated'])) {
            return;
        }

        $details['targeted_update_count'] = $nextCount;
        if ($nextSaturated) {
            $details['correlation_history_saturated'] = true;
        }

        // phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery,WordPress.DB.DirectDatabaseQuery.NoCaching -- Runner-owned metadata synchronization for the plugin log table.
        $wpdb->update(
            $table,
            [
                'details' => wp_json_encode($details),
                'updated_at' => gmdate('Y-m-d H:i:s'),
            ],
            ['run_id' => $runId],
            ['%s', '%s'],
            ['%s']
        );
    }

    /**
     * Transition one non-terminal stored status without touching run counters.
     *
     * @param string $runId Run identifier.
     * @param string $fromStatus Expected current status.
     * @param string $toStatus Replacement status.
     * @return void
     */
    private static function transitionNonTerminalStatus(
        string $runId,
        string $fromStatus,
        string $toStatus
    ): void {
        $runId = trim($runId);
        if ($runId === '') {
            return;
        }

        global $wpdb;

        $table = self::getTableName();
        if ($table === '') {
            return;
        }

        // phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery,WordPress.DB.DirectDatabaseQuery.NoCaching -- Operational status write to the plugin-owned warmup log table.
        $wpdb->update(
            $table,
            [
                'status' => sanitize_key($toStatus),
                'updated_at' => gmdate('Y-m-d H:i:s'),
            ],
            [
                'run_id' => $runId,
                'status' => sanitize_key($fromStatus),
            ],
            ['%s', '%s'],
            ['%s', '%s']
        );
    }

    /**
     * Update counters and append per-request details.
     *
     * @param string $runId   UUID v4.
     * @param int    $warmed  New warmed count.
     * @param int    $failed  New failed count.
     * @param array  $append  Appended detail payload.
     */
    public static function appendProgress(string $runId, int $warmed, int $failed, array $append): void
    {
        global $wpdb;

        $table = self::getTableName();
        if ($table === '') {
            return;
        }

        // phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery,WordPress.DB.DirectDatabaseQuery.NoCaching -- Operational read from the plugin-owned warmup log table.
        $row = $wpdb->get_row(
            $wpdb->prepare('SELECT id, details FROM %i WHERE run_id = %s ORDER BY id DESC LIMIT 1', $table, $runId),
            ARRAY_A
        );

        if (!$row || empty($row['id'])) {
            return;
        }

        $id = (int) $row['id'];
        $details = [];

        if (!empty($row['details'])) {
            $decoded = json_decode((string) $row['details'], true);
            if (is_array($decoded)) {
                $details = $decoded;
            }
        }

        // Keep details bounded.
        $details['requests'] = isset($details['requests']) && is_array($details['requests']) ? $details['requests'] : [];
        $details['requests'][] = $append;
        $max = (int) Repository::getInt('logging.max_request_log_entries');
        if ($max > 0 && count($details['requests']) > $max) {
            $details['requests'] = array_slice($details['requests'], -$max);
        }

        // phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery,WordPress.DB.DirectDatabaseQuery.NoCaching -- Operational progress write to the plugin-owned warmup log table.
        $wpdb->update(
            $table,
            [
                'updated_at' => gmdate('Y-m-d H:i:s'),
                'warmed_urls' => max(0, $warmed),
                'failed_urls' => max(0, $failed),
                'details' => wp_json_encode($details),
            ],
            ['id' => $id],
            ['%s', '%d', '%d', '%s'],
            ['%d']
        );
    }

    /**
     * Finalize a run and persist its terminal state.
     *
     * Purpose:
     * - Marks a run as terminal (finished / failed / restarted / stopped).
     * - Updates timestamps and, when provided, final counters.
     * - Safe to call from recovery paths (restart, stale cleanup).
     *
     * Counters:
     * - When called from Runner, counters must be accurate.
     * - When called from recovery paths, counters may be omitted.
     *
     * @param string      $runId       Run identifier.
     * @param string      $status      Final status: 'finished'|'failed'|'restarted'|'stopped'|'stale'.
     * @param int|null    $totalUrls   Total URLs considered for this run (optional).
     * @param int|null    $warmedUrls  Successfully warmed URLs count (optional).
     * @param int|null    $failedUrls  Failed URLs count (optional).
     * @param string|null $message     Optional status message for the UI.
     * @return void
     */
    public static function finishRun(
        string $runId,
        string $status,
        ?int $totalUrls = null,
        ?int $warmedUrls = null,
        ?int $failedUrls = null,
        ?string $message = null
    ): void {
        $runId = trim($runId);
        if ($runId === '') {
            return;
        }

        global $wpdb;

        $table = self::getTableName();
        if ($table === '') {
            return;
        }

        $data = [
            'status' => sanitize_key($status),
            'updated_at' => gmdate('Y-m-d H:i:s'),
        ];

        $formats = ['%s', '%s'];

        // Only persist counters if explicitly provided
        if ($totalUrls !== null) {
            $data['total_urls'] = max(0, $totalUrls);
            $formats[] = '%d';
        }

        if ($warmedUrls !== null) {
            $data['warmed_urls'] = max(0, $warmedUrls);
            $formats[] = '%d';
        }

        if ($failedUrls !== null) {
            $data['failed_urls'] = max(0, $failedUrls);
            $formats[] = '%d';
        }

        if ($message !== null) {
            $data['message'] = sanitize_textarea_field($message);
            $formats[] = '%s';
        }

        // Terminal statuses get finished_at
        if (in_array($status, ['finished', 'failed', 'restarted', 'stopped', 'stale'], true)) {
            $data['finished_at'] = gmdate('Y-m-d H:i:s');
            $formats[] = '%s';
        }

        // phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery,WordPress.DB.DirectDatabaseQuery.NoCaching -- Operational terminal-state write to the plugin-owned warmup log table.
        $updated = $wpdb->update(
            $table,
            $data,
            ['run_id' => $runId],
            $formats,
            ['%s']
        );

        if ($updated === false) {
            Debug::error('Logger::finishRun failed', [
                'run_id' => $runId,
                'error' => (string) $wpdb->last_error,
            ]);
        }
    }

    /**
     * Delete log rows older than the configured retention window.
     */
    public static function purgeOldLogs(): void
    {
        global $wpdb;

        $days = (int) Repository::getInt('logging.retention_days');
        if ($days <= 0) {
            return;
        }

        $cutoff = gmdate('Y-m-d H:i:s', time() - ($days * DAY_IN_SECONDS));
        $table = self::getTableName();
        if ($table === '') {
            return;
        }

        // phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery,WordPress.DB.DirectDatabaseQuery.NoCaching -- Retention cleanup for the plugin-owned warmup log table.
        $wpdb->query(
            $wpdb->prepare('DELETE FROM %i WHERE started_at < %s', $table, $cutoff)
        );
    }

    /**
     * Update the total URLs count for a run.
     *
     * Purpose:
     * - When URLs are merged into an active run after it starts,
     *   we need to update the log's total_urls count.
     * - Safe to call multiple times; doesn't overwrite warmed/failed counts.
     *
     * @param string $runId  Run identifier.
     * @param int    $total  Updated total URLs count.
     * @return void
     */
    public static function updateTotalUrls(string $runId, int $total): void
    {
        $runId = trim($runId);
        if ($runId === '') {
            return;
        }

        global $wpdb;

        $table = self::getTableName();
        if ($table === '') {
            return;
        }

        // phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery,WordPress.DB.DirectDatabaseQuery.NoCaching -- Operational counter write to the plugin-owned warmup log table.
        $updated = $wpdb->update(
            $table,
            [
                'total_urls' => max(0, $total),
                'updated_at' => gmdate('Y-m-d H:i:s'),
            ],
            ['run_id' => $runId],
            ['%d', '%s'],
            ['%s']
        );

        if ($updated !== false) {
            Debug::verbose('run_total_updated', [
                'run_id' => $runId,
                'total' => $total,
            ]);
        }
    }

    /**
     * Promote the trigger key and merge metadata for an active run.
     *
     * Used when a higher-priority trigger source (e.g. ECI cache_invalidation)
     * merges into an active run that was started by a lower-priority source
     * (e.g. adapter purge_single_url). Preserves existing top-level details
     * and events while merging in the new metadata.
     *
     * @param string              $runId      Run identifier.
     * @param string              $triggerKey New trigger key.
     * @param array<string,mixed> $meta       Metadata to merge into details.
     * @return void
     */
    public static function promoteRunTrigger(string $runId, string $triggerKey, array $meta): void
    {
        $runId = trim($runId);
        if ($runId === '' || $triggerKey === '') {
            return;
        }

        global $wpdb;

        $table = self::getTableName();
        if ($table === '') {
            return;
        }

        // phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery,WordPress.DB.DirectDatabaseQuery.NoCaching -- Operational metadata read from the plugin-owned warmup log table.
        $existingJson = $wpdb->get_var(
            $wpdb->prepare(
                'SELECT details FROM %i WHERE run_id = %s LIMIT 1',
                $table,
                $runId
            )
        );

        $details = [];
        if (is_string($existingJson) && $existingJson !== '') {
            $decoded = json_decode($existingJson, true);
            if (is_array($decoded)) {
                $details = $decoded;
            }
        }

        if (sanitize_key((string) ($details['primary_trigger_class'] ?? '')) === 'system') {
            Debug::verbose('[ECW] promoteRunTrigger skipped: primary system trigger preserved', [
                'run_id' => $runId,
                'incoming_trigger_key' => $triggerKey,
            ]);
            return;
        }

        $protectedKeys = [
            'label_locked',
            'primary_trigger_key',
            'primary_trigger_class',
            'primary_trigger_event',
            'primary_trigger_post_type',
            'primary_resolution',
            'primary_reason',
        ];
        foreach ($protectedKeys as $key) {
            unset($meta[$key]);
        }

        $details = array_merge($details, $meta);

        // phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery,WordPress.DB.DirectDatabaseQuery.NoCaching -- Operational metadata write to the plugin-owned warmup log table.
        $wpdb->update(
            $table,
            [
                'trigger_key' => $triggerKey,
                'details' => wp_json_encode($details),
                'updated_at' => gmdate('Y-m-d H:i:s'),
            ],
            ['run_id' => $runId],
            ['%s', '%s', '%s'],
            ['%s']
        );
    }

    /**
     * Check whether a run is locked to a system-class primary trigger.
     *
     * @param string $runId Run identifier.
     * @return bool
     */
    public static function hasSystemPrimaryTriggerClass(string $runId): bool
    {
        $runId = trim($runId);
        if ($runId === '') {
            return false;
        }

        global $wpdb;

        $table = self::getTableName();
        if ($table === '') {
            return false;
        }
        // phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery,WordPress.DB.DirectDatabaseQuery.NoCaching -- Operational metadata read from the plugin-owned warmup log table.
        $existingJson = $wpdb->get_var(
            $wpdb->prepare(
                'SELECT details FROM %i WHERE run_id = %s LIMIT 1',
                $table,
                $runId
            )
        );

        if (!is_string($existingJson) || $existingJson === '') {
            return false;
        }

        $decoded = json_decode($existingJson, true);
        if (!is_array($decoded)) {
            return false;
        }

        return sanitize_key((string) ($decoded['primary_trigger_class'] ?? '')) === 'system';
    }

    /**
     * Lock primary identity on a run that was born without one.
     *
     * Called when ECI takes over an adapter-initiated run whose primary identity
     * was intentionally left unlocked at birth.
     *
     * @param string              $runId      Run identifier.
     * @param string              $triggerKey New trigger key (e.g. 'cache_invalidation').
     * @param array<string,mixed> $meta       Trigger metadata from ECI.
     * @return void
     */
    public static function lockPrimaryIdentityIfUnlocked(string $runId, string $triggerKey, array $meta): void
    {
        Debug::log('[ECW] lockPrimaryIdentityIfUnlocked enter', [
            'run_id' => $runId,
            'trigger_key' => $triggerKey,
            'meta' => $meta,
        ]);

        $runId = trim($runId);
        if ($runId === '' || $triggerKey === '') {
            return;
        }

        global $wpdb;

        $table = self::getTableName();
        if ($table === '') {
            return;
        }

        // phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery,WordPress.DB.DirectDatabaseQuery.NoCaching -- Operational metadata read from the plugin-owned warmup log table.
        $existingJson = $wpdb->get_var(
            $wpdb->prepare(
                'SELECT details FROM %i WHERE run_id = %s LIMIT 1',
                $table,
                $runId
            )
        );

        $details = [];
        if (is_string($existingJson) && $existingJson !== '') {
            $decoded = json_decode($existingJson, true);
            if (is_array($decoded)) {
                $details = $decoded;
            }
        }

        Debug::log('[ECW] lockPrimaryIdentityIfUnlocked existing', [
            'label_locked' => $details['label_locked'] ?? null,
            'primary_trigger_key' => $details['primary_trigger_key'] ?? null,
            'primary_trigger_class' => $details['primary_trigger_class'] ?? null,
            'primary_trigger_event' => $details['primary_trigger_event'] ?? null,
            'primary_trigger_post_type' => $details['primary_trigger_post_type'] ?? null,
        ]);

        // Already locked — nothing to do.
        if (!empty($details['label_locked'])) {
            return;
        }

        $identity = Queue::buildPrimaryIdentityPublic($meta, $triggerKey);
        $details = array_merge($details, $identity);

        Debug::log('[ECW] lockPrimaryIdentityIfUnlocked after buildPrimaryIdentityPublic call: ', [
            'label_locked' => $details['label_locked'] ?? null,
            'primary_trigger_key' => $details['primary_trigger_key'] ?? null,
            'primary_trigger_class' => $details['primary_trigger_class'] ?? null,
            'primary_trigger_event' => $details['primary_trigger_event'] ?? null,
            'primary_trigger_post_type' => $details['primary_trigger_post_type'] ?? null,
        ]);

        // phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery,WordPress.DB.DirectDatabaseQuery.NoCaching -- Operational metadata write to the plugin-owned warmup log table.
        $wpdb->update(
            $table,
            [
                'details' => wp_json_encode($details),
                'updated_at' => gmdate('Y-m-d H:i:s'),
            ],
            ['run_id' => $runId],
            ['%s', '%s'],
            ['%s']
        );
    }

    /**
     * Persist non-terminal progress counters for an active run.
     *
     * Purpose:
     * - Keeps `/logs` run-level counters current while status is `running`.
     * - Enables manual reload visibility in the Recent Logs table.
     *
     * @param string $runId      Run identifier.
     * @param int    $warmedUrls Current warmed URLs count.
     * @param int    $failedUrls Current failed URLs count.
     * @return void
     */
    public static function updateRunProgress(string $runId, int $warmedUrls, int $failedUrls): void
    {
        $runId = trim($runId);
        if ($runId === '') {
            return;
        }

        global $wpdb;

        $table = self::getTableName();
        if ($table === '') {
            return;
        }

        // phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery,WordPress.DB.DirectDatabaseQuery.NoCaching -- Operational progress write to the plugin-owned warmup log table.
        $updated = $wpdb->update(
            $table,
            [
                'updated_at' => gmdate('Y-m-d H:i:s'),
                'warmed_urls' => max(0, $warmedUrls),
                'failed_urls' => max(0, $failedUrls),
            ],
            [
                'run_id' => $runId,
                'status' => 'running',
            ],
            ['%s', '%d', '%d'],
            ['%s', '%s']
        );

        if ($updated === false) {
            Debug::error('Logger::updateRunProgress failed', [
                'run_id' => $runId,
                'error' => (string) $wpdb->last_error,
            ]);
        }
    }

    /**
     * Append per-URL result entry payload(s) into the JSON "details" field for a run.
     *
     * @param string $runId Run identifier.
     * @param array<string,mixed>|array<int,array<string,mixed>> $detailOrDetails
     * Single row or list of rows.
     * @return void
     */
    public static function appendDetails(string $runId, array $detailOrDetails): void
    {
        $isList = $detailOrDetails === [] || array_keys($detailOrDetails) === range(0, count($detailOrDetails) - 1);
        $rows = $isList ? $detailOrDetails : [$detailOrDetails];
        self::appendDetailsBatch($runId, $rows);
    }

    /**
     * Append multiple per-URL result entries into the JSON "details" field for a run.
     *
     * Performs one read and one write for the whole batch.
     *
     * @param string $runId Run identifier.
     * @param array<int,array<string,mixed>> $rows Per-URL result rows.
     * @return void
     */
    public static function appendDetailsBatch(string $runId, array $rows): void
    {
        $runId = trim($runId);
        if ($runId === '' || $rows === []) {
            return;
        }

        global $wpdb;

        $table = self::getTableName();
        if ($table === '') {
            return;
        }

        // phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery,WordPress.DB.DirectDatabaseQuery.NoCaching -- Operational event-log read from the plugin-owned warmup log table.
        $existingJson = $wpdb->get_var(
            $wpdb->prepare(
                'SELECT details FROM %i WHERE run_id = %s LIMIT 1',
                $table,
                $runId
            )
        );

        $details = [];
        if (is_string($existingJson) && $existingJson !== '') {
            $decoded = json_decode($existingJson, true);
            if (is_array($decoded)) {
                $details = $decoded;
            }
        }

        if (!isset($details['events']) || !is_array($details['events'])) {
            $details['events'] = [];
        }

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

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

            $result = self::normalizeEventResult($detail);
            $statusCode = self::normalizeEventStatusCode($detail);
            $durationMs = self::normalizeEventDurationMs($detail);
            $timestamp = self::normalizeEventTimestamp($detail);
            $transportUsed = isset($detail['transport_used']) ? sanitize_key((string) $detail['transport_used']) : '';
            $fallbackUsed = !empty($detail['fallback_used']);
            $fallbackReason = isset($detail['fallback_reason']) ? sanitize_text_field((string) $detail['fallback_reason']) : '';
            $brotliAttempted = !empty($detail['brotli_attempted']);
            $brotliCode = isset($detail['brotli_code']) && is_numeric($detail['brotli_code'])
                ? max(0, (int) $detail['brotli_code'])
                : 0;
            $brotliError = isset($detail['brotli_error']) ? sanitize_text_field((string) $detail['brotli_error']) : '';
            $brotliSkipReason = isset($detail['brotli_skip_reason']) ? sanitize_text_field((string) $detail['brotli_skip_reason']) : '';

            $eventRow = [
                'ts' => $timestamp,
                'url' => $url,
                'result' => $result,
                'status_code' => $statusCode,
                'duration_ms' => $durationMs,
                'error' => isset($detail['error']) ? sanitize_textarea_field((string) $detail['error']) : '',
                'transport_used' => $transportUsed,
            ];

            if ($fallbackUsed) {
                $eventRow['fallback_used'] = true;
            }
            if ($fallbackReason !== '') {
                $eventRow['fallback_reason'] = $fallbackReason;
            }
            if ($brotliAttempted) {
                $eventRow['brotli_attempted'] = true;
            }
            if ($brotliCode > 0) {
                $eventRow['brotli_code'] = $brotliCode;
            }
            if ($brotliError !== '') {
                $eventRow['brotli_error'] = $brotliError;
            }
            if ($brotliSkipReason !== '') {
                $eventRow['brotli_skip_reason'] = $brotliSkipReason;
            }

            $details['events'][] = $eventRow;
        }

        // phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery,WordPress.DB.DirectDatabaseQuery.NoCaching -- Operational event-log write to the plugin-owned warmup log table.
        $updated = $wpdb->update(
            $table,
            [
                'updated_at' => gmdate('Y-m-d H:i:s'),
                'details' => wp_json_encode($details),
            ],
            ['run_id' => $runId],
            ['%s', '%s'],
            ['%s']
        );

        if ($updated === false) {
            Debug::error('Logger::appendDetailsBatch failed', [
                'run_id' => $runId,
                'error' => (string) $wpdb->last_error,
            ]);
        }
    }

    /**
     * Normalize event result values into stable UI-friendly keys.
     *
     * @param array<string,mixed> $detail Raw event detail payload.
     * @return string Normalized result key.
     */
    private static function normalizeEventResult(array $detail): string
    {
        $rawResult = isset($detail['result']) ? sanitize_key((string) $detail['result']) : '';
        if ($rawResult !== '') {
            return $rawResult;
        }

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

        return 'unknown';
    }

    /**
     * Normalize event status code from supported source fields.
     *
     * @param array<string,mixed> $detail Raw event detail payload.
     * @return int Normalized status code (0 when unavailable).
     */
    private static function normalizeEventStatusCode(array $detail): int
    {
        if (isset($detail['status_code']) && is_numeric($detail['status_code'])) {
            return max(0, (int) $detail['status_code']);
        }

        if (isset($detail['code']) && is_numeric($detail['code'])) {
            return max(0, (int) $detail['code']);
        }

        return 0;
    }

    /**
     * Normalize event duration from supported source fields.
     *
     * @param array<string,mixed> $detail Raw event detail payload.
     * @return int Duration in milliseconds (0 when unavailable).
     */
    private static function normalizeEventDurationMs(array $detail): int
    {
        if (isset($detail['duration_ms']) && is_numeric($detail['duration_ms'])) {
            return max(0, (int) $detail['duration_ms']);
        }

        if (isset($detail['ms']) && is_numeric($detail['ms'])) {
            return max(0, (int) $detail['ms']);
        }

        return 0;
    }

    /**
     * Resolve a stable timestamp for event rows.
     *
     * @param array<string,mixed> $detail Raw event detail payload.
     * @return string UTC timestamp in "Y-m-d H:i:s" format.
     */
    private static function normalizeEventTimestamp(array $detail): string
    {
        $candidate = isset($detail['ts']) ? trim((string) $detail['ts']) : '';
        if ($candidate === '') {
            $candidate = isset($detail['warmed_at']) ? trim((string) $detail['warmed_at']) : '';
        }

        if ($candidate !== '') {
            return sanitize_text_field($candidate);
        }

        return gmdate('Y-m-d H:i:s');
    }

    /**
     * Delete log rows older than a given number of days.
     *
     * Purpose:
     * - Keeps log table small and consistent with retention settings.
     *
     * @param int $days Number of days to keep. Must be >= 1.
     * @return int Number of deleted rows (0+).
     */
    public static function deleteOlderThanDays(int $days): int
    {
        $days = max(1, $days);

        global $wpdb;

        $table = self::getTableName();
        if ($table === '') {
            return 0;
        }

        // Use started_at as the retention anchor (UTC timestamps in the schema).
        $cutoff = gmdate('Y-m-d H:i:s', time() - ($days * DAY_IN_SECONDS));

        // phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery,WordPress.DB.DirectDatabaseQuery.NoCaching -- Retention cleanup for the plugin-owned warmup log table.
        $deleted = $wpdb->query(
            $wpdb->prepare(
                'DELETE FROM %i WHERE started_at < %s',
                $table,
                $cutoff
            )
        );

        if ($deleted === false) {
            Debug::error('Logger::deleteOlderThanDays failed', [
                'error' => (string) $wpdb->last_error,
            ]);
            return 0;
        }

        return (int) $deleted;
    }

    /**
     * Mark active runs stale after a period without activity.
     *
     * @param int $staleAfterSeconds Seconds before a running/preparing row is stale.
     * @return int Number of affected rows.
     */
    public static function markStaleRuns(int $staleAfterSeconds = 600): int
    {
        global $wpdb;

        $table = self::getTableName();
        if ($table === '') {
            return 0;
        }

        $cutoff = gmdate('Y-m-d H:i:s', time() - $staleAfterSeconds);

        // Only touch runs that are actively preparing or running and haven't been updated recently.
        // phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery,WordPress.DB.DirectDatabaseQuery.NoCaching -- Stale-run recovery write to the plugin-owned warmup log table.
        $wpdb->query(
            $wpdb->prepare(
                'UPDATE %i
                 SET status = %s, finished_at = %s, updated_at = %s
                 WHERE status IN (%s, %s)
                   AND (updated_at IS NULL OR updated_at < %s)',
                $table,
                'stale',
                gmdate('Y-m-d H:i:s'),
                gmdate('Y-m-d H:i:s'),
                'running',
                'preparing',
                $cutoff
            )
        );

        return (int) $wpdb->rows_affected;
    }


}
