<?php

declare(strict_types=1);

namespace PpPhp\Core\Update;

use PpPhp\Core\Version;
use RuntimeException;
use ZipArchive;

/**
 * Applies a PPPHP release bundle to a LIVE installation, in place.
 *
 * Built for FTP-only hosting, where there is no shell to run a deploy script
 * and no way to swap a symlink: the running application updates itself from a
 * ZIP the owner uploads.
 *
 * The whole design answers one requirement — **existing users, projects and
 * settings must survive, always**:
 *
 *  • PROTECTED paths (projects/, output/, storage/, config/database.php, …) are
 *    never written, never deleted, and are refused even if a bundle contains
 *    them. This is enforced on the way in AND on the way out.
 *  • Nothing is touched until the ZIP has been fully verified: manifest present,
 *    version sane, every file's sha256 matching release.json.
 *  • Every file about to be overwritten or deleted is copied into a restore
 *    point first, so rollback() puts the site back exactly as it was.
 *  • A maintenance flag is raised for the few seconds files are in flux.
 *  • The database is never migrated destructively — the platform self-heals
 *    (CREATE TABLE IF NOT EXISTS / guarded ALTER) on next use.
 */
final class PlatformUpdater
{
    /**
     * Paths the updater will NEVER create, overwrite or delete.
     * Matched against the release-relative path, as a whole segment.
     */
    private const PROTECTED = [
        'projects',              // every user's project data
        'output',                // generated applications
        'storage',               // logs, backups, deploy state, restore points
        'dist',                  // locally built bundles
        'config/database.php',   // DB credentials
        'config/mail.php',
        'config/sms.php',
        '.env',
        '.env.local',
        'install.lock',
    ];

    /** Only these roots may be updated or pruned — everything else is left alone. */
    private const CODE_ROOTS = [
        'core', 'modules', 'plugins', 'public', 'bootstrap', 'help', 'resources', 'vendor',
    ];

    /**
     * SEED-ONCE files: shipped in the bundle so a FRESH install has them, but never
     * OVERWRITTEN by an update once they exist — they carry customer-editable settings
     * (timezone, env, session-cookie policy). Unlike PROTECTED, they are allowed in the
     * bundle (so inspect() must not refuse them), but apply() skips writing them when a
     * copy already exists on the target. This is what makes config/app.php's own promise
     * — "config/ is never overwritten by an update" — actually true.
     */
    private const SEED_ONCE = [
        'config/app.php',
    ];

    private string $root;
    private string $stateDir;

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

    // ── public API ──────────────────────────────────────────────────────

    /** Version currently installed (from the recorded manifest, else the code constant). */
    public function installedVersion(): string
    {
        $m = $this->installedManifest();
        return (string) ($m['version'] ?? Version::PLATFORM);
    }

    public function installedManifest(): array
    {
        $f = $this->stateDir . '/installed.json';
        if (!is_file($f)) return [];
        return (array) (json_decode((string) file_get_contents($f), true) ?: []);
    }

    /**
     * Environment readiness — call before accepting an upload so the owner sees
     * problems while nothing is at stake.
     *
     * @return array{ok:bool, checks:array<int,array{label:string,ok:bool,detail:string}>}
     */
    public function preflight(): array
    {
        $checks = [];
        $add = static function (string $label, bool $ok, string $detail) use (&$checks): void {
            $checks[] = ['label' => $label, 'ok' => $ok, 'detail' => $detail];
        };

        $add('PHP version', version_compare(PHP_VERSION, Version::MIN_PHP, '>='),
            PHP_VERSION . ' (needs ' . Version::MIN_PHP . '+)');
        $add('ZIP extension', class_exists(ZipArchive::class),
            class_exists(ZipArchive::class) ? 'available' : 'ext-zip is required');

        foreach (self::CODE_ROOTS as $d) {
            $p = $this->root . '/' . $d;
            if (!is_dir($p)) continue;
            $add("Writable: $d/", is_writable($p), is_writable($p) ? 'ok' : 'the web user cannot write here');
        }
        $add('Writable: storage/', is_writable($this->root . '/storage') || @mkdir($this->stateDir, 0775, true),
            'restore points are written here');

        $free = @disk_free_space($this->root);
        $add('Free disk space', $free === false || $free > 200 * 1024 * 1024,
            $free === false ? 'unknown' : round($free / 1048576) . ' MB free (200 MB recommended)');

        $ok = true;
        foreach ($checks as $c) { if (!$c['ok']) $ok = false; }
        return ['ok' => $ok, 'checks' => $checks];
    }

