<?php

declare(strict_types=1);

namespace PpPhp\Core\Databases;

use PpPhp\Core\PlatformSettings;
use PpPhp\Core\Licensing\ServerRole;

/**
 * Phase 4 — when the backup pulls user data, and how it is triggered.
 *
 * WHY THIS IS NOT HUNG OFF `LicenseMirror::tickIfDue()`:
 * the catalogue sync rides on an ordinary page request because it moves a few
 * hundred rows and finishes in milliseconds. A clone moves whole databases and can
 * run for minutes — doing that inline would freeze one unlucky visitor's page load
 * for the length of a database transfer, on a machine chosen precisely because it
 * is the fallback. So cloning is CRON-DRIVEN, following the same shape the Update
 * Engine already uses (shell cron, or a web-cron URL carrying a per-install secret
 * for hosts with no shell).
 *
 * Default cadence is 6 hours, not 15 minutes. The catalogue must be near-live so a
 * failover console shows the right licences; user data only has to be recent
 * enough to be worth having, and every run costs a full transfer of anything that
 * changed.
 */
final class CloneSchedule
{
    public const SETTING     = 'platform.db_clone';
    public const KEY_SETTING = 'platform.db_clone_key';

    /** Default gap between automatic runs. */
    public const INTERVAL = 21600;                    // 6 hours

    /** A run holding the lock longer than this is treated as dead. */
    private const LOCK_TTL = 3600;                    // 1 hour

    private string $root;

    public function __construct(?string $root = null)
    {
        $this->root = rtrim($root ?? (string) constant('BASE_PATH'), '/');
    }

    // ── settings ──────────────────────────────────────────────────────

    /** @return array<string,mixed> */
    private static function state(): array
    {
        $s = PlatformSettings::get(self::SETTING);
        return is_array($s['value'] ?? null) ? $s['value'] : (is_array($s) ? $s : []);
    }

    /**
     * Cloning is AUTOMATIC by default. A paired backup replicates on its own with
     * no owner action and no server cron — so when the setting was never touched we
     * return true. An explicit setEnabled(false) is still honoured (opt-out).
     */
    public static function isEnabled(): bool
    {
        $s = self::state();
        if (array_key_exists('enabled', $s)) {
            return (bool) $s['enabled'];
        }
        return true;   // never configured → self-driving on
    }

    public static function setEnabled(bool $on): void
    {
        PlatformSettings::set(self::SETTING, ['value' => ['enabled' => $on] + self::state()]);
        if ($on) self::secret();
    }

    public static function intervalSeconds(): int
    {
        $v = (int) (self::state()['interval'] ?? 0);
        return $v >= 900 ? $v : self::INTERVAL;        // never faster than 15 min
    }

    public static function setInterval(int $seconds): void
    {
        $seconds = max(900, min(604800, $seconds));    // 15 min … 7 days
        PlatformSettings::set(self::SETTING, ['value' => ['interval' => $seconds] + self::state()]);
    }

    /**
     * The secret a web-cron caller must present (`?key=…`).
     *
     * Deliberately its OWN key, not shared with the auto-updater: these trigger
     * very different actions, and a key leaked from a cron service should not
     * hand over both.
     */
    public static function secret(): string
    {
        $k = trim((string) (PlatformSettings::get(self::KEY_SETTING)['value'] ?? ''));
        if ($k === '') {
            $k = bin2hex(random_bytes(24));
            PlatformSettings::set(self::KEY_SETTING, ['value' => $k]);
        }
        return $k;
    }

    private static function record(array $patch): void
    {
        PlatformSettings::set(self::SETTING, ['value' => $patch + self::state()]);
    }

    // ── run ───────────────────────────────────────────────────────────

