<?php

declare(strict_types=1);

namespace PpPhp\Core\Update;

use PpPhp\Core\Licensing\LicenseTrust;
use PpPhp\Core\PlatformSettings;
use PpPhp\Core\Version;

/**
 * The client half of "Check for Updates" — runs on EVERY installation.
 *
 * It asks the Update Engine (the same authority the licence server runs) for a
 * signed feed, verifies that feed OFFLINE against the public key pinned in this
 * bundle (\PpPhp\Core\Licensing\LicenseTrust), and — if a newer release is on
 * offer — downloads the smallest applicable artifact (a delta if the engine has
 * one for this version, else the full bundle) and verifies its hash. From there
 * the existing \PpPhp\Core\Update\PlatformUpdater applies it with a restore
 * point, exactly as an Update-Locally upload would.
 *
 * Ships to clients: it only ever VERIFIES (needs the public key), never signs.
 * It reuses the licence channel's endpoints, so the same address that answers
 * for licensing answers for updates — nothing extra for a customer to configure.
 */
final class UpdateClient
{
    /** Permanent default authority (mirrors the licence primary, incl. /public). */
    public const DEFAULT_AUTHORITY = 'https://ppphp.ppradhan.com';

    private string $root;

    /** Per-host failure reasons from the last index fetch, for actionable errors. */
    private array $fetchLog = [];

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

    /**
     * Addresses to try, in order. Whatever answers for licensing answers for
     * updates: we reuse the licence client's learned endpoint list (which rides
     * inside the signed lease), plus an optional owner override, plus the pinned
     * default as a last resort.
     *
     * @return array<int,string>
     */
    public function endpoints(): array
    {
        $list = [];

        // Environment override — points the whole install at one authority without
        // touching the database (used in ops and by the integration tests).
        $envAuth = getenv('PP_UPDATE_AUTHORITY');
        if (is_string($envAuth) && trim($envAuth) !== '') $list[] = rtrim(trim($envAuth), '/');

        // An explicit stored override, if a customer was told to point elsewhere.
        try {
            $override = trim((string) (PlatformSettings::get('platform.update_authority')['value'] ?? ''));
            if ($override !== '' && !in_array(rtrim($override, '/'), $list, true)) $list[] = rtrim($override, '/');
        } catch (\Throwable $e) { /* no DB — env/default still work */ }

        // The licence channel's proven endpoints (activated installs).
        if (class_exists('\PpPhp\Core\Licensing\LicenseClient')) {
            try {
                foreach ((new \PpPhp\Core\Licensing\LicenseClient($this->root))->endpoints() as $u) {
                    $u = rtrim(trim((string) $u), '/');
                    if ($u !== '' && !in_array($u, $list, true)) $list[] = $u;
                }
            } catch (\Throwable $e) { /* licensing not set up — fine */ }
        }

        $def = rtrim(self::DEFAULT_AUTHORITY, '/');
        if ($def !== '' && !in_array($def, $list, true)) $list[] = $def;

        return array_slice($list, 0, 4);
    }

    /** The public key this build trusts a feed to be signed by (env override for tests). */
    private function trustedKey(): string
    {
        // 1. Environment override (ops / tests).
        $env = getenv('PP_UPDATE_TRUST_KEY');
        if (is_string($env) && $env !== '') {
            $raw = base64_decode(trim($env), true);
            if (is_string($raw) && strlen($raw) === SODIUM_CRYPTO_SIGN_PUBLICKEYBYTES) return $raw;
        }
        // 2. The stamped, pinned key — the production path for customer installs.
        if (class_exists('\PpPhp\Core\Licensing\LicenseTrust')) {
            $k = LicenseTrust::key();
            if ($k !== '') return $k;
        }
        // 3. An install that HOLDS the signing key IS the authority, so it trusts
        //    its own feed even before the public key is stamped — which lets the
        //    vendor self-test the update flow without turning on licence
        //    enforcement. LicenseKeys is stripped from client bundles, so this
        //    branch is a no-op on any customer install.
        if (class_exists('\PpPhp\Core\Licensing\LicenseKeys')) {
            try {
                $keys = new \PpPhp\Core\Licensing\LicenseKeys();
                if ($keys->exists()) return $keys->publicKey();
            } catch (\Throwable $e) { /* no key — fall through */ }
        }
        return '';
    }

    private function installedVersion(): string
    {
        return (new PlatformUpdater($this->root))->installedVersion();
    }

    private function channel(): string
    {
        $c = strtolower(trim((string) (PlatformSettings::get('platform.update_channel')['value'] ?? 'stable')));
        return in_array($c, ['stable', 'beta'], true) ? $c : 'stable';
    }

