<?php

declare(strict_types=1);

namespace PpPhp\Core\Packaging;

// The packager is also used standalone by app-generator.php (CLI, no autoloader),
// so pull in the version constant it stamps into every bundle.
require_once __DIR__ . '/../Version.php';

use RuntimeException;
use ZipArchive;
use RecursiveIteratorIterator;
use RecursiveCallbackFilterIterator;
use RecursiveDirectoryIterator;
use FilesystemIterator;
use SplFileInfo;

/**
 * PlatformPackager — bundle the PPPHP platform itself into a clean, secret-free
 * ZIP for a fresh server install (unzip → run install.php).
 *
 * Shared by the CLI `app-generator.php` and the owner-only GUI download.
 * It EXCLUDES local/secret/user-generated data (DB credentials, projects,
 * generated output, storage/logs/backups/deploy) and INCLUDES everything needed
 * to run out of the box, recreating the writable runtime folders empty.
 *
 * The build performs a security post-check and REFUSES to produce a bundle that
 * would ship any known-secret path.
 */
final class PlatformPackager
{
    /** Everything: what the vendor runs, licence authority included. */
    public const VARIANT_SERVER = 'server';

    /** What a customer runs. Carries no way to mint licences or build bundles. */
    public const VARIANT_CLIENT = 'client';

    private string $root;

    /** Seal the critical source into this bundle (see protect()). */
    private bool $protect = false;

    /**
     * Source files sealed in a protected bundle. Each is replaced in the ZIP by a
     * loader stub, with the real code encrypted under storage/protected/.
     *
     * Deliberately the BUILD path: it is what customers pay for, so making it
     * undecryptable without a lease is what actually protects the product.
     */
    private array $protectedSources = [
        'core/Build/BuildEngine.php',
    ];

    private string $variant = self::VARIANT_SERVER;

    /** Version to stamp into this release, or null to keep Version::PLATFORM. */
    private ?string $releaseVersion = null;

    /** config/server-role.json to ship (backup bundles only), or null for a main. */
    private ?array $roleFile = null;

    /**
     * Files that belong to the VENDOR only and are cut from a client bundle.
     *
     * Two reasons, and the second is the important one:
     *   1. Size and clutter — a customer has no use for them.
     *   2. Blast radius — a client install cannot be talked into acting as a
     *      licence authority or producing bundles if the code simply is not
     *      there. Removing capability beats gating it behind a role check.
     *
     * The signing key itself is never in ANY bundle (storage/ is excluded and
     * storage/license/ is a hard abort), so this is about code, not secrets.
     */
    private array $serverOnlyFiles = [
        // Serving the signed template catalogue to licensed clients — authority only
        // (holds the signing key + LicenseServer). Clients FETCH via TemplateClient.
        'modules/Projects/Controllers/TemplateFeedController.php',
        // Minting and administering licences
        'core/Licensing/LicenseServer.php',
        'core/Licensing/LicenseKeys.php',
        'core/Auth/SsoIssuer.php',
        'core/Licensing/LicenseConfig.php',
        // Deriving per-instance owner passwords. A client that could derive them
        // would defeat the scheme, so the derivation code never ships to clients.
        'core/Licensing/OwnerDerivation.php',
        // Primary↔backup replication — server infrastructure only.
        'core/Licensing/LicenseSync.php',
        'core/Licensing/LicenseMirror.php',
        'core/Licensing/ServerRole.php',
        // Phase 4 data-plane cloning — main↔backup only. A customer install can
        // never use these, so shipping them is pure attack surface.
        'core/Databases/DbCloner.php',
        'core/Databases/DbReplicator.php',
        'core/Databases/TableDigest.php',
        'core/Databases/CloneSchedule.php',
        'bin/pp-clone-databases.php',
        'modules/Licensing/Controllers/LicenseServerController.php',
        'modules/Licensing/Controllers/LicenseApiController.php',
        'modules/Licensing/Controllers/AdvisoryController.php',
        'modules/Licensing/Controllers/PlanController.php',
        'core/Licensing/PlanRegistry.php',
        'modules/Licensing/Views/server.php',
        'modules/Licensing/Views/advisories.php',
        'modules/Licensing/Views/plans.php',
        // The build AUTHORITY — answering a server-side build. A client SENDS a
        // build (BuildClient ships); it must never be able to ANSWER one, so the
        // endpoint and the generator-invoking stager are authority-only.
        'core/Build/BuildStaging.php',
        'core/Build/BuildCache.php',
        'core/Build/BuildMeter.php',
        'modules/Projects/Controllers/BuildApiController.php',
        // The GENERATOR itself — how a design becomes an app: PPPHP's crown-jewel
        // codegen (~1.7 MB). A client builds ONLY via the server (BuildClient →
        // /api/build/generate), so this is dead weight AND pure IP exposure on a
        // customer's disk. Stripped now that the mesh build is proven live.
        // (BuildEngine.php is NOT here — it stays so protect() can replace it with
        // the sealed loader stub; it is never decrypted on a client because every
        // local build call is routed to BuildClient.)
        'core/Build/BuildWriter.php',
        'core/Build/ComponentRenderer.php',
        // Vendor deliverables — building/shipping the VENDOR's own marketing site
        // and community forum. A customer client has neither project and no use for
        // them; the controller also holds authority-only calls (LicenseKeys,
        // BuildEngine on the 'community' slug). Views go with it.
        'modules/Settings/Controllers/DeliverablesController.php',
        'modules/Settings/Views/deliverables_marketing.php',
        'modules/Settings/Views/deliverables_community.php',
        'modules/Settings/Views/partials/deliverables_analytics.php',
        // Producing release bundles
        'core/Packaging/PlatformPackager.php',
        'modules/Settings/Controllers/PlatformBundleController.php',
        'modules/Settings/Views/app_bundle.php',
        'app-generator.php',
        // The Update Engine (authority) — publishing, the signed feed, the host
        // config with its credentials, the delta builder, the release catalogue.
        // A client keeps UpdateClient/UpdateProtocol/PlatformUpdater (it consumes
        // updates) but never the code to BECOME an authority. Same rationale as
        // the licence server: absent capability can't be re-enabled by editing a
        // role check.
        'core/Update/UpdateHostConfig.php',
        'core/Update/UpdateReleases.php',
        'core/Update/DeltaBuilder.php',
        'core/Update/UpdatePublisher.php',
        'core/Update/BaselineStore.php',
        'core/Update/UpdateHostBrowser.php',
        // Advisory AUTHORITY — composing + signing the Owner→client push feed.
        // A client keeps AdvisoryClient (it consumes the signed feed) but never
        // the code to AUTHOR or SIGN one. Same rationale as the update engine.
        'core/Update/AdvisoryStore.php',
        'core/Update/AdvisoryFeed.php',
        'modules/Update/Controllers/UpdateApiController.php',
        'modules/Update/Controllers/UpdateServerController.php',
        'modules/Update/Views/server.php',
    ];

