<?php

declare(strict_types=1);

namespace PpPhp\Modules\Settings\Controllers;

use PpPhp\Core\Config;
use PpPhp\Core\ModuleManager;
use PpPhp\Core\Security;
use PpPhp\Core\Update\AutoUpdater;
use PpPhp\Core\Update\PlatformUpdater;
use PpPhp\Core\Update\UpdateClient;
use PpPhp\Core\Version;

/**
 * Platform Updates — ADMINISTRATORS of this installation.
 *
 * "Update Locally": the owner uploads a release bundle and the running
 * application installs it in place. Built for FTP-only hosting, where there is
 * no shell to run a deploy script.
 *
 * Every write goes through \PpPhp\Core\Update\PlatformUpdater, which refuses to
 * touch projects/, output/, storage/ or credentials, verifies the whole bundle
 * before changing a single file, and records a restore point for rollback.
 *
 * "Check for Updates" is stubbed for a future release feed; nothing here calls
 * out to the network today.
 */
class PlatformUpdateController
{
    public function __construct(
        private Config $config,
        private ModuleManager $moduleManager,
    ) {}

    /**
     * Updates are an ADMIN capability, not an owner one.
     *
     * Producing a release is the vendor's job (owner-only, see
     * PlatformBundleController). CONSUMING one belongs to whoever runs the
     * hosted install — so any administrator can check for and apply updates on
     * their own server. Owner passes too, since IsAdmin() includes it.
     */
    private function gateAdmin(): void
    {
        Security::RequireAuth();
        if (!Security::IsAdmin()) {
            redirect('/projects');
        }
    }

    /** True when this is a client copy that has never been licensed → OTA is locked. */
    private function updatesLocked(): bool
    {
        if (!\PpPhp\Core\Licensing\LicenseGate::isClientEdition()) return false;
        try {
            return !(class_exists(\PpPhp\Core\Licensing\LicenseClient::class)
                && (new \PpPhp\Core\Licensing\LicenseClient())->isActivated());
        } catch (\Throwable $e) {
            return false;
        }
    }

    /**
     * OTA ("Check for Updates" → download signed vendor bundle → apply). On a
     * CLIENT this is open to any authenticated role (admin/developer/user) once
     * the copy is LICENSED — they can only pull the vendor's SIGNED release, never
     * inject code (that path is owner-only upload()). On the vendor's own
     * server/dev build the stricter admin gate stays.
     */
    private function gateOta(bool $json = true): void
    {
        Security::RequireAuth();
        if (!\PpPhp\Core\Licensing\LicenseGate::isClientEdition()) {
            if (!Security::IsAdmin()) { $json ? $this->json(['success' => false, 'message' => 'Administrators only.'], 403) : redirect('/projects'); }
            return;
        }
        if ($this->updatesLocked()) {
            $msg = 'This installation is not activated. Enter a licence to enable updates.';
            $json ? $this->json(['success' => false, 'message' => $msg], 403) : redirect('/licensing');
        }
    }

    /**
     * The manual / advanced update surfaces (local upload, full-bundle fetch,
     * restore points). On a CLIENT these are OWNER-only; on the vendor's own
     * build the admin gate stays. Also refuses while unlicensed.
     */
    private function gateOwnerManual(): void
    {
        Security::RequireAuth();
        $client = \PpPhp\Core\Licensing\LicenseGate::isClientEdition();
        $ok = $client ? Security::IsOwner() : Security::IsAdmin();
        if (!$ok) {
            $this->json(['success' => false, 'message' => 'Only the platform owner can use manual updates on this installation.'], 403);
        }
        if ($this->updatesLocked()) {
            $this->json(['success' => false, 'message' => 'This installation is not activated. Enter a licence to enable updates.'], 403);
        }
    }

