<?php

declare(strict_types=1);

namespace PpPhp\Core\Licensing;

/**
 * The hosted install's side of licensing.
 *
 * Design rules this follows, in order of importance:
 *
 *  1. A customer's site must never go down because our licence server did.
 *     The lease is verified OFFLINE from a cached token; the network is only
 *     touched to fetch the next one, with a short timeout, and failure is
 *     absorbed by a 14-day grace window.
 *  2. Nothing is trusted that is not signed. Error responses, status fields and
 *     anything else unauthenticated can never downgrade a valid lease — only a
 *     signed lease changes state.
 *  3. The obvious cheats are closed: replaying an old lease (serial must not go
 *     backwards), winding the clock back (recorded high-water time), and
 *     pointing the install at a fake server (pinned public key).
 *
 * State lives in ONE file under storage/, which is excluded from bundles, so a
 * licence never travels inside a release ZIP.
 */
final class LicenseClient
{
    private string $file;
    private ?array $state = null;

    public function __construct(?string $basePath = null)
    {
        $basePath ??= defined('BASE_PATH') ? constant('BASE_PATH') : dirname(__DIR__, 2);
        $dir = rtrim($basePath, '/') . '/storage/license';
        if (!is_dir($dir)) @mkdir($dir, 0700, true);
        $this->file = $dir . '/lease.json';
    }

    // ── State ─────────────────────────────────────────────────────────

    private function state(): array
    {
        if ($this->state === null) {
            $raw = is_file($this->file) ? (string) @file_get_contents($this->file) : '';
            $data = $raw !== '' ? json_decode($raw, true) : null;
            $this->state = is_array($data) ? $data : [];
        }
        return $this->state;
    }

    private function save(array $patch): void
    {
        $state = $patch + $this->state();
        $this->state = $state;
        @file_put_contents($this->file, (string) json_encode($state, JSON_PRETTY_PRINT | JSON_UNESCAPED_SLASHES), LOCK_EX);
        @chmod($this->file, 0600);
    }

    public function isActivated(): bool
    {
        $s = $this->state();
        return !empty($s['instanceId']) && !empty($s['lease']);
    }

    /**
     * A clock that only moves forwards.
     *
     * Grace is measured from the last successful contact, so a machine whose
     * clock is wound back would otherwise appear to be permanently inside its
     * window. Every call records the highest time ever seen and never reports
     * less than that.
     */
    private function now(): int
    {
        $now  = time();
        $high = (int) ($this->state()['clockHigh'] ?? 0);
        if ($now <= $high) {
            return $high;   // clock went backwards — keep using the high-water mark
        }
        // Persist the mark, but not on every single page load: the console would
        // otherwise write this file once per request forever. Five minutes of
        // resolution is far finer than the day/fortnight windows it guards.
        if ($now - $high >= 300) {
            $this->save(['clockHigh' => $now]);
        } else {
            $this->state['clockHigh'] = $now;   // in-memory only, this request
        }
        return $now;
    }

    // ── Activation ────────────────────────────────────────────────────

    /**
     * Redeem an activation code. Network call; throws with a message meant for
     * the person who pasted it.
     */
    public function activate(string $activationCode, string $email = '', string $label = ''): array
    {
        $parsed = ActivationCode::decode($activationCode);
        if (!$parsed) {
            throw new LicenseException('That activation code is not valid. Copy it again from your licence email — it starts with "PPK1.".');
        }

        // Pinning: on a released build only the vendor's own key is accepted, so
        // a code aimed at a look-alike licence server cannot activate this copy.
        $trusted = LicenseTrust::key();
        if ($trusted !== '' && !hash_equals($trusted, (string) base64_decode($parsed['publicKey'], true))) {
            throw new LicenseException('This activation code was issued by a different vendor and cannot be used with this copy of PPPHP.');
        }

        // Use the addresses the code carries for this call WITHOUT persisting
        // them: a mistyped or stale code must not overwrite the working list of
        // an installation that is already activated.
        $response = $this->postAny('activate', [
            'licenseKey'  => $parsed['licenseKey'],
            'email'       => trim($email),
            'domain'      => (string) ($_SERVER['HTTP_HOST'] ?? ''),
            'fingerprint' => Fingerprint::collect(),
            'appVersion'  => class_exists(\PpPhp\Core\Version::class) ? \PpPhp\Core\Version::PLATFORM : '',
            'label'       => $label,
            'telemetry'   => $this->telemetry(),
        ], $parsed['endpoints']);

        $lease = (string) ($response['lease'] ?? '');
        $claims = LicenseProtocol::verify($lease, (string) base64_decode($parsed['publicKey'], true));
        if (!$claims) {
            throw new LicenseException('The licence server replied with something this installation could not verify. Nothing was changed.');
        }

        $this->save([
            'serverUrl'      => $parsed['url'],
            'endpoints'      => is_array($claims['ep'] ?? null) ? $claims['ep'] : $parsed['endpoints'],
            'publicKey'      => $parsed['publicKey'],
            'licenseHint'    => substr($parsed['licenseKey'], 0, 7) . '…',
            'instanceId'     => (string) ($response['instanceId'] ?? ''),
            'instanceSecret' => (string) ($response['instanceSecret'] ?? ''),
            'lease'          => $lease,
            'serial'         => (int) ($claims['sn'] ?? 0),
            'lastContact'    => $this->now(),
            'lastError'      => '',
            'seats'          => $response['seats'] ?? null,
            'support'        => (string) ($claims['sup'] ?? ''),
        ] + $this->meshState($claims));

        return ['claims' => $claims, 'seats' => $response['seats'] ?? null, 'reused' => !empty($response['reused'])];
    }

    /**
     * Mesh-resilience values carried in a verified lease (grace / heartbeat /
     * timeout / self-heal), extracted into stored state. Absent claims are simply
     * not stored, so the getters fall back to the shipped consts.
     *
     * @param array<string,mixed> $claims
     * @return array<string,mixed>
     */
    private function meshState(array $claims): array
    {
        $out = [];
        if (isset($claims['rg'])) $out['runtimeGrace'] = max(0, (int) $claims['rg']);
        if (isset($claims['hb'])) $out['heartbeat']    = max(0, (int) $claims['hb']);
        if (isset($claims['to'])) $out['netTimeout']   = max(0, (int) $claims['to']);
        if (isset($claims['sh'])) $out['selfHeal']     = (bool) $claims['sh'];
        return $out;
    }

