<?php

namespace App\Synapse;

use Illuminate\Support\Facades\File;
use Illuminate\Support\Str;
use RuntimeException;
use ZipArchive;

/**
 * Recursively expands a zip archive (and any zips nested inside it) into its member files,
 * extracted to a temp directory, so callers can run each member through the same per-file
 * pipeline as a standalone attachment/document. Shared by the evidence-extraction jobs and
 * the `evidence:test` console command so a zipped DSAR bundle is handled identically in both.
 */
class ZipExpander
{
    private const MAX_DEPTH = 5;

    /** @var string[] extraction directories created so far, removed by cleanup() */
    private array $extractionDirs = [];

    /** @var string[] loose temp files (materialised zip bytes) created so far, removed by cleanup() */
    private array $tempFiles = [];

    public function isZip(string $mime, string $filename): bool
    {
        return in_array($mime, ['application/zip', 'application/x-zip-compressed', 'application/x-zip'], true)
            || str_ends_with(strtolower($filename), '.zip');
    }

    /**
     * Writes raw zip bytes (e.g. read from a Storage disk) to a local temp file, then expands
     * it. Use this when the archive isn't already sitting on the local filesystem.
     *
     * @return array<int, array{path: string, filename: string}>
     */
    public function expandFromContents(string $contents): array
    {
        $tmpPath = storage_path('app/tmp/evidence-zip-src-' . Str::random(20) . '.zip');
        File::ensureDirectoryExists(dirname($tmpPath));
        File::put($tmpPath, $contents);
        $this->tempFiles[] = $tmpPath;

        return $this->expand($tmpPath);
    }

    /**
     * @return array<int, array{path: string, filename: string}> member files, recursively
     *                                                             flattened out of nested zips
     *
     * @throws RuntimeException if the top-level archive itself can't be opened (corrupt,
     *                           encrypted, not actually a zip). Archives nested inside it that
     *                           fail to open are skipped instead, so one bad inner file doesn't
     *                           void the rest of the bundle.
     */
    public function expand(string $zipPath, int $depth = 0): array
    {
        if ($depth >= self::MAX_DEPTH) {
            return [];
        }

        $zip    = new ZipArchive();
        $opened = $zip->open($zipPath);

        if ($opened !== true) {
            if ($depth === 0) {
                throw new RuntimeException("Could not open zip archive '{$zipPath}' (ZipArchive error code {$opened}).");
            }
            return [];
        }

        $extractTo = storage_path('app/tmp/evidence-zip-' . Str::random(20));
        File::ensureDirectoryExists($extractTo);
        $this->extractionDirs[] = $extractTo;

        $zip->extractTo($extractTo);
        $zip->close();

        $members = [];
        foreach (File::allFiles($extractTo) as $file) {
            // Skip archive-manager junk (macOS resource forks, hidden metadata files).
            if (str_starts_with($file->getFilename(), '.') || str_contains($file->getPath(), '__MACOSX')) {
                continue;
            }

            if (strtolower($file->getExtension()) === 'zip') {
                $members = array_merge($members, $this->expand($file->getPathname(), $depth + 1));
            } else {
                $members[] = ['path' => $file->getPathname(), 'filename' => $file->getFilename()];
            }
        }

        return $members;
    }

    /** Deletes every extraction dir/temp file created by expand()/expandFromContents(). */
    public function cleanup(): void
    {
        foreach ($this->extractionDirs as $dir) {
            File::deleteDirectory($dir);
        }
        foreach ($this->tempFiles as $file) {
            File::delete($file);
        }
        $this->extractionDirs = [];
        $this->tempFiles      = [];
    }
}