    /**
     * Read and validate a bundle WITHOUT applying it. Safe to call on an upload.
     *
     * @return array{ok:bool, version:string, errors:array<int,string>, warnings:array<int,string>,
     *               summary:array{add:int,update:int,unchanged:int,remove:int}, removals:array<int,string>}
     */
    public function inspect(string $zipPath): array
    {
        $errors = [];
        $warnings = [];
        $zip = new ZipArchive();
        if ($zip->open($zipPath) !== true) {
            return $this->inspectFail('That file is not a readable ZIP archive.');
        }
        $raw = $zip->getFromName('release.json');
        if ($raw === false) {
            $zip->close();
            return $this->inspectFail('No release.json inside — this is not a PPPHP release bundle.');
        }
        $manifest = json_decode((string) $raw, true);
        if (!is_array($manifest) || !isset($manifest['files']) || !is_array($manifest['files'])) {
            $zip->close();
            return $this->inspectFail('release.json is malformed.');
        }

        // Authenticate the bundle. The manifest is Ed25519-signed by the vendor
        // (release.sig); a bundle modified after signing can't produce a signature
        // that verifies against the pinned publisher key — so a PRESENT-but-INVALID
        // signature is a hard stop (it flows into apply() via the ok/errors gate).
        // An ABSENT signature only WARNS for now, so already-published unsigned
        // bundles (and the feed-authenticated OTA path) keep working; a client is
        // told the bundle is unsigned so it can insist on a signed one.
        $signed = false;
        $sigB64 = $zip->getFromName('release.sig');
        $pub    = $this->trustedPublicKey();
        if (is_string($sigB64) && trim($sigB64) !== '' && function_exists('sodium_crypto_sign_verify_detached')) {
            $sig = base64_decode(trim($sigB64), true);
            if ($pub !== '' && is_string($sig) && strlen($sig) === SODIUM_CRYPTO_SIGN_BYTES) {
                $signed = sodium_crypto_sign_verify_detached($sig, (string) $raw, $pub);
            }
            if (!$signed) {
                $errors[] = 'This bundle carries a publisher signature that does NOT verify — it was not signed by your provider, or it was modified after signing. It will not be applied.';
            }
        } elseif (class_exists(\PpPhp\Core\Licensing\LicenseGate::class)
            && \PpPhp\Core\Licensing\LicenseGate::isClientEdition()) {
            $warnings[] = 'This bundle is not signed by your provider. Apply only updates you obtained directly from them.';
        }

        $version = Version::normalise((string) ($manifest['version'] ?? '0'));
        $current = Version::normalise($this->installedVersion());
        $cmp = version_compare($version, $current);
        if ($cmp < 0)  $warnings[] = "This bundle ($version) is OLDER than what is installed ($current).";
        if ($cmp === 0) $warnings[] = "This bundle is the same version as installed ($current) — it will be re-applied.";
        if (version_compare(PHP_VERSION, (string) ($manifest['minPhp'] ?? '8.0'), '<')) {
            $errors[] = 'This release needs PHP ' . $manifest['minPhp'] . '; this server runs ' . PHP_VERSION . '.';
        }

        // A DELTA bundle (online updater) ships only the CHANGED file bodies plus
        // the full target manifest; unchanged files are absent from the ZIP on
        // purpose. A full bundle carries every file. delta.json marks the former.
        $isDelta = $zip->getFromName('delta.json') !== false;

        // integrity + protected-path check for every entry
        $add = $update = $unchanged = 0;
        foreach ($manifest['files'] as $rel => $sha) {
            if ($this->isProtected((string) $rel)) {
                $errors[] = "Bundle tries to write a protected path: $rel";
                continue;
            }
            $data = $zip->getFromName((string) $rel);
            if ($data === false) {
                if ($isDelta) {
                    // Absent from a delta means "unchanged since the version this
                    // patch was built against". PROVE it: the live file must
                    // already match the target hash, or applying the patch would
                    // leave it wrong — so we refuse, and the client falls back to
                    // requesting the full bundle. This is the drift guard.
                    $live = $this->root . '/' . $rel;
                    if (!is_file($live) || hash_file('sha256', $live) !== $sha) {
                        $errors[] = "This patch expects '$rel' to be unchanged, but it differs on this server — a full update is needed.";
                    } else {
                        $unchanged++;
                    }
                    if (count($errors) > 25) { $errors[] = '…further errors suppressed.'; break; }
                    continue;
                }
                $errors[] = "Listed in the manifest but missing from the ZIP: $rel";
                continue;
            }
            if (hash('sha256', $data) !== $sha) { $errors[] = "Corrupt file (hash mismatch): $rel"; continue; }
            $live = $this->root . '/' . $rel;
            // Seed-once files that already exist are intentionally left untouched by
            // apply(), so report them as unchanged rather than a pending overwrite.
            if (in_array((string) $rel, self::SEED_ONCE, true) && is_file($live)) { $unchanged++; }
            elseif (!is_file($live)) { $add++; }
            elseif (hash_file('sha256', $live) !== $sha) { $update++; }
            else { $unchanged++; }
            if (count($errors) > 25) { $errors[] = '…further errors suppressed.'; break; }
        }
        $zip->close();

        $removals = $this->plannedRemovals($manifest);

        return [
            'ok'       => $errors === [],
            'version'  => $version,
            'delta'    => $isDelta,
            'signed'   => $signed,   // publisher signature verified against the pinned key
            'codename' => (string) ($manifest['codename'] ?? ''),
            'builtAt'  => (string) ($manifest['builtAt'] ?? ''),
            'current'  => $current,
            'errors'   => $errors,
            'warnings' => $warnings,
            'summary'  => ['add' => $add, 'update' => $update, 'unchanged' => $unchanged, 'remove' => count($removals)],
            'removals' => array_slice($removals, 0, 50),
        ];
    }

