<?php

declare(strict_types=1);

namespace PpPhp\Core\Licensing;

use PpPhp\Core\DB;
use RuntimeException;

/**
 * The licence authority — the side that HOLDS the keys and decides who may run.
 *
 * Runs inside one designated PPPHP install (the vendor's). Remote installs talk
 * to it over the public /api/license/* endpoints and never touch these tables.
 *
 * Model
 * ─────
 *   licence   an entitlement sold to a holder: a plan, a seat limit, an optional
 *             end date. NOT tied to one machine.
 *   instance  one activated installation consuming one seat. Identified by an
 *             id + secret it was given at activation, not by its IP — IPs move,
 *             customers don't expect to be locked out when they do.
 *   lease     a signed 24-hour permission slip. Renewed daily; verified offline.
 *
 * Seat limits are enforced HERE. A client can lie about anything it sends; it
 * cannot mint a lease, and it cannot make the server count differently.
 */
final class LicenseServer
{
    public const STATUS_ACTIVE    = 'active';
    public const STATUS_SUSPENDED = 'suspended';
    public const STATUS_REVOKED   = 'revoked';

    private LicenseKeys $keys;

    public function __construct(?LicenseKeys $keys = null)
    {
        $this->keys = $keys ?? new LicenseKeys();
    }

    // ── Schema ────────────────────────────────────────────────────────

