<?php

declare(strict_types=1);

namespace Duplicator\Package;

use Duplicator\Libs\Snap\SnapURL;
use Duplicator\Libs\Snap\SnapUtil;
use Duplicator\Models\ActivityLog\LogEventLoopbackFailure;
use Duplicator\Models\DynamicGlobalEntity;
use Duplicator\Package\DupPackage;
use Duplicator\Utils\ExpireOptions;
use Duplicator\Utils\Logging\DupLog;
use RuntimeException;
use Throwable;

/**
 * Handles client-side kickoff detection and configuration
 */
class ClientSideKick
{
    /** @var string Parameter carrying the unique probe code of a loopback connectivity test */
    public const LOOPBACK_CHECK_CODE_PARAM = 'duplicator_loopback_check';

    /** @var string ExpireOptions key for caching loopback test result */
    private const LOOPBACK_CACHE_KEY = 'loopback_test';

    /** @var string ExpireOptions key holding the probe code the caller expects back */
    private const LOOPBACK_EXPECTED_KEY = 'loopback_check_expected';

    /** @var string ExpireOptions key the worker writes to confirm it received the probe */
    private const LOOPBACK_RECEIVED_KEY = 'loopback_check_received';

    /** @var int TTL in seconds of the probe expected/received markers */
    private const LOOPBACK_MARKER_TTL = 60;

    /** @var int Cache duration for a PASS result in seconds (1 hour) */
    private const LOOPBACK_CACHE_EXPIRATION = HOUR_IN_SECONDS;

    /** @var int Cache duration for a FAIL result in seconds (10 minutes) — short so the server re-tests soon */
    private const LOOPBACK_FAIL_CACHE_EXPIRATION = 10 * MINUTE_IN_SECONDS;

    /** @var int Delay between loopback scheme-retry attempts, in microseconds */
    private const LOOPBACK_RETRY_DELAY_US = 500000;

    /** @var int Per-attempt max wait for the probe marker, in seconds — short because we retry (worst case ~2x) */
    private const LOOPBACK_TIMEOUT = 5;

    /** @var int Pause between probe marker polls, in microseconds */
    private const LOOPBACK_POLL_INTERVAL_US = 250000;

    /**
     * Initialize hooks for client-side kickoff
     *
     * @return void
     */
    public static function init(): void
    {
        // Only register enqueue hooks if client-side kickoff is enabled for active package
        if (self::isClientSideKickoffEnabled()) {
            add_action('wp_enqueue_scripts', [self::class, 'enqueueKickoffScript']);
            add_action('admin_enqueue_scripts', [self::class, 'enqueueKickoffScript']);
        }
    }

    /**
     * Get the AJAX endpoint URL for the server's own loopback self-request that kicks off build workers
     *
     * This is the URL the server calls from PHP (server-to-self), NOT the URL handed to the browser
     * for client-side kickoff. It reuses the scheme the loopback test proved to work (cached on PASS)
     * so the kickoff never builds an URL with a scheme the server can't reach. Without a cached PASS
     * scheme it falls back to the stored site URL scheme (canonical, unlike admin_url()/site_url()'s
     * is_ssl()/force_ssl_admin() guess which resolves wrong behind a reverse proxy / SSL terminator).
     *
     * @return string
     */
    public static function getBackendAjaxUrl(): string
    {
        /** @var array{result: bool, scheme?: string}|null $cached */
        $cached = ExpireOptions::get(self::LOOPBACK_CACHE_KEY, null);
        if (is_array($cached) && !empty($cached['result']) && !empty($cached['scheme'])) {
            $scheme = $cached['scheme'];
        } else {
            $siteScheme = strtolower((string) parse_url((string) get_option('siteurl'), PHP_URL_SCHEME));
            $scheme     = ($siteScheme === 'http') ? 'http' : 'https';
        }

        return admin_url('admin-ajax.php', $scheme);
    }

    /**
     * Build the worker request args (sslverify + basic auth header)
     *
     * Scheme/URL-agnostic so the same args can be reused against any worker URL (the loopback test
     * probes more than one scheme).
     *
     * @param array<string, mixed> $extraArgs Additional args to merge into the request
     *
     * @return array<string, mixed>
     */
    public static function buildWorkerRequestArgs(array $extraArgs = []): array
    {
        $args = array_merge(
            ['sslverify' => false],
            $extraArgs
        );

        $authHeader = DynamicGlobalEntity::getInstance()->getBasicAuthHeader();
        if ($authHeader !== null) {
            $args['headers'] = ['Authorization' => $authHeader];
        }

        return $args;
    }