    /**
     * "Update Locally" (upload a bundle → inspect → apply). Available to any ADMIN on
     * a LICENSED installation — so a customer can update their OWN install even when
     * the update host is unreachable (a stuck OTA), not just the vendor owner. Applied
     * bundles are hash-checked and CodeIntegrity locks the install if enforcement
     * files are modified, so licensing can't be tampered away.
     */
    private function gateLocalUpdate(): void
    {
        Security::RequireAuth();
        if (!Security::IsAdmin()) {
            $this->json(['success' => false, 'message' => 'Administrators only.'], 403);
        }
        if ($this->updatesLocked()) {
            $this->json(['success' => false, 'message' => 'This installation is not activated. Enter a licence to enable updates.'], 403);
        }
    }

    private function json(array $payload, int $status = 200): void
    {
        http_response_code($status);
        header('Content-Type: application/json');
        echo json_encode($payload);
        exit;
    }

    /** Where uploaded bundles are staged (never inside a web-served folder). */
    private function stageDir(): string
    {
        $d = constant('BASE_PATH') . '/storage/updates/incoming';
        if (!is_dir($d)) @mkdir($d, 0775, true);
        return $d;
    }

    public function index(): string
    {
        // Any authenticated user may VIEW the page (the body is gated by role and
        // licence below); the vendor's own server keeps the admin-only rule.
        Security::RequireAuth();
        if (!\PpPhp\Core\Licensing\LicenseGate::isClientEdition() && !Security::IsAdmin()) {
            redirect('/projects');
        }

        $updater   = new PlatformUpdater(constant('BASE_PATH'));
        $baseUrl   = $this->config->get('base_url');
        $assetsUrl = $this->config->get('assets_url');
        $user      = Security::CurrentUserData();
        $navItems  = \PpPhp\Core\ProjectNav::items('/platform/updates');

        $codeVersion      = Version::PLATFORM;
        $installedVersion = $updater->installedVersion();
        $installed        = $updater->installedManifest();
        $preflight        = $updater->preflight();
        $restorePoints    = $updater->restorePoints();
        $uploadCeiling    = $this->uploadCeiling();
        $uploadCeilingHuman = $uploadCeiling ? $this->humanBytes($uploadCeiling) : 'unlimited';
        $stagedBundles    = $this->stagedBundles();
        $stageDirRel      = 'storage/updates/incoming';

        // Client-edition gating (see updates.php):
        //  • the manual/advanced update paths are OWNER-only;
        //  • OTA is locked until the copy is licensed (activated).
        $isOwner         = Security::IsOwner();
        $isClientEdition = \PpPhp\Core\Licensing\LicenseGate::isClientEdition();
        $licenseActivated = !$isClientEdition
            || (class_exists(\PpPhp\Core\Licensing\LicenseClient::class)
                && (new \PpPhp\Core\Licensing\LicenseClient())->isActivated());
        $updatesLocked   = $isClientEdition && !$licenseActivated;
        // "Update Locally" is available to any ADMIN of a LICENSED install (not just
        // the vendor owner), so a customer can update when OTA can't reach the host.
        $canLocalUpdate  = Security::IsAdmin() && !$updatesLocked;
        $supportContact  = \PpPhp\Core\Licensing\LicenseGate::supportContact();

        // A newer release the background notifier has already spotted (see
        // core/Update/UpdateNotifier). Only surface it when it is genuinely ahead
        // of what is installed — a just-applied update leaves a stale marker until
        // the next feed check clears it.
        $autoSecurity = class_exists(\PpPhp\Core\Update\AdvisoryClient::class)
            ? \PpPhp\Core\Update\AdvisoryClient::autoSecurityEnabled() : true;

        // Friendly name for the update service — the client's JS uses it in any
        // failure message so a raw host/path is never shown.
        $updateServiceName = $this->updateServiceName();

        $updateAvailable = '';
        if (class_exists(\PpPhp\Core\Update\UpdateNotifier::class)) {
            $flagged = \PpPhp\Core\Update\UpdateNotifier::available();
            if ($flagged !== '' && version_compare($flagged, (string) $installedVersion, '>')) {
                $updateAvailable = $flagged;
            }
        }

        ob_start();
        require __DIR__ . '/../Views/updates.php';
        return (string) ob_get_clean();
    }

