<?php

declare(strict_types=1);

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

namespace App\Services\SupportCenter;

use App\Enums\SupportCenter\HostSystemUpdateExecutionStep;
use App\Exceptions\SupportCenter\HostSystemUpdateRecoveryException;
use App\Exceptions\SupportCenter\HostSystemUpdateStagingException;
use App\Models\HostSystemUpdateBackup;
use App\Models\HostSystemUpdateExecution;
use ZipArchive;

/**
 * 从 file_snapshot 备份 ZIP 恢复项目文件（不含 .env 全文恢复）。
 */
final class HostSystemUpdateFileRestoreService
{
    public function __construct(
        private readonly HostSystemUpdateProtectedPathGuard $protectedPathGuard,
        private readonly HostSystemUpdateExecutionLogger $executionLogger,
        private readonly SupportCenterAuditLogger $auditLogger,
    ) {}

    /**
     * @return array{restored_count: int, skipped_count: int, missing_count: int, failed_count: int, removed_new_count: int, file_results: list<array<string, string>>}
     */
    public function restoreFromBackup(HostSystemUpdateExecution $execution): array
    {
        $startedAt = now()->utc();
        $backup = $this->resolveFileSnapshotBackup($execution);
        $zipPath = $this->resolveBackupZipPath($backup, $execution);
        $this->verifyBackupArchiveChecksum($backup, $zipPath);
        $manifest = $this->readBackupManifest($zipPath);
        $this->assertBackupManifestMatchesExecution($manifest, $execution);

        $this->executionLogger->logStepStarted(
            $execution,
            HostSystemUpdateExecutionStep::Rollback->value,
            (string) __('dberp.system_update.recovery.log.file_restore_started'),
            ['backup_no' => $backup->backup_no],
        );

        $restoredCount = 0;
        $skippedCount = 0;
        $missingCount = 0;
        $failedCount = 0;
        /** @var list<array<string, string>> $fileResults */
        $fileResults = [];

        try {
            $backedUpPaths = $this->normalizePathList($manifest['backed_up_paths'] ?? []);
            $missingAtBackup = $this->normalizePathList($manifest['missing_paths'] ?? []);
            $fileMetadata = $this->fileMetadataByPath($manifest);

            foreach ($backedUpPaths as $relativePath) {
                $result = $this->restoreSinglePath(
                    $zipPath,
                    $relativePath,
                    $fileMetadata[$relativePath] ?? null,
                );
                $fileResults[] = $result;

                match ($result['status']) {
                    'restored' => $restoredCount++,
                    'missing' => $missingCount++,
                    'skipped' => $skippedCount++,
                    default => $failedCount++,
                };
            }

            $removedNewCount = $this->removeNewlyCreatedFiles($missingAtBackup);

            if ($failedCount > 0) {
                throw HostSystemUpdateRecoveryException::fileRestoreFailed('partial_restore_failed');
            }

            $summary = [
                'restored_count' => $restoredCount,
                'skipped_count' => $skippedCount,
                'missing_count' => $missingCount,
                'failed_count' => $failedCount,
                'removed_new_count' => $removedNewCount,
                'file_results' => $fileResults,
            ];

            $this->executionLogger->logStepCompleted(
                $execution,
                HostSystemUpdateExecutionStep::Rollback->value,
                (string) __('dberp.system_update.recovery.log.file_restore_completed', [
                    'count' => $restoredCount,
                ]),
                $summary,
                $startedAt,
            );
            $this->auditLogger->logSystemUpdateRollbackFileRestoreCompleted($execution, $summary);

            return $summary;
        } catch (HostSystemUpdateRecoveryException $exception) {
            $this->executionLogger->logStepFailed(
                $execution,
                HostSystemUpdateExecutionStep::Rollback->value,
                $exception->userFacingMessage(),
                $exception->errorCode(),
                $exception->userFacingMessage(),
                [
                    'restored_count' => $restoredCount,
                    'failed_count' => $failedCount,
                ],
                $startedAt,
            );
            $this->auditLogger->logSystemUpdateRollbackFileRestoreFailed($execution, $exception);

            throw $exception;
        }
    }