    /**
     * Routes dropped from a client bundle, by URI prefix.
     * A route whose controller is gone would 500 if anyone reached it, so the
     * manifest is rewritten to match the files that actually ship.
     */
    private array $serverOnlyRoutePrefixes = [
        '/licensing/server',
        '/api/license',
        // The build authority endpoint — a client posts builds, never answers them.
        '/api/build',
        '/platform/app-bundle',
        // Vendor deliverables (marketing site + community forum) — vendor only.
        '/platform/deliverables',
        // Update Engine authority routes — the client keeps only the consumer
        // side (/platform/updates/*), never the feed or the owner console.
        '/api/update',
        '/update/server',
        // Owner→client advisory/push AUTHORITY console (AdvisoryController) — the
        // client consumes the signed feed via AdvisoryClient, never composes it.
        '/licensing/advisories',
        // Plans registry console (PlanController) — vendor-only.
        '/licensing/plans',
        // Template catalogue FEED (TemplateFeedController) — the client fetches it
        // via TemplateClient, never answers it. Its controller is stripped, so the
        // route must go too or a direct hit 500s (ReflectionClass on a gone class).
        '/api/templates',
    ];

    /** Directories stripped from CLIENT bundles only — clients fetch these remotely. */
    private array $serverOnlyDirs = [
        // The curated template catalogue lives on the authority; licensed clients
        // fetch it via TemplateClient and cache it under storage/, so shipping the
        // packages in a client bundle would both bloat it and bypass the licence.
        'core/Projects/builtin-templates',
        // Vendor-only + platform-internal Help guides. The first group describes how
        // the VENDOR licenses, updates, provisions and ships PPPHP itself; the second
        // describes the platform's internal structure and extension points (file
        // paths, class names). A customer copy must expose NEITHER. Removing a folder
        // also drops it from the Help sidebar + search (getTopics() scans help/).
        'help/licensing',
        'help/updates',
        'help/advisories',
        'help/owner_deliverables',
        'help/database_manager',
        'help/designer_components',
        'help/field_plugins',
        'help/performance',
        'help/visitor_engine',
        // Vendor operational content — the marketing site + community-forum source
        // that only the vendor's own DeliverablesController (itself server-only)
        // builds and serves. A customer client has neither project and no use for
        // the source; shipping it is dead weight + an information leak.
        'Extra',
        'public/community',
    ];

    /** Directories excluded ONLY at the project root (user data / runtime / dev). */
    private array $excludeRootDirs = ['projects', 'output', 'storage', 'tests', 'r0100', 'dist',
        // Dev/repo-only material — never read by the running app, and discloses
        // internal architecture (ADRs, engineering notes) or ships vendor tooling.
        // Verified free of runtime references before exclusion. (NB: `packages/` is
        // NOT here — theme/component discovery reads packages/themes + packages/ui.)
        'docs', 'knowledge', 'tools'];

    /** Directory names excluded ANYWHERE in the tree. */
    private array $excludeAnyDirs = [
        '.git', '.github', '.svn', '.hg', '.idea', '.vscode', 'node_modules',
        // AI/agent + dev-tooling state. None of this is read by the running app,
        // and some of it is actively sensitive: `.wolf/` carries a dashboard
        // token, a token ledger and internal engineering notes (buglog, cerebrum,
        // memory, STATUS) that describe the architecture and server addresses.
        // Shipping any of it to a customer is an information-disclosure leak.
        '.claude', '.wolf', '.opencode', '.codex', '.cursor', '.aider', '.continue', '.gemini',
    ];

    /** Exact files excluded (relative path) — secrets, packaging tools, markers.
     *  config/server-role.json is written fresh per bundle (see serverRole()), so
     *  the copy on the build machine must never be walked in.
     *  release.json + DEPLOY.txt are BUNDLE ARTIFACTS this packager writes fresh
     *  (addFromString) at the end of build(). A server installed FROM a bundle has
     *  them sitting at its project root, so if they were walked in they would land
     *  in the integrity manifest with the hash of the OLD on-disk copy — then the
     *  fresh copies we write would not match, and the updater would (correctly)
     *  reject the bundle as "corrupt". They are not tracked platform code, so they
     *  never belong in the manifest; excluding them from the walk fixes that. */
    private array $excludeFiles = ['config/database.php', 'app-generator.php', '.env', '.env.local', 'config/server-role.json', 'release.json', 'DEPLOY.txt',
        // PROTECTED by the updater (never overwritten). A PROTECTED path must NOT be
        // in the bundle, or PlatformUpdater::inspect() sees "bundle tries to write a
        // protected path" and REFUSES EVERY update. Excluding them keeps updates
        // flowing; a customer configures mail/SMS in Settings on their own install.
        'config/mail.php', 'config/sms.php'];