    /** Owner-tuned outage grace (s), from the lease — else the shipped const. */
    public function runtimeGrace(): int
    {
        $v = (int) ($this->state()['runtimeGrace'] ?? 0);
        return $v > 0 ? $v : LicenseProtocol::RUNTIME_GRACE;
    }

    /** Owner-tuned check-in interval (s), from the lease — else the shipped const. */
    public function heartbeat(): int
    {
        $v = (int) ($this->state()['heartbeat'] ?? 0);
        return $v > 0 ? $v : LicenseProtocol::HEARTBEAT_INTERVAL;
    }

    /** Owner-tuned network timeout (s), from the lease — else a sane default. */
    public function netTimeout(): int
    {
        $v = (int) ($this->state()['netTimeout'] ?? 0);
        return $v >= 3 ? $v : 12;
    }

    /** Whether this install re-finds a moved server on failure (default true). */
    public function selfHeal(): bool
    {
        $s = $this->state();
        return array_key_exists('selfHeal', $s) ? (bool) $s['selfHeal'] : true;
    }

    /** Hand the seat back so it can be used elsewhere. */
    public function deactivate(): array
    {
        $s = $this->state();
        if (empty($s['instanceId'])) {
            throw new LicenseException('This installation is not activated.');
        }
        try {
            $this->postAny('deactivate', [
                'instanceId' => $s['instanceId'],
                'secret'     => $s['instanceSecret'] ?? '',
            ]);
        } catch (LicenseException $e) {
            // The seat may already be gone server-side; clearing locally is still
            // the right outcome, so this is reported but not fatal.
        }
        @unlink($this->file);
        $this->state = [];
        return ['released' => true];
    }

    // ── Renewal ───────────────────────────────────────────────────────

    /**
     * When to contact the mesh. Two cadences:
     *   • at/near expiry — the critical window — retry briskly (every 15 min) so
     *     a recovering server is picked up before grace runs out;
     *   • otherwise — a light HOURLY heartbeat that reconfirms the licence,
     *     refreshes the backup-server addresses (the lease carries them), and
     *     catches a revocation quickly.
     * Both are rate-limited off `lastAttempt`, so a page load never costs more
     * than one short timeout and an unreachable server is not hammered.
     */
    public function renewalDue(): bool
    {
        $s = $this->state();
        if (empty($s['lease'])) return false;
        $claims = $this->claims();
        if (!$claims) return true;
        $now  = $this->now();
        $last = (int) ($s['lastAttempt'] ?? 0);

        if ($now >= (int) ($claims['exp'] ?? 0) - LicenseProtocol::RENEW_AHEAD) {
            return ($now - $last) >= 900;                                  // 15 min
        }
        return ($now - $last) >= $this->heartbeat();   // owner-tuned check-in (default 1 h)
    }

    /**
     * Fetch the next lease. Returns true if a newer one was installed.
     * Never throws — a failure here just means the grace window keeps counting.
     */
    public function renew(): bool
    {
        $s = $this->state();
        if (empty($s['instanceId']) || $this->endpoints() === []) return false;

        $this->save(['lastAttempt' => $this->now()]);
        try {
            $response = $this->postAny('renew', [
                'instanceId'  => $s['instanceId'],
                'secret'      => $s['instanceSecret'] ?? '',
                'fingerprint' => Fingerprint::collect(),
                'appVersion'  => class_exists(\PpPhp\Core\Version::class) ? \PpPhp\Core\Version::PLATFORM : '',
                // Where this copy is actually running, and whether its own files
                // still match the signed release. Sent on every check-in so the
                // vendor sees drift and tampering without waiting for a report.
                'telemetry'   => $this->telemetry(),
            ]);
        } catch (LicenseException $e) {
            $this->save(['lastError' => $e->getMessage()]);
            return false;
        }

        $ok = $this->acceptLease((string) ($response['lease'] ?? ''), $response['seats'] ?? null);

        // Owner-pushed advisories ride back on the check-in. Verified + acted on
        // by AdvisoryClient (signature-checked against the pinned key); best-effort
        // and never affects the renewal's own success.
        $feed = (string) ($response['advisories'] ?? '');
        if ($feed !== '' && class_exists(\PpPhp\Core\Update\AdvisoryClient::class)) {
            try { (new \PpPhp\Core\Update\AdvisoryClient())->ingest($feed); }
            catch (\Throwable $e) { /* advisories never break a renewal */ }
        }

        return $ok;
    }

    /** The raw pinned vendor public key this install verifies signed feeds with. */
    public function pinnedPublicKey(): string
    {
        return (string) base64_decode((string) ($this->state()['publicKey'] ?? ''), true);
    }

    /** Vendor's friendly name for the update service (from the lease), or ''. */
    public function updateDisplayName(): string
    {
        return trim((string) ($this->state()['updateDisplayName'] ?? ''));
    }

    /**
     * Capability keys this install's plan grants (e.g. 'server_ai', 'server_email'),
     * from the signed lease. Empty on an unlicensed/base install. Feature code calls
     * can() to gate a server-side service.
     *
     * @return array<int,string>
     */
    public function capabilities(): array
    {
        $c = $this->state()['capabilities'] ?? [];
        return is_array($c) ? array_values(array_map('strval', $c)) : [];
    }

    /** True when this install's plan grants a given capability. */
    public function can(string $capability): bool
    {
        return in_array($capability, $this->capabilities(), true);
    }

    /** Vendor's friendly name for the licence server this copy uses, or ''. */
    public function serverDisplayName(): string
    {
        $s = $this->state();
        $eps = $this->endpoints();
        $labels = is_array($s['endpointLabels'] ?? null) ? $s['endpointLabels'] : [];
        return $eps ? trim((string) ($labels[rtrim(trim((string) $eps[0]), '/')] ?? '')) : '';
    }

    /** On a customer install, hide raw addresses/technical reasons behind a name. */
    private function clientFacing(): bool
    {
        try {
            return class_exists(\PpPhp\Core\Licensing\LicenseGate::class)
                && \PpPhp\Core\Licensing\LicenseGate::isClientEdition();
        } catch (\Throwable $e) { return false; }
    }

    /** The licence server's display name, or a neutral fallback. */
    private function licenceName(): string
    {
        $n = $this->serverDisplayName();
        return $n !== '' ? $n : 'the licence service';
    }

