<?php
declare(strict_types=1);

namespace Ekesto\CacheWarmup;

use Ekesto\CacheWarmup\Debug;
use Ekesto\CacheWarmup\Queue;
use Ekesto\CacheWarmup\Runner;
use Ekesto\CacheWarmup\Settings;

/**
 * Dispatches one authenticated, plugin-specific warmup batch loopback.
 */
final class ImmediateKick
{
    /** Internal REST route receiving immediate warmup requests. */
    private const REST_ROUTE = '/internal/immediate-kick';

    /** Option storing the active run's current immediate-kick claim. */
    private const OPTION_CLAIM = 'ekesto_cache_warmup_immediate_kick_claim';

    /** Maximum age of one immediate-kick claim in seconds. */
    private const CLAIM_TTL_SECONDS = 120;

    /** Maximum time allowed to initiate the non-blocking loopback request. */
    private const DISPATCH_TIMEOUT_SECONDS = 0.75;

    /** Maximum worker time spent waiting for the originating request to finish. */
    private const ORIGIN_WAIT_BUDGET_MS = 5000;

    /** Poll cadence while waiting for the originating request gate. */
    private const ORIGIN_POLL_INTERVAL_US = 20000;

    /** @var array<int,array{run_id:string,requested_at:float}> */
    private static array $authorizedRequests = [];

    /** @var array<string,string> Run-owned queue lease tokens released at shutdown. */
    private static array $originRunLocks = [];

    /** Whether this request registered the origin-gate shutdown callback. */
    private static bool $originShutdownRegistered = false;

    /**
     * Register the internal immediate-kick REST route.
     *
     * @return void
     */
    public static function registerRestEndpoint(): void
    {
        register_rest_route(Settings::REST_NAMESPACE, self::REST_ROUTE, [
            'methods' => 'POST',
            'callback' => [self::class, 'handleRequest'],
            'permission_callback' => [self::class, 'authorizeRequest'],
            'args' => [
                'run_id' => [
                    'type' => 'string',
                    'required' => true,
                ],
                'token' => [
                    'type' => 'string',
                    'required' => true,
                ],
            ],
        ]);
    }

    /**
     * Dispatch one non-blocking request for the currently active warmup run.
     *
     * @return bool True when the loopback request was initiated without an HTTP API error.
     */
    public static function dispatch(): bool
    {
        $state = Queue::get();
        if (!Queue::hasActiveRun($state)) {
            return false;
        }

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

        // Keep only one outstanding request. A consumed claim may be replaced
        // when a later full intent needs to wake the same dormant run again.
        if (self::hasPendingClaimForRun($runId)) {
            return false;
        }

        try {
            $token = bin2hex(random_bytes(32));
        } catch (\Throwable $error) {
            Debug::error('Immediate kick token generation failed', [
                'run_id' => $runId,
                'error' => $error->getMessage(),
            ]);
            return false;
        }

        $originLock = Queue::acquireRunLock(self::CLAIM_TTL_SECONDS);
        $originLockToken = (string) ($originLock['token'] ?? '');
        if (empty($originLock['acquired']) || $originLockToken === '') {
            Debug::log('Immediate kick deferred: origin request gate unavailable', ['run_id' => $runId]);
            return false;
        }

        $requestedAt = microtime(true);
        $claim = [
            'run_id' => $runId,
            'status' => 'pending',
            'token_hash' => self::hashToken($token),
            'requested_at' => $requestedAt,
            'expires_at' => time() + self::CLAIM_TTL_SECONDS,
            'origin_active' => true,
            'origin_lock_token' => $originLockToken,
        ];

        if (!update_option(self::OPTION_CLAIM, $claim, false)) {
            Queue::releaseRunLock($originLockToken);
            Debug::error('Immediate kick claim persistence failed', ['run_id' => $runId]);
            return false;
        }

        self::$originRunLocks[$runId] = $originLockToken;
        if (!self::$originShutdownRegistered) {
            add_action('shutdown', [self::class, 'releaseOriginRequestGates'], PHP_INT_MAX);
            self::$originShutdownRegistered = true;
        }

        $response = wp_remote_post(
            rest_url(Settings::REST_NAMESPACE . self::REST_ROUTE),
            [
                'timeout' => self::DISPATCH_TIMEOUT_SECONDS,
                'blocking' => false,
                'redirection' => 0,
                'sslverify' => true,
                'headers' => [
                    'Cache-Control' => 'no-cache',
                ],
                'body' => [
                    'run_id' => $runId,
                    'token' => $token,
                ],
                'user-agent' => 'Ekesto-Cache-Autopilot/immediate-kick',
            ]
        );

        $result = is_wp_error($response) ? $response->get_error_message() : 'initiated';
        Debug::log('Immediate kick dispatched', [
            'run_id' => $runId,
            'result' => $result,
        ]);

        return !is_wp_error($response);
    }