    /**
     * Build the worker AJAX URL and request args with auth header
     *
     * @param array<string, scalar> $extraQuery Additional query parameters to append to the URL
     * @param array<string, mixed>  $extraArgs  Additional args to merge into the request
     *
     * @return array{url: string, args: array<string, mixed>}
     */
    public static function buildWorkerRequest(array $extraQuery = [], array $extraArgs = []): array
    {
        $url = self::getBackendAjaxUrl();
        $url = SnapURL::appendQueryValue($url, 'action', 'duplicator_process_worker');

        foreach ($extraQuery as $key => $value) {
            $url = SnapURL::appendQueryValue($url, $key, $value);
        }

        return [
            'url'  => $url,
            'args' => self::buildWorkerRequestArgs($extraArgs),
        ];
    }

    /**
     * Tests whether the server can make HTTP requests to itself (loopback connectivity)
     *
     * @return bool True if server can reach itself, false otherwise
     */
    public static function canServerSelfRequest(): bool
    {
        if (DUPLICATOR_FORCE_CLIENT_KICKOFF) { // @phpstan-ignore if.alwaysFalse
            DupLog::trace('KICKOFF TEST: forced by DUPLICATOR_FORCE_CLIENT_KICKOFF [KICK OFF ENABLED]');
            return false;
        }

        // Both outcomes are cached, with different TTLs: a PASS for an hour, a FAIL for only 10
        // minutes so the server re-tests soon and a transient block can't trap later backups in
        // client-side mode for long.
        /** @var array{result: bool, scheme?: string}|null $cached */
        $cached = ExpireOptions::get(self::LOOPBACK_CACHE_KEY, null);
        if (is_array($cached) && array_key_exists('result', $cached)) {
            DupLog::trace('KICKOFF TEST: cached result ' . ($cached['result'] ? 'PASS [KICK OFF DISABLED]' : 'FAIL [KICK OFF ENABLED]'));
            return (bool) $cached['result'];
        }

        // Probe the site URL scheme; if it is http and fails, retry over https (upgrade-only —
        // a downgrade to http for an https site is never attempted).
        $siteScheme = strtolower((string) parse_url((string) get_option('siteurl'), PHP_URL_SCHEME));
        $schemes    = ['https'];
        if ($siteScheme === 'http') {
            $schemes = [
                'http',
                'https',
            ];
        }

        $failureReason = '';
        $context       = [];
        foreach ($schemes as $i => $scheme) {
            $attemptReason  = '';
            $attemptContext = [];
            if (self::attemptSelfRequest($scheme, $attemptReason, $attemptContext)) {
                DupLog::trace('KICKOFF TEST: PASSED over ' . $scheme . ' [KICK OFF DISABLED]');
                ExpireOptions::set(
                    self::LOOPBACK_CACHE_KEY,
                    [
                        'result' => true,
                        'scheme' => $scheme,
                    ],
                    self::LOOPBACK_CACHE_EXPIRATION
                );
                return true;
            }

            // Keep the FIRST failure's diagnostics for the activity-log event; later attempts overwrite otherwise.
            if ($failureReason === '') {
                $failureReason = $attemptReason;
                $context       = $attemptContext;
            }

            DupLog::trace('KICKOFF TEST: FAILED over ' . $scheme . ' - ' . $attemptReason);
            if ($i < count($schemes) - 1) {
                usleep(self::LOOPBACK_RETRY_DELAY_US);
            }
        }

        DupLog::trace('KICKOFF TEST: FAILED on all schemes [KICK OFF ENABLED]');
        ExpireOptions::set(self::LOOPBACK_CACHE_KEY, ['result' => false], self::LOOPBACK_FAIL_CACHE_EXPIRATION);
        LogEventLoopbackFailure::create($failureReason, $context);
        return false;
    }

    /**
     * Perform a single loopback self-request attempt over a given scheme
     *
     * The probe mirrors a real worker kickoff exactly (same endpoint, same args, same
     * non-blocking semantics) and carries a unique code. The worker confirms receipt by
     * writing the code to the database before doing any build work; this method polls
     * for that marker. This catches both hosts that drop non-blocking requests once the
     * client disconnects and anything in the WordPress bootstrap that kills real worker
     * requests — failure modes a blocking early-answered test cannot see.
     *
     * @param string               $scheme        Scheme to probe ('http' or 'https')
     * @param string               $failureReason Out-param: failure reason, empty on success
     * @param array<string, mixed> $context       Out-param: diagnostic context (url, wp_error_code)
     *
     * @return bool True if the worker endpoint confirmed receiving the probe
     */
    private static function attemptSelfRequest(string $scheme, string &$failureReason, array &$context): bool
    {
        $failureReason = '';
        $context       = [];
        try {
            $code = bin2hex(random_bytes(16));
            ExpireOptions::set(self::LOOPBACK_EXPECTED_KEY, $code, self::LOOPBACK_MARKER_TTL);
            ExpireOptions::delete(self::LOOPBACK_RECEIVED_KEY);

            $ajax_url       = admin_url('admin-ajax.php', $scheme);
            $ajax_url       = SnapURL::appendQueryValue($ajax_url, 'action', 'duplicator_process_worker');
            $ajax_url       = SnapURL::appendQueryValue($ajax_url, self::LOOPBACK_CHECK_CODE_PARAM, $code);
            $context['url'] = $ajax_url;
            DupLog::trace('KICKOFF TEST: START TEST self-request URL: ' . $ajax_url);

            $args     = self::buildWorkerRequestArgs(['blocking' => false]);
            $response = wp_remote_get($ajax_url, $args);

            if (is_wp_error($response)) {
                $context['wp_error_code'] = $response->get_error_code();
                throw new RuntimeException('WP_Error: ' . $response->get_error_message());
            }

            if (!self::waitForProbeMarker($code)) {
                throw new RuntimeException('Worker probe confirmation not received within ' . self::LOOPBACK_TIMEOUT . ' seconds');
            }

            return true;
        } catch (Throwable $e) {
            $failureReason = $e->getMessage();
            return false;
        } finally {
            ExpireOptions::delete(self::LOOPBACK_EXPECTED_KEY);
            ExpireOptions::delete(self::LOOPBACK_RECEIVED_KEY);
        }
    }