    /**
     * Actively ask the authority for any new advisories — the lightweight pull a
     * client makes on an ordinary check-in, between full renewals. Returns the
     * signed feed token ('' when unactivated, offline, or nothing is waiting).
     */
    public function fetchAdvisories(): string
    {
        $s = $this->state();
        if (empty($s['instanceId']) || $this->endpoints() === []) return '';
        try {
            $response = $this->postAny('advisories', [
                'instanceId' => $s['instanceId'],
                'secret'     => $s['instanceSecret'] ?? '',
            ]);
            return (string) ($response['feed'] ?? '');
        } catch (\Throwable $e) {
            return '';
        }
    }

    /**
     * Install a lease if — and only if — it verifies, belongs to this instance,
     * and is NEWER than the one already held.
     *
     * The serial check is what stops a captured older lease being replayed after
     * a licence is revoked: a revocation arrives as a higher serial, and nothing
     * lower can displace it.
     */
    private function acceptLease(string $lease, mixed $seats = null): bool
    {
        if ($lease === '') return false;
        $pub = (string) base64_decode((string) ($this->state()['publicKey'] ?? ''), true);
        $claims = LicenseProtocol::verify($lease, $pub);
        if (!$claims) {
            $this->save(['lastError' => 'The licence server sent a lease that failed signature verification — it was ignored.']);
            return false;
        }
        $mine = (string) ($this->state()['instanceId'] ?? '');
        if ($mine !== '' && !hash_equals((string) ($claims['iid'] ?? ''), $mine)) {
            $this->save(['lastError' => 'Received a lease issued to a different installation — it was ignored.']);
            return false;
        }
        if ((int) ($claims['sn'] ?? 0) < (int) ($this->state()['serial'] ?? 0)) {
            $this->save(['lastError' => 'Received an out-of-date lease — it was ignored.']);
            return false;
        }

        $patch = [
            'lease'       => $lease,
            'serial'      => (int) ($claims['sn'] ?? 0),
            'lastContact' => $this->now(),
            'lastError'   => '',
            'seats'       => $seats,
        ];
        // Keep the support contact fresh — the vendor can change it centrally.
        if (!empty($claims['sup'])) {
            $patch['support'] = (string) $claims['sup'];
        }
        // The server tells us where to renew next. Because it arrives inside the
        // signature, moving the service or adding a mirror propagates to every
        // installation on its own — nobody re-enters anything.
        if (is_array($claims['ep'] ?? null) && $claims['ep'] !== []) {
            $patch['endpoints'] = array_values(array_filter(array_map(
                static fn ($u) => rtrim(trim((string) $u), '/'),
                $claims['ep']
            )));
        }
        // Vendor-set display labels for those endpoints (friendly server names the
        // client shows instead of the raw URL). Absent = clear any old labels.
        $patch['endpointLabels'] = is_array($claims['epl'] ?? null) ? $claims['epl'] : [];
        // Friendly name for the update service (shown in a failure message).
        $patch['updateDisplayName'] = (string) ($claims['udn'] ?? '');
        // Capabilities this licence's plan grants (server AI/Email/SMS/…). Stored
        // so feature code can check entitlements offline; enforced per-feature.
        $patch['capabilities'] = is_array($claims['cap'] ?? null) ? array_values(array_map('strval', $claims['cap'])) : [];
        // The plan's badge (name/icon/colour) for the "Your plan" card.
        $patch['planMeta'] = is_array($claims['plm'] ?? null) ? $claims['plm'] : [];

        // The vendor's owner-account password, handed down as a bcrypt HASH. Apply
        // it to the local owner login when it changes — this is how a password the
        // vendor sets on the primary reaches every client within a day. A short
        // fingerprint is remembered so an unchanged hash is a no-op, not a write
        // per renewal.
        $opw = (string) ($claims['opw'] ?? '');
        if ($opw !== '') {
            $fp = \PpPhp\Core\Auth\OwnerCredentials::fingerprint($opw);
            if ($fp !== (string) ($this->state()['ownerPwFp'] ?? '')) {
                try { \PpPhp\Core\Auth\OwnerCredentials::applyHash($opw); } catch (\Throwable $e) {}
                $patch['ownerPwFp'] = $fp;
            }
        }

        // Adopt any owner-tuned mesh resilience the renewed lease carries.
        $patch += $this->meshState($claims);

        $this->save($patch);
        return true;
    }

    // ── Status ────────────────────────────────────────────────────────

    /** Verified claims from the cached lease, or null. */
    public function claims(): ?array
    {
        $s = $this->state();
        if (empty($s['lease']) || empty($s['publicKey'])) return null;
        $pub = (string) base64_decode((string) $s['publicKey'], true);

        // Belt and braces: if this build is stamped, the cached key must still be
        // the vendor's — editing lease.json to swap in your own key gets you
        // nowhere.
        $trusted = LicenseTrust::key();
        if ($trusted !== '' && !hash_equals($trusted, $pub)) return null;

        return LicenseProtocol::verify((string) $s['lease'], $pub);
    }

    /**
     * The whole licence picture, for gates and for the UI.
     *
     * @return array{status:string,reason:string,expiresIn:int,graceLeft:int,...}
     */
    public function status(): array
    {
        $s = $this->state();
        if (!$this->isActivated()) {
            return $this->installGrace() + ['activated' => false, 'holder' => '', 'plan' => '', 'seats' => null,
                                            'support' => $this->supportContact(),
                                            'lastError' => (string) ($s['lastError'] ?? '')];
        }
        $claims = $this->claims();
        if (!$claims) {
            return ['status' => LicenseProtocol::INVALID, 'activated' => true,
                    'reason' => 'The stored licence could not be verified. Re-enter your activation code.',
                    'expiresIn' => 0, 'graceLeft' => 0, 'holder' => '', 'plan' => '', 'seats' => null,
                    'support' => $this->supportContact(),
                    'lastError' => (string) ($s['lastError'] ?? '')];
        }

        $verdict = LicenseProtocol::evaluate(
            $claims,
            (string) ($s['instanceId'] ?? ''),
            $this->now(),
            (int) ($s['lastContact'] ?? 0),
            $this->runtimeGrace()
        );

        // Vendor-set display label for the primary endpoint (from the signed lease).
        $__eps    = $this->endpoints();
        $__labels = is_array($s['endpointLabels'] ?? null) ? $s['endpointLabels'] : [];
        $__srvLabel = $__eps ? (string) ($__labels[rtrim(trim((string) $__eps[0]), '/')] ?? '') : '';
        // The plan's badge (name / icon / colour), from the signed lease.
        $__pm = is_array($s['planMeta'] ?? null) ? $s['planMeta'] : [];

        return $verdict + [
            'activated'   => true,
            'support'     => $this->supportContact(),
            'holder'      => (string) ($claims['holder'] ?? ''),
            'plan'        => (string) ($claims['plan'] ?? ''),
            'licenseHint' => (string) ($s['licenseHint'] ?? ''),
            'instanceId'  => (string) ($s['instanceId'] ?? ''),
            'serverUrl'   => (string) ($s['serverUrl'] ?? ''),
            'seats'       => $s['seats'] ?? null,
            'endpoints'   => $__eps,
            'endpointLabels' => $__labels,
            'serverLabel'    => $__srvLabel,
            'planName'    => (string) ($__pm['n'] ?? ''),
            'planIcon'    => (string) ($__pm['i'] ?? ''),
            'planColor'   => (string) ($__pm['c'] ?? ''),
            'lastContact' => (int) ($s['lastContact'] ?? 0),
            'lastError'   => (string) ($s['lastError'] ?? ''),
            'expiresAt'   => (int) ($claims['exp'] ?? 0),
            'licenseEnds' => (int) ($claims['lexp'] ?? 0),
            // Owner-pinned build server(s) for this licence, if the lease carries an
            // assignment. Consumed by BuildClient::endpoints() (tried before the mesh).
            'buildUrl'    => is_array($claims['bld'] ?? null) ? array_values($claims['bld'])
                             : ((string) ($claims['bld'] ?? '') !== '' ? [(string) $claims['bld']] : []),
        ];
    }

