<?php

declare(strict_types=1);

namespace PpPhp\Core\Build;

/**
 * A content-addressed cache of build results, keyed by a hash of the design.
 *
 * Server-only. Most of a build server's load is REPEAT builds — a customer clicks
 * Build again without changing anything. When the design (+ custom files + options
 * + platform version) hashes to a key we've already built, we return the stored
 * ZIP instead of running the generator again. That turns the common case from
 * "seconds of CPU" into "a disk read", so a single server absorbs far more
 * customers before it needs a second worker.
 */
final class BuildCache
{
    private const TTL_DAYS = 14;
    private const MAX_ENTRIES = 400;

    /** Owner-tunable retention (falls back to the const). */
    private static function ttlDays(): int
    {
        if (class_exists(BuildMeter::class)) { try { return BuildMeter::cacheDays(); } catch (\Throwable $e) {} }
        return self::TTL_DAYS;
    }

    private static function maxEntries(): int
    {
        if (class_exists(BuildMeter::class)) { try { return BuildMeter::cacheMax(); } catch (\Throwable $e) {} }
        return self::MAX_ENTRIES;
    }

    private static function dir(): string
    {
        $base = defined('BASE_PATH') ? (string) constant('BASE_PATH') : getcwd();
        $d = rtrim($base, '/') . '/storage/build-cache';
        if (!is_dir($d)) @mkdir($d, 0775, true);
        return $d;
    }

    /**
     * A stable key for a build request. Same design + custom + options + platform
     * version => same key => same output.
     *
     * @param array<string,mixed>  $design
     * @param array<string,string> $custom
     * @param array<string,mixed>  $options
     */
    public static function key(array $design, array $custom, array $options): string
    {
        $version = class_exists(\PpPhp\Core\Version::class) ? \PpPhp\Core\Version::PLATFORM : '0';
        // Only options that change the OUTPUT belong in the key. `clean` is a
        // client-side directory concern (the server always builds to a fresh temp
        // dir) — keying on it would split the cache and, worse, make a delta rebuild
        // miss the identical full build it should diff against. Whitelist the rest.
        $keyed = [];
        foreach (['compress', 'forceInstaller', 'wm'] as $k) {
            if (array_key_exists($k, $options)) $keyed[$k] = $options[$k];
        }
        // Canonicalise so key order never changes the hash.
        $canon = static function ($v) use (&$canon) {
            if (is_array($v)) { ksort($v); return array_map($canon, $v); }
            return $v;
        };
        $payload = json_encode([$canon($design), $canon($custom), $canon($keyed), $version], JSON_UNESCAPED_SLASHES);
        return hash('sha256', (string) $payload);
    }

    /**
     * A cached build for $key, or null. Returns the base64 ZIP, file count, and the
     * list of static asset paths that were omitted (which the client re-fills).
     *
     * @return array{zip:string,files:int,assets:list<string>,manifest:array<string,string>}|null
     */
    public static function get(string $key): ?array
    {
        try {
            $zipFile = self::dir() . '/' . $key . '.zip';
            $metaFile = self::dir() . '/' . $key . '.json';
            if (!is_file($zipFile) || !is_file($metaFile)) return null;
            if ((time() - (int) @filemtime($zipFile)) > self::ttlDays() * 86400) {
                @unlink($zipFile); @unlink($metaFile);
                return null;
            }
            @touch($zipFile);   // LRU: keep hot entries fresh
            $meta = json_decode((string) @file_get_contents($metaFile), true);
            $zip = (string) @file_get_contents($zipFile);
            if ($zip === '') return null;
            $assets = is_array($meta) && is_array($meta['assets'] ?? null) ? array_values($meta['assets']) : [];
            $manifest = is_array($meta) && is_array($meta['manifest'] ?? null) ? $meta['manifest'] : [];
            return ['zip' => base64_encode($zip),
                    'files' => (int) (is_array($meta) ? ($meta['files'] ?? 0) : 0),
                    'assets' => $assets, 'manifest' => $manifest];
        } catch (\Throwable $e) {
            return null;
        }
    }

    /**
     * Store a build result. $zipB64 is the base64 ZIP; $files is the file count;
     * $assets is the list of omitted static-asset paths the client must re-fill;
     * $manifest is the {path: sha256} integrity map of the whole finished app.
     *
     * @param list<string>          $assets
     * @param array<string,string>  $manifest
     */
    public static function put(string $key, string $zipB64, int $files, array $assets = [], array $manifest = []): void
    {
        try {
            $bytes = base64_decode($zipB64, true);
            if ($bytes === false) return;
            $dir = self::dir();
            @file_put_contents($dir . '/' . $key . '.zip', $bytes, LOCK_EX);
            @file_put_contents($dir . '/' . $key . '.json',
                json_encode(['files' => $files, 'assets' => array_values($assets),
                             'manifest' => $manifest, 'at' => time()],
                    JSON_UNESCAPED_SLASHES), LOCK_EX);
            if (random_int(1, 20) === 1) self::prune();
        } catch (\Throwable $e) { /* best effort */ }
    }

    /** Keep the cache bounded — drop the oldest beyond MAX_ENTRIES. */
    private static function prune(): void
    {
        try {
            $zips = glob(self::dir() . '/*.zip') ?: [];
            if (count($zips) <= self::maxEntries()) return;
            usort($zips, static fn ($a, $b) => (int) @filemtime($a) <=> (int) @filemtime($b));
            foreach (array_slice($zips, 0, count($zips) - self::maxEntries()) as $old) {
                @unlink($old);
                @unlink(preg_replace('/\.zip$/', '.json', $old) ?? '');
            }
        } catch (\Throwable $e) { /* best effort */ }
    }
}
