<?php

declare(strict_types=1);

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

namespace App\Services\Plugin;

use App\Support\DberpProductCode;
use App\Support\PluginRelease\OfficialPluginReleaseArchiveHasher;
use App\Support\PluginRelease\OfficialPluginReleaseChecksumBuilder;
use App\Support\PluginRelease\OfficialPluginReleaseManifestBuilder;

/**
 * 官方插件发布包安装前校验：结构、plugin.json、manifest.json、checksums.json、signature.sig。
 */
final class HostPluginReleasePackageVerifier
{
    /**
     * @var list<string>
     */
    private const REQUIRED_FILES = [
        'plugin.json',
        'manifest.json',
        'checksums.json',
        'signature.sig',
    ];

    /**
     * @var list<string>
     */
    private const CRITICAL_CHECKSUM_FILES = [
        'plugin.json',
    ];

    public function __construct(
        private readonly HostPluginManifestValidator $manifestValidator,
        private readonly HostPluginPackageStagingHelper $stagingHelper,
        private readonly HostPluginReleasePackageSignatureValidator $signatureValidator,
        private readonly OfficialPluginReleaseArchiveHasher $archiveHasher = new OfficialPluginReleaseArchiveHasher,
    ) {}

    public function verifyStaging(
        string $stagingDirectory,
        string $expectedPluginCode,
        string $archivePath,
    ): HostPluginReleasePackageVerificationResult {
        if (! is_dir($stagingDirectory)) {
            return HostPluginReleasePackageVerificationResult::failed('release_package_staging_missing');
        }

        if (! is_file($archivePath)) {
            return HostPluginReleasePackageVerificationResult::failed('release_package_archive_missing');
        }

        if (! $this->hasRequiredStructure($stagingDirectory)) {
            return HostPluginReleasePackageVerificationResult::failed('release_package_structure_invalid');
        }

        try {
            $pluginRoot = $this->stagingHelper->resolveExtractedPluginRoot($stagingDirectory);
        } catch (\Throwable) {
            return HostPluginReleasePackageVerificationResult::failed('release_package_structure_invalid');
        }

        $pluginManifest = $this->readJsonFile($pluginRoot.DIRECTORY_SEPARATOR.'plugin.json');

        if ($pluginManifest === null) {
            return HostPluginReleasePackageVerificationResult::failed('manifest_invalid_json');
        }

        $pluginValidation = $this->validatePluginManifest($pluginManifest, $expectedPluginCode);

        if ($pluginValidation !== null) {
            return HostPluginReleasePackageVerificationResult::failed($pluginValidation);
        }

        $stagingReleaseManifest = $this->readJsonFile($pluginRoot.DIRECTORY_SEPARATOR.'manifest.json');

        if ($stagingReleaseManifest === null) {
            return HostPluginReleasePackageVerificationResult::failed('release_manifest_invalid_json');
        }

        $archiveReleaseManifest = $this->readJsonFromZip($archivePath, 'manifest.json') ?? $stagingReleaseManifest;

        $releaseBusinessValidation = $this->validateReleaseManifestBusinessRules(
            stagingReleaseManifest: $stagingReleaseManifest,
            pluginManifest: $pluginManifest,
        );

        if ($releaseBusinessValidation !== null) {
            return HostPluginReleasePackageVerificationResult::failed($releaseBusinessValidation);
        }

        $checksumsDocument = $this->readJsonFile($pluginRoot.DIRECTORY_SEPARATOR.'checksums.json');

        if ($checksumsDocument === null) {
            return HostPluginReleasePackageVerificationResult::failed('release_checksums_invalid_json');
        }

        $checksumValidation = $this->validateChecksums($checksumsDocument, $pluginRoot);

        if ($checksumValidation !== null) {
            return HostPluginReleasePackageVerificationResult::failed($checksumValidation);
        }

        $signatureContents = @file_get_contents($pluginRoot.DIRECTORY_SEPARATOR.'signature.sig');

        if (! is_string($signatureContents) || trim($signatureContents) === '') {
            return HostPluginReleasePackageVerificationResult::failed('release_signature_missing');
        }

        $signatureRequired = $this->signatureValidator->signatureRequired();
        $signaturePresent = ! $this->signatureValidator->isPlaceholder($signatureContents);
        $signatureVerified = false;

        if ($signatureRequired && ! $signaturePresent) {
            return HostPluginReleasePackageVerificationResult::failed('release_signature_required');
        }

        if ($signaturePresent) {
            $signatureVerified = $this->signatureValidator->verifyArchiveSignature($archivePath, $signatureContents);

            if (! $signatureVerified && $this->signatureValidator->signatureVerificationEnabled()) {
                return HostPluginReleasePackageVerificationResult::failed('release_signature_mismatch');
            }
        }

        $archiveValidation = $this->validateReleaseArchiveMetadata(
            archiveReleaseManifest: $archiveReleaseManifest,
            pluginManifest: $pluginManifest,
            archivePath: $archivePath,
        );

        if ($archiveValidation !== null) {
            return HostPluginReleasePackageVerificationResult::failed($archiveValidation);
        }

        $alias = trim((string) ($pluginManifest['alias'] ?? ''));

        return HostPluginReleasePackageVerificationResult::passed(
            pluginManifest: $pluginManifest,
            releaseManifest: $stagingReleaseManifest,
            pluginRoot: $pluginRoot,
            alias: $alias,
            signatureRequired: $signatureRequired,
            signaturePresent: $signaturePresent,
            signatureVerified: $signatureVerified,
        );
    }