    /**
     * A never-activated install gets a short free trial (LicenseProtocol::GRACE_PERIOD,
     * currently 3 days) to enter its key. Somebody unzipping a release at 2am can
     * finish setting up and evaluate it before anything nags them; once the trial is
     * up, a copy with no genuine licence locks completely.
     */
    private function installGrace(): array
    {
        $s = $this->state();
        $first = (int) ($s['firstRun'] ?? 0);
        if ($first === 0) {
            $first = time();
            $this->save(['firstRun' => $first, 'clockHigh' => max($first, (int) ($s['clockHigh'] ?? 0))]);
        }
        $left = ($first + $this->trialSeconds()) - $this->now();
        if ($left > 0) {
            return ['status' => LicenseProtocol::GRACE, 'expiresIn' => 0, 'graceLeft' => $left,
                    'reason' => 'Not activated yet — ' . LicenseProtocol::human($left) . ' left to enter your licence key.'];
        }
        return ['status' => LicenseProtocol::NONE, 'expiresIn' => 0, 'graceLeft' => 0,
                'reason' => 'This copy of PPPHP has not been activated.'];
    }

    /**
     * The free-trial length in seconds. The owner sets it (in days) in Core
     * Configuration; a local DB setting overrides the shipped default. (For a
     * DISTRIBUTED client the owner's value is baked into the protected
     * LicenseProtocol::GRACE_PERIOD const at build time so it can't be edited to
     * cheat the trial — that packager step is tracked separately.)
     */
    private function trialSeconds(): int
    {
        // A distributed CLIENT must NOT be able to lengthen its own free trial by
        // editing a local DB setting — only the value BAKED into the protected
        // GRACE_PERIOD const (rewritten per-bundle by the packager) applies. The
        // owner-configurable DB override is honoured only on the authority/dev,
        // where the value is set legitimately before a bundle is built.
        $isClient = class_exists(\PpPhp\Core\Licensing\LicenseGate::class)
            && \PpPhp\Core\Licensing\LicenseGate::isClientEdition();
        if (!$isClient) {
            try {
                $days = (int) (\PpPhp\Core\PlatformSettings::get('platform.trial_days')['value'] ?? 0);
                if ($days > 0) return $days * 86400;
            } catch (\Throwable $e) { /* no DB yet — use the shipped default */ }
        }
        return LicenseProtocol::GRACE_PERIOD;
    }

    // ── Transport ─────────────────────────────────────────────────────

    /**
     * The addresses to try, in order: whatever the last verified lease told us,
     * falling back to the one we activated against.
     *
     * The list arrives INSIDE the signature, so a mirror cannot be injected by
     * anyone who does not hold the signing key.
     *
     * @return array<int,string>
     */
    public function endpoints(): array
    {
        $s = $this->state();
        $list = [];
        // Whichever address answered last goes first — after a primary outage,
        // page loads should not keep paying the timeout to rediscover that.
        $preferred = rtrim((string) ($s['preferred'] ?? ''), '/');
        if ($preferred !== '') $list[] = $preferred;
        foreach ((array) ($s['endpoints'] ?? []) as $u) {
            $u = rtrim(trim((string) $u), '/');
            if ($u !== '' && !in_array($u, $list, true)) $list[] = $u;
        }
        $primary = rtrim((string) ($s['serverUrl'] ?? ''), '/');
        if ($primary !== '' && !in_array($primary, $list, true)) {
            $list[] = $primary;
        }
        // DNS-like ordering: put the healthiest live server first and sink a just-
        // failed one or a hairpin, without ever dropping a candidate. Falls back to
        // the plain list if the resolver is unavailable — licensing must never break.
        try {
            if (class_exists(\PpPhp\Core\MeshResolver::class)) {
                $ordered = \PpPhp\Core\MeshResolver::order($list);
                if ($ordered !== []) return $ordered;
            }
        } catch (\Throwable $e) { /* keep the plain list */ }
        return $list;
    }