    /** File names excluded anywhere (OS / editor cruft). */
    private array $excludeAnyFiles = [
        '.DS_Store', 'Thumbs.db', 'desktop.ini',
        // Agent instruction files — dev tooling, never read by the app at runtime.
        'CLAUDE.md', 'AGENTS.md', 'GEMINI.md', '.mcp.json', '.aider.conf.yml',
    ];
    /**
     * Never package an archive. A release ZIP staged anywhere inside the tree
     * (public/, a temp copy next to the source…) would otherwise be swallowed by
     * the next build, silently doubling its size — and a bundle containing a
     * bundle is never intended.
     */
    private array $excludeExtensions = ['zip', 'tar', 'gz', 'tgz', 'rar', '7z'];

    /** Path prefixes excluded anywhere — large, unreferenced source data. */
    private array $excludePaths = [
        // FontAwesome Pro raw metadata (~143 MB). Runtime uses the trimmed index in
        // plugins/IconManager/… instead, so this design-source stays out.
        'resources/Icons/FontAwesomePro/assets/7.2/metadata',
    ];

    /** Writable runtime folders recreated EMPTY in the bundle (each gets a .keep). */
    private array $emptyDirs = [
        'projects', 'output',
        'storage', 'storage/backups', 'storage/cache', 'storage/deploy', 'storage/plugins', 'storage/reports',
    ];

    /** If any of these appear as ZIP entries the build ABORTS (never ship secrets). */
    private array $forbiddenInZip = [
        'config/database.php',
        'projects/',
        'storage/backups/', 'storage/deploy/', 'storage/cache/', 'storage/reports/',
        // The licence signing key and this install's own lease. storage/ is
        // already excluded wholesale, so reaching either of these would mean the
        // exclusion had broken — and shipping the signing key would hand every
        // customer the ability to mint their own licences. Named explicitly so
        // that failure aborts the build loudly instead of shipping quietly.
        'storage/license/',
        'output/',
        // Agent/dev tooling. Excluded above; named here too so that if the
        // exclusion ever breaks, the build ABORTS rather than shipping a
        // dashboard token, a token ledger and internal engineering notes to
        // every customer.
        '.wolf/', '.opencode/', '.codex/', '.claude/',
    ];

    public function __construct(?string $root = null)
    {
        $root ??= defined('BASE_PATH') ? constant('BASE_PATH') : dirname(__DIR__, 2);
        $this->root = rtrim(str_replace('\\', '/', $root), '/');
    }

    /** Choose what kind of bundle to build. */
    public function variant(string $variant): self
    {
        $this->variant = $variant === self::VARIANT_CLIENT ? self::VARIANT_CLIENT : self::VARIANT_SERVER;
        return $this;
    }

    /**
     * Ship this bundle PROTECTED: the designated critical source is encrypted and
     * the key is withheld from the bundle entirely — it reaches the customer only
     * inside their signed lease. An unlicensed copy therefore cannot run that code
     * at all, which is a different guarantee from a check it could edit out.
     *
     * A fresh content key is minted per protected build and recorded on this
     * licence server, so leases start carrying it immediately and a key captured
     * from an older release opens nothing new.
     */
    public function protect(bool $on = true): self
    {
        $this->protect = $on;
        return $this;
    }

    /**
     * Stamp a specific release version into this bundle (release.json + the
     * shipped Version.php), so the updater on the far side sees it as a new
     * release instead of "same version". Blank = keep the code's current
     * Version::PLATFORM.
     */
    public function releaseVersion(string $version): self
    {
        $v = \PpPhp\Core\Version::normalise($version);
        $this->releaseVersion = $v === '0.0.0' ? null : $v;
        return $this;
    }

    /**
     * Mark this as a BACKUP licence-server bundle bound to a main. On install it
     * shows the pairing wizard instead of "create a signing key" and, once
     * paired, syncs from the main. A blank/main role ships no file (default main).
     */
    public function serverRole(string $role, string $mainUrl = ''): self
    {
        $main = rtrim(trim($mainUrl), '/');
        $this->roleFile = ($role === 'backup')
            ? ['role' => 'backup', 'main' => $main, 'stampedAt' => date('c')]
            : null;
        return $this;
    }

    public function isClient(): bool
    {
        return $this->variant === self::VARIANT_CLIENT;
    }

    /** A timestamped download filename, naming the variant so the two never get mixed up. */
    public function suggestedFilename(): string
    {
        return 'ppphp-' . $this->variant . '-' . date('Ymd-His') . '.zip';
    }

    /**
     * Encrypt the protected sources and build their loader stubs.
     *
     * Mints a FRESH content key for this release and records it on this licence
     * server, so leases begin carrying it at once and a key captured from an
     * earlier release cannot open this one. The key is never written into the
     * bundle — that is the entire point.
     *
     * @return array{0:array<string,string>,1:array<string,string>} [stubs, sealed]
     */
    private function sealSources(): array
    {
        if (!class_exists(\PpPhp\Core\Licensing\ProtectedCode::class)) {
            throw new RuntimeException('Protected builds need core/Licensing/ProtectedCode.php.');
        }
        $key = \PpPhp\Core\Licensing\ProtectedCode::newReleaseKey();
        if (!\PpPhp\Core\Licensing\LicenseConfig::setReleaseKey($key)) {
            throw new RuntimeException('Could not record the release key — a protected bundle would be unopenable by every customer.');
        }

        $stubs = [];
        $sealed = [];
        foreach ($this->protectedSources as $rel) {
            $abs = $this->root . '/' . $rel;
            if (!is_file($abs)) continue;
            $src = (string) file_get_contents($abs);
            $name = preg_replace('/[^A-Za-z0-9]/', '_', $rel);

            $sealed['storage/protected/' . $name . '.sealed'] =
                \PpPhp\Core\Licensing\ProtectedCode::seal($src, $key);
            $stubs[$rel] = $this->loaderStub($rel, $name, $src);
        }
        if ($sealed === []) {
            throw new RuntimeException('Protected build requested but none of the protected sources were found.');
        }
        return [$stubs, $sealed];
    }