    /**
     * This install's edition. A server/authority install carries the packager and
     * licence-server code that client bundles strip out — so its presence IS the
     * marker. Critical: it means an authority is only ever offered SERVER bundles;
     * applying a client bundle would prune its authority code and destroy it.
     */
    private function edition(): string
    {
        $env = getenv('PP_UPDATE_EDITION');           // ops / tests override
        if (is_string($env) && in_array(strtolower(trim($env)), ['client', 'server'], true)) {
            return strtolower(trim($env));
        }
        return class_exists('\PpPhp\Core\Packaging\PlatformPackager') ? 'server' : 'client';
    }

    /**
     * Ask the authority whether a newer release exists.
     *
     * Never throws: a build with no pinned key, no reachable authority, or an
     * unverifiable answer is reported as "not available / not configured" so the
     * owner's Check button always gets a clean, honest reply.
     *
     * @return array{available:bool, configured:bool, installed:string, message:string,
     *   version?:string, kind?:string, size?:int, url?:string, sha256?:string, notes?:string, feed?:array}
     */
    public function check(): array
    {
        $installed = $this->installedVersion();
        $base = ['available' => false, 'configured' => false, 'installed' => $installed, 'message' => ''];

        $key = $this->trustedKey();
        if ($key === '') {
            return $base + ['ok' => false, 'message' => 'Online updates are not configured for this build (no trusted key).'];
        }

        // Two different roles, two different paths:
        //
        //  • A YOUR-SIDE authority (dev PC, primary, secondary — server edition)
        //    reads the signed index straight from the update host and self-updates.
        //    It is not licence-gated; it IS the vendor.
        //
        //  • A CUSTOMER install (client edition) goes THROUGH the licence servers
        //    (primary, then secondary if it is down). The server confirms the
        //    install holds a genuine, active licence before it will answer — so a
        //    customer never talks to the update host directly for the decision.
        if ($this->edition() === 'server') {
            // Authorities always decide from the signed host index; checkViaIndex
            // now returns a precise, self-diagnosing result for every outcome
            // (no URL, unreachable, untrusted signature, stale, up-to-date, delta).
            return $this->checkViaIndex($installed, $key);
        }

        // CLIENT: prefer the licence-checked authority feed (proves entitlement, may
        // carry a personalised delta). But if EVERY authority is unreachable — or
        // answers with something this install can't verify — fall back to the signed
        // update MIRRORS, so a customer keeps getting updates when the main is down.
        $endpoints = $this->endpoints();
        $payload = ['installedVersion' => $installed, 'channel' => $this->channel(), 'edition' => $this->edition()]
            + $this->licenseCredentials();
        $feedUpToDate = null;   // an UNSIGNED "up to date" — kept only as a last resort
        if ($endpoints !== []) {
            try {
                $viaFeed = $this->interpretFeedReply($this->postAny($endpoints, $payload), $installed, $key);
                if ($viaFeed !== null) {
                    // A SIGNED "available" is authoritative — take it now (it may carry
                    // a personalised delta the mirror index doesn't). A genuine refusal
                    // (not licensed) is also final — never offer updates past it.
                    if (!empty($viaFeed['available']) || empty($viaFeed['ok'])) {
                        return $viaFeed;
                    }
                    // Otherwise the feed said "up to date" — but that answer is UNSIGNED,
                    // and a stale or misconfigured authority (one that could not reach the
                    // signed host index and fell back to its own catalogue) reports "up to
                    // date" for a release it simply hasn't seen. Do NOT trust it blindly:
                    // verify against the SIGNED index below, keeping this only as the
                    // fallback for a client that can reach the licence server but not the
                    // update host.
                    $feedUpToDate = $viaFeed;
                }
            } catch (\Throwable $e) {
                // all authorities unreachable — fall through to the mirror index
            }
        }

        // Authoritative cross-check / resilient fallback: read the SIGNED index
        // straight from the mirror list. It fixes both "main down = no updates" and
        // "a stale authority wrongly said up-to-date", and its message is precise.
        $viaIndex = $this->checkViaIndex($installed, $key);
        // A real update from the signed index wins over an unsigned "up to date".
        if (!empty($viaIndex['available'])) return $viaIndex;
        // The signed index was reachable and definitive (up-to-date, or a verified
        // reason) — trust it over the feed's unsigned claim.
        if (!empty($viaIndex['ok'])) return $viaIndex;
        // The signed index could NOT be reached. If the feed had given a clean "up to
        // date", honour it so a client that can only reach the licence server still
        // gets a truthful answer rather than a connection error.
        if ($feedUpToDate !== null) return $feedUpToDate;
        return $viaIndex;
    }

