<?php

declare(strict_types=1);

/**
 * 本文件属于 DBErp 进销存系统。
 *
 * 版权所有 © 2026 北京珑大钜商科技有限公司。保留所有权利。
 *
 * 未经官方授权，不得删除、篡改或隐藏本文件中的版权声明、产品标识与来源信息。
 * 商业授权、官方更新与技术支持服务，请以珑大官方支持中心记录为准。
 *
 * @link https://support.loongdom.cn
 * @see  https://www.dberp.com.cn
 */

namespace App\Services\SupportCenter;

use App\Exceptions\SupportCenter\HostSystemUpdateStagingException;
use RecursiveIteratorIterator;
use SplFileInfo;
use ZipArchive;

/**
 * 将已校验更新包（zip / tar.gz）安全解压到 staging 目录（Zip Slip 防护）。
 */
final class HostSystemUpdateArchiveExtractor
{
    public function __construct(
        private readonly HostSystemUpdateProtectedPathGuard $protectedPathGuard,
    ) {}

    /**
     * @return list<string> 解压后的相对路径（相对 staging 根目录）
     */
    public function extractToStaging(string $archivePath, string $stagingDirectory): array
    {
        $entries = $this->listExtractableEntries($archivePath);
        $extracted = [];
        $cursor = 0;

        while ($cursor < count($entries)) {
            $batch = $this->extractEntryBatch($archivePath, $stagingDirectory, $entries, $cursor, count($entries));
            $extracted = array_merge($extracted, $batch['extracted']);
            $cursor = $batch['next_cursor'];
        }

        return array_values(array_unique($extracted));
    }

    /**
     * @return list<string> 稳定排序的压缩包内文件相对路径
     */
    public function listExtractableEntries(string $archivePath): array
    {
        if (! is_file($archivePath)) {
            throw HostSystemUpdateStagingException::packageFileMissing();
        }

        $format = HostSystemUpdatePackageArchiveFormat::detectFromFile($archivePath);

        if (! HostSystemUpdatePackageArchiveFormat::isSupported($format)) {
            throw HostSystemUpdateStagingException::unsupportedArchiveFormat($format);
        }

        $entries = match ($format) {
            HostSystemUpdatePackageArchiveFormat::TAR_GZ => $this->listTarGzEntries($archivePath),
            default => $this->listZipEntries($archivePath),
        };

        sort($entries, SORT_STRING);

        return array_values(array_unique($entries));
    }

    /**
     * @param  list<string>  $entries
     * @return array{extracted: list<string>, next_cursor: int}
     */
    /**
     * @param  array<string, int>|null  $zipEntryIndexByPath  首次 listing 生成的路径→索引（ZIP 分批解压用，避免每批全表扫描）
     */
    public function extractEntryBatch(
        string $archivePath,
        string $stagingDirectory,
        array $entries,
        int $cursor,
        int $batchSize,
        ?array $zipEntryIndexByPath = null,
    ): array {
        if (! is_file($archivePath)) {
            throw HostSystemUpdateStagingException::packageFileMissing();
        }

        HostSystemUpdateWorkspaceStorage::ensureDirectoryExists($stagingDirectory);

        $format = HostSystemUpdatePackageArchiveFormat::detectFromFile($archivePath);

        if (! HostSystemUpdatePackageArchiveFormat::isSupported($format)) {
            throw HostSystemUpdateStagingException::unsupportedArchiveFormat($format);
        }

        $slice = array_slice($entries, $cursor, max(1, $batchSize));

        if ($slice === []) {
            return ['extracted' => [], 'next_cursor' => $cursor];
        }

        $extracted = match ($format) {
            HostSystemUpdatePackageArchiveFormat::TAR_GZ => $this->extractTarGzEntries($archivePath, $stagingDirectory, $slice),
            default => $this->extractZipEntries($archivePath, $stagingDirectory, $slice, $zipEntryIndexByPath),
        };

        return [
            'extracted' => $extracted,
            'next_cursor' => $cursor + count($slice),
        ];
    }