    /**
     * Authenticate and atomically consume one internal immediate-kick request.
     *
     * @param \WP_REST_Request $request Incoming REST request.
     * @return true|\WP_Error True when authorized, otherwise a generic denial.
     */
    public static function authorizeRequest(\WP_REST_Request $request)
    {
        $runId = $request->get_param('run_id');
        $token = $request->get_param('token');

        if (!is_string($runId) || !is_string($token)) {
            return self::deniedResponse();
        }

        $runId = trim($runId);
        $token = trim($token);
        if ($runId === '' || strlen($runId) > 64 || !preg_match('/^[a-f0-9]{64}$/', $token)) {
            return self::deniedResponse();
        }

        $claim = self::consumeClaim($runId, $token);
        if ($claim === null) {
            return self::deniedResponse();
        }

        $state = Queue::get();
        if (!Queue::hasActiveRun($state) || (string) ($state['run_id'] ?? '') !== $runId) {
            return self::deniedResponse();
        }

        self::$authorizedRequests[spl_object_id($request)] = [
            'run_id' => $runId,
            'requested_at' => $claim['requested_at'],
        ];

        return true;
    }

    /**
     * Execute one run-owned warmup batch for an authorized loopback request.
     *
     * @param \WP_REST_Request $request Authorized REST request.
     * @return \WP_REST_Response|\WP_Error REST response.
     */
    public static function handleRequest(\WP_REST_Request $request)
    {
        $requestId = spl_object_id($request);
        $authorization = self::$authorizedRequests[$requestId] ?? null;
        unset(self::$authorizedRequests[$requestId]);

        if (!is_array($authorization)) {
            return self::deniedResponse();
        }

        $runId = (string) ($authorization['run_id'] ?? '');
        $state = Queue::get();
        if (!Queue::hasActiveRun($state) || (string) ($state['run_id'] ?? '') !== $runId) {
            return new \WP_Error(
                'ecw_immediate_kick_stale_run',
                __('Immediate warmup request is no longer current.', 'cache-autopilot'),
                ['status' => 409]
            );
        }

        self::markResponseNoCache();

        $requestedAt = (float) ($authorization['requested_at'] ?? microtime(true));
        Debug::log('Immediate kick received', [
            'run_id' => $runId,
            'dispatch_delay_ms' => max(0, (int) round((microtime(true) - $requestedAt) * 1000)),
        ]);

        self::waitForOriginRequest($runId);

        Runner::run($runId);

        $response = new \WP_REST_Response(['accepted' => true], 200);
        $response->header('Cache-Control', 'no-store, no-cache, must-revalidate, max-age=0');
        return $response;
    }

    /**
     * Delete any stored immediate-kick claim.
     *
     * @return void
     */
    public static function clearPendingClaim(): void
    {
        delete_option(self::OPTION_CLAIM);
    }

    /**
     * Release request-owned runner gates after the invalidation response is complete.
     *
     * @return void
     */
    public static function releaseOriginRequestGates(): void
    {
        foreach (self::$originRunLocks as $runId => $lockToken) {
            Queue::releaseRunLock($lockToken);
            self::markOriginRequestReleased($runId, $lockToken);
        }

        self::$originRunLocks = [];
    }