    public function resolveFileSnapshotBackup(HostSystemUpdateExecution $execution): HostSystemUpdateBackup
    {
        $backup = $execution->backups()
            ->where('backup_type', HostSystemUpdateBackup::TYPE_FILE_SNAPSHOT)
            ->where('status', HostSystemUpdateBackup::STATUS_COMPLETED)
            ->latest('id')
            ->first();

        if (! $backup instanceof HostSystemUpdateBackup) {
            throw HostSystemUpdateRecoveryException::backupUnavailable();
        }

        return $backup;
    }

    private function resolveBackupZipPath(HostSystemUpdateBackup $backup, HostSystemUpdateExecution $execution): string
    {
        $backupDirectory = HostSystemUpdateWorkspaceStorage::backupsDirectory($execution->execution_no);

        $zipPath = $backupDirectory.DIRECTORY_SEPARATOR.'files.zip';

        if (is_file($zipPath)) {
            return $zipPath;
        }

        if (is_string($backup->backup_path) && $backup->backup_path !== '') {
            $resolved = HostSystemUpdateWorkspaceStorage::absoluteFromRelativeStorage($backup->backup_path);

            if (HostSystemUpdateWorkspaceStorage::isWithinUpdatesWorkspace($resolved) && is_file($resolved)) {
                return $resolved;
            }
        }

        throw HostSystemUpdateRecoveryException::backupUnavailable();
    }

    /**
     * @return array<string, mixed>
     */
    private function readBackupManifest(string $zipPath): array
    {
        $zip = new ZipArchive;
        $opened = $zip->open($zipPath);

        if ($opened !== true) {
            throw HostSystemUpdateRecoveryException::fileRestoreFailed('zip_open_failed');
        }

        try {
            $manifestContents = $zip->getFromName('backup-manifest.json');

            if (! is_string($manifestContents) || trim($manifestContents) === '') {
                throw HostSystemUpdateRecoveryException::fileRestoreFailed('backup_manifest_missing');
            }

            $manifest = json_decode($manifestContents, true, 512, JSON_THROW_ON_ERROR);

            if (! is_array($manifest)) {
                throw HostSystemUpdateRecoveryException::fileRestoreFailed('backup_manifest_invalid');
            }

            return $manifest;
        } finally {
            $zip->close();
        }
    }

    /**
     * @return array{path: string, status: string}
     */
    private function restoreSinglePath(string $zipPath, string $relativePath, ?array $expectedMetadata): array
    {
        if ($this->isEnvPath($relativePath)) {
            return ['path' => $relativePath, 'status' => 'skipped'];
        }

        try {
            $this->protectedPathGuard->assertSafeUpdateTargetPath($relativePath, 'rollback');
        } catch (HostSystemUpdateStagingException) {
            return ['path' => $relativePath, 'status' => 'skipped'];
        }

        $normalized = $this->protectedPathGuard->normalizeRelativePath($relativePath);

        if ($normalized === null) {
            throw HostSystemUpdateRecoveryException::fileRestoreFailed($relativePath);
        }

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

        if ($opened !== true) {
            throw HostSystemUpdateRecoveryException::fileRestoreFailed('zip_open_failed');
        }

        try {
            $this->protectedPathGuard->assertSafeArchiveEntryName($normalized);
            $contents = $zip->getFromName(str_replace('\\', '/', $normalized));

            if ($contents === false) {
                return ['path' => $normalized, 'status' => 'missing'];
            }
        } catch (HostSystemUpdateStagingException) {
            throw HostSystemUpdateRecoveryException::fileRestoreFailed($normalized);
        } finally {
            $zip->close();
        }

        if (! is_array($expectedMetadata)
            || ! is_int($expectedMetadata['size'] ?? null)
            || ! is_string($expectedMetadata['checksum'] ?? null)
            || preg_match('/^[a-f0-9]{64}$/i', $expectedMetadata['checksum']) !== 1
            || strlen($contents) !== $expectedMetadata['size']
            || ! hash_equals(strtolower($expectedMetadata['checksum']), hash('sha256', $contents))) {
            throw HostSystemUpdateRecoveryException::fileRestoreFailed($normalized.':checksum_mismatch');
        }

        $targetPath = $this->protectedPathGuard->projectAbsolutePath($normalized);
        $this->copyBytesAtomically($contents, $targetPath);

        return ['path' => $normalized, 'status' => 'restored'];
    }