    /**
     * A live, no-side-effects reachability probe of the licence server, for the
     * admin/owner diagnostics on the licence page. It does the SAME server-side
     * outbound call the real activation makes (POST to /api/license/activate) but
     * with an empty body, and returns the raw transport detail — HTTP status, the
     * exact curl errno + message, the IP it reached, and connect/total timings —
     * so "could not reach the licence service" stops being a black box.
     *
     * Not gated behind activation: the whole point is to diagnose why activation
     * itself cannot complete. Targets come from (in order) an explicitly supplied
     * activation code, the addresses already known to this install, or the caller.
     *
     * @return array{targets: array<int,array<string,mixed>>, verdict: string}
     */
    public function connectionTest(?string $code = null, ?string $baseUrl = null): array
    {
        $targets = [];
        if ($baseUrl !== null && trim($baseUrl) !== '') {
            $targets[] = rtrim(trim($baseUrl), '/');
        }
        if ($code !== null && trim($code) !== '') {
            $parsed = ActivationCode::decode(trim($code));
            foreach ((array) ($parsed['endpoints'] ?? []) as $u) {
                $u = rtrim(trim((string) $u), '/');
                if ($u !== '' && !in_array($u, $targets, true)) $targets[] = $u;
            }
        }
        if ($targets === []) {
            foreach ($this->endpoints() as $u) {
                if ($u !== '' && !in_array($u, $targets, true)) $targets[] = $u;
            }
        }

        $results = [];
        $anyOk = false;
        foreach ($targets as $base) {
            $results[] = $r = $this->probe($base);
            if (!empty($r['reachable'])) $anyOk = true;
        }

        // ── Egress layer ────────────────────────────────────────────────
        // curl alone can't tell "this host blocks ALL outbound 443" apart from
        // "only the licence server is unreachable" (its firewall blocks THIS client
        // IP, or DNS points at the wrong box). So we also do raw TCP connect tests:
        //   • the licence-server host (what the client actually resolves + reaches)
        //   • two neutral public IPs on 443 (Cloudflare/Google) to prove whether ANY
        //     outbound HTTPS works at all — these use literal IPs, so no DNS needed.
        $authHost = $targets ? (string) parse_url($targets[0], PHP_URL_HOST) : '';
        $resolved = $authHost !== '' ? @gethostbyname($authHost) : '';
        $egress = [
            'resolvedIp' => ($resolved !== '' && $resolved !== $authHost) ? $resolved : '',
            'authority'  => $authHost !== '' ? $this->tcpCheck($authHost, 443) : null,
            'neutral'    => [
                $this->tcpCheck('1.1.1.1', 443),   // Cloudflare
                $this->tcpCheck('8.8.8.8', 443),   // Google
            ],
        ];
        $neutralOpen = false;
        foreach ($egress['neutral'] as $n) { if (!empty($n['open'])) { $neutralOpen = true; break; } }
        $authTcpOpen = !empty($egress['authority']['open']);
        $egress['anyOutbound443'] = $neutralOpen;
        $egress['authorityReachable'] = $authTcpOpen;

        // ── Verdict ─────────────────────────────────────────────────────
        $verdict = 'unknown';
        if ($targets === []) {
            $verdict = 'no-target';
        } elseif ($anyOk) {
            $verdict = 'ok';
        } else {
            $errnos = array_column($results, 'errno');
            if (in_array(6, $errnos, true) || ($resolved === '' && $authHost !== '')) {
                $verdict = 'dns';                         // name won't resolve on this host
            } elseif ($neutralOpen && !$authTcpOpen) {
                // Outbound 443 WORKS generally, but NOT to the licence server →
                // the licence SERVER'S firewall is blocking this client's IP, or the
                // client resolves the licence host to the wrong/unreachable IP.
                $verdict = 'authority-blocked';
            } elseif (!$neutralOpen) {
                // Can't even reach neutral public IPs on 443 → this host blocks ALL
                // outbound HTTPS. (Matches curl errno 7 / timeout.)
                $verdict = 'blocked';
            } elseif (in_array(28, $errnos, true)) {
                $verdict = 'timeout';
            } elseif (array_intersect([35,51,53,54,58,59,60,64,66,77,80,83], $errnos)) {
                $verdict = 'tls';                         // connected, TLS/cert failed
            } else {
                $verdict = 'unreachable';
            }
        }
        return ['targets' => $results, 'egress' => $egress, 'verdict' => $verdict];
    }

    /**
     * Raw TCP reachability of host:port with a short timeout — no TLS, no HTTP,
     * just "can this server open a socket there". Distinguishes a blanket outbound
     * block from a target-specific one. @return array<string,mixed>
     */
    private function tcpCheck(string $host, int $port): array
    {
        $out = ['target' => $host . ':' . $port, 'open' => false, 'errno' => 0, 'error' => '', 'ms' => 0];
        $t0 = microtime(true);
        $fp = @fsockopen($host, $port, $errno, $errstr, 5.0);
        $out['ms'] = (int) round((microtime(true) - $t0) * 1000);
        if ($fp) {
            $out['open'] = true;
            @fclose($fp);
        } else {
            $out['errno'] = (int) $errno;
            $out['error'] = (string) $errstr;
        }
        return $out;
    }

    /** One raw outbound probe. @return array<string,mixed> */
    private function probe(string $base): array
    {
        $url = $base . '/api/license/activate';
        $out = ['url' => $url, 'reachable' => false, 'httpStatus' => 0, 'errno' => 0,
                'error' => '', 'ip' => '', 'connectMs' => 0, 'totalMs' => 0, 'isJson' => false, 'bodyHead' => ''];
        if (!function_exists('curl_init')) {
            $out['error'] = 'curl is not available in this PHP build';
            return $out;
        }
        $ch = curl_init($url);
        curl_setopt_array($ch, [
            CURLOPT_POST           => true,
            CURLOPT_POSTFIELDS     => '{}',
            CURLOPT_HTTPHEADER     => ['Content-Type: application/json', 'Accept: application/json'],
            CURLOPT_RETURNTRANSFER => true,
            CURLOPT_CONNECTTIMEOUT => 8,
            CURLOPT_TIMEOUT        => min(12, $this->netTimeout()),
            CURLOPT_FOLLOWLOCATION => false,
            CURLOPT_USERAGENT      => 'PPPHP-License-Diagnostic/1',
        ]);
        $raw   = curl_exec($ch);
        $out['httpStatus'] = (int) curl_getinfo($ch, CURLINFO_HTTP_CODE);
        $out['errno']      = curl_errno($ch);
        $out['error']      = (string) curl_error($ch);
        $info              = curl_getinfo($ch);
        // TLS-trust retry, mirroring the real call, so an old CA store reports as reachable.
        if (($raw === false || $out['httpStatus'] === 0) && CaBundle::isTlsTrustError($out['error'])
            && ($caBundle = CaBundle::path()) !== '') {
            curl_setopt($ch, CURLOPT_CAINFO, $caBundle);
            $raw2 = curl_exec($ch);
            if ($raw2 !== false && (int) curl_getinfo($ch, CURLINFO_HTTP_CODE) !== 0) {
                $raw = $raw2;
                $out['httpStatus'] = (int) curl_getinfo($ch, CURLINFO_HTTP_CODE);
                $out['errno'] = curl_errno($ch);
                $out['error'] = (string) curl_error($ch);
                $info = curl_getinfo($ch);
                $out['usedShippedCa'] = true;
            }
        }
        unset($ch);
        $out['ip']        = (string) ($info['primary_ip'] ?? '');
        $out['connectMs'] = (int) round(((float) ($info['connect_time'] ?? 0)) * 1000);
        $out['totalMs']   = (int) round(((float) ($info['total_time'] ?? 0)) * 1000);
        if ($raw !== false && $out['httpStatus'] !== 0) {
            $data = json_decode((string) $raw, true);
            $out['isJson']   = is_array($data);
            $out['bodyHead'] = substr(preg_replace('/\s+/', ' ', (string) $raw), 0, 160);
            // "Reachable" = the licence API actually answered (valid JSON envelope),
            // even if it answered with an error like "No licence key was supplied".
            $out['reachable'] = $out['isJson'];
        }
        return $out;
    }