    /**
     * @param  array<string, mixed>  $pluginManifest
     */
    private function validatePluginManifest(array $pluginManifest, string $expectedPluginCode): ?string
    {
        $validation = $this->manifestValidator->validateOfficialManifest($pluginManifest);

        if (! $validation['valid']) {
            return $validation['error_code'] ?? 'manifest_invalid';
        }

        if (! $this->manifestValidator->assertAliasMatchesCode($pluginManifest, $expectedPluginCode)) {
            return 'manifest_alias_mismatch';
        }

        $version = trim((string) ($pluginManifest['version'] ?? ''));

        if ($version === '' || ! $this->isValidVersionString($version)) {
            return 'manifest_version_invalid';
        }

        if (isset($pluginManifest['entry_class']) && ! $this->isValidClassReference($pluginManifest['entry_class'])) {
            return 'manifest_entry_class_invalid';
        }

        if (isset($pluginManifest['providers']) && ! $this->isValidProviderList($pluginManifest['providers'])) {
            return 'manifest_providers_invalid';
        }

        $alias = trim((string) ($pluginManifest['alias'] ?? ''));

        if (isset($pluginManifest['permissions']) && ! $this->isValidPermissionList($pluginManifest['permissions'], $alias)) {
            return 'manifest_permissions_invalid';
        }

        return null;
    }

    /**
     * @param  array<string, mixed>  $stagingReleaseManifest
     * @param  array<string, mixed>  $pluginManifest
     */
    private function validateReleaseManifestBusinessRules(
        array $stagingReleaseManifest,
        array $pluginManifest,
    ): ?string {
        $schema = trim((string) ($stagingReleaseManifest['schema'] ?? ''));

        if ($schema !== OfficialPluginReleaseManifestBuilder::SCHEMA) {
            return 'release_manifest_schema_invalid';
        }

        $productCode = trim((string) ($stagingReleaseManifest['product_code'] ?? ''));

        if ($productCode !== DberpProductCode::SUPPORT_CENTER) {
            return 'release_manifest_product_invalid';
        }

        $pluginCode = trim((string) ($stagingReleaseManifest['plugin_code'] ?? ''));
        $alias = trim((string) ($pluginManifest['alias'] ?? ''));

        if ($pluginCode === '' || $pluginCode !== $alias) {
            return 'release_manifest_plugin_code_mismatch';
        }

        $releaseVersion = trim((string) ($stagingReleaseManifest['version'] ?? ''));
        $pluginVersion = trim((string) ($pluginManifest['version'] ?? ''));

        if ($releaseVersion === '' || $releaseVersion !== $pluginVersion) {
            return 'release_manifest_version_mismatch';
        }

        if (isset($stagingReleaseManifest['compatibility']) && is_array($stagingReleaseManifest['compatibility'])) {
            $compatibilityError = $this->validateCompatibility($stagingReleaseManifest['compatibility']);

            if ($compatibilityError !== null) {
                return $compatibilityError;
            }
        }

        return null;
    }