    /**
     * Create/upgrade the licensing tables. Self-healing in the platform's usual
     * idiom (CREATE IF NOT EXISTS + guarded ALTER) — PPPHP has no migration
     * runner, and an update that silently needs one would break hosted installs.
     */
    public static function ensureSchema(): void
    {
        static $done = false;
        if ($done) return;
        $done = true;

        DB::Init();
        DB::SetConnection();

        DB::Exec("CREATE TABLE IF NOT EXISTS `pp_licenses` (
            `id` INT AUTO_INCREMENT PRIMARY KEY,
            `license_key` VARCHAR(64) NOT NULL,
            `holder_name` VARCHAR(190) NOT NULL DEFAULT '',
            `holder_email` VARCHAR(190) NOT NULL DEFAULT '',
            `plan` VARCHAR(60) NOT NULL DEFAULT 'standard',
            `seat_limit` INT NOT NULL DEFAULT 1,
            `status` VARCHAR(20) NOT NULL DEFAULT 'active',
            `expires_at` DATETIME NULL,
            `notes` TEXT NULL,
            `created_at` DATETIME NOT NULL,
            `updated_at` DATETIME NULL,
            UNIQUE KEY `uniq_license_key` (`license_key`)
        ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4");

        DB::Exec("CREATE TABLE IF NOT EXISTS `pp_license_instances` (
            `id` INT AUTO_INCREMENT PRIMARY KEY,
            `license_id` INT NOT NULL,
            `instance_id` CHAR(32) NOT NULL,
            `secret_hash` CHAR(64) NOT NULL,
            `label` VARCHAR(190) NOT NULL DEFAULT '',
            `fingerprint` TEXT NULL,
            `host` VARCHAR(190) NOT NULL DEFAULT '',
            `last_ip` VARCHAR(64) NOT NULL DEFAULT '',
            `app_version` VARCHAR(40) NOT NULL DEFAULT '',
            `php_version` VARCHAR(20) NOT NULL DEFAULT '',
            `status` VARCHAR(20) NOT NULL DEFAULT 'active',
            `lease_serial` INT NOT NULL DEFAULT 0,
            `risk` VARCHAR(30) NOT NULL DEFAULT '',
            `activated_at` DATETIME NOT NULL,
            `last_seen_at` DATETIME NULL,
            UNIQUE KEY `uniq_instance` (`instance_id`),
            KEY `idx_license` (`license_id`)
        ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4");

        DB::Exec("CREATE TABLE IF NOT EXISTS `pp_license_events` (
            `id` INT AUTO_INCREMENT PRIMARY KEY,
            `license_id` INT NULL,
            `instance_id` CHAR(32) NULL,
            `kind` VARCHAR(40) NOT NULL,
            `detail` TEXT NULL,
            `ip` VARCHAR(64) NOT NULL DEFAULT '',
            `created_at` DATETIME NOT NULL,
            KEY `idx_license` (`license_id`),
            KEY `idx_kind` (`kind`),
            KEY `idx_ip_time` (`ip`, `created_at`)
        ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4");

        // The rate limiter counts recent failures per IP on every API call, so
        // that lookup must be indexed — without it, throttling gets slower as the
        // audit trail grows, which is precisely backwards. Added separately for
        // installs whose table predates the index.
        self::ensureIndex('pp_license_events', 'idx_ip_time', '(`ip`, `created_at`)');

        // Telemetry columns, added for installs whose table predates them: where
        // a licence is running and who administers it, so the owner can act on a
        // suspicious install instead of guessing.
        self::ensureColumn('pp_license_instances', 'owner_email', "VARCHAR(190) NOT NULL DEFAULT ''");
        self::ensureColumn('pp_license_instances', 'server_ip',   "VARCHAR(64) NOT NULL DEFAULT ''");
        self::ensureColumn('pp_license_instances', 'domain',      "VARCHAR(190) NOT NULL DEFAULT ''");
        self::ensureColumn('pp_license_instances', 'integrity',   "VARCHAR(20) NOT NULL DEFAULT ''");

        // Owner-assigned build server for this licence (empty = auto / balance across
        // the whole mesh; 'primary' = pin to the primary; a URL = pin to that backup).
        // Ships to the client inside the signed lease as the `bld` claim.
        self::ensureColumn('pp_licenses', 'build_server', "VARCHAR(255) NOT NULL DEFAULT ''");

        // Optional per-licence build-limit OVERRIDE — a JSON {hour,day,week,month}
        // map where a set window beats the plan's allowance (null = inherit the
        // plan, then the global default). Read by BuildMeter at build time. Empty
        // string = no override.
        self::ensureColumn('pp_licenses', 'build_limits_override', "TEXT NULL");
    }

    /** Add a column if it isn't there yet. Never throws — see ensureIndex. */
    private static function ensureColumn(string $table, string $name, string $definition): void
    {
        try {
            $rows = DB::FetchAll("SHOW COLUMNS FROM `{$table}` LIKE ?", [$name]);
            if (empty($rows)) {
                DB::Exec("ALTER TABLE `{$table}` ADD COLUMN `{$name}` {$definition}");
            }
        } catch (\Throwable $e) {
            // older server / no ALTER privilege — telemetry is additive, not required
        }
    }

    /** Add an index if it isn't there yet. Never throws — an index is an optimisation. */
    private static function ensureIndex(string $table, string $name, string $columns): void
    {
        try {
            $rows = DB::FetchAll("SHOW INDEX FROM `{$table}` WHERE `Key_name` = ?", [$name]);
            if (empty($rows)) {
                DB::Exec("ALTER TABLE `{$table}` ADD INDEX `{$name}` {$columns}");
            }
        } catch (\Throwable $e) {
            // non-MySQL, or no ALTER privilege — correctness does not depend on it
        }
    }

    /**
     * Keep the audit trail from growing without bound.
     *
     * Called on a small fraction of renewals rather than on a schedule: hosted
     * installs have no cron we can rely on, and a licence server busy enough to
     * accumulate events is by definition getting enough traffic to trigger this.
     */
    public function pruneEvents(int $keepDays = 180): void
    {
        try {
            DB::Query("DELETE FROM `pp_license_events`
                        WHERE `created_at` < DATE_SUB(NOW(), INTERVAL ? DAY)
                          AND `kind` NOT IN ('issued','revoked','deactivated')",
                      [max(7, $keepDays)]);
        } catch (\Throwable $e) {
            // housekeeping only
        }
    }

    // ── Licences (owner-facing) ───────────────────────────────────────

    /** A readable, unambiguous key: PPP-XXXXX-XXXXX-XXXXX-XXXXX. */
    public static function newLicenseKey(): string
    {
        $alphabet = '23456789ABCDEFGHJKLMNPQRSTUVWXYZ';   // no 0/O/1/I — read aloud safely
        $groups = [];
        for ($g = 0; $g < 4; $g++) {
            $s = '';
            for ($i = 0; $i < 5; $i++) {
                $s .= $alphabet[random_int(0, strlen($alphabet) - 1)];
            }
            $groups[] = $s;
        }
        return 'PPP-' . implode('-', $groups);
    }

    public function issue(array $data): array
    {
        self::ensureSchema();
        $this->keys->ensure();

        $key = self::newLicenseKey();
        $ok = DB::Insert('pp_licenses', [
            'license_key'  => $key,
            'holder_name'  => trim((string) ($data['holder_name'] ?? '')),
            'holder_email' => trim((string) ($data['holder_email'] ?? '')),
            'plan'         => trim((string) ($data['plan'] ?? 'standard')) ?: 'standard',
            'seat_limit'   => max(1, (int) ($data['seat_limit'] ?? 1)),
            'status'       => self::STATUS_ACTIVE,
            'expires_at'   => self::normaliseDate($data['expires_at'] ?? null),
            'notes'        => (string) ($data['notes'] ?? ''),
            'build_server' => self::normaliseBuildServer($data['build_server'] ?? ''),
            'build_limits_override' => self::normaliseBuildLimits($data['build_limits_override'] ?? null),
            'created_at'   => self::now(),
            'updated_at'   => self::now(),
        ]);
        if ($ok === false) {
            throw new RuntimeException('Could not create the licence: ' . DB::LastError());
        }
        $id = (int) DB::LastInsertId();
        $this->log($id, null, 'issued', 'Licence created for ' . ($data['holder_name'] ?? 'unnamed holder'));
        return $this->license($id) ?? [];
    }

    public function update(int $id, array $data): bool
    {
        self::ensureSchema();
        $set = ['updated_at' => self::now()];
        foreach (['holder_name', 'holder_email', 'plan', 'notes'] as $k) {
            if (array_key_exists($k, $data)) $set[$k] = trim((string) $data[$k]);
        }
        if (array_key_exists('build_server', $data)) $set['build_server'] = self::normaliseBuildServer($data['build_server']);
        if (array_key_exists('build_limits_override', $data)) $set['build_limits_override'] = self::normaliseBuildLimits($data['build_limits_override']);
        if (array_key_exists('seat_limit', $data)) $set['seat_limit'] = max(1, (int) $data['seat_limit']);
        if (array_key_exists('status', $data)) {
            $s = (string) $data['status'];
            $set['status'] = in_array($s, [self::STATUS_ACTIVE, self::STATUS_SUSPENDED, self::STATUS_REVOKED], true)
                ? $s : self::STATUS_ACTIVE;
        }
        if (array_key_exists('expires_at', $data)) $set['expires_at'] = self::normaliseDate($data['expires_at']);

        $ok = DB::Update('pp_licenses', $set, ['id' => $id]) !== false;
        if ($ok) {
            $this->log($id, null, 'updated', 'Licence updated: ' . implode(', ', array_keys($set)));
        }
        return $ok;
    }

    /**
     * PERMANENTLY delete a licence and everything tied to it — its installations,
     * its audit trail, and its build-usage counters. This is the owner's hard-delete
     * for an unwanted/test licence (distinct from REVOKE, which keeps the record and
     * frees the seat). Owner-gated at the controller. Best-effort on the satellite
     * tables so a missing one never blocks the delete.
     */
    public function deleteLicense(int $id): bool
    {
        self::ensureSchema();
        if ($id <= 0) return false;
        $lic = $this->license($id);
        if (!$lic) return false;

        // Collect the licence's instance ids first, to purge their build usage.
        $iids = [];
        try {
            foreach (DB::FetchAll("SELECT `instance_id` FROM `pp_license_instances` WHERE `license_id` = ?", [$id]) as $r) {
                $iid = (string) ($r['instance_id'] ?? '');
                if ($iid !== '') $iids[] = $iid;
            }
        } catch (\Throwable $e) { /* table may predate — ignore */ }

        // Log BEFORE the row is gone so the key is still on record, then delete.
        $this->log($id, null, 'deleted', 'Licence ' . (string) ($lic['license_key'] ?? $id) . ' permanently deleted');
        try { DB::Query("DELETE FROM `pp_license_instances` WHERE `license_id` = ?", [$id]); } catch (\Throwable $e) {}
        $ok = DB::Query("DELETE FROM `pp_licenses` WHERE `id` = ?", [$id]) !== false;

        // Best-effort satellite cleanup — never fail the delete on these.
        foreach ($iids as $iid) {
            try {
                DB::Query("DELETE FROM `pp_build_usage` WHERE `instance_id` = ?", [$iid]);
                DB::Query("DELETE FROM `pp_build_usage_hourly` WHERE `instance_id` = ?", [$iid]);
            } catch (\Throwable $e) {}
        }
        return $ok;
    }

    public function license(int $id): ?array
    {
        self::ensureSchema();
        $rows = DB::FetchAll('SELECT * FROM `pp_licenses` WHERE `id` = ?', [$id]);
        return $rows[0] ?? null;
    }

    /**
     * True when (instanceId, secret) identifies a genuine, currently-usable
     * installation — exactly the bar a renewal must clear (active instance +
     * live, unexpired, unrevoked licence). Used to gate update delivery: only
     * licensed installs may receive updates through the feed. Never throws, never
     * writes — a pure read used on the update path.
     */
    public function isEntitled(string $instanceId, string $secret): bool
    {
        self::ensureSchema();
        $instanceId = trim($instanceId);
        if ($instanceId === '' || $secret === '') return false;

        $instance = $this->instance($instanceId);
        if (!$instance || !hash_equals((string) $instance['secret_hash'], hash('sha256', $secret))) {
            return false;
        }
        if ((string) ($instance['status'] ?? '') !== 'active') return false;

        $license = $this->license((int) $instance['license_id']);
        if (!$license) return false;

        try {
            $this->assertUsable($license, '0.0.0.0', $instanceId);
        } catch (\Throwable $e) {
            return false;
        }
        return true;
    }

    public function licenseByKey(string $key): ?array
    {
        self::ensureSchema();
        $rows = DB::FetchAll('SELECT * FROM `pp_licenses` WHERE `license_key` = ?', [self::normaliseKey($key)]);
        return $rows[0] ?? null;
    }

    /** All licences with their live seat usage, newest first. */
    public function licenses(): array
    {
        self::ensureSchema();
        $rows = DB::FetchAll(
            "SELECT l.*,
                    (SELECT COUNT(*) FROM `pp_license_instances` i
                      WHERE i.`license_id` = l.`id` AND i.`status` = 'active') AS `seats_used`
               FROM `pp_licenses` l
              ORDER BY l.`id` DESC"
        );
        return is_array($rows) ? $rows : [];
    }

    public function instances(int $licenseId): array
    {
        self::ensureSchema();
        $rows = DB::FetchAll(
            'SELECT * FROM `pp_license_instances` WHERE `license_id` = ? ORDER BY `last_seen_at` DESC, `id` DESC',
            [$licenseId]
        );
        return is_array($rows) ? $rows : [];
    }

    /**
     * Every installation across every licence, with the licence it belongs to.
     *
     * This is the owner's intelligence view: who is running this software, where,
     * on which address, under whose administrator email, and whether the copy
     * still matches the release we signed. Ordered so the ones that want
     * attention — flagged, then most recently seen — come first.
     *
     * @return array<int,array<string,mixed>>
     */
    public function estate(int $limit = 500): array
    {
        self::ensureSchema();
        $limit = max(1, min(2000, $limit));
        $rows = DB::FetchAll(
            "SELECT i.*,
                    l.`license_key`, l.`holder_name`, l.`holder_email`, l.`plan`,
                    l.`seat_limit`, l.`expires_at`, l.`status` AS `license_status`,
                    l.`build_limits_override`
               FROM `pp_license_instances` i
               JOIN `pp_licenses` l ON l.`id` = i.`license_id`
              ORDER BY (i.`risk` <> '') DESC, i.`last_seen_at` DESC, i.`id` DESC
              LIMIT {$limit}"
        );
        return is_array($rows) ? $rows : [];
    }

    /** Headline counts for the installations view. */
    public function estateSummary(): array
    {
        $rows = $this->estate(2000);
        $now = time();
        $out = ['total' => count($rows), 'active' => 0, 'flagged' => 0, 'tampered' => 0, 'stale' => 0, 'domains' => 0];
        $domains = [];
        foreach ($rows as $r) {
            if ((string) $r['status'] === 'active') $out['active']++;
            if (trim((string) ($r['risk'] ?? '')) !== '') $out['flagged']++;
            if (in_array((string) ($r['integrity'] ?? ''), ['modified', 'missing'], true)
                || str_contains((string) ($r['risk'] ?? ''), 'tamper')) $out['tampered']++;
            $seen = strtotime((string) ($r['last_seen_at'] ?? '')) ?: 0;
            if ($seen > 0 && ($now - $seen) > 172800) $out['stale']++;   // silent > 48h
            $d = trim((string) ($r['domain'] ?? $r['host'] ?? ''));
            if ($d !== '') $domains[strtolower($d)] = true;
        }
        $out['domains'] = count($domains);
        return $out;
    }

    public function events(?int $licenseId = null, int $limit = 100): array
    {
        self::ensureSchema();
        $limit = max(1, min(500, $limit));
        $rows = $licenseId
            ? DB::FetchAll("SELECT * FROM `pp_license_events` WHERE `license_id` = ? ORDER BY `id` DESC LIMIT $limit", [$licenseId])
            : DB::FetchAll("SELECT * FROM `pp_license_events` ORDER BY `id` DESC LIMIT $limit");
        return is_array($rows) ? $rows : [];
    }

    /** Free a seat and tell that instance to stop at its next renewal. */
    public function revokeInstance(string $instanceId, string $reason = 'Revoked by the licence holder'): bool
    {
        self::ensureSchema();
        $inst = $this->instance($instanceId);
        if (!$inst) return false;
        DB::Update('pp_license_instances', ['status' => 'revoked'], ['instance_id' => $instanceId]);
        $this->log((int) $inst['license_id'], $instanceId, 'revoked', $reason);
        return true;
    }

    public function instance(string $instanceId): ?array
    {
        self::ensureSchema();
        $rows = DB::FetchAll('SELECT * FROM `pp_license_instances` WHERE `instance_id` = ?', [$instanceId]);
        return $rows[0] ?? null;
    }

    // ── Public API operations (what remote installs call) ─────────────

    /**
     * Claim a seat. Returns the instance credentials plus a first lease.
     *
     * @throws LicenseException with a message safe to show an end user
     */
    public function activate(string $licenseKey, array $request, string $ip): array
    {
        self::ensureSchema();
        $license = $this->licenseByKey($licenseKey);
        if (!$license) {
            $this->log(null, null, 'activate_failed', 'Unknown key: ' . substr(self::normaliseKey($licenseKey), 0, 12) . '…', $ip);
            // 400, NOT 404: the client treats an HTTP 404 as "reached a host but the
            // licence API is not at this address" (a wrong-address / fail-over signal),
            // so returning 404 here would hide this authoritative answer behind a
            // generic "could not reach the licence service" message. 400 = the request
            // was understood and answered; the key is simply not one we issued.
            throw new LicenseException('That licence key was not recognised.', 400);
        }
        $this->assertUsable($license, $ip);

        // Email binding: an install must activate with the admin email the licence
        // was issued to. Enforced only when the licence carries a holder email, so
        // older keys issued without one keep working.
        $holderEmail = strtolower(trim((string) ($license['holder_email'] ?? '')));
        $presentedEmail = strtolower(trim((string) ($request['email'] ?? '')));
        if ($holderEmail !== '') {
            if ($presentedEmail === '') {
                $this->log((int) $license['id'], null, 'activate_failed', 'No admin email supplied', $ip);
                throw new LicenseException('This licence needs the administrator email it was issued to. Enter the email your licence was sent to, alongside the key.', 400);
            }
            if (!hash_equals($holderEmail, $presentedEmail)) {
                $this->log((int) $license['id'], null, 'email_mismatch', 'Activation email did not match the licence holder', $ip);
                throw new LicenseException('That administrator email does not match this licence. Use the email your licence was issued to.', 403);
            }
        }

        $fingerprint = self::cleanFingerprint($request['fingerprint'] ?? []);

        // Re-activating the same machine reclaims its own seat instead of
        // burning a new one — otherwise a customer reinstalling twice would
        // exhaust a 2-seat licence without ever running two copies.
        $existing = $this->matchExistingInstance((int) $license['id'], $fingerprint);
        if ($existing) {
            $this->log((int) $license['id'], $existing['instance_id'], 'reactivated',
                'Same machine re-activated (seat reused)', $ip);
            return $this->rotateCredentials($license, $existing, $fingerprint, $request, $ip);
        }

        $used = $this->seatsUsed((int) $license['id']);
        if ($used >= (int) $license['seat_limit']) {
            $this->log((int) $license['id'], null, 'seat_limit',
                "Activation refused — {$used}/{$license['seat_limit']} seats in use", $ip);
            throw new LicenseException(
                'All ' . (int) $license['seat_limit'] . ' installations allowed by this licence are already in use. '
                . 'Deactivate one, or ask for more seats.',
                409
            );
        }

        $instanceId = bin2hex(random_bytes(16));
        $secret     = bin2hex(random_bytes(32));

        $ok = DB::Insert('pp_license_instances', [
            'license_id'   => (int) $license['id'],
            'instance_id'  => $instanceId,
            'secret_hash'  => hash('sha256', $secret),
            'label'        => substr(trim((string) ($request['label'] ?? $fingerprint['host'] ?? '')), 0, 190),
            'fingerprint'  => (string) json_encode($fingerprint),
            'host'         => substr((string) ($fingerprint['host'] ?? ''), 0, 190),
            'last_ip'      => substr($ip, 0, 64),
            'server_ip'    => substr($ip, 0, 64),
            'owner_email'  => substr($presentedEmail !== '' ? $presentedEmail : $holderEmail, 0, 190),
            'domain'       => substr((string) ($request['domain'] ?? $fingerprint['host'] ?? ''), 0, 190),
            'app_version'  => substr((string) ($request['appVersion'] ?? ''), 0, 40),
            'php_version'  => substr((string) ($fingerprint['php'] ?? ''), 0, 20),
            'status'       => 'active',
            'lease_serial' => 1,
            'activated_at' => self::now(),
            'last_seen_at' => self::now(),
        ]);
        if ($ok === false) {
            throw new LicenseException('The licence server could not record this activation. Try again shortly.', 500);
        }

        $this->log((int) $license['id'], $instanceId, 'activated',
            'Activated on ' . ($fingerprint['host'] ?: 'unknown host'), $ip);

        $instance = $this->instance($instanceId) ?? [];
        return [
            'instanceId'     => $instanceId,
            'instanceSecret' => $secret,
            'lease'          => $this->issueLease($license, $instance),
            'seats'          => ['used' => $this->seatsUsed((int) $license['id']), 'limit' => (int) $license['seat_limit']],
        ];
    }

    /**
     * Daily rotation. The lease the client is holding is about to expire; this
     * mints the next one — a different token, a new serial, a new 24 h window.
     */
    public function renew(string $instanceId, string $secret, array $request, string $ip): array
    {
        self::ensureSchema();
        $instance = $this->instance($instanceId);
        if (!$instance || !hash_equals((string) $instance['secret_hash'], hash('sha256', $secret))) {
            // Same answer for "no such instance" and "wrong secret" — never
            // confirm to a prober that an instance id exists.
            $this->log(null, $instanceId, 'renew_failed', 'Unknown instance or bad secret', $ip);
            throw new LicenseException('This installation is not recognised. Re-enter your licence key to activate it again.', 401);
        }

        $license = $this->license((int) $instance['license_id']);
        if (!$license) {
            throw new LicenseException('The licence behind this installation no longer exists.', 404);
        }

        // Anything other than an active seat stops here — revoked AND released.
        // Treating only 'revoked' as terminal left a hole: an installation could
        // call deactivate() to hand its seat back, then keep renewing forever,
        // so a one-seat licence would run on unlimited servers for free.
        $status = (string) ($instance['status'] ?? '');
        if ($status !== 'active') {
            $this->log((int) $license['id'], $instanceId, 'renew_' . $status,
                'Renewal refused — instance is ' . $status, $ip);
            // A SIGNED refusal, so the client can act on it without trusting
            // an unauthenticated error response.
            return ['lease' => $this->issueLease($license, $instance, LicenseProtocol::REVOKED), 'revoked' => true];
        }

        $this->assertUsable($license, $ip, $instanceId);

        // IP is a recorded, reviewable factor (owner policy = flag, not auto-block):
        // note when an install renews from a new address so a moved or shared copy
        // stands out in the estate. The lease is still issued — never blocked on IP.
        $prevIp = (string) ($instance['last_ip'] ?? '');
        if ($prevIp !== '' && $prevIp !== $ip) {
            $this->log((int) $license['id'], $instanceId, 'ip_changed', 'Renewed from a new address: ' . $prevIp . ' → ' . $ip, $ip);
            $this->flagInstance($instanceId, 'ip-changed');
        }

        $fingerprint = self::cleanFingerprint($request['fingerprint'] ?? []);
        $this->noteFingerprint($license, $instance, $fingerprint, $ip);

        $serial = (int) $instance['lease_serial'] + 1;
        $update = [
            'lease_serial' => $serial,
            'last_seen_at' => self::now(),
            'last_ip'      => substr($ip, 0, 64),
            'fingerprint'  => (string) json_encode($fingerprint),
            'host'         => substr((string) ($fingerprint['host'] ?? $instance['host']), 0, 190),
            'app_version'  => substr((string) ($request['appVersion'] ?? $instance['app_version']), 0, 40),
            'php_version'  => substr((string) ($fingerprint['php'] ?? $instance['php_version']), 0, 20),
        ];
        DB::Update('pp_license_instances', $update + self::telemetryColumns($request['telemetry'] ?? []),
            ['instance_id' => $instanceId]);

        // A client that reports its own files as modified is recorded and flagged
        // for the owner. It is already locking itself; this is so the vendor can
        // SEE it happen rather than find out from a support ticket.
        $tele = is_array($request['telemetry'] ?? null) ? $request['telemetry'] : [];
        if (in_array((string) ($tele['integrity'] ?? ''), [CodeIntegrity::MODIFIED, CodeIntegrity::MISSING], true)) {
            $this->flagInstance($instanceId, 'tamper-reported');
            $this->log((int) $license['id'], $instanceId, 'integrity_failed',
                'Client reported integrity ' . (string) $tele['integrity'], $ip);
        }

        if (random_int(1, 200) === 1) {
            $this->pruneEvents();
        }

        $instance['lease_serial'] = $serial;
        $reply = [
            'lease'  => $this->issueLease($license, $instance),
            'seats'  => ['used' => $this->seatsUsed((int) $license['id']), 'limit' => (int) $license['seat_limit']],
        ];

        // Owner-pushed advisories for THIS licence ride back on the check-in — a
        // signed feed the client verifies against the pinned key, exactly like the
        // lease. Targeting is free here: the request is authenticated, so the
        // server already knows which licence is asking. Best-effort — never blocks
        // a renewal, and absent entirely on a server without the Update module.
        if (class_exists(\PpPhp\Core\Update\AdvisoryFeed::class)) {
            $feed = \PpPhp\Core\Update\AdvisoryFeed::signedFor(
                [$instanceId, (string) ($license['license_key'] ?? '')], 'client');
            if ($feed !== '') $reply['advisories'] = $feed;
        }

        return $reply;
    }

    /**
     * The signed advisory feed for one installation, on demand — the lightweight
     * "any new notices for me?" pull a client makes on an ordinary check-in
     * (between full renewals). Authenticated with the same id + secret a renewal
     * uses, and never changes entitlement: it only ever returns notices, so it is
     * safe to call often. Targeting is decided server-side from the authenticated
     * licence, so one client can never receive another's targeted push.
     *
     * @return array{feed:string}
     */
    public function advisoriesFeed(string $instanceId, string $secret, string $ip): array
    {
        self::ensureSchema();
        $instance = $this->instance($instanceId);
        if (!$instance || !hash_equals((string) $instance['secret_hash'], hash('sha256', $secret))) {
            throw new LicenseException('This installation is not recognised.', 401);
        }
        $license = $this->license((int) $instance['license_id']);
        if (!$license) {
            throw new LicenseException('The licence behind this installation no longer exists.', 404);
        }
        $feed = '';
        if (class_exists(\PpPhp\Core\Update\AdvisoryFeed::class)) {
            $feed = \PpPhp\Core\Update\AdvisoryFeed::signedFor(
                [$instanceId, (string) ($license['license_key'] ?? '')], 'client');
        }
        return ['feed' => $feed];
    }

    /**
     * The reusable server-side entitlement gate: authenticate an installation (as a
     * renewal does) and confirm its plan grants a capability before doing any work
     * for it. Every future server-side service endpoint (AI / Email / SMS / …) calls
     * this first. Throws 401 (not recognised), 403 (inactive seat, or plan lacks the
     * capability); on success returns the plan + its granted keys.
     *
     * @return array{plan:string,capabilities:array<int,string>}
     */
    public function requireCapability(string $instanceId, string $secret, string $capability, string $ip): array
    {
        self::ensureSchema();
        $instance = $this->instance($instanceId);
        if (!$instance || !hash_equals((string) $instance['secret_hash'], hash('sha256', $secret))) {
            $this->log(null, $instanceId, 'capability_denied', 'Unknown instance or bad secret', $ip);
            throw new LicenseException('This installation is not recognised.', 401);
        }
        $license = $this->license((int) $instance['license_id']);
        if (!$license) {
            throw new LicenseException('The licence behind this installation no longer exists.', 404);
        }
        if ((string) ($instance['status'] ?? '') !== 'active') {
            throw new LicenseException('This installation is not active.', 403);
        }
        $plan = (string) ($license['plan'] ?? '');
        $grants = class_exists(PlanRegistry::class) ? PlanRegistry::grantedKeys($plan) : [];
        if ($capability !== '' && !in_array($capability, $grants, true)) {
            $this->log((int) $license['id'], $instanceId, 'capability_denied',
                'Plan "' . $plan . '" does not grant ' . $capability, $ip);
            throw new LicenseException(
                'Your plan (' . ($plan !== '' ? $plan : 'current') . ') does not include this feature. '
                . 'Contact your provider to upgrade.', 403);
        }
        return ['plan' => $plan, 'capabilities' => $grants];
    }

    /**
     * Map a client's telemetry onto the instance columns, ignoring anything that
     * is not a column we recognise. The columns self-heal in ensureSchema(), so
     * an older licence server simply stores less rather than erroring.
     *
     * @return array<string,string>
     */
    private static function telemetryColumns(mixed $telemetry): array
    {
        if (!is_array($telemetry)) return [];
        $out = [];
        if (isset($telemetry['ownerEmail'])) $out['owner_email'] = substr((string) $telemetry['ownerEmail'], 0, 190);
        if (isset($telemetry['serverIp']))   $out['server_ip']   = substr((string) $telemetry['serverIp'], 0, 64);
        if (isset($telemetry['domain']))     $out['domain']      = substr((string) $telemetry['domain'], 0, 190);
        if (isset($telemetry['integrity']))  $out['integrity']   = substr((string) $telemetry['integrity'], 0, 20);
        return $out;
    }

    /** Mark an instance for the owner's attention without changing its access. */
    private function flagInstance(string $instanceId, string $risk): void
    {
        try {
            DB::Update('pp_license_instances', ['risk' => substr($risk, 0, 30)], ['instance_id' => $instanceId]);
        } catch (\Throwable $e) {
            // never let bookkeeping break a renewal
        }
    }

    /**
     * A client reporting a security event about itself (tampering, mostly).
     *
     * Authenticated with the same id + secret a renewal uses, so it cannot be
     * spoofed by a stranger — and deliberately never changes the client's
     * entitlement. The client is already enforcing; this exists so the vendor can
     * see it, with the IP, domain and administrator email attached.
     */
    public function securityEvent(string $instanceId, string $secret, string $kind, array $detail, array $telemetry, string $ip): array
    {
        self::ensureSchema();
        $instance = $this->instance($instanceId);
        if (!$instance || !hash_equals((string) $instance['secret_hash'], hash('sha256', $secret))) {
            throw new LicenseException('This installation is not recognised.', 401);
        }
        $kind = substr(preg_replace('/[^a-z0-9_-]/i', '', $kind) ?: 'unknown', 0, 30);

        try {
            DB::Update('pp_license_instances', self::telemetryColumns($telemetry), ['instance_id' => $instanceId]);
        } catch (\Throwable $e) {
            // ignore — the event log below is what matters
        }
        $this->flagInstance($instanceId, $kind === 'tamper' ? 'tampered' : $kind);
        $this->log((int) $instance['license_id'], $instanceId, 'security_' . $kind,
            substr((string) json_encode(array_slice($detail, 0, 20)), 0, 500), $ip);

        return ['recorded' => true];
    }

    /** Give a seat back voluntarily (moving hosts, decommissioning). */
    public function deactivate(string $instanceId, string $secret, string $ip): array
    {
        self::ensureSchema();
        $instance = $this->instance($instanceId);
        if (!$instance || !hash_equals((string) $instance['secret_hash'], hash('sha256', $secret))) {
            throw new LicenseException('This installation is not recognised.', 401);
        }
        DB::Update('pp_license_instances', ['status' => 'released'], ['instance_id' => $instanceId]);
        $this->log((int) $instance['license_id'], $instanceId, 'deactivated', 'Seat released by the installation', $ip);
        return ['released' => true, 'seats' => [
            'used'  => $this->seatsUsed((int) $instance['license_id']),
            'limit' => (int) ($this->license((int) $instance['license_id'])['seat_limit'] ?? 0),
        ]];
    }

    // ── Leases ────────────────────────────────────────────────────────

    /**
     * Sign the permission slip.
     *
     * Everything the client enforces is inside the signature: which instance it
     * belongs to, when it stops being valid, and the serial that stops an old
     * lease from being replayed over a newer one.
     */
    public function issueLease(array $license, array $instance, string $state = LicenseProtocol::OK): string
    {
        $now = time();
        $claims = [
            'v'      => 1,
            'iss'    => 'ppphp-license',
            'iid'    => (string) ($instance['instance_id'] ?? ''),
            'lic'    => substr(hash('sha256', (string) $license['license_key']), 0, 16),
            'sn'     => (int) ($instance['lease_serial'] ?? 1),
            'iat'    => $now,
            'nbf'    => $now - LicenseProtocol::CLOCK_SKEW,
            'exp'    => $now + LicenseProtocol::LEASE_TTL,
            'st'     => $state,
            'plan'   => (string) ($license['plan'] ?? 'standard'),
            'holder' => (string) ($license['holder_name'] ?? ''),
            'seats'  => (int) ($license['seat_limit'] ?? 1),
            'lexp'   => self::epoch($license['expires_at'] ?? null),
            // Where to renew next time, primary first. Carried INSIDE the
            // signature so it cannot be redirected in transit — and so moving
            // the service, or adding a mirror, reaches every installation within
            // a day without anyone re-entering anything.
            'ep'     => LicenseConfig::endpoints(),
            // Support contact a client shows if it ever locks — carried in the
            // signature so the vendor can change it centrally without re-bundling.
            'sup'    => LicenseConfig::supportContact(),
        ];

        // Vendor-set display labels for those endpoints (e.g. "SEC-SRV-NP"), so a
        // client shows a friendly name instead of the raw address. Sparse — only
        // present when the owner has named a server; signed like everything else.
        $epl = LicenseConfig::endpointLabels();
        if ($epl !== []) $claims['epl'] = $epl;

        // Friendly name for the UPDATE service (a client shows it instead of the
        // raw update-host URLs in a failure message). Same signed channel.
        if (class_exists(\PpPhp\Core\Update\UpdateHostConfig::class)) {
            $udn = \PpPhp\Core\Update\UpdateHostConfig::displayName();
            if ($udn !== '') $claims['udn'] = $udn;
        }

        // Capabilities this licence's PLAN grants (e.g. server AI / Email / SMS).
        // Carried so the client knows its entitlements offline; each capability is
        // enforced by its own feature over time. Only the granted keys travel.
        if (class_exists(PlanRegistry::class)) {
            $grants = PlanRegistry::grantedKeys((string) ($license['plan'] ?? ''));
            if ($grants !== []) $claims['cap'] = $grants;
            // The plan's badge (display name + icon + colour), so the client shows
            // the same tier identity the Owner set, not just a raw slug.
            $pl = PlanRegistry::get((string) ($license['plan'] ?? ''));
            if ($pl) $claims['plm'] = ['n' => $pl['name'], 'i' => $pl['icon'], 'c' => $pl['color']];
        }

        // Mesh resilience the owner tuned in Core Configuration — carried inside the
        // signature so every install adopts it on its next daily renewal, with no
        // client-side reconfiguration. Absent claims fall back to the shipped consts.
        $claims['rg'] = LicenseConfig::runtimeGraceHours() * 3600;   // outage grace (s)
        $claims['hb'] = LicenseConfig::heartbeatMinutes() * 60;      // check-in every (s)
        $claims['to'] = LicenseConfig::networkTimeout();             // per-call timeout (s)
        $claims['sh'] = LicenseConfig::selfHeal() ? 1 : 0;           // re-find a moved server

        // Owner-pinned build server for this licence, resolved to concrete URL(s).
        // Empty = no pin: the client load-balances builds across the whole mesh (`ep`).
        $buildServers = self::buildServersFor($license);
        if ($buildServers !== []) {
            $claims['bld'] = $buildServers;
        }

        // The owner-account password hash. Distributed this way so a change
        // reaches every client on its next daily renewal. Always a bcrypt HASH,
        // never plaintext, and inside the signature so it cannot be forged.
        //   • unified      → the one vendor password, same hash for everyone;
        //   • per-instance → a hash DERIVED from this instance's id, unique to it,
        //                    which the owner can re-derive on demand (see
        //                    OwnerDerivation) instead of storing anywhere.
        $mode = \PpPhp\Core\Auth\OwnerCredentials::mode();
        $iid  = (string) ($instance['instance_id'] ?? '');
        if ($mode === \PpPhp\Core\Auth\OwnerCredentials::MODE_TOKEN) {
            // No usable password anywhere — access is by signed token only. A
            // deterministic, never-revealed hash actively disables password login
            // even on installs that previously had one.
            $ownerHash = (new OwnerDerivation($this->keys))->disabledHash($iid);
        } elseif ($mode === \PpPhp\Core\Auth\OwnerCredentials::MODE_PER_INSTANCE) {
            $ownerHash = (new OwnerDerivation($this->keys))->instanceHash($iid);
        } else {
            $ownerHash = \PpPhp\Core\Auth\OwnerCredentials::distributableHash();
        }
        if ($ownerHash !== '') {
            $claims['opw'] = $ownerHash;
        }

        // The release content key for PROTECTED bundles. This is the one claim
        // that is genuinely load-bearing: in a protected build the critical code
        // ships encrypted, so an installation that does not receive this key
        // cannot run it at all — there is no check to remove. It is therefore
        // withheld from anything that is not a currently-good seat.
        if ($state === LicenseProtocol::OK) {
            $releaseKey = LicenseConfig::releaseKeyB64();
            if ($releaseKey !== '') {
                $claims[ProtectedCode::CLAIM] = $releaseKey;
            }
        }

        return LicenseProtocol::sign($claims, $this->keys->secretKey());
    }

    /**
     * Normalise the owner's build-server choice for storage. Accepts:
     *   ''         — auto (balance across the mesh; the default)
     *   'primary'  — pin to the current primary
     *   a URL      — pin to that specific server (typically a backup)
     * A non-empty, non-'primary' value must be a valid URL or it is discarded
     * (falls back to auto) so a typo can never strand a licence on a dead server.
     */
    private static function normaliseBuildServer(mixed $raw): string
    {
        $v = trim((string) $raw);
        if ($v === '' || strcasecmp($v, 'auto') === 0) return '';
        if (strcasecmp($v, 'primary') === 0) return 'primary';
        $clean = LicenseConfig::clean($v);
        return ($clean !== '' && LicenseConfig::isValid($clean)) ? $clean : '';
    }

    /**
     * Sanitise a per-licence build-limit override to a compact JSON string of
     * {hour,day,week,month} (null = inherit, 0 = unlimited, N = cap), or '' when no
     * window is set. Accepts a JSON string or an array from the form.
     */
    private static function normaliseBuildLimits(mixed $raw): string
    {
        if (is_string($raw)) { $raw = $raw === '' ? [] : (json_decode($raw, true) ?: []); }
        if (!is_array($raw)) return '';
        $out = [];
        $has = false;
        foreach (['hour', 'day', 'week', 'month'] as $w) {
            $v = $raw[$w] ?? null;
            if ($v === null || $v === '') { $out[$w] = null; }
            else { $out[$w] = max(0, (int) $v); $has = true; }
        }
        return $has ? (string) json_encode($out) : '';
    }

    /**
     * Resolve a licence's stored build-server assignment to the concrete URL(s) the
     * client should prefer, primary/backup addresses taken live from LicenseConfig so
     * moving a server updates every licence on its next daily renewal. Empty = no pin.
     *
     * @return list<string>
     */
    private static function buildServersFor(array $license): array
    {
        $assign = self::normaliseBuildServer($license['build_server'] ?? '');
        if ($assign === '') return [];
        if ($assign === 'primary') {
            $p = LicenseConfig::primary();
            return $p !== '' ? [$p] : [];
        }
        // A pinned backup: honour it only while it is still a known endpoint, else
        // fall back to auto so a retired backup does not strand the licence.
        $known = LicenseConfig::endpoints();
        return in_array($assign, $known, true) ? [$assign] : [];
    }

    // ── Internals ─────────────────────────────────────────────────────

    private function seatsUsed(int $licenseId): int
    {
        $n = DB::FetchOne("SELECT COUNT(*) FROM `pp_license_instances` WHERE `license_id` = ? AND `status` = 'active'", [$licenseId]);
        return (int) $n;
    }

    /** Reject a licence that is suspended, revoked or past its end date. */
    private function assertUsable(array $license, string $ip, ?string $instanceId = null): void
    {
        $status = (string) $license['status'];
        if ($status === self::STATUS_REVOKED) {
            $this->log((int) $license['id'], $instanceId, 'blocked', 'Licence is revoked', $ip);
            throw new LicenseException('This licence has been revoked. Please contact the vendor.', 403);
        }
        if ($status === self::STATUS_SUSPENDED) {
            $this->log((int) $license['id'], $instanceId, 'blocked', 'Licence is suspended', $ip);
            throw new LicenseException('This licence is suspended — usually an unpaid renewal. Please contact the vendor.', 403);
        }
        $expiry = self::epoch($license['expires_at'] ?? null);
        if ($expiry > 0 && $expiry < time()) {
            $this->log((int) $license['id'], $instanceId, 'blocked', 'Licence expired ' . $license['expires_at'], $ip);
            throw new LicenseException('This licence expired on ' . date('j M Y', $expiry) . '.', 403);
        }
    }

    /**
     * Find a seat this machine already owns — an install that was re-activated
     * (key re-entered after a restore, storage/ wiped) rather than a new one.
     * Uses fuzzy matching: the host and install path surviving is enough.
     */
    private function matchExistingInstance(int $licenseId, array $fingerprint): ?array
    {
        if ($fingerprint === []) return null;
        foreach ($this->instances($licenseId) as $row) {
            if (($row['status'] ?? '') !== 'active') continue;
            $known = json_decode((string) ($row['fingerprint'] ?? ''), true);
            if (!is_array($known)) continue;
            if (Fingerprint::score($known, $fingerprint) >= 80) return $row;
        }
        return null;
    }

    /** Re-issue credentials to a machine we already know (seat is not doubled). */
    private function rotateCredentials(array $license, array $instance, array $fingerprint, array $request, string $ip): array
    {
        $secret = bin2hex(random_bytes(32));
        $serial = (int) $instance['lease_serial'] + 1;
        DB::Update('pp_license_instances', [
            'secret_hash'  => hash('sha256', $secret),
            'lease_serial' => $serial,
            'fingerprint'  => (string) json_encode($fingerprint),
            'last_ip'      => substr($ip, 0, 64),
            'app_version'  => substr((string) ($request['appVersion'] ?? $instance['app_version']), 0, 40),
            'status'       => 'active',
            'last_seen_at' => self::now(),
        ], ['instance_id' => $instance['instance_id']]);

        $instance['lease_serial'] = $serial;
        return [
            'instanceId'     => (string) $instance['instance_id'],
            'instanceSecret' => $secret,
            'lease'          => $this->issueLease($license, $instance),
            'seats'          => ['used' => $this->seatsUsed((int) $license['id']), 'limit' => (int) $license['seat_limit']],
            'reused'         => true,
        ];
    }

    /**
     * Watch for a machine's traits changing under a set of credentials.
     *
     * Deliberately does NOT block: a restore from backup onto new hardware and a
     * copied install look identical from here, and locking out the first to
     * catch the second is a bad trade. It is recorded, flagged, and left to the
     * licence holder — who can revoke the instance in one click.
     */
    private function noteFingerprint(array $license, array $instance, array $fingerprint, string $ip): void
    {
        if ($fingerprint === []) return;
        $known = json_decode((string) ($instance['fingerprint'] ?? ''), true);
        if (!is_array($known) || $known === []) return;

        $score = Fingerprint::score($known, $fingerprint);
        if ($score >= 100) {
            if (($instance['risk'] ?? '') !== '') {
                DB::Update('pp_license_instances', ['risk' => ''], ['instance_id' => $instance['instance_id']]);
            }
            return;
        }

        $drift = implode(', ', Fingerprint::drift($known, $fingerprint));
        if ($score < Fingerprint::CLONE_THRESHOLD) {
            DB::Update('pp_license_instances', ['risk' => 'clone-suspected'], ['instance_id' => $instance['instance_id']]);
            $this->log((int) $license['id'], (string) $instance['instance_id'], 'clone_suspected',
                "Machine looks different ({$score}% match; changed: {$drift}). Allowed — could also be a restore or host move.", $ip);
            return;
        }
        DB::Update('pp_license_instances', ['risk' => 'drift'], ['instance_id' => $instance['instance_id']]);
        $this->log((int) $license['id'], (string) $instance['instance_id'], 'fingerprint_drift',
            "Minor change ({$score}% match; changed: {$drift})", $ip);
    }

    public function log(?int $licenseId, ?string $instanceId, string $kind, string $detail, string $ip = ''): void
    {
        try {
            DB::Insert('pp_license_events', [
                'license_id'  => $licenseId,
                'instance_id' => $instanceId,
                'kind'        => $kind,
                'detail'      => mb_substr($detail, 0, 1000),
                'ip'          => substr($ip, 0, 64),
                'created_at'  => self::now(),
            ]);
        } catch (\Throwable $e) {
            // Never let the audit trail break a licence decision.
        }
    }

    /** Canonical licence-key form. Defined on ActivationCode so clients have it too. */
    public static function normaliseKey(string $key): string
    {
        return ActivationCode::normaliseKey($key);
    }

    /** Keep only the traits we know about, as strings — clients send this. */
    private static function cleanFingerprint(mixed $raw): array
    {
        if (!is_array($raw)) return [];
        $out = [];
        foreach (['host', 'root', 'os', 'php', 'srv', 'db'] as $k) {
            if (isset($raw[$k]) && is_scalar($raw[$k])) {
                $out[$k] = substr((string) $raw[$k], 0, 190);
            }
        }
        return $out;
    }

    private static function normaliseDate(mixed $v): ?string
    {
        $s = trim((string) ($v ?? ''));
        if ($s === '') return null;
        $t = strtotime($s);
        return $t ? date('Y-m-d H:i:s', $t) : null;
    }

    /**
     * MySQL-side clock. The platform's PHP runs UTC while MySQL runs local, so
     * timestamps written from PHP would compare wrongly against NOW() — always
     * let the database stamp its own time.
     */
    private static function now(): string
    {
        $t = DB::FetchOne('SELECT NOW()');
        return is_string($t) && $t !== '' ? $t : date('Y-m-d H:i:s');
    }

    /**
     * Epoch for `expires_at`.
     *
     * Two clocks run here — PHP's and MySQL's — and this platform's are hours
     * apart, so what matters is that each column is READ with the same clock it
     * was WRITTEN with:
     *
     *   created_at / last_seen_at  written by NOW(), compared in SQL → MySQL's
     *   expires_at                 written by normaliseDate() in PHP, only ever
     *                              compared in PHP → PHP's, i.e. strtotime()
     *
     * Reading this one through UNIX_TIMESTAMP() silently shifted every licence
     * end-date by the skew.
     */
    private static function epoch(mixed $phpDatetime): int
    {
        $s = trim((string) ($phpDatetime ?? ''));
        if ($s === '') return 0;
        return (int) strtotime($s);
    }
}