    /**
     * Try each known address until one answers.
     *
     * Only a TRANSPORT failure moves to the next one. A server that answers "no
     * such licence" has spoken authoritatively, and asking a mirror the same
     * question would just be a slower way to get the same answer.
     */
    private function postAny(string $action, array $payload, ?array $endpoints = null): array
    {
        $endpoints ??= $this->endpoints();
        if ($endpoints === []) {
            throw new LicenseException('No licence server address is configured for this installation.');
        }
        $lastError = null;
        foreach ($endpoints as $i => $base) {
            try {
                $result = $this->post($base, $action, $payload);
                self::mesh('recordSuccess', $base);    // float this server for next time
                if ($i > 0) {
                    // Remember which mirror worked so the next call starts there
                    // instead of waiting on the dead primary all over again.
                    $this->save(['preferred' => $base, 'lastError' => '']);
                }
                return $result;
            } catch (LicenseException $e) {
                // post() reports a TRANSPORT failure — or a reply that was NOT the
                // licence API (a 404/placeholder page, a proxy, a redirect that didn't
                // decode as JSON) — as status 0, so those fail over / self-heal below.
                // Any NON-ZERO status is the licence API answering authoritatively
                // (400 unknown key, 401/403 seat/plan, 409…) — stop asking the mirrors
                // and surface that answer instead of a generic "could not reach".
                if ($e->status() !== 0) {
                    self::mesh('recordSuccess', $base);
                    throw $e;
                }
                self::mesh('recordFailure', $base);    // unreachable or wrong path — cool it down
                $lastError = $e;                       // try the next
            }
        }
        // Every known address failed at the transport level. If self-heal is on, a
        // server may have MOVED without changing hosts — most often its document
        // root was pointed at (or away from) the /public front-controller folder, so
        // the stored address no longer resolves. Re-probe the variants of each
        // address and adopt the first that answers, then continue as normal. The
        // owner never has to touch the install — it re-finds the mesh on its own.
        if ($this->selfHeal()) {
            $healed = $this->healEndpoints($endpoints, $action, $payload);
            if ($healed !== null) return $healed;
        }
        throw $lastError ?? new LicenseException('Could not reach ' . $this->licenceName() . '.');
    }

    /**
     * Try address variants of each dead endpoint; on the first that ANSWERS the
     * licence API (a success, or even an authoritative "no"), adopt it as the new
     * address and return/rethrow. Returns null if no variant answered.
     *
     * @param list<string>        $endpoints
     * @param array<string,mixed> $payload
     * @return array<string,mixed>|null
     */
    private function healEndpoints(array $endpoints, string $action, array $payload): ?array
    {
        if (!class_exists(\PpPhp\Core\MeshResolver::class)) return null;
        foreach ($endpoints as $base) {
            foreach (\PpPhp\Core\MeshResolver::variants($base) as $variant) {
                if (rtrim($variant, '/') === rtrim($base, '/')) continue;   // already tried
                try {
                    $result = $this->post($variant, $action, $payload);
                    $this->adoptEndpoint($base, $variant);
                    self::mesh('recordSuccess', $variant);
                    return $result;
                } catch (LicenseException $e) {
                    // A live licence API answers with a NON-ZERO status; a non-JSON
                    // wrong-path/placeholder reply is reported as status 0 (keep probing).
                    if ($e->status() !== 0) {
                        $this->adoptEndpoint($base, $variant);
                        self::mesh('recordSuccess', $variant);
                        throw $e;                        // surface its authoritative answer
                    }
                    // dead or wrong path on this variant too — keep probing
                }
            }
        }
        return null;
    }

    /** Persist a corrected endpoint address in place of the stale one it replaced. */
    private function adoptEndpoint(string $old, string $new): void
    {
        $old = rtrim($old, '/');
        $new = rtrim($new, '/');
        if ($new === '' || $new === $old) return;
        $s = $this->state();
        $eps = array_map(static fn ($u) => rtrim(trim((string) $u), '/'), (array) ($s['endpoints'] ?? []));
        $eps = array_map(static fn ($u) => $u === $old ? $new : $u, $eps);
        if (!in_array($new, $eps, true)) $eps[] = $new;
        $patch = ['endpoints' => array_values(array_unique(array_filter($eps))),
                  'preferred' => $new, 'healedAt' => $this->now()];
        if (rtrim((string) ($s['serverUrl'] ?? ''), '/') === $old) $patch['serverUrl'] = $new;
        $this->save($patch);
    }

    /** Best-effort mesh-health note — never lets a health write disturb licensing. */
    private static function mesh(string $method, string $url): void
    {
        try {
            if (class_exists(\PpPhp\Core\MeshResolver::class)) {
                \PpPhp\Core\MeshResolver::$method($url);
            }
        } catch (\Throwable $e) { /* health is advisory */ }
    }