    /**
     * Interpret an authority /api/update/feed reply. Returns a definitive check()
     * result, or NULL when the answer is unverifiable/stale so the caller can fall
     * back to the signed mirror index rather than trust an unverified response.
     *
     * @param array<string,mixed> $reply
     * @return array<string,mixed>|null
     */
    private function interpretFeedReply(array $reply, string $installed, string $key): ?array
    {
        $base = ['available' => false, 'configured' => true, 'installed' => $installed];

        if (empty($reply['available'])) {
            // A "not available" answer is UNSIGNED — unlike an "available" answer,
            // which we require to be Ed25519-signed below. So an unsigned
            // "you're not licensed" from ONE endpoint (a stale/lagging backup, or
            // the wrong authority answering first — the default vendor authority is
            // in the fallback list and need not hold a reseller-issued licence) must
            // NOT override a locally-valid lease: this install already proved it is
            // activated to reach here. Ignore the false refusal and fall back to the
            // SIGNED mirror index, which gives the true answer for a licensed client.
            $refused = ($reply['licensed'] ?? null) === false;
            if ($refused && $this->locallyLicensed()) {
                return null;   // caller falls through to checkViaIndex (signed)
            }
            return $base + [
                'configured' => (bool) ($reply['configured'] ?? true),
                'ok'         => !$refused,   // a genuine refusal is NOT "up to date"
                'message'    => (string) ($reply['message'] ?? 'Up to date.'),
            ];
        }

        $claims = UpdateProtocol::verify((string) ($reply['feed'] ?? ''), $key);
        if ($claims === null) return null;                 // unverifiable → prefer the mirror index
        if (!UpdateProtocol::isFresh($claims)) return null; // stale → a mirror may have a fresher index

        $version = Version::normalise((string) ($claims['v'] ?? '0'));
        if (version_compare($version, $installed, '<=')) {
            return $base + ['message' => 'Up to date.'];
        }
        if (version_compare(PHP_VERSION, (string) ($claims['minPhp'] ?? '8.0'), '<')) {
            return $base + ['version' => $version,
                'message' => 'Update ' . $version . ' needs PHP ' . $claims['minPhp'] . '; this server runs ' . PHP_VERSION . '.'];
        }

        return [
            'available'  => true,
            'configured' => true,
            'installed'  => $installed,
            'version'    => $version,
            'kind'       => (string) ($claims['kind'] ?? 'full'),
            'size'       => (int) ($claims['size'] ?? 0),
            'url'        => (string) ($claims['url'] ?? ''),
            'sha256'     => (string) ($claims['sha256'] ?? ''),
            'notes'      => (string) ($claims['notes'] ?? ''),
            'message'    => 'Update ' . $version . ' is available.',
            'feed'       => $claims,
        ];
    }

    /**
     * Download the artifact a prior check() offered, verifying size + sha256, and
     * return the local path (staged where Update-Locally puts bundles, so the same
     * apply path picks it up).
     *
     * @param array $checkResult the array returned by check() (must be available)
     */
    public function download(array $checkResult): string
    {
        $url    = (string) ($checkResult['url'] ?? '');
        $sha    = (string) ($checkResult['sha256'] ?? '');
        $size   = (int) ($checkResult['size'] ?? 0);
        $kind   = (string) ($checkResult['kind'] ?? 'full');
        $ver    = (string) ($checkResult['version'] ?? 'x');
        if ($url === '' || !preg_match('#^https?://#i', $url)) {
            throw new \RuntimeException('The update did not include a valid download URL.');
        }

        $dir = $this->root . '/storage/updates/incoming';
        if (!is_dir($dir) && !@mkdir($dir, 0775, true) && !is_dir($dir)) {
            throw new \RuntimeException('Cannot create the download folder (storage/updates/incoming).');
        }
        $dest = $dir . '/' . $kind . '-' . preg_replace('/[^0-9.]/', '', $ver) . '-' . date('Ymd-His') . '.zip';

        // Fetch the artifact. If the DELTA is missing/unreachable on the host (a
        // 404, the classic "published the index but the delta didn't upload"
        // case) fall back to the full bundle for the same target — the applier
        // handles a full just as well, so a missing delta never dead-ends an update.
        try {
            $this->fetch($url, $dest);
        } catch (\Throwable $e) {
            $fullUrl = (string) ($checkResult['fullUrl'] ?? '');
            if ($kind === 'delta' && $fullUrl !== '' && preg_match('#^https?://#i', $fullUrl) && $fullUrl !== $url) {
                @unlink($dest);
                $url  = $fullUrl;
                $kind = 'full';
                $sha  = (string) ($checkResult['fullSha256'] ?? '');
                $size = (int) ($checkResult['fullSize'] ?? 0);
                $dest = $dir . '/full-' . preg_replace('/[^0-9.]/', '', $ver) . '-' . date('Ymd-His') . '.zip';
                $this->fetch($url, $dest);
            } else {
                throw $e;
            }
        }

        if ($size > 0 && filesize($dest) !== $size) {
            @unlink($dest);
            throw new \RuntimeException('Downloaded file size does not match — the download may be truncated.');
        }
        if ($sha !== '' && !hash_equals($sha, (string) hash_file('sha256', $dest))) {
            @unlink($dest);
            throw new \RuntimeException('Downloaded file failed its integrity check (hash mismatch). Nothing was applied.');
        }
        return $dest;
    }