    /**
     * @param  array<string, mixed>  $archiveReleaseManifest
     * @param  array<string, mixed>  $pluginManifest
     */
    private function validateReleaseArchiveMetadata(
        array $archiveReleaseManifest,
        array $pluginManifest,
        string $archivePath,
    ): ?string {
        $archive = is_array($archiveReleaseManifest['archive'] ?? null) ? $archiveReleaseManifest['archive'] : null;

        if ($archive === null) {
            return 'release_manifest_archive_invalid';
        }

        $pluginCode = trim((string) ($pluginManifest['alias'] ?? ''));
        $releaseVersion = trim((string) ($pluginManifest['version'] ?? ''));
        $manifestFilename = trim((string) ($archive['filename'] ?? ''));
        $expectedReleaseFilename = sprintf('%s-%s.zip', $pluginCode, $releaseVersion);

        if ($manifestFilename === '' || $manifestFilename !== $expectedReleaseFilename) {
            return 'release_manifest_archive_filename_mismatch';
        }

        $expectedSha256 = strtolower(trim((string) ($archive['sha256'] ?? '')));
        $actualSha256 = $this->archiveHasher->computeSha256($archivePath);

        if ($expectedSha256 === '' || ! hash_equals($expectedSha256, $actualSha256)) {
            return 'release_manifest_archive_sha256_mismatch';
        }

        $expectedSize = (int) ($archive['size_bytes'] ?? 0);
        $actualSize = (int) filesize($archivePath);

        if ($expectedSize <= 0) {
            return 'release_manifest_archive_size_mismatch';
        }

        // manifest 写入 ZIP 后可能出现极小自描述体积漂移；sha256 已覆盖主体内容完整性
        if ($actualSize !== $expectedSize && abs($actualSize - $expectedSize) > 1024) {
            return 'release_manifest_archive_size_mismatch';
        }

        return null;
    }

    /**
     * @param  array<string, mixed>  $checksumsDocument
     */
    private function validateChecksums(array $checksumsDocument, string $pluginRoot): ?string
    {
        $schema = trim((string) ($checksumsDocument['schema'] ?? ''));

        if ($schema !== OfficialPluginReleaseChecksumBuilder::SCHEMA) {
            return 'release_checksums_schema_invalid';
        }

        $algorithm = strtolower(trim((string) ($checksumsDocument['algorithm'] ?? '')));

        if ($algorithm !== OfficialPluginReleaseChecksumBuilder::ALGORITHM) {
            return 'release_checksums_algorithm_invalid';
        }

        if (! isset($checksumsDocument['files']) || ! is_array($checksumsDocument['files'])) {
            return 'release_checksums_files_invalid';
        }

        /** @var array<string, mixed> $files */
        $files = $checksumsDocument['files'];

        foreach (self::CRITICAL_CHECKSUM_FILES as $criticalFile) {
            if (! array_key_exists($criticalFile, $files)) {
                return 'release_checksums_critical_missing';
            }
        }

        if (! $this->checksumsIncludeSrcTree($files)) {
            return 'release_checksums_critical_missing';
        }

        foreach ($files as $relativePath => $expectedChecksum) {
            if (! is_string($relativePath) || trim($relativePath) === '') {
                return 'release_checksums_files_invalid';
            }

            $normalizedPath = str_replace('\\', '/', ltrim($relativePath, '/'));
            $absolutePath = $pluginRoot.DIRECTORY_SEPARATOR.str_replace('/', DIRECTORY_SEPARATOR, $normalizedPath);

            if (! is_file($absolutePath)) {
                return 'release_checksums_file_missing';
            }

            $expected = strtolower(trim((string) $expectedChecksum));
            $actual = @hash_file($algorithm, $absolutePath);

            if (! is_string($actual) || $actual === '' || ! hash_equals($expected, strtolower($actual))) {
                return 'release_checksums_mismatch';
            }
        }

        return null;
    }

    /**
     * @param  array<string, mixed>  $files
     */
    private function checksumsIncludeSrcTree(array $files): bool
    {
        foreach (array_keys($files) as $relativePath) {
            if (! is_string($relativePath)) {
                continue;
            }

            $normalized = str_replace('\\', '/', ltrim($relativePath, '/'));

            if ($normalized === 'src' || str_starts_with($normalized, 'src/')) {
                return true;
            }
        }

        return false;
    }