    /**
     * Inspect a bundle already sitting in the staging folder (dropped there by
     * FTP). Lets a size-limited host update without a large HTTP upload — the
     * request carries only the filename, not the bundle.
     */
    public function inspectStaged(): void
    {
        $this->gateLocalUpdate();
        $input = json_decode((string) file_get_contents('php://input'), true) ?: [];
        if (!Security::CSRF()->Check((string) ($input['_token'] ?? ''))) {
            $this->json(['success' => false, 'message' => 'Invalid security token. Reload the page and try again.'], 400);
        }
        $name = basename((string) ($input['bundle'] ?? ''));
        $path = $this->stageDir() . '/' . $name;
        if ($name === '' || !preg_match('/\.zip$/i', $name) || !is_file($path)) {
            $this->json(['success' => false, 'message' => 'That bundle is not in ' . 'storage/updates/incoming' . ' any more.'], 404);
        }

        $report = (new PlatformUpdater(constant('BASE_PATH')))->inspect($path);
        $report['bundle'] = $name;
        $report['success'] = true;
        $this->json($report);
    }

    /**
     * Hand back the current session CSRF token.
     *
     * A big upload can sit on the page long enough for the session token to move
     * on underneath it (a re-login regenerates it), which then reads as "invalid
     * security token" even though the person is perfectly authenticated. The page
     * fetches a fresh token from here and retries once, so a stale token
     * self-heals instead of forcing a reload. Read-only and same-origin, so it
     * gives an attacker nothing — a cross-origin caller cannot read the response.
     */
    public function csrfToken(): void
    {
        Security::RequireAuth();
        $this->json(['success' => true, 'token' => Security::CSRF()->Token()]);
    }

    /** Parse a php.ini size ("8M", "128M", "1G") to bytes. 0 = unlimited/unset. */
    private function iniBytes(string $v): int
    {
        $v = trim($v);
        if ($v === '') return 0;
        $unit = strtolower(substr($v, -1));
        $n = (int) $v;
        return match ($unit) { 'g' => $n << 30, 'm' => $n << 20, 'k' => $n << 10, default => $n };
    }

    private function humanBytes(int $b): string
    {
        if ($b >= 1073741824) return round($b / 1073741824, 1) . ' GB';
        if ($b >= 1048576)    return round($b / 1048576, 1) . ' MB';
        if ($b >= 1024)       return round($b / 1024) . ' KB';
        return $b . ' B';
    }

    /** The effective single-upload ceiling: min of post_max_size and upload_max_filesize. */
    private function uploadCeiling(): int
    {
        $post = $this->iniBytes((string) ini_get('post_max_size'));
        $upl  = $this->iniBytes((string) ini_get('upload_max_filesize'));
        $vals = array_filter([$post, $upl]);          // drop 0 (unlimited)
        return $vals ? min($vals) : 0;
    }

    /** Bundles dropped into storage/updates/incoming via FTP (the size-limit-free path). */
    private function stagedBundles(): array
    {
        $out = [];
        foreach (glob($this->stageDir() . '/*.zip') ?: [] as $f) {
            $out[] = ['name' => basename($f), 'size' => (int) filesize($f), 'mtime' => (int) filemtime($f)];
        }
        usort($out, static fn ($a, $b) => $b['mtime'] <=> $a['mtime']);
        return $out;
    }