    /**
     * The stub that ships in place of a sealed source.
     *
     * It asks ProtectedCode for the plaintext, which succeeds only on an install
     * holding a current lease. When that fails the stub declares an inert class of
     * the same name whose constructor explains why — without it PHP would die
     * with "class not found", which tells a customer nothing.
     */
    private function loaderStub(string $rel, string $name, string $src): string
    {
        preg_match('/^\s*namespace\s+([^;]+);/m', $src, $ns);
        preg_match('/^\s*(?:final\s+|abstract\s+)?class\s+(\w+)/m', $src, $cl);
        $namespace = trim($ns[1] ?? '');
        $class     = trim($cl[1] ?? '');
        if ($namespace === '' || $class === '') {
            throw new RuntimeException("Cannot seal {$rel}: no single namespaced class found.");
        }

        $msg = 'This copy of PPPHP is not licensed, so its build engine cannot run. '
             . 'Activate a licence, or contact ' . \PpPhp\Core\Licensing\LicenseProtocol::SUPPORT_CONTACT . '.';

        return "<?php\n"
            . "// Sealed by PlatformPackager. The implementation is encrypted under\n"
            . "// storage/protected/ and is decrypted with a key that arrives inside\n"
            . "// this installation's signed licence lease.\n"
            . "namespace {$namespace};\n\n"
            . "\\PpPhp\\Core\\Licensing\\ProtectedCode::load(" . var_export($name, true) . ");\n\n"
            . "if (!\\class_exists(" . var_export($namespace . '\\' . $class, true) . ", false)) {\n"
            . "    /** Inert stand-in used when no valid licence key is available. */\n"
            . "    final class {$class}\n"
            . "    {\n"
            . "        public function __construct() { throw new \\RuntimeException(" . var_export($msg, true) . "); }\n"
            . "        public function __call(\$m, \$a) { throw new \\RuntimeException(" . var_export($msg, true) . "); }\n"
            . "        public static function __callStatic(\$m, \$a) { throw new \\RuntimeException(" . var_export($msg, true) . "); }\n"
            . "    }\n"
            . "}\n";
    }

    /** Relative, forward-slashed path from the project root. */
    private function rel(string $abs): string
    {
        return ltrim(str_replace('\\', '/', substr($abs, strlen($this->root))), '/');
    }

