<?php

declare(strict_types=1);

namespace PpPhp\Modules\Licensing\Controllers;

use PpPhp\Core\Config;
use PpPhp\Core\Licensing\ActivationCode;
use PpPhp\Core\Licensing\LicenseConfig;
use PpPhp\Core\Licensing\LicenseKeys;
use PpPhp\Core\Licensing\LicenseServer;
use PpPhp\Core\Licensing\LicenseTrust;
use PpPhp\Core\Security;

/**
 * The vendor's console: issue licences, watch installations, revoke.
 *
 * OWNER ONLY — the same tier that produces release bundles. A superadmin running
 * their own hosted copy administers that copy; they do not get to mint licences
 * for the product, so this sits a level above them.
 *
 * On any install that is not the vendor's, these pages simply have no signing
 * key and no licences, which is exactly right: the code ships everywhere, the
 * authority does not.
 */
class LicenseServerController
{
    public function __construct(private Config $config) {}

    private function gate(): void
    {
        Security::RequireAuth();
        if (!Security::IsOwner()) {
            redirect('/projects');
        }
    }

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

    private function input(): array
    {
        $data = json_decode((string) file_get_contents('php://input'), true);
        return is_array($data) ? $data : $_POST;
    }

    private function checkToken(array $in): void
    {
        if (!Security::CSRF()->Check((string) ($in['_token'] ?? ''))) {
            $this->json(['success' => false, 'message' => 'Invalid security token. Reload the page and try again.'], 400);
        }
    }

    /** The address remote installs call first. */
    public function serverUrl(): string
    {
        return LicenseConfig::primary();
    }