    /** Receive a bundle, store it, and report what it WOULD do. Nothing is applied. */
    public function upload(): void
    {
        $this->gateLocalUpdate();

        // When an upload exceeds post_max_size, PHP throws the WHOLE body away —
        // $_POST and $_FILES arrive empty even though bytes were sent. That then
        // fails the CSRF check and reads as "invalid security token", which sends
        // people chasing the wrong problem. Detect it and tell the truth.
        $sent = (int) ($_SERVER['CONTENT_LENGTH'] ?? 0);
        if ($_POST === [] && $_FILES === [] && $sent > 0) {
            $ceiling = $this->uploadCeiling();
            $this->json([
                'success' => false,
                'tooLarge' => true,
                'message' => 'The bundle (' . $this->humanBytes($sent) . ') is bigger than this server accepts in one upload'
                    . ($ceiling ? ' — the limit here is ' . $this->humanBytes($ceiling)
                        . ' (post_max_size / upload_max_filesize)' : '')
                    . '. Either raise those two php.ini values above ' . $this->humanBytes($sent)
                    . ', or upload the bundle by FTP into storage/updates/incoming/ and install it from this page (no size limit).',
            ], 413);
        }

        if (!Security::CSRF()->Check($_POST['_token'] ?? '')) {
            $this->json(['success' => false, 'message' => 'Invalid security token. Reload the page and try again.'], 400);
        }
        $file = $_FILES['bundle'] ?? null;
        if (!is_array($file) || ($file['error'] ?? UPLOAD_ERR_NO_FILE) !== UPLOAD_ERR_OK) {
            $this->json(['success' => false, 'message' => 'No file was uploaded, or it exceeded the server upload limit.'], 400);
        }
        if (!preg_match('/\.zip$/i', (string) $file['name'])) {
            $this->json(['success' => false, 'message' => 'Please upload the .zip release bundle.'], 400);
        }

        $dest = $this->stageDir() . '/bundle-' . date('Ymd-His') . '.zip';
        if (!@move_uploaded_file($file['tmp_name'], $dest)) {
            $this->json(['success' => false, 'message' => 'Could not save the upload — is storage/ writable?'], 500);
        }

        $updater = new PlatformUpdater(constant('BASE_PATH'));
        $report  = $updater->inspect($dest);
        $report['bundle'] = basename($dest);
        $report['success'] = true;
        $this->json($report);
    }

    /** Apply a previously uploaded + inspected bundle. */
    public function apply(): void
    {
        $this->gateOta();
        $input = json_decode((string) file_get_contents('php://input'), true) ?: [];
        if (!Security::CSRF()->Check((string) ($input['_token'] ?? ''))) {
            $this->json(['success' => false, 'message' => 'Invalid security token.'], 400);
        }
        $name = basename((string) ($input['bundle'] ?? ''));
        $path = $this->stageDir() . '/' . $name;
        if ($name === '' || !is_file($path)) {
            $this->json(['success' => false, 'message' => 'That bundle is no longer available — upload it again.'], 404);
        }

        // Applying can be I/O heavy; don't let the request time out or a dropped
        // connection abort it half-way. Raise memory too — a big file is read
        // whole from the ZIP.
        @set_time_limit(0);
        @ignore_user_abort(true);
        if ((int) preg_replace('/\D/', '', (string) ini_get('memory_limit')) < 256) {
            @ini_set('memory_limit', '256M');
        }

        try {
            $updater = new PlatformUpdater(constant('BASE_PATH'));
            $result  = $updater->apply($path);
        } catch (\Throwable $e) {
            $this->json(['success' => false, 'message' => $this->safeError($e, 'The update could not be applied. Please try again later.')], 500);
        }

        @unlink($path);                                   // don't leave bundles lying around
        \PpPhp\Core\PlatformSettings::setVersion($result['version']);
        $result['success'] = true;
        $result['message'] = 'Updated to ' . $result['version'] . '.';
        $this->json($result);
    }

    /** Undo the last update. */
    public function rollback(): void
    {
        // Rollback is SELF-RECOVERY: any admin of a licensed install may undo an
        // update they applied (it only restores a snapshot the updater itself took —
        // the previous, vendor-signed version). Owner-only would strand a customer
        // with a bad update and no way back except FTP. Mirrors gateLocalUpdate.
        $this->gateLocalUpdate();
        $input = json_decode((string) file_get_contents('php://input'), true) ?: [];
        if (!Security::CSRF()->Check((string) ($input['_token'] ?? ''))) {
            $this->json(['success' => false, 'message' => 'Invalid security token.'], 400);
        }
        try {
            $updater = new PlatformUpdater(constant('BASE_PATH'));
            $result  = $updater->rollback((string) ($input['id'] ?? ''));
            $result['success'] = true;
            $result['message'] = 'Rolled back — ' . $result['restored'] . ' files restored.';
            \PpPhp\Core\PlatformSettings::setVersion($updater->installedVersion());
            $this->json($result);
        } catch (\Throwable $e) {
            $this->json(['success' => false, 'message' => $this->safeError($e, 'The rollback could not be completed. Please try again later.')], 500);
        }
    }