    /**
     * Force-download the FULL bundle for a check result, ignoring any delta. Used
     * as the fallback when a delta downloads fine but its inspect fails the drift
     * guard ("this file differs on this server") — the full always applies cleanly
     * because it carries every file, so a drifted install is never stuck.
     */
    public function downloadFull(array $checkResult): string
    {
        $url  = (string) ($checkResult['fullUrl'] ?? '');
        $sha  = (string) ($checkResult['fullSha256'] ?? '');
        $size = (int) ($checkResult['fullSize'] ?? 0);
        $ver  = (string) ($checkResult['version'] ?? 'x');
        if ($url === '' || !preg_match('#^https?://#i', $url)) {
            throw new \RuntimeException('No full bundle is available to fall back to for this version.');
        }
        $dir = $this->root . '/storage/updates/incoming';
        if (!is_dir($dir) && !@mkdir($dir, 0775, true) && !is_dir($dir)) {
            throw new \RuntimeException('Cannot create the download folder (storage/updates/incoming).');
        }
        $dest = $dir . '/full-' . preg_replace('/[^0-9.]/', '', $ver) . '-' . date('Ymd-His') . '.zip';
        $this->fetch($url, $dest);
        if ($size > 0 && filesize($dest) !== $size) {
            @unlink($dest);
            throw new \RuntimeException('The full bundle size does not match — the download may be truncated.');
        }
        if ($sha !== '' && !hash_equals($sha, (string) hash_file('sha256', $dest))) {
            @unlink($dest);
            throw new \RuntimeException('The full bundle failed its integrity check (hash mismatch). Nothing was applied.');
        }
        return $dest;
    }

    /** True when a check result offered a delta AND a full is available to fall back to. */
    public static function canFallBackToFull(array $checkResult): bool
    {
        return (string) ($checkResult['kind'] ?? '') === 'delta'
            && (string) ($checkResult['fullUrl'] ?? '') !== ''
            && preg_match('#^https?://#i', (string) ($checkResult['fullUrl'] ?? '')) === 1;
    }

    /**
     * Where this install reads the signed index.json from, resolved in order:
     * an ops/test env override; an explicit stored setting (a plain URL any
     * install can be pointed at); the configured Update host's public URL (on
     * authority installs that have UpdateHostConfig); the URL pinned into this
     * bundle at package time (shipped customer installs). '' when none is known.
     */
    private function indexUrl(): string
    {
        $env = getenv('PP_UPDATE_INDEX_URL');
        if (is_string($env) && preg_match('#^https?://#i', trim($env))) return rtrim(trim($env), '/');

        try {
            $s = trim((string) (PlatformSettings::get('platform.update_index_url')['value'] ?? ''));
            if (preg_match('#^https?://#i', $s)) return rtrim($s, '/');
        } catch (\Throwable $e) { /* no DB — other sources still work */ }

        if (class_exists('\PpPhp\Core\Update\UpdateHostConfig')) {
            try {
                $base = \PpPhp\Core\Update\UpdateHostConfig::publicUrl();
                if ($base !== '') return rtrim($base, '/') . '/index.json';
            } catch (\Throwable $e) { /* fall through */ }
        }

        $pinned = UpdateTrust::indexUrl();
        return $pinned !== '' ? $pinned : '';
    }