    /**
     * Atomically validate and consume the stored one-time claim.
     *
     * @param string $runId Expected active run ID.
     * @param string $token Raw bearer token supplied in the request body.
     * @return array{run_id:string,status:string,token_hash:string,requested_at:float,expires_at:int,origin_active:bool,origin_lock_token:string}|null Consumed claim or null.
     */
    private static function consumeClaim(string $runId, string $token): ?array
    {
        global $wpdb;

        // Read the authoritative row so validation and deletion can be coupled atomically.
        // phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery,WordPress.DB.DirectDatabaseQuery.NoCaching -- Single-use internal request claims require an authoritative compare-and-delete.
        $storedValue = $wpdb->get_var($wpdb->prepare(
            "SELECT option_value FROM `{$wpdb->options}` WHERE option_name = %s LIMIT 1",
            self::OPTION_CLAIM
        ));
        if (!is_string($storedValue)) {
            return null;
        }

        $rawClaim = maybe_unserialize($storedValue);
        if (!is_array($rawClaim)) {
            return null;
        }

        $claim = [
            'run_id' => is_string($rawClaim['run_id'] ?? null) ? $rawClaim['run_id'] : '',
            'status' => is_string($rawClaim['status'] ?? null) ? $rawClaim['status'] : '',
            'token_hash' => is_string($rawClaim['token_hash'] ?? null) ? $rawClaim['token_hash'] : '',
            'requested_at' => isset($rawClaim['requested_at']) ? (float) $rawClaim['requested_at'] : 0.0,
            'expires_at' => isset($rawClaim['expires_at']) ? (int) $rawClaim['expires_at'] : 0,
            'origin_active' => !empty($rawClaim['origin_active']),
            'origin_lock_token' => is_string($rawClaim['origin_lock_token'] ?? null)
                ? $rawClaim['origin_lock_token']
                : '',
        ];

        if ($claim['expires_at'] <= time()) {
            return null;
        }

        if (
            $claim['run_id'] !== $runId
            || $claim['status'] !== 'pending'
            || $claim['token_hash'] === ''
            || !hash_equals($claim['token_hash'], self::hashToken($token))
        ) {
            return null;
        }

        $consumedClaim = $claim;
        $consumedClaim['status'] = 'consumed';
        $consumedClaim['token_hash'] = '';

        if (!self::replaceStoredClaim($storedValue, $consumedClaim)) {
            return null;
        }

        return $claim;
    }

    /**
     * Wait until the request that dispatched this kick releases its runner gate.
     *
     * The bounded wait is a worker-safety budget. Correctness comes from the
     * persisted origin_active signal, not from an assumed cache purge delay.
     *
     * @param string $runId Expected active warmup run ID.
     * @return void
     */
    private static function waitForOriginRequest(string $runId): void
    {
        $startedAt = microtime(true);
        if (!self::isOriginRequestActive($runId)) {
            return;
        }

        $deadline = $startedAt + (self::ORIGIN_WAIT_BUDGET_MS / 1000);
        do {
            usleep(self::ORIGIN_POLL_INTERVAL_US);
        } while (self::isOriginRequestActive($runId) && microtime(true) < $deadline);

        Debug::log('Immediate kick origin gate wait completed', [
            'run_id' => $runId,
            'released' => !self::isOriginRequestActive($runId),
            'waited_ms' => max(0, (int) round((microtime(true) - $startedAt) * 1000)),
        ]);
    }

    /**
     * Whether the current run is still owned by its originating request.
     *
     * @param string $runId Expected active warmup run ID.
     * @return bool True while the originating request still owns the gate.
     */
    private static function isOriginRequestActive(string $runId): bool
    {
        wp_cache_delete(self::OPTION_CLAIM, 'options');
        wp_cache_delete('notoptions', 'options');

        $claim = get_option(self::OPTION_CLAIM, []);
        return is_array($claim)
            && (string) ($claim['run_id'] ?? '') === $runId
            && !empty($claim['origin_active']);
    }