    /**
     * Build the bundle at $zipPath.
     *
     * @return array{entries:int, sourceBytes:int, zipBytes:int}
     * @throws RuntimeException on any failure (incl. a security abort).
     */
    public function build(string $zipPath): array
    {
        if (!class_exists(ZipArchive::class)) {
            throw new RuntimeException('The PHP zip extension (ext-zip) is required.');
        }

        // Reconcile config/owner-seed.json to the current owner-password mode
        // before the walk picks it up, so a fresh install boots with the right
        // hash (the unified password, or a derived bootstrap one). Best-effort:
        // needs a DB + signing key, which the GUI download has and a bare CLI may
        // not — in which case whatever is already on disk ships.
        if (class_exists(\PpPhp\Core\Licensing\OwnerDerivation::class)) {
            try { (new \PpPhp\Core\Licensing\OwnerDerivation())->refreshSeedFile(); } catch (\Throwable $e) {}
        }

        // Bake this build machine's Settings → Email into the SERVER bundle so a
        // fresh vendor install ships with working email by default. NEVER for a
        // CLIENT bundle — shipping the vendor's live SMTP credential to customers
        // is a credential leak (they could send mail as the vendor). Client
        // installs ship unstamped and require the customer's own SMTP; the
        // rewrittenEntries() pass additionally forces MailDefault empty in every
        // client bundle in case a prior server build stamped it onto disk.
        if (!$this->isClient() && class_exists(\PpPhp\Core\MailDefault::class)) {
            try {
                $email = \PpPhp\Core\PlatformSettings::get(\PpPhp\Core\PlatformSettings::EMAIL);
                if (is_array($email) && trim((string) ($email['host'] ?? '')) !== '') {
                    \PpPhp\Core\MailDefault::stamp($email);
                }
            } catch (\Throwable $e) { /* no DB / no email set — ship unstamped */ }
        }

        // Sign an integrity manifest over the enforcement-critical files, so a
        // client can prove its own licensing code is the code we shipped. Written
        // LAST of the stamping steps, because every stamp above rewrites source
        // that the manifest has to describe. Needs the signing key — an unsigned
        // dev build simply ships without one and enforces nothing.
        if (class_exists(\PpPhp\Core\Licensing\CodeIntegrity::class)) {
            try {
                $keys = new \PpPhp\Core\Licensing\LicenseKeys();
                if ($keys->exists()) {
                    // Write, then CONFIRM the manifest matches the source. The write
                    // is best-effort and has silently left a stale manifest before
                    // (shipping the previous version's hashes with new code); a
                    // confirm-and-retry turns that into a reliable regeneration
                    // rather than a bundle that fails its own check on first boot.
                    for ($attempt = 0; $attempt < 2; $attempt++) {
                        \PpPhp\Core\Licensing\CodeIntegrity::write($keys->secretKey(), $this->root);
                        \PpPhp\Core\Licensing\CodeIntegrity::flush();
                        if ((\PpPhp\Core\Licensing\CodeIntegrity::verify($this->root)['status'] ?? '') === 'ok') break;
                    }
                }
            } catch (\Throwable $e) { /* no key on this box — ship without a manifest */ }
        }

        $filter = function (SplFileInfo $c): bool {
            $name = $c->getFilename();
            $r = $this->rel($c->getPathname());
            if ($c->isDir()) {
                if (in_array($name, $this->excludeAnyDirs, true)) return false;
                if (in_array($r, $this->excludeRootDirs, true)) return false;
                if ($this->isClient()) {
                    foreach ($this->serverOnlyDirs as $p) {
                        if ($r === $p || str_starts_with($r, $p . '/')) return false;
                    }
                }
                foreach ($this->excludePaths as $p) {
                    if ($r === $p || str_starts_with($r, $p . '/')) return false;
                }
                return true;
            }
            if (in_array($name, $this->excludeAnyFiles, true)) return false;
            if (in_array($r, $this->excludeFiles, true)) return false;
            if ($this->isClient() && in_array($r, $this->serverOnlyFiles, true)) return false;
            if ($this->isClient()) {
                foreach ($this->serverOnlyDirs as $p) {
                    if (str_starts_with($r, $p . '/')) return false;
                }
            }
            if (in_array(strtolower((string) pathinfo($name, PATHINFO_EXTENSION)), $this->excludeExtensions, true)) return false;
            foreach ($this->excludePaths as $p) {
                if ($r === $p || str_starts_with($r, $p . '/')) return false;
            }
            return true;
        };

        $it = new RecursiveIteratorIterator(
            new RecursiveCallbackFilterIterator(
                new RecursiveDirectoryIterator($this->root, FilesystemIterator::SKIP_DOTS),
                $filter
            ),
            RecursiveIteratorIterator::LEAVES_ONLY
        );

        $files = [];
        $sourceBytes = 0;
        foreach ($it as $file) {
            /** @var SplFileInfo $file */
            if (!$file->isFile()) continue;
            $files[] = $file->getPathname();
            $sourceBytes += (int) $file->getSize();
        }
        if ($files === []) {
            throw new RuntimeException('No files matched — refusing to build an empty bundle.');
        }
        sort($files);

        @unlink($zipPath);
        $dir = dirname($zipPath);
        if (!is_dir($dir) && !@mkdir($dir, 0775, true) && !is_dir($dir)) {
            throw new RuntimeException("Cannot create output directory: $dir");
        }

        $zip = new ZipArchive();
        if ($zip->open($zipPath, ZipArchive::CREATE | ZipArchive::OVERWRITE) !== true) {
            throw new RuntimeException("Cannot create ZIP at $zipPath");
        }

        // Entries whose CONTENT differs from the file on disk (client manifests
        // with server-only routes stripped). Kept in one place so the ZIP and the
        // hash manifest can never disagree about what shipped.
        $rewritten = $this->rewrittenEntries($files);

        // PROTECTED bundle: swap the critical sources for loader stubs and carry
        // the real code encrypted. Merged into $rewritten for exactly the reason
        // above — the manifest must describe the stub that actually shipped, not
        // the plaintext on this machine.
        $sealed = [];
        if ($this->protect) {
            [$stubs, $sealed] = $this->sealSources();
            $rewritten = $stubs + $rewritten;
        }

        foreach ($files as $abs) {
            $entry = $this->rel($abs);
            if (isset($rewritten[$entry])) {
                $zip->addFromString($entry, $rewritten[$entry]);
            } else {
                $zip->addFile($abs, $entry);
            }
        }
        foreach ($this->emptyDirs as $d) {
            $zip->addEmptyDir($d);
            $zip->addFromString($d . '/.keep', '');
        }
        // The encrypted payloads for a protected bundle. Opaque without the lease
        // key, so shipping them in the clear costs nothing.
        foreach ($sealed as $entry => $blob) {
            $zip->addFromString($entry, $blob);
        }

        $zip->addFromString('DEPLOY.txt', $this->deployNote());
        // release.json is what makes a bundle UPDATABLE rather than just
        // installable: the updater reads the version to detect upgrade vs
        // downgrade, checks each file against its hash before touching the
        // live site, and diffs this manifest against the installed one to know
        // which files a release DELETED.
        $releaseJson = $this->releaseManifest($files, $rewritten);
        $zip->addFromString('release.json', $releaseJson);
        // Ed25519-SIGN the manifest with the vendor key, so a MANUAL "Update
        // Locally" upload is authenticated the same way the OTA feed is — a bundle
        // tampered with after signing can't fake a matching signature, and the
        // updater rejects it. Best-effort: only when a signing key is present (the
        // authority / GUI download has it; a bare-CLI build without keys ships
        // unsigned, which the updater flags rather than trusts blindly).
        try {
            if (class_exists(\PpPhp\Core\Licensing\LicenseKeys::class)
                && function_exists('sodium_crypto_sign_detached')) {
                $keys = new \PpPhp\Core\Licensing\LicenseKeys($this->root);
                if ($keys->exists()) {
                    $zip->addFromString('release.sig',
                        base64_encode(sodium_crypto_sign_detached($releaseJson, $keys->secretKey())));
                }
            }
        } catch (\Throwable $e) { /* no key — ship unsigned; the updater surfaces it */ }

        // Backup bundles carry their role + the main's address, so a fresh
        // install already knows to run the pairing wizard. Under config/, so a
        // later update never overwrites it.
        if ($this->roleFile !== null) {
            $zip->addFromString('config/server-role.json',
                (string) json_encode($this->roleFile, JSON_PRETTY_PRINT | JSON_UNESCAPED_SLASHES));
        }

        $entries = $zip->numFiles;
        if (!$zip->close()) {
            throw new RuntimeException('Failed to finalize the ZIP.');
        }

        $this->assertNoSecrets($zipPath);
        $this->assertIntegrityConsistent($zipPath);

        return [
            'entries'     => $entries,
            'sourceBytes' => $sourceBytes,
            'zipBytes'    => (int) filesize($zipPath),
            // Advisory for client builds: shipped code that still touches a
            // server-only class WITHOUT a class_exists/try guard — the exact shape
            // of bug that 500'd /licensing. Empty is good.
            'clientWarnings' => $this->isClient() ? $this->auditClientRefs($files) : [],
        ];
    }