    /**
     * @return array<string, int> 相对路径 → ZipArchive 内索引
     */
    public function buildZipEntryIndexMap(string $zipPath): array
    {
        if (! is_file($zipPath)) {
            throw HostSystemUpdateStagingException::packageFileMissing();
        }

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

        if ($opened !== true) {
            throw HostSystemUpdateStagingException::archiveOpenFailed();
        }

        $indexByPath = [];

        try {
            for ($index = 0; $index < $zip->numFiles; $index++) {
                $name = $zip->getNameIndex($index);

                if (! is_string($name) || trim($name) === '' || str_ends_with(str_replace('\\', '/', $name), '/')) {
                    continue;
                }

                $relativeEntry = $this->protectedPathGuard->assertSafeArchiveEntryName($name);
                $indexByPath[$relativeEntry] = $index;
            }
        } finally {
            $zip->close();
        }

        return $indexByPath;
    }

    /**
     * @return list<string>
     */
    private function listZipEntries(string $zipPath): array
    {
        $entries = array_keys($this->buildZipEntryIndexMap($zipPath));
        sort($entries, SORT_STRING);

        return array_values(array_unique($entries));
    }

    /**
     * @param  list<string>  $entries
     * @param  array<string, int>|null  $zipEntryIndexByPath
     * @return list<string>
     */
    private function extractZipEntries(
        string $zipPath,
        string $stagingDirectory,
        array $entries,
        ?array $zipEntryIndexByPath = null,
    ): array {
        $zip = new ZipArchive;
        $opened = $zip->open($zipPath);

        if ($opened !== true) {
            throw HostSystemUpdateStagingException::archiveOpenFailed();
        }

        $indexByPath = $zipEntryIndexByPath ?? $this->buildZipEntryIndexMapFromOpenZip($zip);
        $extracted = [];

        try {
            foreach ($entries as $relativeEntry) {
                $index = $indexByPath[$relativeEntry] ?? null;

                if ($index === null) {
                    throw HostSystemUpdateStagingException::unsafeArchiveEntry($relativeEntry);
                }

                $name = $zip->getNameIndex($index);

                if (! is_string($name) || trim($name) === '') {
                    throw HostSystemUpdateStagingException::unsafeArchiveEntry($relativeEntry);
                }

                $normalizedEntry = $this->protectedPathGuard->assertSafeArchiveEntryName($name);

                if ($normalizedEntry !== $relativeEntry) {
                    throw HostSystemUpdateStagingException::unsafeArchiveEntry($name);
                }

                $targetPath = $stagingDirectory.DIRECTORY_SEPARATOR.str_replace('/', DIRECTORY_SEPARATOR, $relativeEntry);
                $targetDirectory = dirname($targetPath);

                HostSystemUpdateWorkspaceStorage::ensureDirectoryExists($targetDirectory);
                $this->protectedPathGuard->assertPathWithinDirectory($targetPath, $stagingDirectory);

                $contents = $zip->getFromIndex($index);

                if ($contents === false) {
                    throw HostSystemUpdateStagingException::unsafeArchiveEntry($name);
                }

                if (file_put_contents($targetPath, $contents) === false) {
                    throw HostSystemUpdateStagingException::unsafeArchiveEntry($name);
                }

                $extracted[] = $relativeEntry;
            }
        } finally {
            $zip->close();
        }

        return array_values(array_unique($extracted));
    }

    /**
     * @return array<string, int>
     */
    private function buildZipEntryIndexMapFromOpenZip(ZipArchive $zip): array
    {
        $indexByPath = [];

        for ($index = 0; $index < $zip->numFiles; $index++) {
            $name = $zip->getNameIndex($index);

            if (! is_string($name) || trim($name) === '' || str_ends_with(str_replace('\\', '/', $name), '/')) {
                continue;
            }

            $relativeEntry = $this->protectedPathGuard->assertSafeArchiveEntryName($name);
            $indexByPath[$relativeEntry] = $index;
        }

        return $indexByPath;
    }