    /**
     * The pinned publisher public key (raw 32 bytes) used to verify a release
     * signature — the SAME key the OTA feed is trusted against. On a customer
     * install this is the stamped LicenseTrust key; on the vendor's own authority
     * it falls back to the live signing key. '' when neither is available.
     */
    private function trustedPublicKey(): string
    {
        if (class_exists(\PpPhp\Core\Licensing\LicenseTrust::class)) {
            $k = \PpPhp\Core\Licensing\LicenseTrust::key();
            if ($k !== '') return $k;
        }
        try {
            if (class_exists(\PpPhp\Core\Licensing\LicenseKeys::class)) {
                $keys = new \PpPhp\Core\Licensing\LicenseKeys($this->root);
                if ($keys->exists()) return $keys->publicKey();
            }
        } catch (\Throwable $e) { /* ignore */ }
        return '';
    }

    /**
     * Apply a bundle. Verifies first, snapshots everything it will change, then
     * writes. Returns a report including the restore-point id for rollback.
     */
    public function apply(string $zipPath): array
    {
        $check = $this->inspect($zipPath);
        if (!$check['ok']) {
            throw new RuntimeException('Refusing to apply: ' . implode(' ', $check['errors']));
        }
        $zip = new ZipArchive();
        if ($zip->open($zipPath) !== true) throw new RuntimeException('Cannot reopen the bundle.');
        $manifest = json_decode((string) $zip->getFromName('release.json'), true);

        $restoreId = date('Ymd-His') . '-' . substr(bin2hex(random_bytes(3)), 0, 6);
        $restore   = $this->stateDir . '/restore/' . $restoreId;
        if (!is_dir($restore) && !@mkdir($restore, 0775, true)) {
            $zip->close();
            throw new RuntimeException('Cannot create the restore point directory.');
        }
        // Snapshot the installed-version record too. It lives under storage/ (a
        // PROTECTED path, so it is never in $changed and never snapshotted by the
        // file loop), yet a rollback must put back the OLD version — otherwise
        // installedVersion() keeps reporting the new number and OTA answers "up to
        // date" while the code has been rolled back (a real stranding bug).
        if (is_file($this->stateDir . '/installed.json')) {
            @copy($this->stateDir . '/installed.json', $restore . '/installed.prev.json');
        }

        $removals = $this->plannedRemovals($manifest);
        $written = $removed = 0;
        // A big update can be slow on shared hosting; don't let the default time
        // limit kill it half-way. (The controller sets this too — belt and braces.)
        @set_time_limit(0);
        @ignore_user_abort(true);

        // Work out which files ACTUALLY change (new, or content differs). Rewriting
        // and snapshotting the entire unchanged tree for a two-file update is what
        // made apply copy thousands of files and time out — a real bug. We touch
        // only what changes; unchanged files never need snapshotting or rewriting.
        $changed = [];
        foreach ($manifest['files'] as $rel => $sha) {
            $rel = (string) $rel;
            if ($this->isProtected($rel)) continue;               // belt and braces
            $live = $this->root . '/' . $rel;
            // Seed-once file already present → never overwrite the customer's copy.
            if (in_array($rel, self::SEED_ONCE, true) && is_file($live)) continue;
            if (!is_file($live) || hash_file('sha256', $live) !== (string) $sha) {
                $changed[$rel] = (string) $sha;
            }
        }

        $this->maintenance(true);

        try {
            // 1. snapshot every file we will change or delete. Files that already
            //    exist are copied so they can be put back; NEW files are recorded
            //    with existed=false (nothing to copy) so rollback knows to REMOVE
            //    them — otherwise an added file would linger after a rollback.
            foreach (array_keys($changed) as $rel) {
                $this->snapshot($rel, $restore);
            }
            foreach ($removals as $rel) $this->snapshot($rel, $restore);

            // Mark the apply as IN PROGRESS now that a full restore point exists but
            // BEFORE the first enforcement file is touched. If the process is killed
            // mid-write (OOM, provider kill, disk full that also kills PHP — none of
            // which throw a catchable Throwable), the integrity manifest and the code
            // it describes fall out of sync and the tamper lock would otherwise brick
            // the console with only FTP recovery. On the next boot LicenseGate sees
            // this marker + a failing integrity check and AUTO-ROLLS-BACK this restore
            // point instead of locking (see LicenseGate::guard). Cleared on success.
            $this->markApplyInProgress($restoreId, Version::normalise((string) ($manifest['version'] ?? '')));

            // 2. write only the changed / new files
            foreach ($changed as $rel => $sha) {
                $data = $zip->getFromName($rel);
                if ($data === false) throw new RuntimeException("Missing from ZIP during apply: $rel");
                $dest = $this->root . '/' . $rel;
                $dir  = dirname($dest);
                if (!is_dir($dir) && !@mkdir($dir, 0775, true) && !is_dir($dir)) {
                    throw new RuntimeException("Cannot create directory: $dir");
                }
                if (@file_put_contents($dest, $data) === false) {
                    throw new RuntimeException("Cannot write: $rel (check file permissions for the web user)");
                }
                $written++;
            }

            // 3. prune files this release removed (code roots only)
            foreach ($removals as $rel) {
                $p = $this->root . '/' . $rel;
                if (is_file($p) && @unlink($p)) $removed++;
            }

            // 4. record what is now installed
            $this->writeInstalled($manifest, $restoreId);
        } catch (\Throwable $e) {
            // A catchable failure (disk full, permission denied, a file missing
            // from the ZIP): immediately put back everything from the restore point
            // so the code + integrity manifest are a consistent set again and the
            // tamper lock never trips. Best-effort — if the rollback itself fails,
            // the marker (cleared only on a clean apply) lets the next boot recover.
            $rolledBack = false;
            try { $this->rollback($restoreId); $rolledBack = true; } catch (\Throwable $re) {}
            if ($rolledBack) $this->clearApplyInProgress();
            $this->maintenance(false);
            $zip->close();
            throw new RuntimeException('Update failed and was stopped: ' . $e->getMessage()
                . ($rolledBack
                    ? ' — the previous version was automatically restored; your site is unchanged.'
                    : ' — automatic restore did not complete; restore point ' . $restoreId . ' is available.'));
        }

        $this->clearApplyInProgress();   // clean apply — no recovery needed
        $zip->close();
        $this->maintenance(false);

        return [
            'ok'        => true,
            'version'   => Version::normalise((string) $manifest['version']),
            'written'   => $written,
            'removed'   => $removed,
            'restoreId' => $restoreId,
        ];
    }