    /**
     * The short names of the classes that are STRIPPED from a client bundle. Any
     * client-shipped code that names one of these must guard it (class_exists /
     * try-catch), or the page fatals on a customer install where the class is gone.
     *
     * @return array<int,string>
     */
    private function strippedClassNames(): array
    {
        return [
            'LicenseServer', 'LicenseKeys', 'LicenseConfig', 'OwnerDerivation', 'LicenseSync',
            'LicenseMirror', 'ServerRole', 'DbCloner', 'DbReplicator', 'TableDigest', 'CloneSchedule',
            'UpdateHostConfig', 'UpdateReleases', 'DeltaBuilder', 'UpdatePublisher', 'BaselineStore',
            'UpdateHostBrowser', 'SsoIssuer',
            // The stripped generator — so any future client-reachable call is caught
            // at build time instead of fataling in a customer's app.
            'BuildWriter', 'ComponentRenderer',
        ];
    }

    /**
     * Scan the SHIPPED client files for references to a stripped class that are
     * not obviously guarded (no class_exists / no enclosing try on the preceding
     * lines). Advisory — a reviewer confirms each — but it turns the /licensing
     * class of bug from "discovered in production" into "listed at build time".
     *
     * @param array<int,string> $files absolute paths that will ship
     * @return array<int,string> "relative/path.php:line — Class" strings
     */
    private function auditClientRefs(array $files): array
    {
        $classes = $this->strippedClassNames();
        $re = '/\b(' . implode('|', $classes) . ')::|new\s+[\\\\A-Za-z]*\b(' . implode('|', $classes) . ')\s*\(/';
        // A file cut from the client (serverOnlyFiles) OR sealed into an encrypted
        // stub (protectedSources) does not ship its plaintext, so its internal
        // references to a stripped class are not a client-runtime hazard. Skip both
        // — e.g. BuildEngine.php (protected) legitimately calls new BuildWriter().
        $selfCut = array_flip(array_merge($this->serverOnlyFiles, $this->protectedSources));
        $hits = [];
        foreach ($files as $abs) {
            if (substr($abs, -4) !== '.php') continue;
            $rel = $this->rel($abs);
            if (isset($selfCut[$rel])) continue;                 // file itself is cut
            $lines = @file($abs);
            if ($lines === false) continue;
            foreach ($lines as $i => $line) {
                // Skip comment / docblock lines and strip a trailing line-comment,
                // so a class name in a `// …` note or `@param` never trips the scan.
                $trim = ltrim($line);
                if ($trim === '' || $trim[0] === '*'
                    || strncmp($trim, '//', 2) === 0 || strncmp($trim, '/*', 2) === 0 || $trim[0] === '#') continue;
                $code = (string) preg_replace('#//.*$#', '', $line);
                if (!preg_match($re, $code, $m)) continue;
                $cls = ($m[1] ?? '') !== '' ? $m[1] : ($m[2] ?? '');

                // Look back to the enclosing function's start for a guard: a
                // class_exists() naming THIS class (a method-entry guard counts),
                // or a try{ that wraps the reference. Either means it's safe.
                $start = $i;
                for ($j = $i; $j >= max(0, $i - 120); $j--) {
                    if (preg_match('/function\s+\w+\s*\(/', $lines[$j])) { $start = $j; break; }
                    $start = $j;
                }
                $block = implode("\n", array_slice($lines, $start, $i - $start + 1));
                if (preg_match('/class_exists\s*\(\s*[^)]*' . preg_quote($cls, '/') . '/', $block)) continue;
                if (preg_match('/\btry\s*\{/', implode("\n", array_slice($lines, max(0, $i - 4), 5)))) continue;
                $hits[] = $rel . ':' . ($i + 1) . ' — ' . $cls;
            }
        }
        return $hits;
    }

    /**
     * Build release.json — the bundle's identity card.
     *
     * @param array<int,string> $files absolute paths already selected for the ZIP
     */
    private function releaseManifest(array $files, array $rewritten = []): string
    {
        $hashes = [];
        foreach ($files as $abs) {
            $entry = $this->rel($abs);
            // Hash the bytes that actually went into the ZIP, not the source on
            // disk — otherwise the updater's verify step would reject every
            // rewritten manifest as corrupt.
            $hashes[$entry] = isset($rewritten[$entry])
                ? hash('sha256', $rewritten[$entry])
                : hash_file('sha256', $abs);
        }
        ksort($hashes);
        return (string) json_encode([
            'product'   => 'PPPHP',
            'variant'   => $this->variant,
            'version'   => $this->releaseVersion ?? \PpPhp\Core\Version::PLATFORM,
            'codename'  => \PpPhp\Core\Version::CODENAME,
            'minPhp'    => \PpPhp\Core\Version::MIN_PHP,
            'builtAt'   => date('c'),
            'fileCount' => count($hashes),
            'files'     => $hashes,
        ], JSON_PRETTY_PRINT | JSON_UNESCAPED_SLASHES);
    }