    /**
     * Poll the database until the worker writes the probe confirmation marker
     *
     * @param string $code Probe code to wait for
     *
     * @return bool True if the marker with the given code appeared within the timeout
     */
    private static function waitForProbeMarker(string $code): bool
    {
        $start    = microtime(true);
        $deadline = $start + self::LOOPBACK_TIMEOUT;
        do {
            usleep(self::LOOPBACK_POLL_INTERVAL_US);
            // Fresh read on purpose: the marker is written by another PHP process
            if (ExpireOptions::getFresh(self::LOOPBACK_RECEIVED_KEY, '') === $code) {
                DupLog::trace('KICKOFF TEST: probe confirmed in ' . round(microtime(true) - $start, 2) . 's' . ' [KICK OFF DISABLED]');
                return true;
            }
        } while (microtime(true) < $deadline);

        DupLog::trace('KICKOFF TEST: probe NOT confirmed within ' . self::LOOPBACK_TIMEOUT . 's');
        return false;
    }

    /**
     * Worker-side probe confirmation: write the received code so the caller's poll sees it
     *
     * The marker is written only when the received code matches the one registered by the
     * caller right before firing the probe, so this unauthenticated endpoint cannot be
     * used to write arbitrary values.
     *
     * @param string $code Probe code received with the request
     *
     * @return bool True when the code matched and the marker was written
     */
    public static function confirmLoopbackProbe(string $code): bool
    {
        $expected = ExpireOptions::get(self::LOOPBACK_EXPECTED_KEY, '');
        if (!is_string($expected) || $expected === '' || !hash_equals($expected, $code)) {
            DupLog::trace('KICKOFF TEST: probe rejected (no pending probe or code mismatch)');
            return false;
        }

        ExpireOptions::set(self::LOOPBACK_RECEIVED_KEY, $code, self::LOOPBACK_MARKER_TTL);
        DupLog::trace('KICKOFF TEST: probe received, marker written');
        return true;
    }

    /**
     * Check whether the current request is a loopback probe fired by canServerSelfRequest()
     *
     * Used as a recursion brake: a probe request must never lazy-resolve the kickoff mode,
     * or it would fire a nested probe and block waiting on it.
     *
     * @return bool
     */
    public static function isLoopbackProbeRequest(): bool
    {
        $action = SnapUtil::sanitizeTextInput(SnapUtil::INPUT_REQUEST, 'action');
        $code   = SnapUtil::sanitizeTextInput(SnapUtil::INPUT_REQUEST, self::LOOPBACK_CHECK_CODE_PARAM, '');

        return $action === 'duplicator_process_worker' && $code !== '';
    }

    /**
     * Check if client-side kickoff is enabled for the currently active package
     *
     * @return bool True if there's an active package that requires client-side kickoff
     */
    public static function isClientSideKickoffEnabled(): bool
    {
        $package = DupPackage::getNextActive();
        return $package !== null && $package->isClientSideKickoff();
    }

    /**
     * Enqueues the JavaScript for client-side kickoff
     *
     * @return void
     */
    public static function enqueueKickoffScript(): void
    {
        $callPeriodInMs = 10000; // How often client calls into the service

        // Client-side kickoff runs in the visitor's browser, which already reached the site through
        // any proxy/SSL terminator. Use WordPress' standard admin-ajax URL so the request matches the
        // host and scheme of the current page, not the backend loopback URL.
        $ajax_url = admin_url('admin-ajax.php');

        $gateway = [
            'ajaxurl'               => $ajax_url,
            'client_call_frequency' => $callPeriodInMs,
        ];

        wp_register_script('dupli-kick', DUPLICATOR_PLUGIN_URL . 'assets/js/dupli-kick.js', ['jquery'], DUPLICATOR_VERSION);
        wp_localize_script('dupli-kick', 'dupli_gateway', $gateway);
        DupLog::trace('KICKOFF: Client-side kickoff script enqueued (dupli-kick.js)');
        wp_enqueue_script('dupli-kick');
    }
}