    /** Put back every file captured in a restore point. */
    public function rollback(string $restoreId): array
    {
        $restoreId = preg_replace('/[^A-Za-z0-9\-]/', '', $restoreId) ?? '';
        $dir = $this->stateDir . '/restore/' . $restoreId;
        $meta = $dir . '/.manifest.json';
        if ($restoreId === '' || !is_dir($dir) || !is_file($meta)) {
            throw new RuntimeException('That restore point does not exist.');
        }
        $entries = (array) (json_decode((string) file_get_contents($meta), true) ?: []);
        $restored = $deleted = 0;
        $this->maintenance(true);
        foreach ($entries as $rel => $info) {
            $rel = (string) $rel;
            if ($this->isProtected($rel)) continue;
            $live = $this->root . '/' . $rel;
            $saved = $dir . '/files/' . $rel;
            if (($info['existed'] ?? false) && is_file($saved)) {
                if (!is_dir(dirname($live))) @mkdir(dirname($live), 0775, true);
                if (@copy($saved, $live)) $restored++;
            } elseif (!($info['existed'] ?? false) && is_file($live)) {
                // the update created this file; undo that
                if (@unlink($live)) $deleted++;
            }
        }
        // Put back the version record so installedVersion() reports the version we
        // just rolled back TO (not the abandoned new one) — otherwise OTA sees
        // installed ≥ available and refuses to offer the update again.
        $prevInstalled = $dir . '/installed.prev.json';
        if (is_file($prevInstalled)) {
            @copy($prevInstalled, $this->stateDir . '/installed.json');
        }
        $this->maintenance(false);
        return ['ok' => true, 'restored' => $restored, 'deleted' => $deleted];
    }

