<?php
declare(strict_types=1);

namespace App\Support;

final class ReleaseManifest
{
    /** @return array<string, mixed>|null */
    public static function load(): ?array
    {
        $path = app_root_path('config', 'release-manifest.json');
        if (!is_file($path)) {
            return null;
        }
        $decoded = json_decode((string) file_get_contents($path), true);

        return is_array($decoded) ? $decoded : [];
    }

    /** @return array{present:bool,valid:bool,build_id:string,built_at:string,issues:list<string>} */
    public static function inspect(bool $verifyFiles = false, bool $requirePresent = false, bool $verifyInventory = false): array
    {
        $manifest = self::load();
        if ($manifest === null) {
            $issues = $requirePresent ? ['release manifest is required but missing'] : [];
            return ['present' => false, 'valid' => $issues === [], 'build_id' => 'development', 'built_at' => '', 'issues' => $issues];
        }

        $issues = [];
        $buildId = trim((string) ($manifest['build_id'] ?? ''));
        $builtAt = trim((string) ($manifest['built_at'] ?? ''));
        $files = is_array($manifest['files'] ?? null) ? $manifest['files'] : [];
        if ($manifest === [] || $buildId === '' || $files === []) {
            $issues[] = 'release manifest is malformed';
        }
        if ((string) ($manifest['hash_algorithm'] ?? '') !== 'sha256') {
            $issues[] = 'release manifest hash algorithm is invalid';
        }
        if ((string) ($manifest['version'] ?? '') !== app_version()) {
            $issues[] = 'release manifest version does not match VERSION';
        }

        $manifestPaths = [];
        foreach ($files as $relative => $expectedHash) {
            if (!self::canonicalRelativePath($relative) || !is_string($expectedHash) || preg_match('/^[a-f0-9]{64}$/', $expectedHash) !== 1) {
                $issues[] = 'release manifest contains an invalid file entry';
                continue;
            }
            $manifestPaths[] = $relative;
        }

        $inventory = self::inventoryFiles();
        if ($verifyInventory) {
            if ($inventory === []) {
                $issues[] = 'release inventory is missing or malformed';
            } else {
                $expectedPaths = $inventory;
                sort($manifestPaths);
                if ($manifestPaths !== $expectedPaths) $issues[] = 'release manifest does not match the reviewed inventory';
                $actualPaths = self::deploymentFiles();
                $expectedDeployment = $expectedPaths;
                $expectedDeployment[] = 'config/release-manifest.json';
                sort($expectedDeployment);
                if ($actualPaths !== $expectedDeployment) $issues[] = 'release deployment contains missing or extra files';
            }
        }

        if ($verifyFiles) {
            foreach ($files as $relative => $expectedHash) {
                if (!self::canonicalRelativePath($relative) || !is_string($expectedHash) || preg_match('/^[a-f0-9]{64}$/', $expectedHash) !== 1) continue;
                $path = app_root_path(...explode('/', $relative));
                if (!is_file($path)) {
                    $issues[] = 'release file is missing: ' . $relative;
                    continue;
                }
                if (!hash_equals($expectedHash, hash_file('sha256', $path))) {
                    $issues[] = 'release file hash mismatch: ' . $relative;
                }
            }
        }

        return [
            'present' => true,
            'valid' => $issues === [],
            'build_id' => $buildId !== '' ? $buildId : 'invalid',
            'built_at' => $builtAt,
            'issues' => $issues,
        ];
    }

    private static function canonicalRelativePath(mixed $path): bool
    {
        return is_string($path)
            && $path !== ''
            && !str_starts_with($path, '/')
            && !str_contains($path, '\\')
            && preg_match('/[\x00-\x1F\x7F]/', $path) !== 1
            && preg_match('#(^|/)(?:\.|\.\.)(?:/|$)#', $path) !== 1;
    }

    /** @return list<string> */
    private static function inventoryFiles(): array
    {
        $path = app_root_path('config', 'release-inventory.json');
        $decoded = is_file($path) ? json_decode((string) file_get_contents($path), true) : null;
        $files = is_array($decoded) && ($decoded['schema'] ?? null) === 1 && is_array($decoded['files'] ?? null)
            ? array_values(array_map('strval', $decoded['files']))
            : [];
        if ($files === [] || count($files) !== count(array_unique($files))) return [];
        foreach ($files as $file) if (!self::canonicalRelativePath($file)) return [];
        sort($files);
        return $files;
    }

    /** @return list<string> */
    private static function deploymentFiles(): array
    {
        $root = app_root_path();
        $paths = [];
        $iterator = new \RecursiveIteratorIterator(
            new \RecursiveDirectoryIterator($root, \FilesystemIterator::SKIP_DOTS),
            \RecursiveIteratorIterator::SELF_FIRST
        );
        foreach ($iterator as $item) {
            /** @var \SplFileInfo $item */
            $relative = str_replace('\\', '/', substr($item->getPathname(), strlen($root) + 1));
            $top = explode('/', $relative)[0];
            if (str_starts_with($relative, 'docs/superpowers/')) continue;
            if (in_array($top, ['dist', 'tests', 'storage', '.git', '.github', 'vendor', 'node_modules'], true)) {
                if ($relative === 'storage/.htaccess') $paths[] = $relative;
                continue;
            }
            if (in_array($relative, ['.env', 'config/database.local.php', 'config/site.local.php'], true)) continue;
            if ($item->isLink()) {
                $paths[] = $relative . '::symlink';
                continue;
            }
            if ($item->isFile()) $paths[] = $relative;
        }
        sort($paths);
        return $paths;
    }
}