    /**
     * Module manifests rewritten for a client bundle.
     *
     * A route pointing at a controller that is not in the bundle would fatal the
     * moment anyone hit it, so the routes leave with the files.
     *
     * @param array<int,string> $files absolute paths selected for the ZIP
     * @return array<string,string> entry path => replacement content
     */
    private function rewrittenEntries(array $files): array
    {
        $out = [];

        // Stamp the chosen release version into the shipped Version.php, so the
        // installed code — not just release.json — reports the new number after
        // an update. Without this a rebuilt bundle looks like "same version" and
        // the updater treats it as a no-op re-apply.
        if ($this->releaseVersion !== null) {
            foreach ($files as $abs) {
                if ($this->rel($abs) !== 'core/Version.php') continue;
                $src = (string) @file_get_contents($abs);
                $new = preg_replace(
                    "/(const PLATFORM = ')[^']*(';)/",
                    '${1}' . $this->releaseVersion . '${2}',
                    $src, 1, $n
                );
                if (is_string($new) && $n === 1) $out['core/Version.php'] = $new;
                break;
            }
        }

        // A CLIENT bundle must never carry the vendor's baked email credential.
        // Even with stamping skipped for client builds, the on-disk source can be
        // stamped by a preceding SERVER build in the same session, so force the
        // shipped MailDefault.php back to an empty DATA const here — client
        // installs configure their own SMTP in Settings → Email.
        if ($this->isClient()) {
            foreach ($files as $abs) {
                if ($this->rel($abs) !== 'core/MailDefault.php') continue;
                $src = (string) @file_get_contents($abs);
                $new = preg_replace("/(const DATA = ')[^']*(';)/", '${1}${2}', $src, 1, $n);
                if (is_string($new) && $n === 1) $out['core/MailDefault.php'] = $new;
                break;
            }
        }

        if (!$this->isClient()) return $out;

        // Bake the owner's free-trial length into the shipped GRACE_PERIOD const so
        // a distributed client can't lengthen its own trial by editing a local DB
        // setting (the client reads ONLY this const — LicenseClient::trialSeconds()
        // ignores the DB override on a client). Best-effort: needs a DB with
        // platform.trial_days set; otherwise the source default (3 days) ships.
        try {
            $days = class_exists(\PpPhp\Core\PlatformSettings::class)
                ? (int) (\PpPhp\Core\PlatformSettings::get('platform.trial_days')['value'] ?? 0) : 0;
            if ($days > 0 && $days <= 3650) {
                foreach ($files as $abs) {
                    if ($this->rel($abs) !== 'core/Licensing/LicenseProtocol.php') continue;
                    $src = (string) @file_get_contents($abs);
                    $new = preg_replace('/(const\s+GRACE_PERIOD\s*=\s*)\d+(\s*;)/',
                        '${1}' . ($days * 86400) . '${2}', $src, 1, $n);
                    if (is_string($new) && $n === 1) $out['core/Licensing/LicenseProtocol.php'] = $new;
                    break;
                }
            }
        } catch (\Throwable $e) { /* no DB — the source default trial ships */ }

        foreach ($files as $abs) {
            $entry = $this->rel($abs);
            if (!preg_match('#^modules/[^/]+/manifest\.json$#', $entry)) continue;

            $json = json_decode((string) @file_get_contents($abs), true);
            if (!is_array($json) || !isset($json['routes']) || !is_array($json['routes'])) continue;

            $kept = [];
            foreach ($json['routes'] as $route) {
                $uri = (string) ($route['uri'] ?? '');
                $drop = false;
                foreach ($this->serverOnlyRoutePrefixes as $prefix) {
                    if ($uri === $prefix || str_starts_with($uri, $prefix . '/')) { $drop = true; break; }
                }
                if (!$drop) $kept[] = $route;
            }
            if (count($kept) === count($json['routes'])) continue;   // nothing to change

            $json['routes'] = $kept;
            $out[$entry] = (string) json_encode($json, JSON_PRETTY_PRINT | JSON_UNESCAPED_SLASHES);
        }
        return $out;
    }

    /** Re-open the finished ZIP and abort (deleting it) if any secret slipped in. */
    private function assertNoSecrets(string $zipPath): void
    {
        $verify = new ZipArchive();
        if ($verify->open($zipPath) !== true) return;
        $allowedEmpty = array_map(static fn ($d) => $d . '/', $this->emptyDirs);
        $leaks = [];
        // High-signal live-credential patterns. Kept deliberately narrow so a false
        // positive can't block a legitimate build; these prefixes are only ever real
        // secrets. NB a base64-encoded secret (e.g. MailDefault::DATA) will NOT match
        // here — that class is caught by the explicit empty-DATA assertion below.
        // A PEM PRIVATE KEY only counts when it carries a real base64 BODY between
        // BEGIN and END — a bare "-----BEGIN PRIVATE KEY-----" is a UI placeholder
        // (e.g. the Apple .p8 paste field), NOT a secret, and must not abort a build.
        $tokenRe = '/\b(re_[A-Za-z0-9]{20,}|sk_(?:live|test)_[A-Za-z0-9]{16,}|AKIA[0-9A-Z]{16}|ghp_[A-Za-z0-9]{36}|xox[baprs]-[A-Za-z0-9-]{10,})\b|-----BEGIN [A-Z ]*PRIVATE KEY-----[\s]*[A-Za-z0-9+\/=]{40}/';
        $scanExt = ['php', 'json', 'js', 'env', 'ini', 'yml', 'yaml', 'txt', 'xml', 'html'];
        for ($i = 0; $i < $verify->numFiles; $i++) {
            $entry = $verify->getNameIndex($i);
            if ($entry === false) continue;
            if (str_ends_with($entry, '/.keep')) continue;
            if (in_array($entry, $allowedEmpty, true)) continue;
            foreach ($this->forbiddenInZip as $bad) {
                if ($entry === $bad || str_starts_with($entry, $bad)) { $leaks[] = $entry; break; }
            }
            // Content scan — text entries only, size-capped so the pass stays cheap.
            $ext = strtolower((string) pathinfo($entry, PATHINFO_EXTENSION));
            if (!in_array($ext, $scanExt, true)) continue;
            $stat = $verify->statIndex($i);
            if (is_array($stat) && ($stat['size'] ?? 0) > 1048576) continue;   // skip >1MB
            $content = $verify->getFromIndex($i);
            if (!is_string($content) || $content === '') continue;
            if (preg_match($tokenRe, $content)) { $leaks[] = $entry . ' (plaintext credential)'; continue; }
            // A CLIENT bundle must never carry a stamped email credential. Check the
            // ACTUAL value, not the source text: MailDefault.php's own stamp() method
            // contains the literal "const DATA = '…'" in its regex, so a naive text
            // match false-positives. Instead decode each base64-looking literal and
            // flag only one that decodes to a real mail config (host/password) — the
            // method's regex fragments never do.
            if ($this->isClient() && $entry === 'core/MailDefault.php'
                && preg_match_all("/'([A-Za-z0-9+\\/=]{24,})'/", $content, $mm)) {
                foreach ($mm[1] as $cand) {
                    $dec = base64_decode($cand, true);
                    if ($dec === false) continue;
                    $j = json_decode($dec, true);
                    if (is_array($j) && (trim((string) ($j['password'] ?? '')) !== '' || trim((string) ($j['host'] ?? '')) !== '')) {
                        $leaks[] = $entry . ' (baked email credential in client bundle)';
                        break;
                    }
                }
            }
        }
        $verify->close();
        if ($leaks !== []) {
            @unlink($zipPath);
            throw new RuntimeException('Security abort — secret/forbidden content would have shipped: ' . implode(', ', array_slice($leaks, 0, 5)));
        }
    }

