<?php

declare(strict_types=1);

namespace PpPhp\Core\Build;

use PpPhp\Core\ModuleState;
use PpPhp\Core\ProjectLocator;

/**
 * AUTHORITY side of server-side builds — runs the real generator on a design that
 * a {@see BuildClient} sent over the wire, and returns the generated files.
 *
 * Server-only: this is stripped from client bundles (it invokes BuildEngine, the
 * crown jewel). It stages the received design as a throwaway project, builds it
 * to a temp directory, collects the output as {relative-path: base64}, and
 * deletes both. Nothing about the caller's project persists here.
 *
 * SCAFFOLD — the mechanism is complete and correct; the production hardening
 * surface is marked below (concurrency isolation, per-licence quota, a sandboxed
 * build worker instead of an inline call).
 */
final class BuildStaging
{
    /**
     * Build a design and return a transportable result. When the client passes what
     * it already HAS (a {path: sha256} map from its last build), the reply is a DELTA
     * — only the generated files whose bytes changed, plus a list of files to delete
     * — so an iterate-and-rebuild loop ships a handful of files instead of the whole
     * app. The client verifies the applied tree against the full signed manifest and
     * falls back to a full rebuild on any mismatch, so a delta can never leave a
     * broken app on disk. `$have` is deliberately NOT part of the cache key: the
     * expensive full build is cached once (keyed by design), and the per-client delta
     * is a cheap post-cache filter over it.
     *
     * @param array<string,mixed>  $design  design modules (BuildProtocol::DESIGN_MODULES)
     * @param array<string,string> $custom  {relative-path: base64} custom files
     * @param array<string,mixed>  $options clean/compress/forceInstaller, and 'wm'
     * @param array<string,string> $have    {path: sha256} the client already holds
     * @return array{success:bool,message:string,zip:string,filesGenerated:int,cached:bool,assetsOmitted:list<string>,manifest:array<string,string>,delta:bool,deleted:list<string>}
     */
    public static function run(array $design, array $custom, array $options, array $have = []): array
    {
        $full = self::fullBuild($design, $custom, $options);
        // No prior state to diff against (first build / forced clean), or the owner
        // turned delta builds off → send the whole app.
        if (!$full['success'] || $have === [] || !self::deltaEnabled()) {
            return $full + ['delta' => false, 'deleted' => []];
        }

        // Delta: keep only the packed (generated) files whose hash differs from what
        // the client holds; list files the client has that no longer exist.
        [$deltaZip, $deltaCount] = self::filterZip($full['zip'], $full['manifest'], $have);
        $deleted = array_values(array_diff(array_keys($have), array_keys($full['manifest'])));
        return [
            'success' => true, 'message' => $full['message'],
            'zip' => $deltaZip, 'filesGenerated' => $deltaCount,
            'cached' => $full['cached'], 'assetsOmitted' => $full['assetsOmitted'],
            'manifest' => $full['manifest'], 'delta' => true, 'deleted' => $deleted,
        ];
    }

    /**
     * The full (non-delta) build, cached by design. Shape mirrors run() minus the
     * delta fields.
     *
     * @param array<string,mixed>  $design
     * @param array<string,string> $custom
     * @param array<string,mixed>  $options
     * @return array{success:bool,message:string,zip:string,filesGenerated:int,cached:bool,assetsOmitted:list<string>,manifest:array<string,string>}
     */
    private static function fullBuild(array $design, array $custom, array $options): array
    {
        // Repeat build of an unchanged design → serve the stored ZIP, no codegen.
        $cacheKey = BuildCache::key($design, $custom, $options);
        if ($hit = BuildCache::get($cacheKey)) {
            return ['success' => true, 'message' => 'Build complete (served from cache).',
                    'zip' => $hit['zip'], 'filesGenerated' => $hit['files'], 'cached' => true,
                    'assetsOmitted' => $hit['assets'], 'manifest' => $hit['manifest']];
        }

        $slug   = 'buildsvc_' . bin2hex(random_bytes(6));
        $projDir = ProjectLocator::dir($slug);                 // where ModuleState/BuildEngine read
        $outDir  = rtrim(sys_get_temp_dir(), '/\\') . '/ppbuild_' . bin2hex(random_bytes(6));

        try {
            // 1. Stage the design exactly where BuildEngine::build() will look for it.
            foreach (BuildProtocol::DESIGN_MODULES as $module) {
                ModuleState::save($slug, $module, is_array($design[$module] ?? null) ? $design[$module] : []);
            }

            // 2. Stage custom files (author code / assets the generator copies).
            self::writeCustom($projDir . '/custom', $custom);

            // 3. Run the real generator — the code that never ships to a client.
            $result = (new BuildEngine())->build($slug, [
                'clean'          => (bool) ($options['clean'] ?? true),
                'compress'       => (bool) ($options['compress'] ?? false),
                'forceInstaller' => (bool) ($options['forceInstaller'] ?? false),
                'outputDir'      => $outDir,
            ]);

            if (!$result->success) {
                return ['success' => false, 'message' => $result->message, 'zip' => '',
                        'filesGenerated' => 0, 'cached' => false, 'assetsOmitted' => [], 'manifest' => []];
            }

            // 4. Stamp the per-licence watermark into the generated output (piracy
            //    traceback — a leaked build carries the fingerprint of its licence).
            self::stampWatermark($outDir, (string) ($options['wm'] ?? ''));

            // 5. Pack the output into one signed-once ZIP, OMITTING static vendor
            //    assets the client already ships (asset-slimming), and build the
            //    integrity manifest the client verifies the finished app against.
            $packed = self::zipDir($outDir);
            BuildCache::put($cacheKey, $packed['zip'], $packed['count'], $packed['omitted'], $packed['manifest']);
            return ['success' => true, 'message' => $result->message,
                    'zip' => $packed['zip'], 'filesGenerated' => $packed['count'], 'cached' => false,
                    'assetsOmitted' => $packed['omitted'], 'manifest' => $packed['manifest']];
        } finally {
            // 6. Leave nothing behind — the caller's design never persists here.
            self::rmrf($projDir);
            self::rmrf($outDir);
        }
    }