    /**
     * Every index URL to try, in fallback order. An authority reads from all its
     * configured hosts (primary → secondary → Drive backup); any other install
     * uses the single resolved index URL. Deduped, blanks dropped.
     *
     * @return array<int,string>
     */
    private function indexUrlList(): array
    {
        $urls = [];

        // 1. Owner-managed + baked-in mirrors — the resilient list EVERY install
        //    (server and client) tries, so one host being down never stops updates.
        if (class_exists('\PpPhp\Core\Update\UpdateMirrors')) {
            try { foreach (\PpPhp\Core\Update\UpdateMirrors::indexUrls() as $u) $urls[] = $u; }
            catch (\Throwable $e) { /* env/defaults still work */ }
        }

        // 2. An authority's own configured hosts (primary → secondary → Drive backup).
        if (class_exists('\PpPhp\Core\Update\UpdateHostConfig')) {
            try { foreach (\PpPhp\Core\Update\UpdateHostConfig::indexUrls() as $u) $urls[] = $u; }
            catch (\Throwable $e) { /* fall through */ }
        }

        // 3. The single resolved/pinned URL as a final catch-all.
        $one = $this->indexUrl();
        if ($one !== '') $urls[] = $one;

        // Dedupe (normalised), drop blanks — order preserved.
        $seen = [];
        $out = [];
        foreach ($urls as $u) {
            $u = rtrim(trim((string) $u), '/');
            if ($u === '' || isset($seen[$u])) continue;
            $seen[$u] = true;
            $out[] = $u;
        }
        return $out;
    }

    /**
     * The licence credentials a customer presents so the server can confirm this
     * install is genuinely licensed before offering an update — the same id +
     * secret a renewal uses. Empty on an install with no licence client (then the
     * server simply finds it unlicensed and offers nothing).
     *
     * @return array{instanceId?:string, secret?:string}
     */
    private function licenseCredentials(): array
    {
        if (!class_exists('\PpPhp\Core\Licensing\LicenseClient')) return [];
        try {
            $c = (new \PpPhp\Core\Licensing\LicenseClient($this->root))->updateCredentials();
            $id = (string) ($c['instanceId'] ?? '');
            $secret = (string) ($c['secret'] ?? '');
            return $id !== '' ? ['instanceId' => $id, 'secret' => $secret] : [];
        } catch (\Throwable $e) {
            return [];
        }
    }

    /**
     * True on a customer (client-edition) install — where raw host URLs and the
     * technical reasons behind a failure should be hidden behind the vendor's
     * display name. False on the authority/owner, who needs the full detail to
     * diagnose (as a real incident proved).
     */
    private function clientFacing(): bool
    {
        try {
            return class_exists(\PpPhp\Core\Licensing\LicenseGate::class)
                && \PpPhp\Core\Licensing\LicenseGate::isClientEdition();
        } catch (\Throwable $e) { return false; }
    }

    /**
     * True when this install holds a genuine, currently-valid licence LOCALLY (its
     * signed lease verifies and is not expired/revoked/tampered — LicenseGate is not
     * "locked"). Used to reject an UNSIGNED server "you're not licensed" as a false
     * negative in favour of the signed index. An authority (no enforcement) counts
     * as licensed; a store we can't read counts as NOT licensed (fail safe).
     */
    private function locallyLicensed(): bool
    {
        try {
            if (!class_exists(\PpPhp\Core\Licensing\LicenseGate::class)) return true;
            return !\PpPhp\Core\Licensing\LicenseGate::locked();
        } catch (\Throwable $e) {
            return false;
        }
    }

    /** The vendor's friendly name for the update service, or a neutral fallback. */
    private function serviceName(): string
    {
        try {
            if (class_exists(\PpPhp\Core\Licensing\LicenseClient::class)) {
                $n = (new \PpPhp\Core\Licensing\LicenseClient($this->root))->updateDisplayName();
                if ($n !== '') return $n;
            }
        } catch (\Throwable $e) { /* fall through */ }
        return 'the update service';
    }

    /** " If it keeps happening, contact <support>." when a support contact is set. */
    private function supportSuffix(): string
    {
        try {
            $s = class_exists(\PpPhp\Core\Licensing\LicenseGate::class)
                ? \PpPhp\Core\Licensing\LicenseGate::supportContact() : '';
            return $s !== '' ? ' If it keeps happening, contact ' . $s . '.' : '';
        } catch (\Throwable $e) { return ''; }
    }

