<?php

declare(strict_types=1);

namespace PpPhp\Core\Update;

use RuntimeException;
use ZipArchive;

/**
 * Turns a full release bundle into a tiny DELTA patch against a previous version.
 *
 * The whole point of the online updater's efficiency: a normal release changes a
 * handful of files, so shipping the other ~5,200 unchanged ones every time is
 * pure waste. A delta carries ONLY the files whose sha256 differs from the
 * previous version, plus the complete (signed-by-transport-hash) target manifest
 * so the updater still knows the full picture — what to keep, what to remove.
 *
 * A delta ZIP contains:
 *   • `release.json` — the FULL target manifest, copied verbatim from the new
 *     bundle (every file + hash). The updater trusts it for unchanged files.
 *   • `delta.json`   — {from, to, removed:[…]} so the updater/UI can show intent.
 *   • the file bodies for changed/added entries only.
 *
 * Integrity is anchored end-to-end: the feed carries the delta's sha256 (signed),
 * the client verifies the download against it, and the updater then verifies each
 * file body against the manifest — so a delta cannot smuggle a wrong file in.
 */
final class DeltaBuilder
{
    /**
     * Build a delta from `$fromManifest` (the previous version's {rel:sha} map) to
     * the new full bundle at `$fullBundlePath`, writing the delta ZIP to `$outPath`.
     *
     * @param array<string,string> $fromManifest previous release's file→sha256 map
     * @return array{ok:bool, path:string, size:int, sha256:string, changed:int, removed:int, from:string, to:string}
     */
    public function build(array $fromManifest, string $fullBundlePath, string $outPath): array
    {
        $src = new ZipArchive();
        if ($src->open($fullBundlePath) !== true) {
            throw new RuntimeException('Cannot open the source bundle to build a delta.');
        }
        $rawManifest = $src->getFromName('release.json');
        if ($rawManifest === false) {
            $src->close();
            throw new RuntimeException('The source bundle has no release.json — cannot build a delta.');
        }
        $manifest = json_decode((string) $rawManifest, true);
        if (!is_array($manifest) || !isset($manifest['files']) || !is_array($manifest['files'])) {
            $src->close();
            throw new RuntimeException('The source bundle manifest is malformed.');
        }
        $to   = (string) ($manifest['version'] ?? '');
        $from = self::versionOf($fromManifest, 'unknown');
        $newFiles = $manifest['files'];

        // Files whose content differs (added or changed) go in the delta body.
        $changed = [];
        foreach ($newFiles as $rel => $sha) {
            if (!isset($fromManifest[$rel]) || $fromManifest[$rel] !== $sha) {
                $changed[(string) $rel] = (string) $sha;
            }
        }
        // Files the previous version had but the new one does not → removal list.
        $removed = [];
        foreach (array_keys($fromManifest) as $rel) {
            if ($rel === '__version__') continue;
            if (!isset($newFiles[$rel])) $removed[] = (string) $rel;
        }
        sort($removed);

        $out = new ZipArchive();
        if ($out->open($outPath, ZipArchive::CREATE | ZipArchive::OVERWRITE) !== true) {
            $src->close();
            throw new RuntimeException('Cannot create the delta file.');
        }

        // The full target manifest travels with the delta.
        $out->addFromString('release.json', (string) $rawManifest);
        $out->addFromString('delta.json', (string) json_encode([
            'from'    => $from,
            'to'      => $to,
            'removed' => $removed,
            'changed' => array_keys($changed),
            'builtAt' => date('c'),
        ], JSON_UNESCAPED_SLASHES | JSON_PRETTY_PRINT));

        // Only the changed/added file bodies.
        foreach (array_keys($changed) as $rel) {
            $data = $src->getFromName($rel);
            if ($data === false) {
                $src->close();
                $out->close();
                @unlink($outPath);
                throw new RuntimeException("Source bundle is missing a changed file: {$rel}");
            }
            $out->addFromString($rel, $data);
            if (defined('ZipArchive::CM_DEFLATE')) {
                @$out->setCompressionName($rel, ZipArchive::CM_DEFLATE, 9);
            }
        }
        $src->close();
        $out->close();

        clearstatcache(true, $outPath);
        return [
            'ok'      => true,
            'path'    => $outPath,
            'size'    => (int) filesize($outPath),
            'sha256'  => (string) hash_file('sha256', $outPath),
            'changed' => count($changed),
            'removed' => count($removed),
            'from'    => $from,
            'to'      => $to,
        ];
    }

    /**
     * Read a full bundle's manifest as a {rel:sha256} map, tagged with its version
     * under the reserved `__version__` key so a later delta knows its "from".
     *
     * @return array<string,string>
     */
    public static function manifestOfBundle(string $bundlePath): array
    {
        $zip = new ZipArchive();
        if ($zip->open($bundlePath) !== true) return [];
        $raw = $zip->getFromName('release.json');
        $zip->close();
        if ($raw === false) return [];
        $m = json_decode((string) $raw, true);
        if (!is_array($m) || !isset($m['files']) || !is_array($m['files'])) return [];
        $out = $m['files'];
        $out['__version__'] = (string) ($m['version'] ?? '');
        return $out;
    }

    private static function versionOf(array $manifest, string $default): string
    {
        return (string) ($manifest['__version__'] ?? $default);
    }
}