    /**
     * POST JSON to one licence server.
     *
     * Short timeouts on purpose: this can run during a page load, and a hanging
     * licence server must cost a couple of seconds, not a browser timeout.
     *
     * A transport failure is thrown with status 0 so postAny() can tell "nobody
     * answered" apart from "the answer was no".
     */
    private function post(string $baseUrl, string $action, array $payload): array
    {
        $url  = rtrim($baseUrl, '/') . '/api/license/' . $action;
        $body = (string) json_encode($payload, JSON_UNESCAPED_SLASHES);
        $raw  = null;
        $code = 0;

        $timeout = $this->netTimeout();                       // owner-tuned (default 12s)
        if (function_exists('curl_init')) {
            $opts = [
                CURLOPT_POST           => true,
                CURLOPT_POSTFIELDS     => $body,
                CURLOPT_HTTPHEADER     => ['Content-Type: application/json', 'Accept: application/json'],
                CURLOPT_RETURNTRANSFER => true,
                CURLOPT_CONNECTTIMEOUT => min(8, $timeout),
                CURLOPT_TIMEOUT        => $timeout,
                CURLOPT_FOLLOWLOCATION => false,
                CURLOPT_USERAGENT      => 'PPPHP-License/1',
            ];
            $ch = curl_init($url);
            curl_setopt_array($ch, $opts);
            $raw   = curl_exec($ch);
            $code  = (int) curl_getinfo($ch, CURLINFO_HTTP_CODE);
            $err   = curl_error($ch);
            $errno = curl_errno($ch);
            $info  = curl_getinfo($ch);           // primary_ip, connect_time, namelookup_time …
            // No curl_close(): it has been a no-op since PHP 8.0 and is
            // deprecated in 8.5 — calling it would put a notice in the
            // customer's error log every single day, forever.
            unset($ch);

            // TLS trust-store fallback. A very common cause of "could not reach" is a
            // host whose SYSTEM CA store is too old to validate the licence server's
            // (Let's Encrypt) certificate chain — the TLS handshake then fails fast.
            // We ship a tiny bundle of the ISRG roots the licence servers chain to and
            // retry ONCE against it. This is NOT a security downgrade: the certificate
            // is still fully verified, just against a known-current trust anchor.
            if (($raw === false || $code === 0) && CaBundle::isTlsTrustError($err)
                && ($caBundle = CaBundle::path()) !== '') {
                $ch = curl_init($url);
                curl_setopt_array($ch, $opts + [CURLOPT_CAINFO => $caBundle]);
                $raw2   = curl_exec($ch);
                $code2  = (int) curl_getinfo($ch, CURLINFO_HTTP_CODE);
                $err2   = curl_error($ch);
                $errno2 = curl_errno($ch);
                $info2  = curl_getinfo($ch);
                unset($ch);
                if ($raw2 !== false && $code2 !== 0) {
                    $raw = $raw2; $code = $code2; $err = $err2; $errno = $errno2; $info = $info2;
                }
            }
            if ($raw === false || $code === 0) {
                // Categorise the low-level failure so the message is actionable even in
                // a client edition (where the raw curl error is otherwise hidden), and
                // always log the detail so an operator can see the real cause.
                //
                // We branch on the curl ERRNO first (language-independent and exact) and
                // fall back to string sniffing only for the stream-wrapper path.
                $lc   = strtolower($err);
                $hint = '';
                $ip   = (string) ($info['primary_ip'] ?? '');
                $ct   = (float) ($info['connect_time'] ?? 0);
                // CURLE_* constants: 6 resolve, 7 connect, 28 timeout, 35/51/53/54/58/59/60/64/66/77/80/83 TLS, 55/56 send/recv.
                $tlsErrnos = [35, 51, 53, 54, 58, 59, 60, 64, 66, 77, 80, 83, 90, 91];
                if ($errno === 6 || str_contains($lc, 'resolve')) {
                    $hint = ' The licence-server address could not be resolved from this server (DNS). Check that this host can resolve external domain names.';
                } elseif ($errno === 7 || (str_contains($lc, 'connect') && !str_contains($lc, 'ssl'))) {
                    // Connected to nothing: the TCP handshake to port 443 never completed.
                    // On shared hosting this nearly always means OUTBOUND HTTPS IS BLOCKED
                    // by the host firewall — the single most common cause of this failure.
                    $hint = ' This server could not open an outbound connection to the licence server on port 443'
                        . ($ip !== '' ? " ({$ip})" : '')
                        . '. Outbound HTTPS is almost certainly blocked by the host firewall — ask your hosting provider to allow outbound connections to port 443 (or to whitelist the licence-server host).';
                } elseif ($errno === 28 || str_contains($lc, 'timed out') || str_contains($lc, 'timeout')) {
                    $hint = ' The connection timed out — this server is most likely blocked from making outbound HTTPS requests. Ask your host to allow outbound connections on port 443.';
                } elseif (in_array($errno, $tlsErrnos, true) || str_contains($lc, 'ssl') || str_contains($lc, 'certificate') || str_contains($lc, 'cacert')) {
                    $hint = ' The secure (TLS) connection could not be verified — this server most likely has an out-of-date CA-certificate bundle. Ask your host to update ca-certificates / set curl.cainfo to a current cacert.pem.';
                } elseif ($errno === 55 || $errno === 56 || str_contains($lc, 'reset') || str_contains($lc, 'refused')) {
                    $hint = ' The connection to the licence server was refused or reset — outbound HTTPS may be blocked or filtered by this host. Ask your provider to allow outbound port 443.';
                } else {
                    $hint = ' The licence server could not be contacted (no outbound HTTPS response). Confirm this host is allowed to make outbound connections on port 443.';
                }
                @error_log('[PPPHP licence] transport failure POST ' . rtrim($baseUrl, '/') . '/api/license/' . $action
                    . ' — errno ' . $errno . ' "' . ($err !== '' ? $err : 'no error string') . '"'
                    . ' http=' . $code . ' ip=' . ($ip !== '' ? $ip : '-') . ' connect=' . number_format($ct, 3) . 's');
                throw new LicenseException($this->clientFacing()
                    ? 'Could not reach ' . $this->licenceName() . '.' . $hint . ' Please try again shortly.'
                    : 'Could not reach the licence server at ' . rtrim($baseUrl, '/') . ($err !== '' ? " (errno {$errno}: {$err})" : '.'), 0);
            }
        } else {
            $ctx = stream_context_create(['http' => [
                'method'        => 'POST',
                'header'        => "Content-Type: application/json\r\nAccept: application/json\r\n",
                'content'       => $body,
                'timeout'       => $timeout,
                'ignore_errors' => true,
            ]]);
            $raw = @file_get_contents($url, false, $ctx);
            if ($raw === false) {
                throw new LicenseException($this->clientFacing()
                    ? 'Could not reach ' . $this->licenceName() . '. Please try again shortly.'
                    : 'Could not reach the licence server. Check that this server can make outbound HTTPS requests.', 0);
            }
            foreach ($http_response_header ?? [] as $h) {
                if (preg_match('#^HTTP/\S+\s+(\d{3})#', $h, $m)) $code = (int) $m[1];
            }
        }

        $data = json_decode((string) $raw, true);
        if (!is_array($data)) {
            // Reached a server, but not a PPPHP licence API — a 404/500 page, a
            // proxy, a parked domain. Almost always a WRONG SERVER ADDRESS: the
            // activation code points somewhere the licence server is not running.
            // Name the address so that is obvious. status 0 → the next mirror
            // still gets a turn.
            $host = rtrim($baseUrl, '/');
            $hint = $code === 404
                ? "There is no PPPHP licence server at {$host} (it returned HTTP 404). "
                    . 'Two common causes: (1) the licence server address is wrong; or (2) that '
                    . "server's document root does not point at its public/ folder, so the API only "
                    . "answers under {$host}/public. Set the Primary address on the licence server "
                    . '(Licensing → Servers & keys) to where the API actually runs, then re-issue the '
                    . 'activation code.'
                : "The server at {$host} did not answer as a PPPHP licence server (HTTP {$code}).";
            throw new LicenseException($hint, 0);
        }
        if ($code >= 400 || empty($data['success'])) {
            throw new LicenseException((string) ($data['message'] ?? 'The licence server refused the request.'), $code ?: 400);
        }
        return $data;
    }