    /**
     * @param  array<string, mixed>  $compatibility
     */
    private function validateCompatibility(array $compatibility): ?string
    {
        if (isset($compatibility['php'])) {
            $constraint = trim((string) $compatibility['php']);

            if ($constraint !== '' && ! $this->satisfiesConstraint(PHP_VERSION, $constraint)) {
                return 'release_manifest_php_incompatible';
            }
        }

        if (isset($compatibility['dberp'])) {
            $constraint = trim((string) $compatibility['dberp']);
            $currentVersion = trim((string) config('dberp.version.display_version', ''));

            if ($constraint !== '' && $currentVersion !== '' && ! $this->satisfiesConstraint($currentVersion, $constraint)) {
                return 'release_manifest_dberp_incompatible';
            }
        }

        return null;
    }

    private function satisfiesConstraint(string $version, string $constraint): bool
    {
        $normalizedVersion = ltrim($version, 'vV');
        $normalizedConstraint = trim($constraint);

        if ($normalizedConstraint === '') {
            return true;
        }

        if (preg_match('/^(>=|<=|>|<|!=|=)\s*(.+)$/', $normalizedConstraint, $matches) === 1) {
            return version_compare(
                $normalizedVersion,
                ltrim((string) $matches[2], 'vV'),
                (string) $matches[1],
            );
        }

        return version_compare($normalizedVersion, ltrim($normalizedConstraint, 'vV'), '>=');
    }

    private function hasRequiredStructure(string $directory): bool
    {
        foreach (self::REQUIRED_FILES as $requiredFile) {
            if (! is_file($directory.DIRECTORY_SEPARATOR.$requiredFile)) {
                return false;
            }
        }

        return is_dir($directory.DIRECTORY_SEPARATOR.'src');
    }

    /**
     * @return array<string, mixed>|null
     */
    private function readJsonFromZip(string $archivePath, string $entryName): ?array
    {
        $zip = new \ZipArchive;
        $opened = $zip->open($archivePath);

        if ($opened !== true) {
            return null;
        }

        $raw = $zip->getFromName($entryName);
        $zip->close();

        if ($raw === false || trim($raw) === '') {
            return null;
        }

        try {
            $decoded = json_decode($raw, true, 512, JSON_THROW_ON_ERROR);
        } catch (\JsonException) {
            return null;
        }

        return is_array($decoded) ? $decoded : null;
    }

    /**
     * @return array<string, mixed>|null
     */
    private function readJsonFile(string $path): ?array
    {
        if (! is_file($path)) {
            return null;
        }

        $raw = @file_get_contents($path);

        if ($raw === false || trim($raw) === '') {
            return null;
        }

        try {
            $decoded = json_decode($raw, true, 512, JSON_THROW_ON_ERROR);
        } catch (\JsonException) {
            return null;
        }

        return is_array($decoded) ? $decoded : null;
    }

    private function isValidVersionString(string $version): bool
    {
        return preg_match('/^\d+\.\d+\.\d+(?:[-+][0-9A-Za-z.-]+)?$/', $version) === 1;
    }

    private function isValidClassReference(mixed $value): bool
    {
        if (! is_string($value) || trim($value) === '') {
            return false;
        }

        return preg_match('/^[A-Za-z_][A-Za-z0-9_\\\\]*[A-Za-z0-9_]$/', trim($value)) === 1;
    }

    private function isValidProviderList(mixed $value): bool
    {
        if (! is_array($value) || $value === []) {
            return false;
        }

        foreach ($value as $provider) {
            if (! $this->isValidClassReference($provider)) {
                return false;
            }
        }

        return true;
    }

    /**
     * @param  array<int, mixed>  $permissions
     */
    private function isValidPermissionList(array $permissions, string $alias): bool
    {
        $expectedSegment = $this->manifestValidator->aliasToPermissionSegment($alias);
        $pattern = '/^plugin\.[a-z][a-z0-9_]*\.[a-z][a-z0-9_]*$/';

        foreach ($permissions as $permission) {
            if (! is_string($permission) || trim($permission) === '') {
                return false;
            }

            if (preg_match($pattern, $permission) !== 1) {
                return false;
            }

            $segments = explode('.', $permission);

            if (($segments[1] ?? '') !== $expectedSegment) {
                return false;
            }
        }

        return true;
    }
}