    /** Restore points available for rollback, newest first. */
    public function restorePoints(): array
    {
        $base = $this->stateDir . '/restore';
        if (!is_dir($base)) return [];
        $out = [];
        foreach ((array) glob($base . '/*', GLOB_ONLYDIR) as $d) {
            $meta = $d . '/.meta.json';
            $out[] = [
                'id'   => basename((string) $d),
                'when' => date('c', (int) @filemtime((string) $d)),
                'info' => is_file($meta) ? (json_decode((string) file_get_contents($meta), true) ?: []) : [],
            ];
        }
        usort($out, static fn ($a, $b) => strcmp($b['id'], $a['id']));
        return $out;
    }

    /**
     * Permanently delete a restore point and every file it snapshotted.
     *
     * Restore points accumulate one-per-update and can grow large over many
     * updates; the owner/admin prunes them from the console so the install stays
     * lean. Safe by construction: the id is sanitised to the same charset
     * rollback() accepts, and the resolved path is re-checked to sit strictly
     * inside storage/updates/restore/ — deletion can never escape that folder.
     */
    public function deleteRestorePoint(string $restoreId): array
    {
        $restoreId = preg_replace('/[^A-Za-z0-9\-]/', '', $restoreId) ?? '';
        $base = $this->stateDir . '/restore';
        $dir  = $base . '/' . $restoreId;
        $real = $restoreId !== '' ? realpath($dir) : false;
        $baseReal = realpath($base);
        if ($real === false || $baseReal === false || !str_starts_with($real, $baseReal . DIRECTORY_SEPARATOR)) {
            throw new RuntimeException('That restore point does not exist.');
        }
        $removed = $this->removeTree($real);
        return ['ok' => true, 'id' => $restoreId, 'removed' => $removed];
    }