    /**
     * Permanently delete a restore point and its snapshot files from disk.
     *
     * Restore points pile up one-per-update; over many updates they can grow
     * large. Owner/superadmin (both pass gateAdmin) can prune them so the install
     * stays clean. This removes the files from the server — it is not undoable.
     */
    public function restoreDelete(): void
    {
        // Deleting a restore point is part of the same self-service surface as
        // rollback — any admin of a licensed install may prune their own snapshots.
        $this->gateLocalUpdate();
        $input = json_decode((string) file_get_contents('php://input'), true) ?: [];
        if (!Security::CSRF()->Check((string) ($input['_token'] ?? ''))) {
            $this->json(['success' => false, 'message' => 'Invalid security token.'], 400);
        }
        try {
            $updater = new PlatformUpdater(constant('BASE_PATH'));
            $result  = $updater->deleteRestorePoint((string) ($input['id'] ?? ''));
            $result['success'] = true;
            $result['message'] = 'Restore point deleted — ' . $result['removed'] . ' file(s) removed from the server.';
            $this->json($result);
        } catch (\Throwable $e) {
            $this->json(['success' => false, 'message' => $this->safeError($e, 'The restore point could not be deleted. Please try again later.')], 500);
        }
    }

    /**
     * Ask the Update Engine (the signed authority) whether a newer release is on
     * offer for this install's version + channel. Verified offline; never throws.
     */
    /**
     * The friendly name shown to a customer for the update service — the vendor's
     * label (from the signed lease on a client, or the App-Bundle display name on
     * the authority), or a neutral fallback. Used so a failure message never has to
     * name a raw host/path.
     */
    private function updateServiceName(): string
    {
        try {
            if (class_exists(\PpPhp\Core\Licensing\LicenseGate::class)
                && \PpPhp\Core\Licensing\LicenseGate::isClientEdition()
                && class_exists(\PpPhp\Core\Licensing\LicenseClient::class)) {
                $n = (new \PpPhp\Core\Licensing\LicenseClient(constant('BASE_PATH')))->updateDisplayName();
                if (trim($n) !== '') return trim($n);
            } elseif (class_exists(\PpPhp\Core\Update\UpdateHostConfig::class)) {
                $n = \PpPhp\Core\Update\UpdateHostConfig::displayName();
                if (trim($n) !== '') return trim($n);
            }
        } catch (\Throwable $e) { /* fall through */ }
        return 'the update service';
    }

    /**
     * A failure message that is safe to show on THIS installation. On a customer
     * install a raw exception may carry a filesystem path or a host address, so the
     * customer only ever sees a clean, path-free sentence; the vendor's own
     * authority/dev console keeps the real detail for diagnosis. Either way the real
     * message is written to the server log so nothing is lost.
     */
    private function safeError(\Throwable $e, string $friendly): string
    {
        error_log('[platform-update] ' . $e->getMessage());
        $client = class_exists(\PpPhp\Core\Licensing\LicenseGate::class)
            && \PpPhp\Core\Licensing\LicenseGate::isClientEdition();
        return $client ? $friendly : $e->getMessage();
    }