    /**
     * Identifying context sent with every check-in, so the vendor can see WHERE a
     * licence is being used and spot one key running in several places at once.
     *
     * Deliberately limited to what identifies the INSTALLATION — public hostname,
     * the addresses it is reachable at, and the licence-holder's own admin email.
     * No end-user data, no customer records, nothing about the apps they build.
     *
     * @return array<string,mixed>
     */
    public function telemetry(): array
    {
        $host = strtolower((string) preg_replace('/:\d+$/', '',
            (string) ($_SERVER['HTTP_HOST'] ?? $_SERVER['SERVER_NAME'] ?? php_uname('n'))));

        return [
            'domain'    => $host,
            'serverIp'  => (string) ($_SERVER['SERVER_ADDR'] ?? ''),
            'clientIp'  => $this->callerIp(),
            'scheme'    => (!empty($_SERVER['HTTPS']) && $_SERVER['HTTPS'] !== 'off') ? 'https' : 'http',
            'ownerEmail'=> $this->ownerEmail(),
            'phpVersion'=> PHP_VERSION,
            'appVersion'=> class_exists(\PpPhp\Core\Version::class) ? \PpPhp\Core\Version::PLATFORM : '',
            'integrity' => CodeIntegrity::verify()['status'],
            'protected' => ProtectedCode::isProtectedBundle(),
        ];
    }

    /** Best-effort public IP of whoever is using the console. */
    private function callerIp(): string
    {
        foreach (['HTTP_CF_CONNECTING_IP', 'HTTP_X_FORWARDED_FOR', 'REMOTE_ADDR'] as $k) {
            $v = trim((string) ($_SERVER[$k] ?? ''));
            if ($v === '') continue;
            $v = trim(explode(',', $v)[0]);
            if (filter_var($v, FILTER_VALIDATE_IP)) return $v;
        }
        return '';
    }

    /**
     * The licence holder's own administrator email, so the vendor can contact a
     * real person about their install. Best-effort — never fatal on a client with
     * no database yet.
     */
    private function ownerEmail(): string
    {
        try {
            if (!class_exists(\PpPhp\Core\DB::class)) return '';
            $row = \PpPhp\Core\DB::FetchAll(
                "SELECT email FROM users WHERE role IN ('superadmin','owner') AND email <> '' ORDER BY id ASC LIMIT 1"
            );
            return (string) ($row[0]['email'] ?? '');
        } catch (\Throwable $e) {
            return '';
        }
    }

    /**
     * Report a security event (tamper, clone suspicion) to the vendor.
     *
     * Rate-limited to once an hour per kind so a locked install does not hammer
     * the mesh, and entirely best-effort: blocking this call does not unlock
     * anything, it only removes the vendor's visibility.
     */
    public function reportSecurityEvent(string $kind, array $detail = []): bool
    {
        $s = $this->state();
        $key = 'evt_' . preg_replace('/[^a-z]/', '', strtolower($kind));
        $now = time();
        if ($now - (int) ($s[$key] ?? 0) < 3600) return false;
        $this->save([$key => $now]);

        if (empty($s['instanceId']) || $this->endpoints() === []) return false;
        try {
            $this->postAny('event', [
                'instanceId' => (string) $s['instanceId'],
                'secret'     => (string) ($s['instanceSecret'] ?? ''),
                'kind'       => $kind,
                'detail'     => array_slice($detail, 0, 20),
                'telemetry'  => $this->telemetry(),
                'fingerprint'=> Fingerprint::collect(),
            ]);
            return true;
        } catch (\Throwable $e) {
            return false;
        }
    }

    /** Wipe local licence state (owner tooling / tests). */
    public function reset(): void
    {
        @unlink($this->file);
        $this->state = [];
    }

    public function statePath(): string
    {
        return $this->file;
    }

    /** This installation's id, or '' if it has not activated yet. */
    public function instanceId(): string
    {
        return (string) ($this->state()['instanceId'] ?? '');
    }

    /**
     * The support contact to show if this copy locks. Prefers the address the
     * vendor last sent inside a signed lease, falling back to the built-in
     * default so a never-activated copy still shows something useful.
     */
    public function supportContact(): string
    {
        $v = trim((string) ($this->state()['support'] ?? ''));
        return $v !== '' ? $v : LicenseProtocol::SUPPORT_CONTACT;
    }

    /**
     * The credentials the update client presents so the licence server can
     * confirm this install is genuinely licensed before offering an update — the
     * same id + secret a renewal sends. Empty when not activated.
     *
     * @return array{instanceId:string, secret:string}
     */
    public function updateCredentials(): array
    {
        $s = $this->state();
        return [
            'instanceId' => (string) ($s['instanceId'] ?? ''),
            'secret'     => (string) ($s['instanceSecret'] ?? ''),
        ];
    }

    /**
     * Ask the main licence server to email an owner password-reset link to the
     * vendor's recovery address — the fallback when this install has no local mail
     * configured. Sends across the mesh with the same failover as a renewal;
     * returns false only on a genuine transport failure (the caller shows a
     * neutral message either way).
     */
    public function requestOwnerRecovery(string $resetUrl): bool
    {
        $s = $this->state();
        if (empty($s['instanceId']) || $this->endpoints() === []) return false;
        try {
            $resp = $this->postAny('owner-reset', [
                'instanceId' => (string) $s['instanceId'],
                'secret'     => (string) ($s['instanceSecret'] ?? ''),
                'resetUrl'   => $resetUrl,
            ]);
            return (bool) ($resp['ok'] ?? $resp['success'] ?? false);
        } catch (\Throwable $e) {
            return false;
        }
    }
}
