<?php

declare(strict_types=1);

namespace PpPhp\Core\Update;

use PpPhp\Core\Packaging\PlatformPackager;
use PpPhp\Core\Packaging\BundleScanner;
use RuntimeException;
use ZipArchive;

/**
 * Owner-side orchestration of "publish a release to the update host".
 *
 * One call: build the release bundle, build a small delta patch against every
 * recent published version (so a client on any of them gets the tiny download,
 * not the full one), upload them all to the configured host over FTP/FTPS/SFTP,
 * and record them in the catalogue the signed feed reads. Authority-only — the
 * packager keeps this class out of client bundles.
 */
final class UpdatePublisher
{
    /** How many prior versions to build deltas back to (keeps publish bounded). */
    private const DELTA_DEPTH = 6;

    private string $root;
    private string $workDir;

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

    /**
     * Build + upload + register a release.
     *
     * @param array{version?:string,notes?:string,channel?:string,edition?:string,
     *   kind?:string,ota?:bool} $opts
     *   kind: 'full' (installer only) | 'delta' (patches only, needs an existing
     *   full for this version) | 'both' (default). ota: true = live (served), else
     *   staged (uploaded + recorded, held back).
     *
     * @return array{ok:bool, version:string, full:?array, deltas:array<int,array>, messages:array<int,string>, status:string, kind:string}
     */
    public function publish(array $opts): array
    {
        $version = trim((string) ($opts['version'] ?? ''));
        $notes   = (string) ($opts['notes'] ?? '');
        $channel = in_array(($opts['channel'] ?? 'stable'), ['stable', 'beta'], true) ? (string) $opts['channel'] : 'stable';
        $edition = ($opts['edition'] ?? 'client') === 'server' ? 'server' : 'client';
        $kind    = in_array(($opts['kind'] ?? 'both'), ['full', 'delta', 'both'], true) ? (string) $opts['kind'] : 'both';
        $ota     = (bool) ($opts['ota'] ?? true);
        $status  = $ota ? 'live' : 'staged';
        $wantFull  = $kind === 'full'  || $kind === 'both';
        $wantDelta = $kind === 'delta' || $kind === 'both';

        // Live progress: the caller may pass an onProgress callback (the streaming
        // publish endpoint uses it to push each step to the browser as it happens).
        $onProgress = $opts['onProgress'] ?? null;
        $step = static function (string $icon, string $text) use ($onProgress, $edition) {
            if (is_callable($onProgress)) {
                try { $onProgress(['type' => 'progress', 'icon' => $icon, 'text' => $text, 'edition' => $edition]); } catch (\Throwable $e) {}
            }
        };

        // Which backup targets to mirror to (primary is always used). When the
        // caller doesn't specify, fall back to whatever is configured — keeping the
        // old behaviour for callers that don't pass targets.
        $targets = is_array($opts['targets'] ?? null) ? $opts['targets'] : null;
        $useSecondary = $targets === null ? UpdateHostConfig::hasSecondary() : !empty($targets['secondary']);
        $useGdrive    = $targets === null ? UpdateHostConfig::hasGdrive()    : !empty($targets['gdrive']);

        if (!UpdateHostConfig::isConfigured()) {
            throw new RuntimeException('Configure the update host (directory + public URL) before publishing.');
        }
        if (!is_dir($this->workDir) && !@mkdir($this->workDir, 0775, true) && !is_dir($this->workDir)) {
            throw new RuntimeException('Cannot create the publish work folder (storage/updates/publish).');
        }
        @set_time_limit(0);
        @ignore_user_abort(true);

        // 1. Build the full bundle (needed to upload AND/OR to diff deltas against).
        $step('run', "Building the {$edition} bundle…");
        $packager = (new PlatformPackager($this->root))->variant($edition);
        if ($version !== '') $packager->releaseVersion($version);
        $fullName = $packager->suggestedFilename();
        $fullPath = $this->workDir . '/' . $fullName;
        $packager->build($fullPath);

        $meta = $this->bundleMeta($fullPath);
        $version  = $meta['version'];
        $fullSha  = (string) hash_file('sha256', $fullPath);
        $fullSize = (int) filesize($fullPath);
        $messages = ["Built {$fullName} ({$this->human($fullSize)}), version {$version}, {$edition} edition."];
        $step('ok', "Built {$fullName} — {$this->human($fullSize)}, v{$version}.");

        // ── Pre-publish safety scan ──────────────────────────────────────────
        // A shared host's malware scanner matches static patterns; a legitimate
        // bundle can be flagged and the whole account suspended. Scan the built
        // bundle BEFORE anything is uploaded and BLOCK on a high-risk pattern
        // (unless the operator has reviewed it and passes allowFlagged).
        $step('run', 'Scanning the bundle for malware-flaggable patterns…');
        $scan = $this->scanBundle($fullPath);
        $scanTally = BundleScanner::tally($scan);
        if ($scanTally['total'] > 0) {
            $messages[] = "Safety scan: {$scanTally['high']} high, {$scanTally['medium']} medium, {$scanTally['low']} low flagged pattern(s).";
            $step($scanTally['high'] > 0 ? 'warn' : 'ok', "Safety scan: {$scanTally['high']} high, {$scanTally['medium']} medium, {$scanTally['low']} low pattern(s).");
        } else {
            $messages[] = 'Safety scan: clean — no host-flaggable patterns.';
            $step('ok', 'Safety scan clean — no host-flaggable patterns.');
        }
        if ($scanTally['high'] > 0 && empty($opts['allowFlagged'])) {
            $step('fail', "Publish blocked: {$scanTally['high']} high-risk pattern(s) a host may quarantine.");
            @unlink($fullPath);
            $lines = [];
            foreach ($scan as $f) {
                if ($f['severity'] === 'high') {
                    $lines[] = "{$f['file']}:{$f['line']} [{$f['rule']}] {$f['why']}";
                }
            }
            throw new RuntimeException(
                'Publish blocked: the bundle contains ' . $scanTally['high']
                . ' high-risk pattern(s) a shared host is likely to quarantine as malware — '
                . 'nothing was uploaded. Fix these, or re-publish with "allow flagged" if you have reviewed them:'
                . "\n• " . implode("\n• ", array_slice($lines, 0, 12))
            );
        }

        // Delta-only guard: the target version's full must already be on the host
        // (the feed's fallback) AND unchanged, or a client taking the full instead
        // of the patch would get inconsistent code.
        $existing = UpdateReleases::release($version, $channel, $edition);
        if ($kind === 'delta') {
            if ($existing === null || (string) ($existing['full_url'] ?? '') === '') {
                @unlink($fullPath);
                throw new RuntimeException("Delta-only needs the full installer of {$version} already published (as the fallback). Publish a Full first.");
            }
            $existingManifest = json_decode((string) ($existing['manifest_json'] ?? ''), true) ?: [];
            if ($meta['files'] != $existingManifest) {
                @unlink($fullPath);
                throw new RuntimeException("The code changed since the full bundle for {$version} was published — a delta-only publish would be inconsistent with it. Publish a new Full, or bump the version.");
            }
        }

        // 2. Build deltas from recent same-edition versions.
        $deltaJobs = [];
        if ($wantDelta) {
            $step('run', 'Building delta patches from recent versions…');
            // Baselines to patch forward from: PUBLISHED releases AND locally BUILT
            // bundles (BaselineStore) — same edition, an OLDER version, with a
            // manifest. Merging both means a client on a version you only
            // DOWNLOADED (never published) still gets a tiny delta, not the full.
            // Filter to eligible BEFORE the cap so higher versions can't crowd out
            // the real priors.
            $pool = [];   // version => manifest
            foreach (UpdateReleases::all($channel) as $r) {
                if (($r['edition'] ?? 'client') !== $edition || empty($r['manifest_json'])) continue;
                $v = \PpPhp\Core\Version::normalise((string) $r['version']);
                if (version_compare($v, $version, '>=') || isset($pool[$v])) continue;
                $m = json_decode((string) $r['manifest_json'], true);
                if (is_array($m) && $m !== []) $pool[$v] = $m;
            }
            $bs = new BaselineStore($this->root);
            foreach ($bs->list($edition) as $b) {
                $v = \PpPhp\Core\Version::normalise((string) $b['version']);
                if (version_compare($v, $version, '>=') || isset($pool[$v])) continue;
                $m = $bs->manifest($edition, $v);
                if ($m !== []) $pool[$v] = $m;
            }
            uksort($pool, static fn ($a, $b) => version_compare((string) $b, (string) $a));   // newest first
            $priors = array_slice($pool, 0, self::DELTA_DEPTH, true);
            if ($priors === []) {
                $messages[] = "No earlier {$edition} version (published or built) to diff against yet — uploaded the full only. "
                    . "Deltas appear on the next release once an earlier version exists.";
            }
            foreach ($priors as $fromVer => $fromManifest) {
                $fromVer = \PpPhp\Core\Version::normalise((string) $fromVer);
                $fromManifest['__version__'] = $fromVer;
                $deltaName = "delta-{$edition}-{$fromVer}-to-{$version}.zip";
                $deltaPath = $this->workDir . '/' . $deltaName;
                try {
                    $d = (new DeltaBuilder())->build($fromManifest, $fullPath, $deltaPath);
                    if ($d['size'] > 0 && $d['size'] < (int) ($fullSize * 0.85)) {
                        $deltaJobs[] = ['from' => $fromVer, 'name' => $deltaName, 'path' => $deltaPath] + $d;
                    } else {
                        @unlink($deltaPath);
                        $messages[] = "Delta from {$fromVer} was not smaller than the full bundle — skipped.";
                    }
                } catch (\Throwable $e) {
                    $messages[] = "Could not build a delta from {$fromVer}: " . $e->getMessage();
                }
            }
            $step('ok', $deltaJobs === [] ? 'No delta patches this time (no earlier version to diff).' : count($deltaJobs) . ' delta patch(es) built.');
        }

        // 3. Upload — full/ and delta/ sub-folders, created on demand.
        $step('run', 'Uploading to the primary host…');
        $transport = UpdateHostConfig::transport();
        try {
            $transport->connect();
            if ($wantFull) {
                $transport->ensureDir(UpdateHostConfig::fullDir());
                if (!$transport->put($fullPath, UpdateHostConfig::fullDir() . '/' . $fullName)) {
                    $step('fail', 'Upload of the full bundle to the primary host failed.');
                    throw new RuntimeException('Upload of the full bundle failed.');
                }
                $messages[] = "Uploaded full/{$fullName}.";
                $step('ok', "Uploaded full/{$fullName} to the primary host.");
            }
            if ($deltaJobs !== []) {
                $transport->ensureDir(UpdateHostConfig::deltaDir());
                foreach ($deltaJobs as &$job) {
                    if ($transport->put($job['path'], UpdateHostConfig::deltaDir() . '/' . $job['name'])) {
                        $messages[] = "Uploaded delta/{$job['name']} ({$this->human((int) $job['size'])}).";
                        $step('ok', "Uploaded delta/{$job['name']} ({$this->human((int) $job['size'])}).");
                        $job['uploaded'] = true;
                    } else {
                        $messages[] = "Upload of {$job['name']} failed — that version will take the full download.";
                        $step('warn', "Upload of {$job['name']} failed — that version will take the full download.");
                        $job['uploaded'] = false;
                    }
                }
                unset($job);
            }
        } finally {
            try { $transport->close(); } catch (\Throwable $e) {}
        }

        // 3b. Mirror the artifacts to the secondary FTP host + Drive backup so a
        //     client can fall back when the primary is down. Best-effort: a mirror
        //     failure only adds a warning — it never fails the publish.
        if ($useSecondary && UpdateHostConfig::hasSecondary()) {
            $step('run', 'Mirroring to the secondary host…');
            $secFullOk = false;
            try {
                $t2 = UpdateHostConfig::secondaryTransport();
                $t2->connect();
                try {
                    if ($wantFull) {
                        $t2->ensureDir(UpdateHostConfig::secondaryRemoteDir() . '/full');
                        $secFullOk = (bool) $t2->put($fullPath, UpdateHostConfig::secondaryRemoteDir() . '/full/' . $fullName);
                        if (!$secFullOk) {
                            $messages[] = 'NOTE: the secondary FTP upload returned failure.';
                            $step('warn', 'The secondary FTP upload returned failure — check the credentials and upload directory.');
                        }
                    }
                    foreach ($deltaJobs as $job) {
                        if (empty($job['uploaded'])) continue;
                        $t2->ensureDir(UpdateHostConfig::secondaryRemoteDir() . '/delta');
                        $t2->put($job['path'], UpdateHostConfig::secondaryRemoteDir() . '/delta/' . $job['name']);
                    }
                    if (!$wantFull || $secFullOk) {
                        $messages[] = 'Mirrored the bundle to the secondary host (' . UpdateHostConfig::secondaryRemoteDir() . ').';
                        $step('ok', 'Mirrored the bundle to the secondary host → ' . UpdateHostConfig::secondaryRemoteDir());
                    }
                } finally {
                    try { $t2->close(); } catch (\Throwable $e) {}
                }
                // Prove the mirrored full is actually served at the secondary public
                // URL — the #1 reason a "secondary is empty / 404" surprise happens is
                // the upload directory not matching the public URL's document root.
                if ($wantFull && $secFullOk) {
                    $surl = UpdateHostConfig::secondaryDownloadUrl($fullName, 'full');
                    $scode = $this->reachable($surl);
                    if ($scode === 200) {
                        $messages[] = "Verified secondary reachable: {$surl}";
                        $step('ok', "Verified reachable on the secondary: {$surl}");
                    } else {
                        $messages[] = "WARNING: mirrored to the secondary, but NOT reachable at {$surl}"
                            . ($scode ? " (HTTP {$scode})" : ' (no response)')
                            . ' — the secondary Upload directory does not match its Public download URL. Use "Test connection" on the secondary host to browse and pick the folder the URL serves.';
                        $step('warn', "Mirrored, but NOT reachable at {$surl}"
                            . ($scode ? " (HTTP {$scode})" : ' (no response)')
                            . " — the secondary's Upload directory doesn't match its Public URL.");
                    }
                }
            } catch (\Throwable $e) {
                $messages[] = 'NOTE: could not mirror to the secondary host (' . $e->getMessage() . ') — the primary still has the bundle.';
                $step('warn', 'Secondary mirror failed (' . $e->getMessage() . ') — the primary still has the bundle.');
            }
        }
        if ($useGdrive && UpdateHostConfig::hasGdrive() && $wantFull) {
            $step('run', 'Backing up to Google Drive…');
            try {
                if (self::driveBackupPut($fullPath, $fullName, 'application/zip') !== '') {
                    $messages[] = 'Backed the full bundle up to Google Drive.';
                    $step('ok', 'Backed the full bundle up to Google Drive.');
                } else {
                    $step('warn', 'Google Drive backup did not complete — the FTP hosts still have the bundle.');
                }
            } catch (\Throwable $e) { $step('warn', 'Google Drive backup failed (' . $e->getMessage() . ').'); }
        }

        // 4. Register in the catalogue (the feed's source of truth).
        $fullInfo = null;
        if ($wantFull) {
            UpdateReleases::publish([
                'version'    => $version, 'channel' => $channel, 'edition' => $edition, 'status' => $status,
                'codename'   => $meta['codename'], 'minPhp' => $meta['minPhp'],
                'fullName'   => $fullName, 'fullUrl' => UpdateHostConfig::downloadUrl($fullName, 'full'),
                'fullSha256' => $fullSha,   'fullSize' => $fullSize,
                'manifest'   => $meta['files'], 'notes' => $notes,
            ]);
            $fullInfo = ['name' => $fullName, 'size' => $fullSize, 'url' => UpdateHostConfig::downloadUrl($fullName, 'full')];
        } elseif ($kind === 'delta') {
            // Keep the existing full row; just refresh its notes if the owner gave any.
            if ($notes !== '') {
                UpdateReleases::publish([
                    'version'    => $version, 'channel' => $channel,
                    'edition'    => (string) ($existing['edition'] ?? $edition),
                    'status'     => (string) ($existing['status'] ?? 'live'),
                    'codename'   => (string) $existing['codename'], 'minPhp' => (string) $existing['min_php'],
                    'fullName'   => (string) $existing['full_name'], 'fullUrl' => (string) $existing['full_url'],
                    'fullSha256' => (string) $existing['full_sha256'], 'fullSize' => (int) $existing['full_size'],
                    'manifest'   => $meta['files'], 'notes' => $notes,
                ]);
            }
            $status = (string) ($existing['status'] ?? 'live');
        }

        $deltaSummary = [];
        foreach ($deltaJobs as $job) {
            if (empty($job['uploaded'])) continue;
            UpdateReleases::addDelta([
                'from'    => $job['from'], 'to' => $version, 'channel' => $channel, 'edition' => $edition,
                'name'    => $job['name'], 'url' => UpdateHostConfig::downloadUrl($job['name'], 'delta'),
                'sha256'  => (string) $job['sha256'], 'size' => (int) $job['size'],
            ]);
            $deltaSummary[] = ['from' => $job['from'], 'size' => (int) $job['size'], 'changed' => (int) $job['changed']];
        }

        // 5. Refresh the signed index on the host — this is what makes a publish
        //    from ANY install (a dev laptop included) reach the whole estate: the
        //    host, not this install's database, becomes the source of truth. When
        //    publishing several editions in one go the caller defers this to a
        //    single write at the very end (see publishMany).
        if (empty($opts['_deferIndex'])) {
            $step('run', 'Publishing the signed update index…');
            try {
                $idx = self::syncIndex($useSecondary, $useGdrive);
                $messages[] = $idx['ok']
                    ? "Published the signed update index → {$idx['url']}"
                    : "NOTE: could not refresh the update index ({$idx['message']}) — clients may not see this release yet.";
                $step($idx['ok'] ? 'ok' : 'warn', $idx['ok']
                    ? "Signed update index published → {$idx['url']}"
                    : "Could not refresh the update index ({$idx['message']}).");
            } catch (\Throwable $e) {
                $messages[] = 'NOTE: could not refresh the update index: ' . $e->getMessage();
                $step('warn', 'Could not refresh the update index: ' . $e->getMessage());
            }
        }

        // Local work files are transient — the catalogue points at the host now.
        @unlink($fullPath);
        foreach ($deltaJobs as $job) @unlink($job['path']);

        // Sanity: confirm what we just uploaded is actually reachable at its public
        // URL, so a wrong "Public download URL" is caught HERE, not by a customer.
        if ($fullInfo !== null) {
            $step('run', 'Verifying the bundle is reachable at its public URL…');
            $code = $this->reachable($fullInfo['url']);
            if ($code === 200) {
                $messages[] = "Verified reachable: {$fullInfo['url']}";
                $step('ok', "Verified reachable: {$fullInfo['url']}");
            } else {
                $messages[] = "WARNING: uploaded, but NOT reachable at {$fullInfo['url']}"
                    . ($code ? " (HTTP {$code})" : ' (no response)')
                    . " — check the Public download URL in the Update host tab.";
                $step('warn', "Uploaded, but NOT reachable at {$fullInfo['url']}"
                    . ($code ? " (HTTP {$code})" : ' (no response)') . ' — check the Public download URL.');
            }
        }
        // Same reachability check for EVERY uploaded delta. A delta-only publish
        // has no full to verify above, and an unreachable delta is precisely the
        // silent gap that dead-ends a customer's "Check for Updates" with a 404 —
        // catch it HERE, at publish, not out in the field.
        foreach ($deltaJobs as $job) {
            if (empty($job['uploaded'])) continue;
            $durl = UpdateHostConfig::downloadUrl($job['name'], 'delta');
            $dcode = $this->reachable($durl);
            if ($dcode === 200) {
                $messages[] = "Verified reachable: {$durl}";
            } else {
                $messages[] = "WARNING: delta uploaded, but NOT reachable at {$durl}"
                    . ($dcode ? " (HTTP {$dcode})" : ' (no response)')
                    . " — clients would get a 404. Check the Public download URL and the host's delta/ folder.";
            }
        }

        // This machine is the source of the release it just published (it built
        // the bundle from its own working copy). On a LIVE (OTA) publish, stamp
        // the code version forward so the Update section reports the published
        // build as current — never offering this machine an update to a build it
        // produced. Forward-only + fail-safe; staged publishes don't stamp.
        if ($status === 'live' && \PpPhp\Core\Version::stampCode($version)) {
            $messages[] = "This machine is now marked as running {$version}.";
        }

        return [
            'ok'       => true,
            'version'  => $version,
            'channel'  => $channel,
            'edition'  => $edition,
            'kind'     => $kind,
            'status'   => $status,
            'full'     => $fullInfo,
            'deltas'   => $deltaSummary,
            'messages' => $messages,
        ];
    }