    private function verifyBackupArchiveChecksum(HostSystemUpdateBackup $backup, string $zipPath): void
    {
        $algorithm = strtolower(trim((string) ($backup->checksum_algorithm ?? 'sha256')));
        $expected = strtolower(trim((string) ($backup->checksum ?? '')));

        if ($algorithm !== 'sha256' || preg_match('/^[a-f0-9]{64}$/', $expected) !== 1) {
            throw HostSystemUpdateRecoveryException::fileRestoreFailed('backup_checksum_missing');
        }

        $actual = hash_file($algorithm, $zipPath);

        if (! is_string($actual) || ! hash_equals($expected, strtolower($actual))) {
            throw HostSystemUpdateRecoveryException::fileRestoreFailed('backup_checksum_mismatch');
        }
    }

    /** @param array<string, mixed> $manifest */
    private function assertBackupManifestMatchesExecution(
        array $manifest,
        HostSystemUpdateExecution $execution,
    ): void {
        if (($manifest['execution_no'] ?? null) !== (string) $execution->execution_no
            || ($manifest['checksum_algorithm'] ?? null) !== 'sha256') {
            throw HostSystemUpdateRecoveryException::fileRestoreFailed('backup_manifest_execution_mismatch');
        }
    }

    /**
     * @param  array<string, mixed>  $manifest
     * @return array<string, array{size: int, checksum: string}>
     */
    private function fileMetadataByPath(array $manifest): array
    {
        $files = $manifest['files'] ?? null;

        if (! is_array($files)) {
            throw HostSystemUpdateRecoveryException::fileRestoreFailed('backup_manifest_file_metadata_missing');
        }

        $metadata = [];

        foreach ($files as $file) {
            if (! is_array($file)
                || ! is_string($file['path'] ?? null)
                || ! is_int($file['size'] ?? null)
                || ! is_string($file['checksum'] ?? null)) {
                throw HostSystemUpdateRecoveryException::fileRestoreFailed('backup_manifest_file_metadata_invalid');
            }

            $metadata[str_replace('\\', '/', trim($file['path']))] = [
                'size' => $file['size'],
                'checksum' => strtolower(trim($file['checksum'])),
            ];
        }

        return $metadata;
    }

    /**
     * 删除备份时尚未存在、由本次更新新建的文件。路径以备份 manifest 的 missing_paths 为准。
     *
     * @param  list<string>  $missingAtBackup
     */
    private function removeNewlyCreatedFiles(array $missingAtBackup): int
    {
        if ($missingAtBackup === []) {
            return 0;
        }

        $removed = 0;

        foreach ($missingAtBackup as $path) {
            if ($this->isEnvPath($path) || $this->protectedPathGuard->isBlockedPath($path)) {
                continue;
            }

            $absolute = $this->protectedPathGuard->projectAbsolutePath($path);

            if (is_file($absolute) && @unlink($absolute)) {
                $removed++;
            }
        }

        return $removed;
    }

    /**
     * @return list<string>
     */
    private function normalizePathList(mixed $value): array
    {
        if (! is_array($value)) {
            return [];
        }

        $paths = [];

        foreach ($value as $path) {
            if (is_string($path) && trim($path) !== '') {
                $paths[] = str_replace('\\', '/', trim($path));
            }
        }

        return $paths;
    }

    private function isEnvPath(string $path): bool
    {
        $lower = strtolower(str_replace('\\', '/', trim($path)));

        return $lower === '.env' || str_starts_with($lower, '.env.');
    }

    private function copyBytesAtomically(string $contents, string $targetPath): void
    {
        $directory = dirname($targetPath);

        if (! is_dir($directory) && ! mkdir($directory, 0755, true) && ! is_dir($directory)) {
            throw HostSystemUpdateRecoveryException::fileRestoreFailed(basename($targetPath));
        }

        $mode = is_file($targetPath) ? (fileperms($targetPath) & 0777) : 0644;
        $temporaryPath = $targetPath.'.dberp-restore-'.bin2hex(random_bytes(8)).'.tmp';

        if (file_put_contents($temporaryPath, $contents) === false) {
            throw HostSystemUpdateRecoveryException::fileRestoreFailed(basename($targetPath));
        }

        @chmod($temporaryPath, $mode);

        if (! rename($temporaryPath, $targetPath)) {
            @unlink($temporaryPath);

            throw HostSystemUpdateRecoveryException::fileRestoreFailed(basename($targetPath));
        }
    }
}