    /**
     * Embed a per-licence watermark in the generated app so a leaked build can be
     * traced to the licence it was built under. The fingerprint is derived from the
     * caller's instance id (see BuildApiController), never the id itself.
     *
     * Two carriers, so deleting one does not scrub it: a dedicated `.ppbuild.json`
     * marker (a dotfile, not web-served), and the same fingerprint inlined as a
     * harmless comment right after the opening tag of a couple of generated PHP
     * files (always-valid PHP — never reaches page output).
     */
    private static function stampWatermark(string $outDir, string $fp): void
    {
        if ($fp === '' || !is_dir($outDir)) return;
        // Deterministic on purpose: the fingerprint identifies the licence (the whole
        // point), while a wall-clock timestamp would only make every build differ and
        // bloat deltas. Build timing lives server-side in BuildMeter.
        @file_put_contents($outDir . '/.ppbuild.json',
            json_encode(['fp' => $fp], JSON_UNESCAPED_SLASHES));

        $marker = '/* ppb:' . $fp . ' */';
        $done = 0;
        foreach (['index.php', 'config/app.php', 'config/config.php', 'app.php'] as $rel) {
            if ($done >= 2) break;
            $f = $outDir . '/' . $rel;
            if (!is_file($f)) continue;
            $src = (string) @file_get_contents($f);
            if (!str_starts_with($src, '<?php') || str_contains($src, '/* ppb:')) continue;
            $patched = preg_replace('/^<\?php/', '<?php ' . $marker, $src, 1);
            if (is_string($patched) && @file_put_contents($f, $patched) !== false) $done++;
        }
    }

    /** @param array<string,string> $custom */
    private static function writeCustom(string $dir, array $custom): void
    {
        $base = rtrim($dir, '/');
        foreach ($custom as $rel => $b64) {
            $rel = str_replace('\\', '/', (string) $rel);
            if ($rel === '' || $rel[0] === '/' || preg_match('#(^|/)\.\.(/|$)#', $rel)) continue;
            $dest = $base . '/' . $rel;
            $parent = dirname($dest);
            if (!is_dir($parent) && !@mkdir($parent, 0775, true) && !is_dir($parent)) continue;
            if (!str_starts_with(str_replace('\\', '/', realpath($parent) ?: $parent) . '/', $base . '/')) continue;
            $bytes = base64_decode((string) $b64, true);
            if ($bytes !== false) @file_put_contents($dest, $bytes);
        }
    }