    /**
     * Publish the SAME version as several editions in one action — the release
     * pipeline's "one-click both editions". Your two live servers take the
     * `server` build; customers take the `client` build. Each edition is a full
     * publish (bundle + deltas + catalogue row); the signed index is written ONCE
     * at the end so the whole estate flips together.
     *
     * @param array $opts    same shape as publish(), minus 'edition'
     * @param array $editions e.g. ['server','client']
     * @return array{ok:bool, version:string, editions:array<int,array>, messages:array<int,string>, index:array}
     */
    public function publishMany(array $opts, array $editions): array
    {
        $editions = array_values(array_unique(array_map(
            static fn ($e) => $e === 'server' ? 'server' : 'client',
            $editions
        )));
        if ($editions === []) $editions = ['client'];

        $onProgress = $opts['onProgress'] ?? null;
        $step = static function (string $icon, string $text) use ($onProgress) {
            if (is_callable($onProgress)) { try { $onProgress(['type' => 'progress', 'icon' => $icon, 'text' => $text, 'edition' => '']); } catch (\Throwable $e) {} }
        };
        $targets = is_array($opts['targets'] ?? null) ? $opts['targets'] : null;
        $useSecondary = $targets === null ? UpdateHostConfig::hasSecondary() : !empty($targets['secondary']);
        $useGdrive    = $targets === null ? UpdateHostConfig::hasGdrive()    : !empty($targets['gdrive']);

        $done = [];
        $messages = [];
        $version = trim((string) ($opts['version'] ?? ''));
        foreach ($editions as $ed) {
            $step('run', "Publishing the {$ed} edition…");
            $r = $this->publish(['edition' => $ed, '_deferIndex' => true] + $opts);
            $version = $r['version'];
            $done[] = ['edition' => $ed, 'status' => $r['status'], 'kind' => $r['kind'], 'full' => $r['full'], 'deltas' => $r['deltas']];
            $messages[] = "── {$ed} edition ──";
            foreach ($r['messages'] as $m) $messages[] = $m;
        }

        // One index write covers every edition just published.
        $step('run', 'Publishing the signed update index…');
        $index = ['ok' => false, 'url' => '', 'message' => 'skipped'];
        try {
            $index = self::syncIndex($useSecondary, $useGdrive);
            $messages[] = $index['ok']
                ? "Published the signed update index → {$index['url']}"
                : "NOTE: could not refresh the update index ({$index['message']}).";
            $step($index['ok'] ? 'ok' : 'warn', $index['ok']
                ? "Signed update index published → {$index['url']}"
                : "Could not refresh the update index ({$index['message']}).");
        } catch (\Throwable $e) {
            $messages[] = 'NOTE: could not refresh the update index: ' . $e->getMessage();
            $step('warn', 'Could not refresh the update index: ' . $e->getMessage());
        }

        return ['ok' => true, 'version' => $version, 'editions' => $done, 'messages' => $messages, 'index' => $index];
    }