    /** Recursively delete a directory tree; returns the count of files removed. */
    private function removeTree(string $dir): int
    {
        if (!is_dir($dir)) return 0;
        $count = 0;
        $it = new \RecursiveIteratorIterator(
            new \RecursiveDirectoryIterator($dir, \FilesystemIterator::SKIP_DOTS),
            \RecursiveIteratorIterator::CHILD_FIRST
        );
        foreach ($it as $item) {
            if ($item->isDir()) {
                @rmdir($item->getPathname());
            } elseif (@unlink($item->getPathname())) {
                $count++;
            }
        }
        @rmdir($dir);
        return $count;
    }

    /** True while an update is in flight (used by the front controller). */
    public function inMaintenance(): bool
    {
        return is_file($this->root . '/storage/maintenance.flag');
    }

    /** The apply-in-progress marker path (an interrupted apply left it behind). */
    private function applyMarkerPath(): string
    {
        return $this->stateDir . '/apply-in-progress.json';
    }

    /** Record that an apply has begun (a restore point exists but code is mid-write). */
    private function markApplyInProgress(string $restoreId, string $toVersion): void
    {
        if (!is_dir($this->stateDir)) @mkdir($this->stateDir, 0775, true);
        @file_put_contents($this->applyMarkerPath(),
            json_encode(['restoreId' => $restoreId, 'toVersion' => $toVersion, 'at' => date('c')]));
    }

    /** Clear the marker — the apply completed (or was fully rolled back). */
    private function clearApplyInProgress(): void
    {
        @unlink($this->applyMarkerPath());
    }

    /** The pending marker, or null if there is no interrupted apply. */
    public function applyInProgress(): ?array
    {
        $f = $this->applyMarkerPath();
        if (!is_file($f)) return null;
        $d = json_decode((string) @file_get_contents($f), true);
        return is_array($d) && ($d['restoreId'] ?? '') !== '' ? $d : null;
    }

    /**
     * Self-heal an interrupted apply. Called by LicenseGate when the integrity
     * check fails AND a marker is present: an apply was killed mid-write (OOM,
     * process kill — nothing PHP could catch), so the code + signed integrity
     * manifest are out of sync. Rolling back the marked restore point puts the
     * previous, self-consistent, still-signed version back, turning a would-be
     * tamper brick into an automatic recovery to the last working version.
     *
     * Deliberately narrow: it only ever reverts to the vendor-signed version the
     * updater itself snapshotted — it cannot install arbitrary code — and it only
     * runs when integrity is ALREADY failing, so it is not a bypass.
     */
    public function recoverInterruptedApply(): array
    {
        $marker = $this->applyInProgress();
        if ($marker === null) return ['ok' => false, 'recovered' => false, 'reason' => 'no interrupted apply'];
        try {
            $res = $this->rollback((string) $marker['restoreId']);
        } catch (\Throwable $e) {
            return ['ok' => false, 'recovered' => false, 'reason' => $e->getMessage()];
        }
        $this->clearApplyInProgress();
        return ['ok' => true, 'recovered' => true, 'restoreId' => $marker['restoreId']] + $res;
    }

