<?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 : []);
    }

    public static function isEnabled(): bool
    {
        return (bool) (self::state()['enabled'] ?? false);
    }

    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);

            $res = (new DbReplicator())->run();

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

            return ['ran' => true, 'success' => $res['success'], 'message' => $res['message'],
                    'copied' => $res['copied'], 'skipped' => $res['skipped'],
                    'failed' => $res['failed'], 'details' => $res['details']];
        } 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);
        }
    }

    /**
     * Scheduled cloning is ON, but nothing is actually running it.
     *
     * Turning the switch on does NOT make clones happen — a cron entry (shell or
     * web) has to call this. Without that the console looks configured while
     * quietly doing nothing, which is the worst of both worlds: an owner believes
     * they have a warm replica and finds out otherwise 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();
    }

    /** @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',
        ];
    }
}