    /**
     * Build, sign and upload the whole-catalogue index.json to the host root, so
     * every client can read "what is the newest release" straight from the host.
     * Idempotent and safe to call after any catalogue change (publish, status flip,
     * delete). Also (re)stamps UpdateTrust so future client bundles ship knowing
     * the index URL. Never throws — returns an ok/message result.
     *
     * @return array{ok:bool, url:string, message:string}
     */
    public static function syncIndex(bool $mirrorSecondary = true, bool $mirrorGdrive = true): array
    {
        if (!UpdateHostConfig::isConfigured()) {
            return ['ok' => false, 'url' => '', 'message' => 'update host not configured'];
        }
        $keys = new \PpPhp\Core\Licensing\LicenseKeys();
        if (!$keys->exists()) {
            return ['ok' => false, 'url' => '', 'message' => 'no signing key on this install'];
        }

        $token = UpdateIndex::sign(UpdateIndex::build(), $keys->secretKey());
        $tmp = (string) tempnam(sys_get_temp_dir(), 'ppidx');
        if ($tmp === '' || @file_put_contents($tmp, $token) === false) {
            return ['ok' => false, 'url' => '', 'message' => 'could not stage the index file'];
        }

        // Keep shipped client bundles pointing at the right index (source pin).
        try { UpdateTrust::stamp(UpdateHostConfig::publicUrl() . '/index.json'); } catch (\Throwable $e) {}

        $transport = UpdateHostConfig::transport();
        $ok = false;
        try {
            $transport->connect();
            $transport->ensureDir(UpdateHostConfig::remoteDir());
            $ok = $transport->put($tmp, UpdateHostConfig::remoteDir() . '/index.json');
        } finally {
            try { $transport->close(); } catch (\Throwable $e) {}
        }

        // Mirror the index to the secondary host + Drive backup so the client can
        // fall back to them. Best-effort: a mirror failure never fails the publish.
        if ($mirrorSecondary && UpdateHostConfig::hasSecondary()) {
            try {
                $t2 = UpdateHostConfig::secondaryTransport();
                $t2->connect();
                $t2->ensureDir(UpdateHostConfig::secondaryRemoteDir());
                $t2->put($tmp, UpdateHostConfig::secondaryRemoteDir() . '/index.json');
                try { $t2->close(); } catch (\Throwable $e) {}
            } catch (\Throwable $e) { /* best-effort mirror */ }
        }
        if ($mirrorGdrive && UpdateHostConfig::hasGdrive()) {
            try {
                $url = self::driveBackupPut($tmp, 'index.json', 'application/json');
                if ($url !== '') {
                    $st = UpdateHostConfig::gdrive();
                    $ids = is_array($st['fileIds'] ?? null) ? $st['fileIds'] : [];
                    UpdateHostConfig::setGdriveState($url, $ids);
                }
            } catch (\Throwable $e) { /* best-effort */ }
        }
        @unlink($tmp);

        return $ok
            ? ['ok' => true, 'url' => UpdateHostConfig::publicUrl() . '/index.json', 'message' => 'ok']
            : ['ok' => false, 'url' => '', 'message' => 'index upload failed'];
    }