    // ── internals ───────────────────────────────────────────────────────

    /** A path is protected if it IS, or lives inside, any protected entry. */
    private function isProtected(string $rel): bool
    {
        $rel = ltrim(str_replace('\\', '/', $rel), '/');
        if ($rel === '' || str_contains($rel, '..')) return true;      // never trust traversal
        foreach (self::PROTECTED as $p) {
            if ($rel === $p || str_starts_with($rel, $p . '/')) return true;
        }
        return false;
    }

    /** Files present in the installed manifest but absent from the new one. */
    private function plannedRemovals(array $newManifest): array
    {
        $old = $this->installedManifest();
        if (empty($old['files']) || !is_array($old['files'])) return [];
        $new = $newManifest['files'] ?? [];
        $out = [];
        foreach (array_keys($old['files']) as $rel) {
            $rel = (string) $rel;
            if (isset($new[$rel])) continue;
            if ($this->isProtected($rel)) continue;
            // only prune inside code roots — never anything user-owned
            $root = explode('/', $rel)[0];
            if (!in_array($root, self::CODE_ROOTS, true)) continue;
            if (!is_file($this->root . '/' . $rel)) continue;
            $out[] = $rel;
        }
        sort($out);
        return $out;
    }

    /** Copy one live file into the restore point and note whether it existed. */
    private function snapshot(string $rel, string $restoreDir): void
    {
        $live = $this->root . '/' . $rel;
        $meta = $restoreDir . '/.manifest.json';
        $entries = is_file($meta) ? (array) (json_decode((string) file_get_contents($meta), true) ?: []) : [];
        if (isset($entries[$rel])) return;                              // already captured

        $existed = is_file($live);
        if ($existed) {
            $dest = $restoreDir . '/files/' . $rel;
            if (!is_dir(dirname($dest))) @mkdir(dirname($dest), 0775, true);
            @copy($live, $dest);
        }
        $entries[$rel] = ['existed' => $existed];
        @file_put_contents($meta, json_encode($entries));
    }

    private function writeInstalled(array $manifest, string $restoreId): void
    {
        if (!is_dir($this->stateDir)) @mkdir($this->stateDir, 0775, true);
        // Capture the OUTGOING version before the manifest is replaced, otherwise
        // the restore point records "1.1.0 → 1.1.0" and the rollback screen cannot
        // tell the owner what they would be going back to.
        $fromVersion = (string) ($this->installedManifest()['version'] ?? '');

        $manifest['installedAt'] = date('c');
        $manifest['restoreId']   = $restoreId;
        @file_put_contents($this->stateDir . '/installed.json',
            json_encode($manifest, JSON_PRETTY_PRINT | JSON_UNESCAPED_SLASHES));
        @file_put_contents($this->stateDir . '/restore/' . $restoreId . '/.meta.json',
            json_encode([
                'toVersion'   => $manifest['version'] ?? '',
                'fromVersion' => $fromVersion,
                'at'          => date('c'),
            ]));
    }

    private function maintenance(bool $on): void
    {
        $flag = $this->root . '/storage/maintenance.flag';
        if ($on) {
            if (!is_dir(dirname($flag))) @mkdir(dirname($flag), 0775, true);
            @file_put_contents($flag, (string) time());
        } else {
            @unlink($flag);
        }
    }

    private function inspectFail(string $msg): array
    {
        return ['ok' => false, 'version' => '', 'codename' => '', 'builtAt' => '',
                'current' => $this->installedVersion(), 'errors' => [$msg], 'warnings' => [],
                'summary' => ['add' => 0, 'update' => 0, 'unchanged' => 0, 'remove' => 0], 'removals' => []];
    }
}