    /**
     * The host-authoritative check: fetch the signed index, verify it OFFLINE
     * against the pinned key, and pick this install's artifact.
     *
     * Always returns a check()-shaped result (never null) with a precise, honest
     * message for every outcome, so an authority's Check button — and anyone
     * reading the logs — can tell WHICH thing went wrong:
     *   • no index URL configured           → point at Update host settings
     *   • index unreachable                  → host offline / wrong URL
     *   • signature does not verify          → the publisher signs with a key this
     *     server does not trust (the classic "keys diverged" cause of a silent
     *     "no updates") — the fix is a shared signing key
     *   • index stale                        → publisher offline, index not refreshed
     *   • no delta from this version         → full-only jump (OTA is delta-only)
     *   • delta available                    → offer it
     */
    private function checkViaIndex(string $installed, string $key): array
    {
        $base = ['available' => false, 'configured' => true, 'installed' => $installed];

        // Try every configured host in order (primary → secondary → Drive backup)
        // so a down or misconfigured primary self-heals to a mirror. The signed
        // index verifies identically from any host (same key), and its artifact
        // URLs point at whichever host actually answered.
        $urls = $this->indexUrlList();
        if ($urls === []) {
            return $base + ['configured' => false, 'ok' => false,
                'message' => $this->clientFacing()
                    ? 'Updates are not available on this copy yet.' . $this->supportSuffix()
                    : 'No update index is configured. Set the Update host (its public URL) in App Bundle → Update host.'];
        }

        // DNS-like ordering: try the healthiest update host first, never dropping one.
        try {
            if (class_exists(\PpPhp\Core\MeshResolver::class)) {
                $ordered = \PpPhp\Core\MeshResolver::order($urls);
                if ($ordered !== []) $urls = $ordered;
            }
        } catch (\Throwable $e) { /* keep plain list */ }

        $index = null;
        $url = '';
        $reachedButUntrusted = false;
        $this->fetchLog = [];
        foreach ($urls as $candidate) {
            try { $token = $this->fetchString($candidate); }
            catch (\Throwable $e) { $token = ''; }
            if ($token === '') {
                $this->mesh('recordFailure', $candidate);
                continue;   // host down / URL wrong → try the next mirror
            }
            $this->mesh('recordSuccess', $candidate);   // this host answered
            $verified = UpdateIndex::verify($token, $key);
            if ($verified !== null) {
                $index = $verified;
                $url = $candidate;
                break;
            }
            $reachedButUntrusted = true;   // reachable, but the signature failed
        }

        if ($index === null) {
            if ($reachedButUntrusted) {
                // An index was reachable but its Ed25519 signature does not verify —
                // the publisher signs with a key this server does not trust (the
                // classic "keys diverged" cause of a silent "no updates").
                return $base + ['ok' => false, 'message' => $this->clientFacing()
                    ? 'The response from ' . $this->serviceName() . ' could not be verified, so nothing was changed. Please try again later.' . $this->supportSuffix()
                    : 'An update index was reachable but is signed by a key this server does not trust — its signature did not verify. The publisher and this server must share the same signing key. Nothing was changed.'];
            }
            // A customer never sees raw hosts or the technical reasons — just the
            // service's display name and a plain "try again". The authority/owner
            // keeps every address and reason, which is what makes a real outage
            // diagnosable.
            if ($this->clientFacing()) {
                return $base + ['ok' => false, 'message' => 'Could not reach ' . $this->serviceName()
                    . ' right now. This is usually a brief connection problem — please try again later.' . $this->supportSuffix()];
            }
            // Name each host with the ACTUAL reason it failed, so the operator can
            // tell an SSL / CA-bundle problem from a blocked outbound port, a hairpin
            // to the server's own IP, a DNS miss, or a host with no index published.
            $lines = [];
            foreach ($urls as $u) {
                $why = (string) ($this->fetchLog[$u] ?? 'unreachable');
                $lines[] = $u . ' — ' . $why;
            }
            $detail = $lines !== [] ? ' Tried ' . count($urls) . ' host(s): ' . implode('; ', $lines) . '.'
                                    : ' (' . $urls[0] . ')';
            return $base + ['ok' => false, 'message' => 'Could not reach ' . $this->serviceName() . '.' . $detail
                . ' If the hosts are up in a browser but not from here, this server likely cannot make outbound HTTPS'
                . ' (host firewall or missing CA bundle), or a host resolves to this server\'s own IP (hairpin).'];
        }
        if (!UpdateIndex::isFresh($index)) {
            return $base + ['ok' => false, 'message' => $this->clientFacing()
                ? $this->serviceName() . ' is briefly out of date — please try again later.' . $this->supportSuffix()
                : 'The update index is stale (published ' . date('Y-m-d H:i', (int) ($index['iat'] ?? 0)) . '). The publisher may be offline; try again later.'];
        }

        // Download from the SAME host that just served us the index (it's provably
        // up), not whatever absolute base was baked in at publish time. Each mirror
        // holds the same full/ + delta/ files, so rebasing the artifact URL to the
        // answering host completes the failover: index AND bundle come from one live
        // mirror. $answered is the mirror base (index URL minus /index.json).
        $answered = (string) preg_replace('#/index\.json$#i', '', $url);

        $sel = UpdateIndex::select($index, $installed, $this->channel(), $this->edition());
        if ($sel === null) {
            // No OTA delta for this version. If a newer version exists at all, it's
            // a full-only jump — over-the-air is delta-only, so point at the full.
            $latest = UpdateIndex::latestVersion($index, $this->channel(), $this->edition());
            if ($latest !== '' && version_compare($latest, $installed, '>')) {
                // No delta from THIS version, but a newer full exists. Carry its URL
                // so the console's "Fetch full from the update host" button can pull
                // it server-side (bypassing the delta and the browser upload cap).
                $full = UpdateIndex::fullFor($index, $latest, $this->channel(), $this->edition());
                return $base + [
                    'version'    => $latest,
                    'fullOnly'   => true,
                    'fullUrl'    => $this->rebaseToHost((string) ($full['url'] ?? ''), $answered),
                    'fullSha256' => (string) ($full['sha256'] ?? ''),
                    'fullSize'   => (int) ($full['size'] ?? 0),
                    'message'    => 'Version ' . $latest . ' is available as a full installer. Over-the-air sends small deltas only, '
                        . 'so use "Fetch full from the update host" below to pull it, or apply it with Update Locally.',
                ];
            }
            return $base + ['message' => 'Up to date.'];
        }

        $version = Version::normalise((string) $sel['v']);
        if (version_compare(PHP_VERSION, (string) ($sel['minPhp'] ?? '8.0'), '<')) {
            return $base + ['version' => $version,
                'message' => 'Update ' . $version . ' needs PHP ' . $sel['minPhp'] . '; this server runs ' . PHP_VERSION . '.'];
        }

        return [
            'available'  => true,
            'configured' => true,
            'installed'  => $installed,
            'version'    => $version,
            'kind'       => (string) $sel['kind'],
            'size'       => (int) $sel['size'],
            'url'        => $this->rebaseToHost((string) $sel['url'], $answered),
            'sha256'     => (string) $sel['sha256'],
            'notes'      => (string) $sel['notes'],
            // Full-bundle fallback (carried from the index) so download() can
            // recover from a missing/unreachable delta on the host.
            'fullUrl'    => $this->rebaseToHost((string) ($sel['fullUrl'] ?? ''), $answered),
            'fullSha256' => (string) ($sel['fullSha256'] ?? ''),
            'fullSize'   => (int) ($sel['fullSize'] ?? 0),
            'message'    => 'Update ' . $version . ' is available.',
            'feed'       => $sel,
        ];
    }