    /**
     * Mark one claim runnable after releasing its request-owned queue lease.
     *
     * @param string $runId Expected active warmup run ID.
     * @param string $lockToken Request-owned queue lease token.
     * @return void
     */
    private static function markOriginRequestReleased(string $runId, string $lockToken): void
    {
        global $wpdb;

        for ($attempt = 0; $attempt < 5; $attempt++) {
            // phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery,WordPress.DB.DirectDatabaseQuery.NoCaching -- Cross-request gate release requires an authoritative compare-and-swap.
            $storedValue = $wpdb->get_var($wpdb->prepare(
                "SELECT option_value FROM `{$wpdb->options}` WHERE option_name = %s LIMIT 1",
                self::OPTION_CLAIM
            ));
            if (!is_string($storedValue)) {
                return;
            }

            $claim = maybe_unserialize($storedValue);
            if (
                !is_array($claim)
                || (string) ($claim['run_id'] ?? '') !== $runId
                || (string) ($claim['origin_lock_token'] ?? '') !== $lockToken
            ) {
                return;
            }

            $claim['origin_active'] = false;
            $claim['origin_lock_token'] = '';
            if (self::replaceStoredClaim($storedValue, $claim)) {
                return;
            }
        }

        Debug::error('Immediate kick origin gate release failed', ['run_id' => $runId]);
    }

    /**
     * Replace the claim only when its serialized value is unchanged.
     *
     * @param string $storedValue Exact serialized option value that was validated.
     * @param array<string,mixed> $replacement Replacement claim state.
     * @return bool True when this request atomically consumed the claim.
     */
    private static function replaceStoredClaim(string $storedValue, array $replacement): bool
    {
        global $wpdb;

        // phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery,WordPress.DB.DirectDatabaseQuery.NoCaching -- Atomic claim consumption prevents duplicate immediate batch execution.
        $updated = $wpdb->update(
            $wpdb->options,
            ['option_value' => maybe_serialize($replacement)],
            [
                'option_name' => self::OPTION_CLAIM,
                'option_value' => $storedValue,
            ],
            ['%s'],
            ['%s', '%s']
        );

        wp_cache_delete(self::OPTION_CLAIM, 'options');
        wp_cache_delete('notoptions', 'options');

        return $updated === 1;
    }

    /**
     * Whether this run already owns an outstanding immediate request.
     *
     * @param string $runId Active warmup run ID.
     * @return bool True when the run already owns a pending claim.
     */
    private static function hasPendingClaimForRun(string $runId): bool
    {
        $claim = get_option(self::OPTION_CLAIM, []);
        return is_array($claim)
            && (string) ($claim['run_id'] ?? '') === $runId
            && (string) ($claim['status'] ?? '') === 'pending';
    }

    /**
     * Hash a raw request token with the site's authentication salt.
     *
     * @param string $token Raw request token.
     * @return string Token digest stored in the database.
     */
    private static function hashToken(string $token): string
    {
        return hash_hmac('sha256', $token, wp_salt('auth'));
    }

    /**
     * Return one intentionally generic authorization failure.
     *
     * @return \WP_Error Generic forbidden response.
     */
    private static function deniedResponse(): \WP_Error
    {
        return new \WP_Error(
            'ecw_immediate_kick_denied',
            __('Immediate warmup request denied.', 'cache-autopilot'),
            ['status' => 403]
        );
    }

    /**
     * Prevent the internal execution response from entering page caches.
     *
     * @return void
     */
    private static function markResponseNoCache(): void
    {
        if (!defined('DONOTCACHEPAGE')) {
            // phpcs:ignore WordPress.NamingConventions.PrefixAllGlobals.NonPrefixedConstantFound -- WordPress/cache-plugin global control constant.
            define('DONOTCACHEPAGE', true);
        }

        // phpcs:ignore WordPress.NamingConventions.PrefixAllGlobals.NonPrefixedHooknameFound -- LiteSpeed Cache third-party control hook.
        do_action('litespeed_control_set_nocache', 'ekesto-cache-warmup immediate kick');
    }
}