    /**
     * Upload one file to the configured Google Drive backup connection and return
     * a public download URL, updating the same Drive file in place when we already
     * know its id (so the URL stays stable). Empty string on any failure.
     */
    private static function driveBackupPut(string $localPath, string $remoteName, string $mime): string
    {
        $connId = UpdateHostConfig::gdriveConnectionId();
        if ($connId === '' || !class_exists('\PpPhp\Core\Storage\StorageRegistry')) {
            return '';
        }
        try {
            $driver = \PpPhp\Core\Storage\StorageRegistry::driver($connId);
        } catch (\Throwable $e) {
            return '';
        }
        if (!($driver instanceof \PpPhp\Core\Storage\GoogleDriveDriver)) {
            return '';
        }
        $st  = UpdateHostConfig::gdrive();
        $ids = is_array($st['fileIds'] ?? null) ? $st['fileIds'] : [];
        try {
            // Update the same Drive file when we know its id → stable URL, no dupes.
            if (!empty($ids[$remoteName]) && method_exists($driver, 'updateFile')) {
                $id = (string) $ids[$remoteName];
                if ($driver->updateFile($id, $localPath, $mime)) {
                    return 'https://drive.google.com/uc?export=download&id=' . rawurlencode($id);
                }
            }
            $ref = $driver->put($localPath, ['name' => $remoteName, 'mime' => $mime, 'size' => (int) @filesize($localPath), 'ext' => pathinfo($remoteName, PATHINFO_EXTENSION)]);
            $id = (string) ($ref['key'] ?? '');
            if ($id === '') return '';
            $ids[$remoteName] = $id;
            UpdateHostConfig::setGdriveState('https://drive.google.com/uc?export=download&id=' . rawurlencode($id), $ids);
            return 'https://drive.google.com/uc?export=download&id=' . rawurlencode($id);
        } catch (\Throwable $e) {
            return '';
        }
    }