    /**
     * @return list<string>
     */
    private function listTarGzEntries(string $tarGzPath): array
    {
        try {
            $accessor = new HostSystemUpdateTarGzPharAccessor($tarGzPath);
            $phar = $accessor->phar();
        } catch (\Throwable) {
            throw HostSystemUpdateStagingException::archiveOpenFailed();
        }

        $entries = [];

        try {
            $iterator = new RecursiveIteratorIterator($phar, RecursiveIteratorIterator::LEAVES_ONLY);

            /** @var SplFileInfo $fileInfo */
            foreach ($iterator as $fileInfo) {
                if (! $fileInfo->isFile()) {
                    continue;
                }

                $entryName = $this->archiveEntryName($fileInfo);

                if ($entryName === null || $entryName === '') {
                    continue;
                }

                $entries[] = $this->protectedPathGuard->assertSafeArchiveEntryName($entryName);
            }
        } finally {
            $accessor->close();
        }

        return $entries;
    }

    /**
     * @param  list<string>  $entries
     * @return list<string>
     */
    private function extractTarGzEntries(string $tarGzPath, string $stagingDirectory, array $entries): array
    {
        try {
            $accessor = new HostSystemUpdateTarGzPharAccessor($tarGzPath);
            $phar = $accessor->phar();
        } catch (\Throwable) {
            throw HostSystemUpdateStagingException::archiveOpenFailed();
        }

        $extracted = [];
        $wanted = array_fill_keys($entries, true);
        $remaining = count($entries);

        try {
            $iterator = new RecursiveIteratorIterator($phar, RecursiveIteratorIterator::LEAVES_ONLY);

            /** @var SplFileInfo $fileInfo */
            foreach ($iterator as $fileInfo) {
                if ($remaining === 0) {
                    break;
                }

                if (! $fileInfo->isFile()) {
                    continue;
                }

                $entryName = $this->archiveEntryName($fileInfo);

                if ($entryName === null || $entryName === '') {
                    continue;
                }

                $relativeEntry = $this->protectedPathGuard->assertSafeArchiveEntryName($entryName);

                if (! isset($wanted[$relativeEntry])) {
                    continue;
                }

                $targetPath = $stagingDirectory.DIRECTORY_SEPARATOR.str_replace('/', DIRECTORY_SEPARATOR, $relativeEntry);
                $targetDirectory = dirname($targetPath);

                HostSystemUpdateWorkspaceStorage::ensureDirectoryExists($targetDirectory);
                $this->protectedPathGuard->assertPathWithinDirectory($targetPath, $stagingDirectory);

                $contents = $this->readPharFileContents($fileInfo);

                if ($contents === false) {
                    throw HostSystemUpdateStagingException::unsafeArchiveEntry($entryName);
                }

                if (file_put_contents($targetPath, $contents) === false) {
                    throw HostSystemUpdateStagingException::unsafeArchiveEntry($entryName);
                }

                $extracted[] = $relativeEntry;
                $remaining--;
            }
        } finally {
            $accessor->close();
        }

        if ($remaining > 0) {
            throw HostSystemUpdateStagingException::stagingVerificationFailed('tar.gz batch entries missing');
        }

        return array_values(array_unique($extracted));
    }

    private function archiveEntryName(SplFileInfo $fileInfo): ?string
    {
        $pathname = str_replace('\\', '/', $fileInfo->getPathname());

        if (str_starts_with($pathname, 'phar://')) {
            if (preg_match('#^phar://[^/]+/.+\.(?:tar|phar)/(.+)$#', $pathname, $matches) === 1) {
                return $matches[1];
            }
        }

        if (str_contains($pathname, '.phar/')) {
            $parts = explode('.phar/', $pathname, 2);

            return $parts[1] ?? null;
        }

        return basename($pathname);
    }

    private function readPharFileContents(SplFileInfo $fileInfo): string|false
    {
        if ($fileInfo instanceof \PharFileInfo) {
            $content = $fileInfo->getContent();

            if (is_string($content)) {
                return $content;
            }
        }

        return false;
    }
}
