<?php

declare(strict_types=1);

namespace PpPhp\Core\Update;

use PpPhp\Core\PlatformSettings;

/**
 * Unattended "check + apply" so a server keeps itself current without a human.
 *
 * This is the consumer end of the release pipeline: you publish once from your
 * dev PC (server + client editions) → the signed index lands on the host → every
 * install running this on a schedule pulls the edition it needs and applies it
 * with a restore point, all on its own. Your two licence servers converge on a
 * new release within a cron tick of you publishing it — zero clicks.
 *
 * Ships to every install (a customer can opt in too). It only ever CONSUMES
 * updates — it reuses UpdateClient (verifies the signed index offline) and
 * PlatformUpdater (restore point + protected paths + prune), so it is exactly the
 * manual "Download & apply" path with nobody at the keyboard.
 *
 * Safe by construction: off unless explicitly enabled; a non-blocking lock stops
 * two runs colliding; it stands down during an in-progress maintenance/apply; and
 * every run is recorded so the console can show what happened.
 */
final class AutoUpdater
{
    public const SETTING = 'platform.update_auto';
    public const KEY_SETTING = 'platform.update_auto_key';

    /** A run older than this (seconds) is assumed dead and its lock reclaimed. */
    private const LOCK_TTL = 1800;                    // 30 min

    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
    {
        $v = PlatformSettings::get(self::SETTING)['value'] ?? [];
        return is_array($v) ? $v : [];
    }

    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();                       // ensure a trigger key exists
    }

    /**
     * The shared secret a URL-based cron must present (`?key=…`). Generated once
     * and stored; a caller without it can never trigger a code update.
     */
    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;
    }

    /** Record the outcome of a run for the console. */
    private static function record(array $patch): void
    {
        PlatformSettings::set(self::SETTING, ['value' => $patch + self::state()]);
    }

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

    /**
     * Do one check-and-maybe-apply cycle.
     *
     * @param array{force?:bool} $opts force runs even when disabled (a manual
     *   "Run now" from the console).
     * @return array{ran:bool, applied:bool, message:string, version?:string, from?:string}
     */
    public function run(array $opts = []): array
    {
        $force = !empty($opts['force']);
        if (!$force && !self::isEnabled()) {
            return ['ran' => false, 'applied' => false, 'message' => 'Automatic updates are off.'];
        }

        // A single non-blocking lock — a second cron tick (or a manual run over a
        // still-running one) simply steps aside instead of applying twice.
        $lockPath = $this->root . '/storage/updates/auto.lock';
        @mkdir(dirname($lockPath), 0775, true);
        if (is_file($lockPath) && (time() - (int) @filemtime($lockPath)) < self::LOCK_TTL) {
            return ['ran' => false, 'applied' => false, 'message' => 'Another update run is in progress.'];
        }
        $lock = @fopen($lockPath, 'c');
        if ($lock === false || !@flock($lock, LOCK_EX | LOCK_NB)) {
            if ($lock) @fclose($lock);
            return ['ran' => false, 'applied' => false, 'message' => 'Another update run is in progress.'];
        }
        @touch($lockPath);

        try {
            @set_time_limit(0);
            @ignore_user_abort(true);
            if ((int) preg_replace('/\D/', '', (string) ini_get('memory_limit')) < 256) {
                @ini_set('memory_limit', '256M');
            }

            $updater = new PlatformUpdater($this->root);
            if ($updater->inMaintenance()) {
                self::record(['lastRun' => time(), 'lastResult' => 'skipped (maintenance in progress)']);
                return ['ran' => true, 'applied' => false, 'message' => 'An apply is already in progress — stood down.'];
            }
            $from = $updater->installedVersion();

            $client = new UpdateClient($this->root);
            $check  = $client->check();
            // Distinguish "reached the host, nothing new" from "could not reach the
            // host". Recording an unreachable check as "up to date" hides a broken
            // update channel — the customer would think they are current when the
            // console simply never talked to the update server.
            if (isset($check['ok']) && !$check['ok']) {
                self::record(['lastRun' => time(), 'lastResult' => 'could not check', 'lastVersion' => $from]);
                return ['ran' => true, 'applied' => false, 'message' => (string) ($check['message'] ?? 'Could not reach the update server.'), 'from' => $from];
            }
            if (empty($check['available'])) {
                self::record(['lastRun' => time(), 'lastResult' => 'up to date', 'lastVersion' => $from]);
                return ['ran' => true, 'applied' => false, 'message' => (string) ($check['message'] ?? 'Up to date.'), 'from' => $from];
            }

            $path   = $client->download($check);         // verifies size + sha256
            $report = $updater->inspect($path);          // integrity + protected-path plan
            // Drift guard: a delta can download fine yet fail inspect because a file
            // it expects unchanged differs on THIS server. The full always applies
            // cleanly (it carries every file), so fall back to it automatically.
            if (empty($report['ok']) && UpdateClient::canFallBackToFull($check)) {
                @unlink($path);
                $path   = $client->downloadFull($check);
                $report = $updater->inspect($path);
            }
            if (empty($report['ok'])) {
                @unlink($path);
                throw new \RuntimeException('Update could not be applied: ' . implode(' ', (array) ($report['errors'] ?? ['unknown error'])));
            }
            $result = $updater->apply($path);            // restore point + apply + prune
            @unlink($path);

            $version = (string) ($result['version'] ?? ($check['version'] ?? '?'));
            PlatformSettings::setVersion($version);
            self::record(['lastRun' => time(), 'lastResult' => 'updated to ' . $version, 'lastVersion' => $version]);

            return ['ran' => true, 'applied' => true, 'message' => 'Updated ' . $from . ' → ' . $version . '.',
                    'version' => $version, 'from' => $from];
        } catch (\Throwable $e) {
            self::record(['lastRun' => time(), 'lastResult' => 'error: ' . $e->getMessage()]);
            return ['ran' => true, 'applied' => false, 'message' => 'Auto-update failed: ' . $e->getMessage()];
        } finally {
            @flock($lock, LOCK_UN);
            @fclose($lock);
            @unlink($lockPath);
        }
    }

    /**
     * Everything the console needs to render the auto-update card + the two ways
     * to schedule it (a CLI cron line and a URL a web-cron can hit).
     *
     * @return array<string,mixed>
     */
    public static function status(?string $root = null): array
    {
        $root = rtrim($root ?? (string) constant('BASE_PATH'), '/');
        $s = self::state();
        $installed = '';
        try { $installed = (new PlatformUpdater($root))->installedVersion(); } catch (\Throwable $e) {}

        return [
            'enabled'    => (bool) ($s['enabled'] ?? false),
            'installed'  => $installed,
            'lastRun'    => (int) ($s['lastRun'] ?? 0),
            'lastResult' => (string) ($s['lastResult'] ?? ''),
            'secret'     => self::secret(),
            'cliCommand' => 'php ' . $root . '/bin/pp-auto-update.php',
        ];
    }
}