    /** Read version/codename/minPhp/files from a bundle's release.json. */
    private function bundleMeta(string $bundlePath): array
    {
        $zip = new ZipArchive();
        if ($zip->open($bundlePath) !== true) throw new RuntimeException('Built bundle could not be reopened.');
        $raw = $zip->getFromName('release.json');
        $zip->close();
        $m = is_string($raw) ? json_decode($raw, true) : null;
        if (!is_array($m) || empty($m['files'])) throw new RuntimeException('Built bundle has no usable manifest.');
        return [
            'version'  => \PpPhp\Core\Version::normalise((string) ($m['version'] ?? '0')),
            'codename' => (string) ($m['codename'] ?? ''),
            'minPhp'   => (string) ($m['minPhp'] ?? \PpPhp\Core\Version::MIN_PHP),
            'files'    => (array) $m['files'],
        ];
    }

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

    /** HTTP status of a HEAD request, or null if unreachable / not a URL. */
    private function reachable(string $url): ?int
    {
        if (!function_exists('curl_init') || !preg_match('#^https?://#i', $url)) return null;
        $ch = curl_init($url);
        curl_setopt_array($ch, [
            CURLOPT_NOBODY         => true,
            CURLOPT_FOLLOWLOCATION => true,
            CURLOPT_CONNECTTIMEOUT => 6,
            CURLOPT_TIMEOUT        => 12,
            CURLOPT_RETURNTRANSFER => true,
            CURLOPT_USERAGENT      => 'PPPHP-Update/1',
        ]);
        curl_exec($ch);
        $code = (int) curl_getinfo($ch, CURLINFO_HTTP_CODE);
        return $code ?: null;
    }