    /**
     * Rebase an artifact URL onto the host that just answered with the index, so the
     * download comes from a host we KNOW is up. Bundles live under a `/full/<file>`
     * or `/delta/<file>` sub-path on every mirror, so we keep that tail and swap the
     * base. Leaves the URL untouched if it has no recognisable tail or there is no
     * answering base (then the baked absolute URL is used as-is).
     */
    private function rebaseToHost(string $artifactUrl, string $answeredBase): string
    {
        if ($artifactUrl === '' || $answeredBase === '') return $artifactUrl;
        if (preg_match('#/(full|delta)/[^/?\#]+$#i', $artifactUrl, $m)) {
            return rtrim($answeredBase, '/') . $m[0];
        }
        return $artifactUrl;
    }

    // ── transport ───────────────────────────────────────────────────────

    private function postAny(array $endpoints, array $payload): array
    {
        $lastError = null;
        foreach ($endpoints as $base) {
            try {
                return $this->post($base, $payload);
            } catch (\RuntimeException $e) {
                $lastError = $e;                 // transport failure — try the next
            }
        }
        throw $lastError ?? new \RuntimeException('Could not reach any update server.');
    }

    private function post(string $baseUrl, array $payload): array
    {
        $url  = rtrim($baseUrl, '/') . '/api/update/feed';
        $body = (string) json_encode($payload, JSON_UNESCAPED_SLASHES);
        if (!function_exists('curl_init')) {
            throw new \RuntimeException('cURL is required to check for updates.');
        }
        $ch = curl_init($url);
        curl_setopt_array($ch, [
            CURLOPT_POST           => true,
            CURLOPT_POSTFIELDS     => $body,
            CURLOPT_HTTPHEADER     => ['Content-Type: application/json', 'Accept: application/json'],
            CURLOPT_RETURNTRANSFER => true,
            CURLOPT_CONNECTTIMEOUT => 5,
            CURLOPT_TIMEOUT        => 15,
            CURLOPT_FOLLOWLOCATION => false,
            CURLOPT_USERAGENT      => 'PPPHP-Update/1',
        ]);
        $raw  = curl_exec($ch);
        $code = (int) curl_getinfo($ch, CURLINFO_HTTP_CODE);
        $err  = curl_error($ch);
        unset($ch);
        if ($raw === false || $code === 0) {
            throw new \RuntimeException('Could not reach ' . rtrim($baseUrl, '/') . ($err !== '' ? " ({$err})" : '.'));
        }
        $data = json_decode((string) $raw, true);
        if (!is_array($data)) {
            throw new \RuntimeException('There is no PPPHP update server at ' . rtrim($baseUrl, '/') . '.');
        }
        return $data;
    }