    /**
     * One clone cycle.
     *
     * @param array{force?:bool} $opts force = a manual "Clone now" from the
     *        console: runs even when the schedule is off and ignores the interval,
     *        but still respects the lock.
     * @return array{ran:bool,success:bool,message:string,copied?:int,skipped?:int,failed?:int,details?:array}
     */
    public function run(array $opts = []): array
    {
        $force = !empty($opts['force']);

        if (!ServerRole::isBackup()) {
            return ['ran' => false, 'success' => false,
                    'message' => 'Cloning runs on a backup server only — this one is the main.'];
        }
        if (!$force && !self::isEnabled()) {
            return ['ran' => false, 'success' => false, 'message' => 'Scheduled cloning is off.'];
        }
        if (!$force && !self::due()) {
            return ['ran' => false, 'success' => true, 'message' => 'Not due yet.'];
        }

        // Non-blocking lock: a second cron tick, or a manual run started over a
        // still-running one, steps aside rather than dumping the same databases
        // twice at once.
        $lockPath = $this->root . '/storage/replication/clone.lock';
        @mkdir(dirname($lockPath), 0775, true);
        $lock = @fopen($lockPath, 'c');
        if ($lock === false || !@flock($lock, LOCK_EX | LOCK_NB)) {
            if ($lock) @fclose($lock);
            return ['ran' => false, 'success' => false, 'message' => 'Another clone run is already in progress.'];
        }
        @touch($lockPath);

        try {
            @set_time_limit(0);
            @ignore_user_abort(true);

            // 1) Server MySQL managed databases → replica_* databases.
            $res = (new DbReplicator())->run();

            // 2) On-disk file sets — virtual-database (.sqlite) files and every
            //    project's design tree. Same lock, same cadence: a warm replica must
            //    hold the actual data and app designs, not just the catalogue rows.
            //    Never lets a file failure mask or abort the DB clone result.
            $files = ['success' => true, 'message' => '', 'sets' => []];
            if (class_exists(\PpPhp\Core\Replication\FileReplicator::class)) {
                try {
                    $files = (new \PpPhp\Core\Replication\FileReplicator())->run();
                } catch (\Throwable $e) {
                    $files = ['success' => false, 'message' => 'File replication failed: ' . $e->getMessage(), 'sets' => []];
                }
            }

            $ok = $res['success'] && $files['success'];
            $message = $res['message'] . ' · ' . ($files['message'] ?: 'Files up to date.');

            self::record([
                'lastRun'    => time(),
                'lastResult' => $message,
                'lastOk'     => $ok,
            ]);

            return ['ran' => true, 'success' => $ok, 'message' => $message,
                    'copied' => $res['copied'], 'skipped' => $res['skipped'],
                    'failed' => $res['failed'], 'details' => $res['details'],
                    'files' => $files];
        } catch (\Throwable $e) {
            self::record(['lastRun' => time(), 'lastResult' => 'error: ' . $e->getMessage(), 'lastOk' => false]);
            return ['ran' => true, 'success' => false, 'message' => 'Clone run failed: ' . $e->getMessage()];
        } finally {
            @flock($lock, LOCK_UN);
            @fclose($lock);
        }
    }

    /**
     * A backup that hasn't cloned in a long time.
     *
     * The clone is now SELF-DRIVING (selfDriveIfDue fires on any request), so this
     * no longer means "no cron configured" — it means this backup has received
     * almost no traffic for over twice the interval, so nothing has triggered it.
     * Surfaced so the owner can send it a little traffic (or hit "Replicate now")
     * rather than discovering a cold replica when they need it.
     *
     * Reported once more than twice the interval has passed with no run.
     */
    public static function stale(): bool
    {
        if (!ServerRole::isBackup() || !self::isEnabled()) return false;
        $last = (int) (self::state()['lastRun'] ?? 0);
        if ($last === 0) return true;                     // enabled, never ran
        return (time() - $last) > (self::intervalSeconds() * 2);
    }

    public static function due(): bool
    {
        if (!ServerRole::isBackup() || !self::isEnabled()) return false;
        $last = (int) (self::state()['lastRun'] ?? 0);
        return (time() - $last) >= self::intervalSeconds();
    }

    // ── Self-driving trigger (NO external cron) ───────────────────────────
    //
    // A clone can run for minutes, so it must NEVER run inline in a visitor's
    // request. Instead, when a due backup serves ANY request, it fires a single
    // non-blocking loopback call to its OWN web-cron endpoint (/clone-run) and
    // returns instantly; that endpoint acks immediately and keeps running the
    // clone in a detached worker (respondAndContinue). The owner sets up nothing —
    // the server drives itself. The flock in run() prevents overlap, a cooldown
    // stops re-firing while one is in flight, and because progress is recorded
    // per item, a worker killed by an FPM timeout simply resumes on the next kick.

    /** Don't re-fire a due clone more than once per this window. */
    public const KICK_COOLDOWN = 600;                 // 10 minutes

