<?php

declare(strict_types=1);

namespace PpPhp\Core\Build;

/**
 * Asset-slimming — the shared contract that lets a server OMIT static vendor assets
 * from a build and lets the client FILL them from its own copy.
 *
 * A generated app ships ~22 MB of static vendor libraries (bootstrap, icons, fonts,
 * jquery, sweetalert2, …) that are byte-for-byte identical to the ones every PPPHP
 * install already carries in public/assets. Sending them over the wire on every
 * build is pure waste — only the generated PHP and the app-specific assets (theme
 * CSS, pp.js, uploads) are actually unique to the customer's project.
 *
 * The rule is exact and self-verifying: an asset file is OMITTABLE iff a file with
 * the same path under public/assets is byte-identical (same SHA-256). The server
 * skips those from the ZIP and records their paths; the client copies exactly those
 * paths back from its own public/assets after extracting. Nothing is guessed, and
 * because both sides run the same platform version their public/assets match.
 *
 * Ships to clients (the client half — {@see fill()} — runs there). Contains no
 * generator logic; it only ever reads the platform's own public/assets.
 */
final class BuildAssets
{
    /** Only files under this build-relative prefix are ever slimmed. */
    private const PREFIX = 'assets/';

    /** The platform's own shipped asset tree — identical on server and client. */
    public static function platformDir(string $root): string
    {
        return rtrim(str_replace('\\', '/', $root), '/') . '/public/assets';
    }

    /**
     * The platform twin of a build-relative path, or '' if the path is not a
     * slimmable asset path (outside assets/, absolute, or containing "..").
     */
    public static function twin(string $root, string $zipRel): string
    {
        $rel = str_replace('\\', '/', $zipRel);
        if ($rel === '' || $rel[0] === '/' || !str_starts_with($rel, self::PREFIX)) return '';
        if (preg_match('#(^|/)\.\.(/|$)#', $rel)) return '';
        $sub = substr($rel, strlen(self::PREFIX));
        if ($sub === '') return '';
        return self::platformDir($root) . '/' . $sub;
    }

    /**
     * SERVER side: is this built asset byte-identical to the platform's own copy,
     * i.e. safe to omit from the ZIP because the client already has it?
     */
    public static function isShared(string $root, string $zipRel, string $absBuildFile): bool
    {
        return self::sharedHash($root, $zipRel, $absBuildFile) !== null;
    }

    /**
     * SERVER side: if this built asset is byte-identical to the platform's own copy
     * (so it can be omitted from the ZIP and re-filled by the client), return its
     * SHA-256; otherwise null. Returning the hash lets the caller record it for the
     * integrity manifest without hashing the file a second time.
     */
    public static function sharedHash(string $root, string $zipRel, string $absBuildFile): ?string
    {
        $twin = self::twin($root, $zipRel);
        if ($twin === '' || !is_file($twin) || !is_file($absBuildFile)) return null;
        // Cheap size check before the hash — a mismatch is common (theme CSS, pp.js).
        if (@filesize($twin) !== @filesize($absBuildFile)) return null;
        $a = @hash_file('sha256', $twin);
        $b = @hash_file('sha256', $absBuildFile);
        return ($a !== false && $b !== false && hash_equals($a, $b)) ? $a : null;
    }

    /**
     * CLIENT side: materialise the omitted static assets into a freshly-extracted
     * build by copying each recorded path from this install's own public/assets.
     *
     * Every path is validated to be a plain assets/ path inside $outputDir; a copy
     * is skipped if the build already wrote that file (an app-specific asset always
     * wins) or if this install has no such platform file. Returns the count filled.
     *
     * @param list<string> $paths build-relative paths the server omitted
     */
    public static function fill(string $root, string $outputDir, array $paths): int
    {
        $base = rtrim(str_replace('\\', '/', realpath($outputDir) ?: $outputDir), '/');
        if ($base === '') return 0;
        $filled = 0;
        foreach ($paths as $rel) {
            $rel = str_replace('\\', '/', (string) $rel);
            $twin = self::twin($root, $rel);
            if ($twin === '' || !is_file($twin)) continue;
            $dest = $base . '/' . substr($rel, 0); // $rel already begins with assets/
            // Containment: the destination must stay inside the output directory.
            $parent = dirname($dest);
            if (!is_dir($parent) && !@mkdir($parent, 0775, true) && !is_dir($parent)) continue;
            $parentReal = str_replace('\\', '/', realpath($parent) ?: $parent);
            if (!str_starts_with($parentReal . '/', $base . '/')) continue;
            if (is_file($dest)) continue;                 // build already provided it
            if (@copy($twin, $dest)) $filled++;
        }
        return $filled;
    }

    /**
     * CLIENT side integrity check: given a signed {build-relative path: sha256}
     * manifest, return the paths under $outputDir that are MISSING or whose bytes
     * do not match. An empty result means the materialised app is exactly what the
     * server signed — every generated file and every re-filled static asset. A
     * non-empty result means corruption in transit or on disk and the build must
     * be rejected. Bounded work: it hashes only the files the manifest names.
     *
     * @param array<string,string> $manifest
     * @return list<string> offending paths (missing or mismatched), capped
     */
    public static function verify(string $outputDir, array $manifest): array
    {
        $base = rtrim(str_replace('\\', '/', realpath($outputDir) ?: $outputDir), '/');
        if ($base === '') return array_slice(array_keys($manifest), 0, 50);
        $bad = [];
        foreach ($manifest as $rel => $want) {
            $rel = str_replace('\\', '/', (string) $rel);
            if ($rel === '' || $rel[0] === '/' || preg_match('#(^|/)\.\.(/|$)#', $rel)) { $bad[] = $rel; continue; }
            $f = $base . '/' . $rel;
            if (!is_file($f)) { $bad[] = $rel; if (count($bad) >= 50) break; continue; }
            $got = @hash_file('sha256', $f);
            if ($got === false || !hash_equals((string) $want, $got)) { $bad[] = $rel; if (count($bad) >= 50) break; }
        }
        return $bad;
    }
}