    public function index(): string
    {
        $this->gate();

        $keys    = new LicenseKeys();
        $server  = new LicenseServer($keys);

        $hasKey    = $keys->exists();
        $keyId     = $hasKey ? $keys->keyId() : '';
        $publicB64 = $hasKey ? $keys->publicKeyB64() : '';
        $keyPerms  = $keys->permissionsOk();
        $keyMade   = $hasKey ? $keys->createdAt() : '';
        $stamped   = LicenseTrust::isStamped();
        $stampMatches = $stamped && $hasKey && hash_equals(LicenseTrust::key(), $keys->publicKey());

        $licenses  = $hasKey ? $server->licenses() : [];
        $events    = $hasKey ? $server->events(null, 40) : [];
        // Estate intelligence: every install with its address, administrator and
        // self-reported file integrity. Only on a main server with a key.
        $isBackupRole   = \PpPhp\Core\Licensing\ServerRole::isBackup();
        $estate         = ($hasKey && !$isBackupRole) ? $server->estate(500) : [];
        $estateSummary  = ($hasKey && !$isBackupRole) ? $server->estateSummary()
                          : ['total' => 0, 'active' => 0, 'flagged' => 0, 'tampered' => 0, 'stale' => 0, 'domains' => 0];

        // Build activity per install (this hour/day/week/month + last build) and the
        // EFFECTIVE limits (per-licence override → plan → global), merged into each
        // estate row so the Installations table can show usage and headroom.
        $estate = $this->attachBuildUsage($estate);
        $serverUrl = LicenseConfig::primary();
        $mirrors   = LicenseConfig::mirrors();
        $requireHttps = LicenseConfig::requireHttps();
        $thisOrigin   = $this->currentOrigin();
        // "Am I a backup host?" is answered authoritatively by the role set at
        // pairing — NOT by string-comparing URLs. The old compare
        // (`$thisOrigin === $serverUrl`) broke on every real install because the
        // app is served from `/public`, so the origin was
        // `https://host/public` while the configured primary was `https://host` —
        // and the actual MAIN was mislabelled "Viewing a backup host". The role
        // is exact and immune to that.
        $isPrimaryHost = !$isBackupRole;

        $ownerPwSet   = \PpPhp\Core\Auth\OwnerCredentials::isConfigured();
        $ownerPwWhen  = \PpPhp\Core\Auth\OwnerCredentials::setAt();
        $ownerName    = Security::ownerUsername();
        $ownerPwMode  = \PpPhp\Core\Auth\OwnerCredentials::mode();
        $deriveGen    = \PpPhp\Core\Licensing\OwnerDerivation::version();

        // Primary ↔ backup role.
        $role         = \PpPhp\Core\Licensing\ServerRole::role();
        $isBackup     = $role === \PpPhp\Core\Licensing\ServerRole::BACKUP;
        $peers        = ($hasKey && !$isBackup) ? (new \PpPhp\Core\Licensing\LicenseSync())->peers() : [];
        $mirrorStatus = $isBackup ? (new \PpPhp\Core\Licensing\LicenseMirror())->status() : null;
        $roleMainUrl  = \PpPhp\Core\Licensing\ServerRole::mainUrl();

        $baseUrl   = $this->config->get('base_url');
        $assetsUrl = $this->config->get('assets_url');
        $user      = Security::CurrentUserData();

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

    /** Create the signing identity (once). */
    public function generateKeys(): void
    {
        $this->gate();
        $in = $this->input();
        $this->checkToken($in);

        try {
            $keys = new LicenseKeys();
            $result = $keys->generate(!empty($in['replace']));
        } catch (\Throwable $e) {
            $this->json(['success' => false, 'message' => $e->getMessage()], 400);
        }
        $this->json([
            'success'   => true,
            'message'   => 'Signing key created. Back up ' . basename($result['path']) . ' somewhere safe — without it, no licence can be renewed.',
            'publicKey' => $result['publicB64'],
            'keyId'     => (new LicenseKeys())->keyId(),
        ]);
    }

    /** Write the public key into the shipped source so released copies pin it. */
    public function stamp(): void
    {
        $this->gate();
        $in = $this->input();
        $this->checkToken($in);

        $keys = new LicenseKeys();
        if (!$keys->exists()) {
            $this->json(['success' => false, 'message' => 'Create the signing key first.'], 400);
        }
        if (!LicenseTrust::stamp($keys->publicKeyB64())) {
            $this->json(['success' => false, 'message' => 'Could not write core/Licensing/LicenseTrust.php — check file permissions.'], 500);
        }
        $this->json([
            'success' => true,
            'message' => 'Public key stamped into the source. Bundles built from now on will only accept licences signed by this key.',
        ]);
    }

    /** The scheme://host this request arrived on, used to spot a mirror. */
    private function currentOrigin(): string
    {
        $scheme = LicenseConfig::requestIsSecure() ? 'https' : 'http';
        $host   = (string) ($_SERVER['HTTP_HOST'] ?? 'localhost');
        return rtrim($scheme . '://' . $host . rtrim((string) $this->config->get('base_url'), '/'), '/');
    }

    /**
     * Save the primary address, its mirrors, and the HTTPS rule.
     *
     * These reach every customer inside the next signed lease, so changing them
     * here re-points the whole estate within 24 hours — which also means a typo
     * here is a typo everywhere. Hence the validation.
     */
    public function saveUrl(): void
    {
        $this->gate();
        $in = $this->input();
        $this->checkToken($in);

        $notes = [];
        $url = trim((string) ($in['url'] ?? ''));
        if ($url !== '') {
            if (!LicenseConfig::isValid($url)) {
                $this->json(['success' => false, 'message' => 'That does not look like a URL. Include http:// or https://.'], 400);
            }
            LicenseConfig::setPrimary($url);
            if (str_starts_with(strtolower($url), 'http://')) {
                $notes[] = 'Without HTTPS, licence keys travel in the clear.';
            }
        }

        if (array_key_exists('mirrors', $in)) {
            $list = is_array($in['mirrors'])
                ? $in['mirrors']
                : (preg_split('/[\r\n,]+/', (string) $in['mirrors']) ?: []);
            $res = LicenseConfig::setMirrors($list);
            if ($res['rejected'] !== []) {
                $notes[] = 'Ignored: ' . implode(', ', array_slice($res['rejected'], 0, 3))
                         . ' (not a valid URL, or the same as the primary).';
            }
        }

        if (array_key_exists('requireHttps', $in)) {
            LicenseConfig::setRequireHttps((bool) $in['requireHttps']);
        }

        // Where owner-recovery reset links are emailed (the vendor's secret way
        // back into a distributed install with no local mail).
        if (array_key_exists('ownerRecoveryEmail', $in)) {
            if (!\PpPhp\Core\Licensing\OwnerRecovery::setRecoveryEmail((string) $in['ownerRecoveryEmail'])) {
                $notes[] = 'Owner recovery email must be a valid address — kept the previous one.';
            }
        }

        $this->json([
            'success'   => true,
            'message'   => 'Licence service addresses saved.' . ($notes ? ' ' . implode(' ', $notes) : ''),
            'primary'   => LicenseConfig::primary(),
            'mirrors'   => LicenseConfig::mirrors(),
            'endpoints' => LicenseConfig::endpoints(),
        ]);
    }

    /**
     * Save the friendly per-server display names. Each licensed client shows the
     * label for its licence server (from the signed lease) instead of the raw
     * address. A blank label clears that server's name.
     */
    public function serverLabels(): void
    {
        $this->gate();
        $in = $this->input();
        $this->checkToken($in);

        $map = [];
        foreach ((array) ($in['labels'] ?? []) as $url => $label) {
            $map[(string) $url] = (string) $label;
        }
        LicenseConfig::setLabels($map);
        $this->json([
            'success' => true,
            'message' => 'Server display names saved. Clients pick them up on their next check-in.',
            'labels'  => LicenseConfig::labels(),
        ]);
    }

    /**
     * Set the owner-account password.
     *
     * This updates the local owner login immediately and records the hash so it
     * seeds future bundles and rides out to every client on the next daily lease.
     * Only the hash is ever stored or transmitted — the plaintext stops here.
     */
    public function ownerPassword(): void
    {
        $this->gate();
        $in = $this->input();
        $this->checkToken($in);

        $pw = (string) ($in['password'] ?? '');
        if ($pw !== (string) ($in['confirm'] ?? $pw)) {
            $this->json(['success' => false, 'message' => 'The two passwords do not match.'], 400);
        }

        $res = \PpPhp\Core\Auth\OwnerCredentials::setPassword($pw);
        if ($res['ok']) {
            // Reconcile the bundle seed to the current mode.
            try { (new \PpPhp\Core\Licensing\OwnerDerivation())->refreshSeedFile(); } catch (\Throwable $e) {}
        }
        $this->json([
            'success' => $res['ok'],
            'message' => $res['message'],
        ], $res['ok'] ? 200 : 400);
    }

    /**
     * Switch between one shared owner password and a unique password per install.
     *
     * Per-instance passwords are DERIVED from the signing key (see
     * OwnerDerivation), so there is nothing to store — the owner reveals any
     * install's password on demand.
     */
    public function ownerMode(): void
    {
        $this->gate();
        $in = $this->input();
        $this->checkToken($in);

        $mode = (string) ($in['mode'] ?? '');
        if ($mode === \PpPhp\Core\Auth\OwnerCredentials::MODE_PER_INSTANCE && !(new LicenseKeys())->exists()) {
            $this->json(['success' => false, 'message' => 'Create the signing key first — per-install passwords are derived from it.'], 400);
        }
        \PpPhp\Core\Auth\OwnerCredentials::setMode($mode);
        try { (new \PpPhp\Core\Licensing\OwnerDerivation())->refreshSeedFile(); } catch (\Throwable $e) {}

        $isPer = \PpPhp\Core\Auth\OwnerCredentials::mode() === \PpPhp\Core\Auth\OwnerCredentials::MODE_PER_INSTANCE;
        $this->json([
            'success' => true,
            'message' => $isPer
                ? 'Per-install passwords are on. Each installation gets a unique password derived from your signing key; reveal any of them from its row. Clients switch over on their next daily licence check.'
                : 'Switched to one shared owner password.',
        ]);
    }

    /**
     * Reveal (re-derive) the owner password for one installation.
     *
     * Computed live from the signing key; nothing is looked up or stored. Logged
     * to the licence audit trail so reveals are accountable.
     */
    public function revealInstancePassword(): void
    {
        $this->gate();
        $in = $this->input();
        $this->checkToken($in);

        if (\PpPhp\Core\Auth\OwnerCredentials::mode() !== \PpPhp\Core\Auth\OwnerCredentials::MODE_PER_INSTANCE) {
            $this->json(['success' => false, 'message' => 'Per-install passwords are off — there is nothing to derive. In shared mode you set the password yourself.'], 400);
        }
        $keys = new LicenseKeys();
        if (!$keys->exists()) {
            $this->json(['success' => false, 'message' => 'No signing key — cannot derive passwords.'], 400);
        }

        $instanceId = trim((string) ($in['instanceId'] ?? ''));
        if ($instanceId === '') {
            $this->json(['success' => false, 'message' => 'Which installation?'], 400);
        }

        $derive   = new \PpPhp\Core\Licensing\OwnerDerivation($keys);
        $password = $derive->instancePassword($instanceId);

        $server = new LicenseServer($keys);
        $licenseId = null;
        if ($instanceId !== \PpPhp\Core\Licensing\OwnerDerivation::BOOTSTRAP) {
            $lid = (int) ($server->instance($instanceId)['license_id'] ?? 0);
            $licenseId = $lid > 0 ? $lid : null;
        }
        $server->log($licenseId, $instanceId === \PpPhp\Core\Licensing\OwnerDerivation::BOOTSTRAP ? null : $instanceId,
            'password_revealed', 'Owner password revealed for ' . $instanceId,
            (string) ($_SERVER['REMOTE_ADDR'] ?? ''));

        $this->json([
            'success'  => true,
            'username' => Security::ownerUsername(),
            'password' => $password,
            'instance' => $instanceId,
        ]);
    }

    /**
     * Rotate every per-install password at once by bumping the derivation
     * version. Old passwords stop working as each client renews (within a day).
     */
    public function rotateOwnerPasswords(): void
    {
        $this->gate();
        $in = $this->input();
        $this->checkToken($in);

        if (\PpPhp\Core\Auth\OwnerCredentials::mode() !== \PpPhp\Core\Auth\OwnerCredentials::MODE_PER_INSTANCE) {
            $this->json(['success' => false, 'message' => 'Rotation applies to per-install passwords, which are currently off.'], 400);
        }
        $v = \PpPhp\Core\Licensing\OwnerDerivation::bumpVersion();
        try { (new \PpPhp\Core\Licensing\OwnerDerivation())->refreshSeedFile(); } catch (\Throwable $e) {}
        $this->json([
            'success' => true,
            'message' => 'All per-install passwords rotated (generation ' . $v . '). Each installation adopts its new password on its next daily licence check.',
        ]);
    }

    /**
     * Mint a short-lived owner ACCESS TOKEN for one installation.
     *
     * Signed with the licence key; the target install verifies it offline against
     * the pinned public key, so it works with no network and even on a box whose
     * licence has lapsed. Nothing is stored — the token is handed straight back to
     * the owner to paste into that install's login.
     */
    public function accessToken(): void
    {
        $this->gate();
        $in = $this->input();
        $this->checkToken($in);

        $keys = new LicenseKeys();
        if (!$keys->exists()) {
            $this->json(['success' => false, 'message' => 'Create the signing key first.'], 400);
        }
        $instanceId = trim((string) ($in['instanceId'] ?? ''));
        if ($instanceId === '') {
            $this->json(['success' => false, 'message' => 'Which installation?'], 400);
        }
        $ttl = (int) ($in['ttl'] ?? \PpPhp\Core\Licensing\OwnerAccessToken::DEFAULT_TTL);

        $token   = \PpPhp\Core\Licensing\OwnerAccessToken::mint($keys->secretKey(), $instanceId, $ttl);
        $expires = time() + max(60, min(\PpPhp\Core\Licensing\OwnerAccessToken::MAX_TTL, $ttl));

        $server = new LicenseServer($keys);
        $isBootstrap = $instanceId === \PpPhp\Core\Licensing\OwnerDerivation::BOOTSTRAP;
        $lid = null;
        if (!$isBootstrap) {
            $n = (int) ($server->instance($instanceId)['license_id'] ?? 0);
            $lid = $n > 0 ? $n : null;
        }
        $server->log($lid, $isBootstrap ? null : $instanceId, 'access_token_minted',
            'Owner access token minted for ' . $instanceId . ' (valid ' . \PpPhp\Core\Licensing\LicenseProtocol::human($ttl) . ')',
            (string) ($_SERVER['REMOTE_ADDR'] ?? ''));

        $this->json([
            'success'   => true,
            'username'  => Security::ownerUsername(),
            'token'     => $token,
            'expiresAt' => $expires,
            'ttlHuman'  => \PpPhp\Core\Licensing\LicenseProtocol::human($ttl),
            'instance'  => $instanceId,
        ]);
    }

    // ── Primary ↔ backup replication ──────────────────────────────────

    /** MAIN: generate a one-time pairing code for a new backup server. */
    public function createPairing(): void
    {
        $this->gate();
        $in = $this->input();
        $this->checkToken($in);

        if (!(new LicenseKeys())->exists()) {
            $this->json(['success' => false, 'message' => 'Create the signing key first — a backup pairs to it.'], 400);
        }
        $sync = new \PpPhp\Core\Licensing\LicenseSync();
        $sync->prunePending();
        $res = $sync->createPairing((string) ($in['label'] ?? ''));
        $this->json([
            'success' => true,
            'code'    => $res['code'],
            'ttlHuman'=> \PpPhp\Core\Licensing\LicenseProtocol::human($res['ttl']),
            'mainUrl' => \PpPhp\Core\Licensing\LicenseConfig::primary(),
        ]);
    }

    /** MAIN: revoke a paired backup — it can no longer sync. */
    public function revokePeer(): void
    {
        $this->gate();
        $in = $this->input();
        $this->checkToken($in);
        $ok = (new \PpPhp\Core\Licensing\LicenseSync())->revokePeer((int) ($in['id'] ?? 0));
        $this->json(['success' => $ok, 'message' => $ok ? 'Backup server revoked — it can no longer sync.' : 'Not found.']);
    }

    /** MAIN: manage a backup peer — suspend / resume / revoke / delete. */
    public function peerManage(): void
    {
        $this->gate();
        $in = $this->input();
        $this->checkToken($in);

        $id     = (int) ($in['id'] ?? 0);
        $action = (string) ($in['action'] ?? '');
        $sync   = new \PpPhp\Core\Licensing\LicenseSync();

        $ok = match ($action) {
            'suspend' => $sync->suspendPeer($id),
            'resume'  => $sync->resumePeer($id),
            'revoke'  => $sync->revokePeer($id),
            'delete'  => $sync->deletePeer($id),
            default   => false,
        };
        $msg = match (true) {
            !$ok && $action === 'resume' => 'That backup was revoked, not just suspended — it must re-pair.',
            !$ok                         => 'That action could not be completed.',
            $action === 'suspend'        => 'Backup suspended — it stops syncing until you resume it.',
            $action === 'resume'         => 'Backup resumed — it will sync on its next check.',
            $action === 'revoke'         => 'Backup revoked — it can no longer sync.',
            $action === 'delete'         => 'Backup entry removed.',
            default                      => 'Done.',
        };
        $this->json(['success' => $ok, 'message' => $msg]);
    }

    /**
     * Convert THIS server into a backup instead of a main.
     *
     * The pairing wizard is normally hidden once a signing key exists (a keyed
     * server IS the main). But a fresh secondary is easy to key by accident —
     * "Create signing key" is the prominent button on the Servers tab — and there
     * was no way back, so the owner could never pair it as a backup. This archives
     * the local signing key (reversibly — see LicenseKeys::archive) so the console
     * offers pairing again; the owner then pairs to the real main, which installs
     * the main's key.
     *
     * Guardrails: if this server actually looks like a live main (it has issued
     * licences or has paired backups of its own), the conversion is refused unless
     * the caller passes an explicit `confirm` — the UI makes the operator type it.
     */
    public function becomeBackup(): void
    {
        $this->gate();
        $in = $this->input();
        $this->checkToken($in);

        if (\PpPhp\Core\Licensing\ServerRole::isBackup()) {
            $this->json(['success' => false, 'message' => 'This server is already a backup.'], 400);
        }

        $keys = new LicenseKeys();
        if (!$keys->exists()) {
            // Nothing to archive — it is already an undesignated install; just show
            // the pairing wizard.
            $this->json(['success' => true, 'message' => 'This server has no signing key — enter the main address and pairing code below to pair it as a backup.']);
        }

        // Is this a real, in-use main? Count issued licences and paired backups.
        $licenceCount = 0;
        $peerCount    = 0;
        try { $licenceCount = count((new LicenseServer($keys))->licenses()); } catch (\Throwable $e) {}
        try { $peerCount    = count((new \PpPhp\Core\Licensing\LicenseSync())->peers()); } catch (\Throwable $e) {}

        if (($licenceCount > 0 || $peerCount > 0) && empty($in['confirm'])) {
            $this->json([
                'success' => false,
                'needsConfirm' => true,
                'message' => 'This server looks like a live main: '
                    . $licenceCount . ' licence' . ($licenceCount === 1 ? '' : 's')
                    . ' issued and ' . $peerCount . ' backup' . ($peerCount === 1 ? '' : 's')
                    . ' paired to it. Converting it to a backup archives its signing key and it will stop issuing. '
                    . 'Only do this if this server was set up by mistake.',
            ], 409);
        }

        try {
            $archived = $keys->archive();
        } catch (\Throwable $e) {
            $this->json(['success' => false, 'message' => $e->getMessage()], 500);
        }

        // Keep the role as main-with-no-key for now; pairing will flip it to backup
        // and install the main's key. Log it on the way out for accountability.
        try {
            (new LicenseServer())->log(null, null, 'converted_to_backup',
                'Signing key archived to ' . basename((string) $archived) . ' to convert this server into a backup',
                (string) ($_SERVER['REMOTE_ADDR'] ?? ''));
        } catch (\Throwable $e) {}

        $this->json([
            'success' => true,
            'message' => 'Signing key archived (' . basename((string) $archived) . '). '
                . 'This server no longer issues licences — enter the main address and a pairing code below to pair it as a backup.',
        ]);
    }

    /** BACKUP: pair with the main server using a code. */
    public function pair(): void
    {
        $this->gate();
        $in = $this->input();
        $this->checkToken($in);
        // Pairing makes a blocking server-to-server call to the main. On the
        // default 30s limit that call can get killed mid-flight, which the
        // browser sees as "Load failed" instead of a real message. Lift it so
        // the request always finishes and returns a clear result.
        @set_time_limit(0);
        @ignore_user_abort(true);
        // This backup's own public URL, so the main auto-adds it to the mesh with
        // nothing typed by hand. Prefer an explicit value; else derive it from the
        // request the owner is making right now.
        $selfUrl = trim((string) ($in['selfUrl'] ?? ''));
        if ($selfUrl === '') {
            $scheme = (!empty($_SERVER['HTTPS']) && $_SERVER['HTTPS'] !== 'off') ? 'https' : 'http';
            $host   = (string) ($_SERVER['HTTP_HOST'] ?? '');
            $base   = (string) $this->config->get('base_url');
            if ($host !== '') $selfUrl = $scheme . '://' . $host . $base;
        }
        try {
            $status = (new \PpPhp\Core\Licensing\LicenseMirror())->pair(
                (string) ($in['mainUrl'] ?? ''),
                (string) ($in['code'] ?? ''),
                (string) ($in['label'] ?? ''),
                $selfUrl
            );
        } catch (\PpPhp\Core\Licensing\LicenseException $e) {
            $this->json(['success' => false, 'message' => $e->getMessage()], 400);
        } catch (\Throwable $e) {
            $this->json(['success' => false, 'message' => 'Pairing failed: ' . $e->getMessage()], 500);
        }
        $this->json(['success' => true, 'message' => 'Paired with the main server. Licence data is now syncing.', 'status' => $status]);
    }

    /** BACKUP: check the main is reachable before pairing. */
    public function testMain(): void
    {
        $this->gate();
        $in = $this->input();
        $this->checkToken($in);
        @set_time_limit(0);
        $res = (new \PpPhp\Core\Licensing\LicenseMirror())->testMain((string) ($in['mainUrl'] ?? ''));
        $this->json(['success' => $res['ok'], 'message' => $res['message']]);
    }

    /** BACKUP: pull a fresh snapshot now. */
    public function syncNow(): void
    {
        $this->gate();
        $in = $this->input();
        $this->checkToken($in);
        @set_time_limit(0);
        @ignore_user_abort(true);
        $mirror = new \PpPhp\Core\Licensing\LicenseMirror();
        // sync() is contracted never to throw, but a DB/transport fault could still
        // surface — return the intended JSON error instead of an unhandled 500.
        try {
            $ok = $mirror->sync();
        } catch (\Throwable $e) {
            $this->json([
                'success' => false,
                'message' => 'Could not sync with the main server: ' . $e->getMessage(),
                'status'  => $mirror->status(),
            ], 500);
        }
        $st = $mirror->status();
        $this->json([
            'success' => $ok,
            'message' => $ok ? 'Synced with the main server.' : ($st['lastError'] ?: 'Could not reach the main server. The backup keeps serving its last-synced data.'),
            'status'  => $st,
        ]);
    }

    /**
     * BACKUP: clone the main's user databases now.
     *
     * Separate from `syncNow` on purpose. That pulls the control plane (licences,
     * accounts, catalogue) and is cheap enough to run every 15 minutes; this moves
     * whole databases and must never be dragged along on that cadence.
     */
    public function cloneNow(): void
    {
        $this->gate();
        $in = $this->input();
        $this->checkToken($in);
        @set_time_limit(0);
        @ignore_user_abort(true);
        // Through the schedule, not DbReplicator directly, so a manual run takes
        // the same lock as cron and its outcome is recorded for the console.
        $res = (new \PpPhp\Core\Databases\CloneSchedule(constant('BASE_PATH')))->run(['force' => true]);
        $this->json([
            'success' => $res['success'],
            'message' => $res['message'],
            'details' => $res['details'] ?? [],
            'status'  => \PpPhp\Core\Databases\CloneSchedule::status(constant('BASE_PATH')),
        ]);
    }

    /** BACKUP: turn scheduled cloning on/off, or change its cadence. */
    public function cloneSettings(): void
    {
        $this->gate();
        $in = $this->input();
        $this->checkToken($in);
        if (array_key_exists('enabled', $in)) {
            \PpPhp\Core\Databases\CloneSchedule::setEnabled(!empty($in['enabled']));
        }
        if (!empty($in['interval'])) {
            \PpPhp\Core\Databases\CloneSchedule::setInterval((int) $in['interval']);
        }
        $this->json(['success' => true, 'status' => \PpPhp\Core\Databases\CloneSchedule::status(constant('BASE_PATH'))]);
    }

    /**
     * Web-cron trigger. Two callers: the server's OWN self-cron (fired
     * automatically when a due backup serves a request — no external cron needed),
     * and, optionally, a real cron service. NO login — authority is the per-install
     * secret (?key=…). A wrong/absent key is a flat 403.
     *
     * It ACKS IMMEDIATELY and keeps running the clone in a detached worker, so the
     * self-cron loopback (and any cron hit) returns in milliseconds while a
     * minutes-long transfer proceeds. The flock in run() prevents a second run
     * overlapping; a worker killed by an FPM timeout just resumes on the next kick.
     */
    public function cloneRun(): void
    {
        $key = (string) ($_GET['key'] ?? $_POST['key'] ?? '');
        if (!hash_equals(\PpPhp\Core\Databases\CloneSchedule::secret(), $key)) {
            $this->json(['success' => false, 'message' => 'Forbidden.'], 403);
        }
        @ignore_user_abort(true);
        @set_time_limit(0);
        $this->respondAndContinue(['success' => true, 'queued' => true, 'message' => 'Replication started in the background.']);
        try {
            (new \PpPhp\Core\Databases\CloneSchedule(constant('BASE_PATH')))->run();
        } catch (\Throwable $e) {
            // best-effort; the outcome is recorded in the schedule state either way
        }
        exit;
    }

    /**
     * Flush a small JSON acknowledgement to the caller and let it disconnect, then
     * keep executing. On FPM/LiteSpeed uses fastcgi_finish_request; elsewhere sends
     * Content-Length + Connection: close so the client stops reading.
     */
    private function respondAndContinue(array $payload): void
    {
        $body = (string) json_encode($payload, JSON_UNESCAPED_SLASHES);
        if (!headers_sent()) {
            http_response_code(200);
            header('Content-Type: application/json');
            header('Content-Length: ' . strlen($body));
            header('Connection: close');
            header('Cache-Control: no-store');
        }
        echo $body;
        if (function_exists('fastcgi_finish_request')) {
            @fastcgi_finish_request();
        } else {
            @ob_end_flush();
            @flush();
        }
    }

    /** BACKUP: stop syncing (data already pulled stays valid). */
    public function unpair(): void
    {
        $this->gate();
        $in = $this->input();
        $this->checkToken($in);
        (new \PpPhp\Core\Licensing\LicenseMirror())->unpair();
        \PpPhp\Core\Licensing\ServerRole::setMain();
        $this->json(['success' => true, 'message' => 'This server is no longer paired to a main.']);
    }

    /** Issue a new licence. */
    public function issue(): void
    {
        $this->gate();
        $in = $this->input();
        $this->checkToken($in);

        if (\PpPhp\Core\Licensing\ServerRole::isBackup()) {
            $this->json(['success' => false, 'message' => 'This is a backup server — issue licences on the main server. A backup is read-only and syncs from the main.'], 403);
        }

        try {
            $license = (new LicenseServer())->issue($in);
        } catch (\Throwable $e) {
            $this->json(['success' => false, 'message' => $e->getMessage()], 400);
        }
        $this->json([
            'success'  => true,
            'message'  => 'Licence ' . $license['license_key'] . ' created.',
            'license'  => $license,
            'code'     => $this->activationCodeFor($license),
        ]);
    }

    public function update(): void
    {
        $this->gate();
        $in = $this->input();
        $this->checkToken($in);

        $id = (int) ($in['id'] ?? 0);
        if ($id <= 0) $this->json(['success' => false, 'message' => 'Which licence?'], 400);

        $ok = (new LicenseServer())->update($id, $in);
        $this->json(['success' => $ok, 'message' => $ok ? 'Licence updated.' : 'Nothing was changed.']);
    }

    /**
     * PERMANENTLY delete a licence and all its data (installations, audit, build
     * usage). Owner-only (the whole console is gated to the owner via gate()); a
     * backup server is read-only. This is the hard-delete for an unwanted/test
     * licence — REVOKE (per-installation) remains the softer, reversible option.
     */
    public function deleteLicense(): void
    {
        $this->gate();
        $in = $this->input();
        $this->checkToken($in);

        if (\PpPhp\Core\Licensing\ServerRole::isBackup()) {
            $this->json(['success' => false, 'message' => 'This is a backup server — delete licences on the main server. A backup is read-only and syncs from the main.'], 403);
        }
        $id = (int) ($in['id'] ?? 0);
        if ($id <= 0) $this->json(['success' => false, 'message' => 'Which licence?'], 400);

        $ok = (new LicenseServer())->deleteLicense($id);
        $this->json(['success' => $ok, 'message' => $ok ? 'Licence permanently deleted.' : 'That licence could not be deleted.']);
    }

    /**
     * Merge per-install build usage + effective limits (override → plan → global)
     * into estate rows. One bulk usage query + one plans read; the per-row resolve
     * is pure array work. Shared by the Installations tab and the analytics page.
     *
     * @param  array<int,array<string,mixed>> $estate
     * @return array<int,array<string,mixed>>
     */
    private function attachBuildUsage(array $estate): array
    {
        if ($estate === []) return $estate;
        $usage  = \PpPhp\Core\Build\BuildMeter::usageFor(array_column($estate, 'instance_id'));
        $global = \PpPhp\Core\Build\BuildMeter::globalLimits();
        $planLim = [];
        foreach (\PpPhp\Core\Licensing\PlanRegistry::all() as $pl) {
            $planLim[(string) $pl['slug']] = is_array($pl['build_limits'] ?? null) ? $pl['build_limits'] : [];
        }
        foreach ($estate as &$row) {
            $iid = (string) ($row['instance_id'] ?? '');
            $row['build_usage'] = $usage[$iid]
                ?? ['hour' => 0, 'day' => 0, 'week' => 0, 'month' => 0, 'total' => 0, 'last_at' => null];
            $ov  = \PpPhp\Core\Build\BuildMeter::decodeLimits($row['build_limits_override'] ?? null);
            $pln = $planLim[(string) ($row['plan'] ?? '')] ?? [];
            $eff = [];
            foreach (\PpPhp\Core\Build\BuildMeter::WINDOWS as $w) {
                if (($ov[$w] ?? null) !== null)      $eff[$w] = (int) $ov[$w];
                elseif (($pln[$w] ?? null) !== null) $eff[$w] = (int) $pln[$w];
                else                                 $eff[$w] = (int) ($global[$w] ?? 0);
            }
            $row['build_limits'] = $eff;
        }
        unset($row);
        return $estate;
    }

    /**
     * Build-usage analytics — a statistical view of how much each licence/install is
     * building and how much headroom is left. Owner-only (gate()); a backup shows the
     * empty state (the estate lives on the main). GET /licensing/build-usage.
     */
    public function buildAnalytics(): string
    {
        $this->gate();
        $keys   = new LicenseKeys();
        $server = new LicenseServer($keys);
        $hasKey = $keys->exists();
        $isBackupRole = \PpPhp\Core\Licensing\ServerRole::isBackup();

        $estate       = ($hasKey && !$isBackupRole) ? $this->attachBuildUsage($server->estate(1000)) : [];
        $totals       = \PpPhp\Core\Build\BuildMeter::globalTotals();
        $globalLimits = \PpPhp\Core\Build\BuildMeter::globalLimits();
        // Interactive dashboard payload (default range) — the page hydrates from this
        // and re-fetches /licensing/build-usage/data on range change + auto-refresh.
        $analytics    = \PpPhp\Core\Build\BuildMeter::analytics('30d');

        // Per-plan rollup (installs + build volume) for the plan breakdown.
        $byPlan = [];
        foreach ($estate as $i) {
            $p = (string) ($i['plan'] ?? '') ?: 'unknown';
            $byPlan[$p] ??= ['plan' => $p, 'installs' => 0, 'day' => 0, 'month' => 0, 'total' => 0];
            $byPlan[$p]['installs']++;
            $byPlan[$p]['day']   += (int) ($i['build_usage']['day'] ?? 0);
            $byPlan[$p]['month'] += (int) ($i['build_usage']['month'] ?? 0);
            $byPlan[$p]['total'] += (int) ($i['build_usage']['total'] ?? 0);
        }
        usort($byPlan, static fn ($a, $b) => $b['month'] <=> $a['month']);
        // Installs ranked by this-month build volume (top consumers first).
        usort($estate, static fn ($a, $b) => (int) ($b['build_usage']['month'] ?? 0) <=> (int) ($a['build_usage']['month'] ?? 0));

        $baseUrl   = $this->config->get('base_url');
        $assetsUrl = $this->config->get('assets_url');
        $user      = Security::CurrentUserData();

        ob_start();
        require __DIR__ . '/../Views/build-usage.php';
        return (string) ob_get_clean();
    }

    /**
     * Replication dashboard — the owner's single view of every sync: the mesh of
     * paired backups (on a main), or this backup's own control-plane + data/file
     * sync health, plus what is being protected and human-readable timestamps.
     * Owner-only. GET /licensing/replication.
     */
    public function replicationDashboard(): string
    {
        $this->gate();
        $report    = \PpPhp\Core\Replication\ReplicationReport::gather($_SERVER);
        $baseUrl   = $this->config->get('base_url');
        $assetsUrl = $this->config->get('assets_url');
        $user      = Security::CurrentUserData();

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

    /**
     * JSON snapshot of the replication report — powers the dashboard's live
     * auto-refresh without a full reload. Owner-only. GET /licensing/replication/data.
     */
    public function replicationData(): void
    {
        $this->gate();
        $this->json(['ok' => true, 'report' => \PpPhp\Core\Replication\ReplicationReport::gather($_SERVER)]);
    }

    /**
     * JSON analytics payload for the build-usage dashboard — powers the range
     * selector and live auto-refresh without a full page reload. Owner-only.
     * GET /licensing/build-usage/data?range=24h|7d|30d|90d
     */
    public function buildAnalyticsData(): void
    {
        $this->gate();
        $range = (string) ($_GET['range'] ?? '30d');
        $this->json(['ok' => true] + \PpPhp\Core\Build\BuildMeter::analytics($range));
    }

    /** Full detail for one licence: its installations and its history. */
    public function detail(string $id): void
    {
        $this->gate();
        $server  = new LicenseServer();
        $license = $server->license((int) $id);
        if (!$license) $this->json(['success' => false, 'message' => 'No such licence.'], 404);

        $this->json([
            'success'   => true,
            'license'   => $license,
            'instances' => $server->instances((int) $id),
            'events'    => $server->events((int) $id, 60),
            'code'      => $this->activationCodeFor($license),
            'perInstance' => \PpPhp\Core\Auth\OwnerCredentials::mode() === \PpPhp\Core\Auth\OwnerCredentials::MODE_PER_INSTANCE,
        ]);
    }

    /** Free a seat on the holder's behalf (lost server, decommissioned host). */
    public function revokeInstance(): void
    {
        $this->gate();
        $in = $this->input();
        $this->checkToken($in);

        $instanceId = trim((string) ($in['instanceId'] ?? ''));
        $ok = (new LicenseServer())->revokeInstance($instanceId);
        $this->json([
            'success' => $ok,
            'message' => $ok
                ? 'Installation revoked. It will stop working at its next daily check, and the seat is free now.'
                : 'That installation was not found.',
        ]);
    }

    /**
     * The single string a customer pastes — primary address, mirrors, public key
     * and licence key in one.
     */
    private function activationCodeFor(array $license): string
    {
        $keys = new LicenseKeys();
        if (!$keys->exists()) return '';
        return ActivationCode::encode(
            LicenseConfig::primary(),
            $keys->publicKeyB64(),
            (string) $license['license_key'],
            LicenseConfig::mirrors()
        );
    }
}