    public function check(): void
    {
        $this->gateOta();
        // Never let an unexpected throw become a 500. On a CUSTOMER install a failure
        // message must be clean and NEVER expose a host/path — so a client sees only
        // the display name + "try again later"; the real cause goes to the log and to
        // the owner's authority console.
        try {
            $result = (new UpdateClient(constant('BASE_PATH')))->check();
        } catch (\Throwable $e) {
            $this->json([
                'success'    => false,
                'ok'         => false,
                'available'  => false,
                'configured' => true,
                'installed'  => (string) Version::PLATFORM,
                'message'    => $this->safeError(
                    $e,
                    'We couldn\'t reach ' . $this->updateServiceName() . ' just now. Please try again later.'
                ),
            ]);
            return;
        }
        $this->json([
            'success'    => true,
            // 'ok' distinguishes a completed check (up to date / update available)
            // from one that couldn't reach the service — the view needs this to
            // pick between "You're up to date" and "Couldn't check for updates".
            'ok'         => (bool) ($result['ok'] ?? true),
            'available'  => (bool) ($result['available'] ?? false),
            'configured' => (bool) ($result['configured'] ?? false),
            'installed'  => (string) ($result['installed'] ?? Version::PLATFORM),
            'version'    => (string) ($result['version'] ?? ''),
            'kind'       => (string) ($result['kind'] ?? ''),
            'size'       => (int) ($result['size'] ?? 0),
            'notes'      => (string) ($result['notes'] ?? ''),
            'message'    => (string) ($result['message'] ?? ''),
        ]);
    }

    /**
     * Download the offered update, verify its hash, and report the plan — reusing
     * the EXACT same inspect → plan → confirm → apply() flow as an Update-Locally
     * upload. The verified bundle lands in the staging folder, so the existing
     * apply() endpoint finishes the job (restore point, protected paths, prune).
     */
    public function download(): void
    {
        $this->gateOta();
        $input = json_decode((string) file_get_contents('php://input'), true) ?: [];
        if (!Security::CSRF()->Check((string) ($input['_token'] ?? ''))) {
            $this->json(['success' => false, 'message' => 'Invalid security token. Reload the page and try again.'], 400);
        }
        @set_time_limit(0);
        @ignore_user_abort(true);
        if ((int) preg_replace('/\D/', '', (string) ini_get('memory_limit')) < 256) {
            @ini_set('memory_limit', '256M');
        }

        $client = new UpdateClient(constant('BASE_PATH'));
        $check  = $client->check();
        if (empty($check['available'])) {
            $this->json(['success' => false, 'message' => (string) ($check['message'] ?? 'No update is available.')], 400);
        }
        try {
            $updater = new PlatformUpdater(constant('BASE_PATH'));
            $path    = $client->download($check);
            $report  = $updater->inspect($path);
            // Drift guard: a delta may download but fail inspect because a file it
            // expects unchanged differs on this server. Fall back to the full bundle,
            // which always applies cleanly — so the install is never stuck on a delta.
            if (empty($report['ok']) && UpdateClient::canFallBackToFull($check)) {
                @unlink($path);
                $path   = $client->downloadFull($check);
                $report = $updater->inspect($path);
                $report['fellBackToFull'] = true;
            }
        } catch (\Throwable $e) {
            $this->json(['success' => false, 'message' => $this->safeError($e, 'The update could not be prepared. Please try again later.')], 500);
        }
        $report['bundle']     = basename($path);
        $report['success']    = true;
        $report['downloaded'] = true;
        $this->json($report);
    }

    /**
     * Fetch the FULL bundle from the update host, server-side, and stage it for
     * apply. This bypasses BOTH the delta (so a drifted install that can't take a
     * patch still updates) AND the browser upload cap (PHP upload_max_filesize) —
     * the 30 MB full is pulled by the server, not uploaded. Returns the same
     * inspect report shape as upload()/download() so the existing plan+apply UI
     * works unchanged.
     */
    public function fetchFull(): void
    {
        $this->gateOwnerManual();
        $input = json_decode((string) file_get_contents('php://input'), true) ?: [];
        if (!Security::CSRF()->Check((string) ($input['_token'] ?? ''))) {
            $this->json(['success' => false, 'message' => 'Invalid security token. Reload the page and try again.'], 400);
        }
        @set_time_limit(0);
        @ignore_user_abort(true);
        if ((int) preg_replace('/\D/', '', (string) ini_get('memory_limit')) < 256) {
            @ini_set('memory_limit', '256M');
        }

        $client = new UpdateClient(constant('BASE_PATH'));
        $check  = $client->check();
        // Works whether the feed offered a delta (available) or a full-only jump.
        $newer  = (string) ($check['version'] ?? '') !== ''
            && version_compare((string) $check['version'], (string) ($check['installed'] ?? '0'), '>');
        if (!$newer) {
            $this->json(['success' => false, 'message' => (string) ($check['message'] ?? 'No newer version is available.')], 400);
        }
        if ((string) ($check['fullUrl'] ?? '') === '') {
            $this->json(['success' => false, 'message' => 'The update host did not advertise a full bundle for ' . (string) $check['version']
                . '. Republish the index from App Bundle → Update host, then try again.'], 400);
        }
        try {
            $path   = $client->downloadFull($check);
            $report = (new PlatformUpdater(constant('BASE_PATH')))->inspect($path);
        } catch (\Throwable $e) {
            $this->json(['success' => false, 'message' => $this->safeError($e, 'The full update bundle could not be fetched. Please try again later.')], 500);
        }
        $report['bundle']     = basename($path);
        $report['success']    = true;
        $report['downloaded'] = true;
        $report['fetchedFull'] = true;
        $this->json($report);
    }

