<?php

declare(strict_types=1);

namespace App\Uploads\Services;

use RuntimeException;
use ZipArchive;

final class ArchiveInspectorService
{
    private const MAX_DEPTH = 5;
    private const MAX_FILES = 5000;
    private const MAX_UNCOMPRESSED_BYTES = 524288000; // 500 MB
    private const MAX_RATIO = 50.0;

    /** @var array<int, string> */
    private const BLOCKED_EXTENSIONS = [
        'php',
        'exe',
        'sh',
        'bat',
        'js',
        'ps1',
        'cmd',
        'vbs',
        'jar',
        'elf',
    ];

    public function inspect(string $archivePath): \App\Uploads\Services\InspectionResult
    {
        $zip = new ZipArchive();
        $opened = $zip->open($archivePath);

        if ($opened !== true) {
            throw new RuntimeException('Unable to read archive metadata.');
        }

        $files = 0;
        $maxDepth = 0;
        $totalUncompressed = 0;
        $totalCompressed = 0;

        for ($index = 0; $index < $zip->numFiles; $index++) {
            $stat = $zip->statIndex($index);
            if (! is_array($stat)) {
                continue;
            }

            $entryName = (string) ($stat['name'] ?? '');
            $uncompressedSize = (int) ($stat['size'] ?? 0);
            $compressedSize = (int) ($stat['comp_size'] ?? 0);
            $isDirectory = str_ends_with($entryName, '/');

            $pathCheck = $this->validatePath($entryName);
            if ($pathCheck !== null) {
                return $this->closeAndFail($zip, $pathCheck, $files, $maxDepth, $totalUncompressed, $totalCompressed);
            }

            if ($this->isSymlink($zip, $index)) {
                return $this->closeAndFail($zip, 'Archive contains a symlink entry.', $files, $maxDepth, $totalUncompressed, $totalCompressed);
            }

            $depth = $this->depthForEntry($entryName, $isDirectory);
            $maxDepth = max($maxDepth, $depth);

            if ($maxDepth > self::MAX_DEPTH) {
                return $this->closeAndFail($zip, 'Archive directory depth exceeds 5.', $files, $maxDepth, $totalUncompressed, $totalCompressed);
            }

            if (! $isDirectory) {
                $files++;

                if ($files > self::MAX_FILES) {
                    return $this->closeAndFail($zip, 'Archive file count exceeds 5000.', $files, $maxDepth, $totalUncompressed, $totalCompressed);
                }

                $extension = strtolower((string) pathinfo($entryName, PATHINFO_EXTENSION));
                if (in_array($extension, self::BLOCKED_EXTENSIONS, true)) {
                    return $this->closeAndFail($zip, 'Archive contains blocked executable/script file.', $files, $maxDepth, $totalUncompressed, $totalCompressed);
                }

                $totalUncompressed += max(0, $uncompressedSize);
                $totalCompressed += max(0, $compressedSize);

                if ($totalUncompressed > self::MAX_UNCOMPRESSED_BYTES) {
                    return $this->closeAndFail($zip, 'Archive uncompressed size exceeds 500 MB.', $files, $maxDepth, $totalUncompressed, $totalCompressed);
                }

                $ratio = $this->compressionRatio($totalUncompressed, $totalCompressed);
                if ($ratio > self::MAX_RATIO) {
                    return $this->closeAndFail($zip, 'Archive compression ratio exceeds safety threshold.', $files, $maxDepth, $totalUncompressed, $totalCompressed);
                }
            }
        }

        $stats = $this->stats($files, $maxDepth, $totalUncompressed, $totalCompressed);
        $zip->close();

        return \App\Uploads\Services\InspectionResult::pass($stats);
    }

    private function validatePath(string $entryName): ?string
    {
        $normalized = str_replace('\\', '/', $entryName);

        if ($normalized === '' || str_contains($normalized, "\0")) {
            return 'Archive contains invalid entry path.';
        }

        if (
            strlen($entryName) >= 3
            && ctype_alpha($entryName[0])
            && $entryName[1] === ':'
            && in_array($entryName[2], ['\\', '/'], true)
        ) {
            return 'Archive entry contains drive-letter absolute path.';
        }

        if (str_starts_with($normalized, '/') || str_starts_with($normalized, '\\')) {
            return 'Archive entry contains absolute path.';
        }

        if (str_contains($entryName, '../') || str_contains($entryName, '..\\') || str_contains($normalized, '../')) {
            return 'Archive entry contains path traversal sequence.';
        }

        $segments = array_filter(explode('/', trim($normalized, '/')), static fn (string $segment): bool => $segment !== '');
        foreach ($segments as $segment) {
            if ($segment === '..') {
                return 'Archive entry contains parent traversal segment.';
            }
        }

        return null;
    }

    private function isSymlink(ZipArchive $zip, int $index): bool
    {
        $attributes = 0;
        $opsys = 0;

        if (! $zip->getExternalAttributesIndex($index, $opsys, $attributes)) {
            return false;
        }

        if ($opsys !== ZipArchive::OPSYS_UNIX) {
            return false;
        }

        $mode = ($attributes >> 16) & 0xF000;

        return $mode === 0xA000;
    }

    private function depthForEntry(string $entryName, bool $isDirectory): int
    {
        $normalized = trim(str_replace('\\', '/', $entryName), '/');
        if ($normalized === '') {
            return 0;
        }

        $segments = array_values(array_filter(explode('/', $normalized), static fn (string $segment): bool => $segment !== ''));
        if ($segments === []) {
            return 0;
        }

        return max(0, count($segments) - ($isDirectory ? 0 : 1));
    }

    private function compressionRatio(int $uncompressed, int $compressed): float
    {
        if ($uncompressed <= 0) {
            return 0.0;
        }

        if ($compressed <= 0) {
            return (float) $uncompressed;
        }

        return $uncompressed / $compressed;
    }

    /**
     * @return array{files:int,depth:int,size:int,ratio:float}
     */
    private function stats(int $files, int $depth, int $size, int $compressed): array
    {
        return [
            'files' => $files,
            'depth' => $depth,
            'size' => $size,
            'ratio' => $this->compressionRatio($size, $compressed),
        ];
    }

    private function closeAndFail(ZipArchive $zip, string $reason, int $files, int $depth, int $size, int $compressed): \App\Uploads\Services\InspectionResult
    {
        $stats = $this->stats($files, $depth, $size, $compressed);
        $zip->close();

        return \App\Uploads\Services\InspectionResult::fail($reason, $stats);
    }
}