    /** GET a small text resource (the index) into a string; '' on any failure. */
    /** Best-effort mesh-health note — never lets a health write disturb an update. */
    private function mesh(string $method, string $url): void
    {
        try {
            if (class_exists(\PpPhp\Core\MeshResolver::class)) {
                \PpPhp\Core\MeshResolver::$method($url);
            }
        } catch (\Throwable $e) { /* advisory */ }
    }

    private function fetchString(string $url): string
    {
        if (!function_exists('curl_init')) {
            $this->fetchLog[$url] = 'cURL is disabled on this server (enable the php-curl extension)';
            return '';
        }
        if (!preg_match('#^https?://#i', $url)) { $this->fetchLog[$url] = 'not an http(s) URL'; return ''; }
        $ch = curl_init($url);
        curl_setopt_array($ch, [
            CURLOPT_RETURNTRANSFER => true,
            CURLOPT_FOLLOWLOCATION => true,
            CURLOPT_MAXREDIRS      => 3,
            CURLOPT_CONNECTTIMEOUT => 6,
            CURLOPT_TIMEOUT        => 20,
            CURLOPT_FAILONERROR    => true,
            CURLOPT_USERAGENT      => 'PPPHP-Update/1',
        ]);
        $raw  = curl_exec($ch);
        $code = (int) curl_getinfo($ch, CURLINFO_HTTP_CODE);
        $err  = trim((string) curl_error($ch));
        unset($ch);
        // Record WHY a host failed so "could not reach any" can name the real cause —
        // a missing CA bundle (SSL), a blocked outbound port / hairpin (connection or
        // timeout), a DNS miss, or a 404 (no index published here) — instead of a
        // blanket "unreachable" that leaves the operator guessing.
        if (!is_string($raw) || $raw === '') {
            $this->fetchLog[$url] = $err !== '' ? $err
                : ($code >= 400 ? 'HTTP ' . $code . ' — no update index at this URL'
                                : 'no response (connection failed or timed out)');
            return '';
        }
        if ($code >= 400) { $this->fetchLog[$url] = 'HTTP ' . $code . ' — no update index at this URL'; return ''; }
        if (strlen($raw) > 2000000) { $this->fetchLog[$url] = 'response too large to be an index'; return ''; }
        return $raw;   // the index is small
    }

    /** Stream a URL to a file. Follows redirects; caps at a sane ceiling. */
    private function fetch(string $url, string $dest): void
    {
        if (!function_exists('curl_init')) {
            throw new \RuntimeException('cURL is required to download updates.');
        }
        $fh = @fopen($dest, 'wb');
        if ($fh === false) throw new \RuntimeException('Cannot open the download file for writing.');

        $ch = curl_init($url);
        curl_setopt_array($ch, [
            CURLOPT_FILE           => $fh,
            CURLOPT_FOLLOWLOCATION => true,
            CURLOPT_MAXREDIRS      => 3,
            CURLOPT_CONNECTTIMEOUT => 10,
            CURLOPT_TIMEOUT        => 600,
            CURLOPT_FAILONERROR    => true,
            CURLOPT_USERAGENT      => 'PPPHP-Update/1',
        ]);
        $ok   = curl_exec($ch);
        $code = (int) curl_getinfo($ch, CURLINFO_HTTP_CODE);
        $err  = curl_error($ch);
        unset($ch);
        fclose($fh);

        if ($ok === false || ($code >= 400)) {
            @unlink($dest);
            throw new \RuntimeException('Download failed' . ($err !== '' ? ": {$err}" : ' (HTTP ' . $code . ')') . '.');
        }
    }
}