    // ── Automatic updates ────────────────────────────────────────────────

    /** Turn unattended updates on/off (admin, CSRF-guarded). */
    public function autoToggle(): void
    {
        $this->gateOwnerManual();
        $input = json_decode((string) file_get_contents('php://input'), true) ?: [];
        if (!Security::CSRF()->Check((string) ($input['_token'] ?? ''))) {
            $this->json(['success' => false, 'message' => 'Invalid security token.'], 400);
        }
        AutoUpdater::setEnabled(!empty($input['enabled']));
        $this->json(['success' => true, 'status' => AutoUpdater::status(constant('BASE_PATH'))]);
    }

    /**
     * Turn "auto-install critical security patches" on/off (licensed admin, CSRF).
     * Default is ON — this only ever lets an admin OPT OUT of unattended security
     * patching. Never applies ordinary updates unattended (that is autoToggle).
     */
    public function securityAutoToggle(): void
    {
        $this->gateLocalUpdate();
        $input = json_decode((string) file_get_contents('php://input'), true) ?: [];
        if (!Security::CSRF()->Check((string) ($input['_token'] ?? ''))) {
            $this->json(['success' => false, 'message' => 'Invalid security token.'], 400);
        }
        if (class_exists(\PpPhp\Core\Update\AdvisoryClient::class)) {
            \PpPhp\Core\Update\AdvisoryClient::setAutoSecurity(!empty($input['enabled']));
        }
        $this->json(['success' => true, 'enabled' => !empty($input['enabled'])]);
    }

    /** Run a check-and-apply cycle right now from the console (admin, CSRF). */
    public function autoRunNow(): void
    {
        $this->gateOta();
        $input = json_decode((string) file_get_contents('php://input'), true) ?: [];
        if (!Security::CSRF()->Check((string) ($input['_token'] ?? ''))) {
            $this->json(['success' => false, 'message' => 'Invalid security token.'], 400);
        }
        @set_time_limit(0);
        @ignore_user_abort(true);
        $res = (new AutoUpdater(constant('BASE_PATH')))->run(['force' => true]);
        $res['success'] = true;
        $res['status']  = AutoUpdater::status(constant('BASE_PATH'));
        $this->json($res);
    }

    /**
     * Web-cron trigger for hosts without shell cron. NO login — the caller proves
     * authority by presenting the per-install secret (?key=…), which only the
     * owner can read from the console. A wrong/absent key is a flat 403, so this
     * URL can never be used to force a code change by an outsider.
     */
    public function autoRun(): void
    {
        $key = (string) ($_GET['key'] ?? $_POST['key'] ?? '');
        if (!hash_equals(AutoUpdater::secret(), $key)) {
            $this->json(['success' => false, 'message' => 'Forbidden.'], 403);
        }
        @set_time_limit(0);
        @ignore_user_abort(true);
        $res = (new AutoUpdater(constant('BASE_PATH')))->run();
        $res['success'] = true;
        $this->json($res);
    }
}