    /**
     * Run the pre-publish safety scan over a built bundle ZIP: extract to a temp
     * dir, scan every file for host-flaggable patterns, clean up. Best-effort —
     * a scanner hiccup never blocks a publish (returns no findings).
     *
     * @return array<int, array<string,mixed>>
     */
    private function scanBundle(string $zipPath): array
    {
        if (!class_exists(BundleScanner::class) || !class_exists(ZipArchive::class)) {
            return [];
        }
        $zip = new ZipArchive();
        if ($zip->open($zipPath) !== true) {
            return [];
        }
        $tmp = sys_get_temp_dir() . '/ppscan-' . bin2hex(random_bytes(4));
        if (!@mkdir($tmp, 0700, true) && !is_dir($tmp)) {
            $zip->close();
            return [];
        }
        try {
            $zip->extractTo($tmp);
            $zip->close();
            return (new BundleScanner())->scanDir($tmp);
        } catch (\Throwable $e) {
            return [];
        } finally {
            $this->rrmdir($tmp);
        }
    }

    /** Recursively delete a temp directory (used only for the scan work dir). */
    private function rrmdir(string $dir): void
    {
        if (!is_dir($dir)) {
            return;
        }
        $it = new \RecursiveIteratorIterator(
            new \RecursiveDirectoryIterator($dir, \FilesystemIterator::SKIP_DOTS),
            \RecursiveIteratorIterator::CHILD_FIRST
        );
        foreach ($it as $f) {
            $f->isDir() ? @rmdir($f->getPathname()) : @unlink($f->getPathname());
        }
        @rmdir($dir);
    }
}