    /**
     * Re-open the finished ZIP and abort if its integrity manifest does not match
     * the code it ships.
     *
     * A stale or half-written manifest is worse than a leaked secret: the bundle
     * passes every other check, then fails its OWN `CodeIntegrity::verify()` on
     * first boot and a fresh install locks closed. The manifest write is
     * best-effort (`@file_put_contents`, broad catch), so a silent miss must not
     * escape the build. Verifying the ACTUAL zip contents — not the dev tree —
     * catches staleness from any cause.
     */
    private function assertIntegrityConsistent(string $zipPath): void
    {
        if (!class_exists(\PpPhp\Core\Licensing\CodeIntegrity::class)) return;

        $verify = new ZipArchive();
        if ($verify->open($zipPath) !== true) return;

        // No manifest in the bundle → an unsigned dev build; nothing to check.
        $manifestRaw = $verify->getFromName(\PpPhp\Core\Licensing\CodeIntegrity::MANIFEST);
        if ($manifestRaw === false) { $verify->close(); return; }

        $doc = json_decode((string) $manifestRaw, true);
        $files = is_array($doc['body']['files'] ?? null) ? $doc['body']['files'] : null;
        if ($files === null) { $verify->close(); return; }

        $mismatch = [];
        foreach ($files as $rel => $wantHash) {
            $content = $verify->getFromName((string) $rel);
            if ($content === false) { $mismatch[] = $rel . ' (missing)'; continue; }
            if (!hash_equals((string) $wantHash, hash('sha256', $content))) {
                $mismatch[] = (string) $rel;
            }
        }
        $verify->close();

        if ($mismatch !== []) {
            @unlink($zipPath);
            throw new RuntimeException(
                'Integrity abort — the bundle\'s manifest does not match the code it ships ('
                . implode(', ', array_slice($mismatch, 0, 5))
                . '). A fresh install would fail its own integrity check. '
                . 'Rebuild (the manifest is regenerated each build).'
            );
        }
    }

    private function deployNote(): string
    {
        $kind = $this->isClient()
            ? 'PPPHP — fresh install (client edition)'
            : 'PPPHP — fresh install (server edition)';

        return implode("\n", [
            $kind,
            '======================',
            '',
            '1. Upload this bundle to your server and unzip it. Point your domain\'s',
            '   document root EITHER at the bundle folder OR at its public/ sub-folder',
            '   — the installer works with both (there is an install.php in each).',
            '2. Ensure PHP ' . PHP_MAJOR_VERSION . '.' . PHP_MINOR_VERSION . '+ and that these are writable:',
            '       projects/  output/  storage/  config/',
            '3. Open  install.php  in your browser (e.g. https://yourdomain/install.php)',
            '   and follow the installer. It creates config/database.php and sets up the',
            '   database + admin account.',
            '4. If the document root is the bundle folder, the app then lives under  public/ ;',
            '   if it is the public/ folder, the app is served at the domain root. Either works.',
            '',
            $this->isClient()
                ? 'This is the CLIENT edition: it runs and builds applications, and activates'
                  . "\n   against a licence server. It contains no licence-issuing or bundle-building"
                  . "\n   code."
                : 'This is the SERVER edition: it includes the licence authority and the bundle'
                  . "\n   builder. Only the vendor should run it.",
            '',
            // Only a protected bundle carries this requirement, so only a
            // protected bundle mentions it — a note that does not apply is worse
            // than no note at all.
            $this->protect
                ? "LICENCE-PROTECTED BUILD\n"
                  . "-----------------------\n"
                  . "The build engine in this bundle is encrypted and is unlocked by your\n"
                  . "licence. Two practical consequences:\n"
                  . "  * Activate your licence before your first build.\n"
                  . "  * Keep this server able to reach the internet. It collects a fresh key\n"
                  . "    automatically after each update, on its next hourly check-in.\n"
                  . "Applications you have already built are unaffected either way.\n"
                : '',
            'Packaged: ' . date('c'),
            '',
        ]) . "\n";
    }
}