    /** Decide (only) whether this request should kick off a clone. Never throws. */
    public static function shouldKick(): bool
    {
        try {
            if (!ServerRole::isBackup() || !self::isEnabled() || !self::due()) {
                return false;
            }
            $last = (int) (self::state()['lastKick'] ?? 0);
            return (time() - $last) >= self::KICK_COOLDOWN;
        } catch (\Throwable $e) {
            return false;
        }
    }

    /**
     * Called on every request (cheap). If a clone is due, records the kick and
     * schedules the non-blocking self-request to run AFTER this response is sent —
     * so the visitor is never delayed.
     */
    public static function selfDriveIfDue(): void
    {
        if (!self::shouldKick()) {
            return;
        }
        self::record(['lastKick' => time()]);
        $url = self::selfCronUrl();
        if ($url === '') {
            return;
        }
        // Fire after the page response is flushed, so nothing about this waits on
        // the visitor's request.
        register_shutdown_function(static function () use ($url): void {
            if (function_exists('fastcgi_finish_request')) {
                @fastcgi_finish_request();            // release the visitor first (FPM/LiteSpeed)
            }
            self::fireSelfCron($url);
        });
    }

    /** This server's own /clone-run URL with its secret, or '' if undetectable. */
    private static function selfCronUrl(): string
    {
        $base = '';
        if (class_exists(\PpPhp\Core\CoreConfig::class)) {
            try { $base = (string) \PpPhp\Core\CoreConfig::detectSelfUrl($_SERVER ?? []); } catch (\Throwable $e) {}
        }
        if ($base === '' || !preg_match('#^https?://#i', $base)) {
            return '';
        }
        return rtrim($base, '/') . '/licensing/server/clone-run?key=' . urlencode(self::secret());
    }

    /**
     * Fire the loopback kick and return immediately — a pure fire-and-forget.
     *
     * On FPM/LiteSpeed the visitor is already released (fastcgi_finish_request in
     * selfDriveIfDue) before this runs; on Apache mod_php there is no such call, so
     * this MUST NOT wait on the response or it would hold the visitor's request open
     * for the length of the connect/ack. We give the curl a sub-second timeout and
     * ignore the result: /clone-run uses ignore_user_abort(true) and keeps running
     * the clone after we drop, so a "timeout" here just means the kick was delivered.
     * The kick cooldown is already recorded (selfDriveIfDue) before we fire, so a
     * dropped response never causes a re-kick storm.
     */
    public static function fireSelfCron(string $url): void
    {
        if (function_exists('curl_init')) {
            $ch = curl_init($url);
            curl_setopt_array($ch, [
                CURLOPT_RETURNTRANSFER    => true,
                CURLOPT_NOSIGNAL          => true,   // safe to time out from any thread
                CURLOPT_CONNECTTIMEOUT_MS => 500,
                CURLOPT_TIMEOUT_MS        => 700,    // deliver the kick, don't await the work
                CURLOPT_SSL_VERIFYPEER    => false,  // we are calling ourselves
                CURLOPT_SSL_VERIFYHOST    => 0,
                CURLOPT_USERAGENT         => 'PPPHP-SelfCron/1',
            ]);
            @curl_exec($ch);                          // a timeout is expected == kick delivered
            @curl_close($ch);
        } else {
            $ctx = stream_context_create([
                'http' => ['method' => 'GET', 'timeout' => 1, 'ignore_errors' => true],
                'ssl'  => ['verify_peer' => false, 'verify_peer_name' => false],
            ]);
            @file_get_contents($url, false, $ctx);
        }
    }

    /** @return array<string,mixed> */
    public static function status(?string $root = null): array
    {
        $root = rtrim($root ?? (string) constant('BASE_PATH'), '/');
        $s = self::state();
        return [
            'enabled'    => (bool) ($s['enabled'] ?? false),
            'isBackup'   => ServerRole::isBackup(),
            'interval'   => self::intervalSeconds(),
            'lastRun'    => (int) ($s['lastRun'] ?? 0),
            'lastResult' => (string) ($s['lastResult'] ?? ''),
            'lastOk'     => (bool) ($s['lastOk'] ?? false),
            'due'        => self::due(),
            'stale'      => self::stale(),
            'secret'     => self::secret(),
            'cliCommand' => 'php ' . $root . '/bin/pp-clone-databases.php',
        ];
    }
}