    /**
     * Pack a built directory into a base64 ZIP, bounded by
     * BuildProtocol::MAX_BUILD_BYTES. Static vendor assets that are byte-identical
     * to the platform's own public/assets are OMITTED (the client re-materialises
     * them from its local copy — see {@see BuildAssets}).
     *
     * Also produces the INTEGRITY MANIFEST: a {build-relative path: sha256} map for
     * every file in the finished app — the generated files packed here plus the
     * omitted static assets (whose hash we already computed to decide the omit). The
     * client verifies the whole materialised tree against this after extract+fill,
     * so any corruption in transit or on disk is caught, not silently shipped.
     *
     * @return array{zip:string,count:int,omitted:list<string>,manifest:array<string,string>}
     */
    private static function zipDir(string $dir): array
    {
        $empty = ['zip' => '', 'count' => 0, 'omitted' => [], 'manifest' => []];
        if (!is_dir($dir) || !class_exists(\ZipArchive::class)) return $empty;
        $root = defined('BASE_PATH') ? (string) constant('BASE_PATH') : getcwd();
        $slim = self::slimEnabled();
        $tmp = rtrim(sys_get_temp_dir(), '/\\') . '/ppbuildzip_' . bin2hex(random_bytes(6)) . '.zip';

        $zip = new \ZipArchive();
        if ($zip->open($tmp, \ZipArchive::CREATE | \ZipArchive::OVERWRITE) !== true) return $empty;
        $count = 0; $total = 0; $omitted = []; $manifest = [];
        $it = new \RecursiveIteratorIterator(
            new \RecursiveDirectoryIterator($dir, \FilesystemIterator::SKIP_DOTS),
            \RecursiveIteratorIterator::SELF_FIRST
        );
        // getSubPathname() is the entry's path RELATIVE to $dir — symlink-agnostic,
        // so it is correct even when sys_get_temp_dir() resolves through a symlink.
        foreach ($it as $f) {
            $rel = str_replace('\\', '/', $it->getSubPathname());
            if ($rel === '') continue;
            if ($f->isDir()) { $zip->addEmptyDir($rel); continue; }
            // Slim: skip any vendor asset the client already carries, unchanged —
            // but still record its hash in the manifest (the app HAS this file).
            // Owner can turn slimming off to always ship a self-contained bundle.
            if ($slim) {
                $sharedHash = BuildAssets::sharedHash($root, $rel, $f->getPathname());
                if ($sharedHash !== null) { $omitted[] = $rel; $manifest[$rel] = $sharedHash; continue; }
            }
            $total += (int) $f->getSize();
            if ($total > BuildProtocol::MAX_BUILD_BYTES) break;
            $zip->addFile($f->getPathname(), $rel);
            $h = @hash_file('sha256', $f->getPathname());
            if ($h !== false) $manifest[$rel] = $h;
            $count++;
        }
        $zip->close();

        $bytes = (string) @file_get_contents($tmp);
        @unlink($tmp);
        return ['zip' => $bytes !== '' ? base64_encode($bytes) : '',
                'count' => $count, 'omitted' => $omitted, 'manifest' => $manifest];
    }

    /**
     * Produce a DELTA zip from a full build's base64 zip: keep only the entries whose
     * manifest hash differs from what the client already holds ($have). Directory
     * entries are preserved so nested paths still extract. Returns [base64Zip, count].
     *
     * @param array<string,string> $manifest {path: sha256} of the full build
     * @param array<string,string> $have     {path: sha256} the client holds
     * @return array{0:string,1:int}
     */
    private static function filterZip(string $fullZipB64, array $manifest, array $have): array
    {
        if ($fullZipB64 === '' || !class_exists(\ZipArchive::class)) return ['', 0];
        $bytes = base64_decode($fullZipB64, true);
        if ($bytes === false) return ['', 0];

        $srcPath = rtrim(sys_get_temp_dir(), '/\\') . '/ppdsrc_' . bin2hex(random_bytes(6)) . '.zip';
        $dstPath = rtrim(sys_get_temp_dir(), '/\\') . '/ppddst_' . bin2hex(random_bytes(6)) . '.zip';
        if (@file_put_contents($srcPath, $bytes) === false) return ['', 0];

        $src = new \ZipArchive();
        $dst = new \ZipArchive();
        if ($src->open($srcPath) !== true || $dst->open($dstPath, \ZipArchive::CREATE | \ZipArchive::OVERWRITE) !== true) {
            @unlink($srcPath); @unlink($dstPath);
            return ['', 0];
        }
        $count = 0;
        for ($i = 0; $i < $src->numFiles; $i++) {
            $name = str_replace('\\', '/', (string) $src->getNameIndex($i));
            if ($name === '') continue;
            if (str_ends_with($name, '/')) { $dst->addEmptyDir(rtrim($name, '/')); continue; }
            // Unchanged on the client → skip; changed or new → include.
            if (isset($have[$name]) && isset($manifest[$name]) && hash_equals($manifest[$name], $have[$name])) {
                continue;
            }
            $content = $src->getFromIndex($i);
            if ($content === false) continue;
            $dst->addFromString($name, $content);
            $count++;
        }
        $src->close();
        $dst->close();

        $out = (string) @file_get_contents($dstPath);
        @unlink($srcPath); @unlink($dstPath);
        return [$out !== '' ? base64_encode($out) : '', $count];
    }

    /** Owner tuning: are delta rebuilds on? (default yes). */
    private static function deltaEnabled(): bool
    {
        if (class_exists(BuildMeter::class)) { try { return BuildMeter::deltaBuilds(); } catch (\Throwable $e) {} }
        return true;
    }

    /** Owner tuning: is asset-slimming on? (default yes). */
    private static function slimEnabled(): bool
    {
        if (class_exists(BuildMeter::class)) { try { return BuildMeter::assetSlimming(); } catch (\Throwable $e) {} }
        return true;
    }

    private static function rmrf(string $dir): void
    {
        if ($dir === '' || !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);
    }
}
