<?php

declare(strict_types=1);

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

namespace App\Auth\Passport;

use Illuminate\Contracts\Cache\LockTimeoutException;
use Illuminate\Support\Facades\Artisan;
use Illuminate\Support\Facades\Cache;
use Laravel\Passport\Passport;
use phpseclib3\Crypt\RSA;
use Symfony\Component\Console\Exception\CommandNotFoundException;
use Throwable;

/**
 * 仅在尚未配置时生成一次 Passport 签名密钥。禁止 --force，升级不得覆盖已有完整密钥对。
 */
final class DberpPassportSigningKeyBootstrap
{
    private const GENERATE_LOCK_SECONDS = 30;

    private const GENERATE_LOCK_WAIT_SECONDS = 10;

    /**
     * league/oauth2-server CryptKey 接受的权限。644/664 不在其中。
     *
     * @var list<string>
     */
    private const LEAGUE_ACCEPTED_KEY_MODES = ['400', '440', '600', '640', '660'];

    /**
     * DBPanel 上站点属主与 PHP-FPM 常不同账号但同组；660 同时满足 league 与组内可读。
     */
    private const LINUX_KEY_FILE_MODE = 0660;

    private static bool $inProgress = false;

    public function isConfigured(): bool
    {
        return $this->privateKeyIsConfigured() && $this->publicKeyIsConfigured();
    }

    public function ensureOnce(): bool
    {
        if ($this->isConfigured()) {
            return true;
        }

        if (self::$inProgress) {
            return false;
        }

        if (! class_exists(Passport::class)) {
            return false;
        }

        if ($this->environmentKeysArePartial()) {
            logger()->warning('dberp.oauth.signing_keys_partial');

            return false;
        }

        self::$inProgress = true;

        try {
            return Cache::lock('dberp.oauth.signing_keys.generate', self::GENERATE_LOCK_SECONDS)
                ->block(self::GENERATE_LOCK_WAIT_SECONDS, function (): bool {
                    if ($this->isConfigured()) {
                        return true;
                    }

                    if (! $this->removeOrphanKeyFiles()) {
                        return false;
                    }

                    // Passport 仅在 runningInConsole() 时注册 passport:keys。
                    // Web / PHP-FPM 请求必须进程内生成，否则会 CommandNotFound 并刷屏。
                    if (! app()->runningInConsole()) {
                        return $this->writeGeneratedKeyPair();
                    }

                    try {
                        $exitCode = Artisan::call('passport:keys', [
                            '--no-interaction' => true,
                        ]);
                    } catch (CommandNotFoundException) {
                        return $this->writeGeneratedKeyPair();
                    }

                    if ($exitCode !== 0 || ! $this->isConfigured()) {
                        logger()->warning('dberp.oauth.signing_keys_generate_failed', [
                            'exit_code' => $exitCode,
                        ]);

                        return false;
                    }

                    // passport:keys 受 umask 影响，Linux 上常写成 644/664，会被 CryptKey 打成 500。
                    $this->normalizeFilePermissions();

                    return true;
                });
        } catch (LockTimeoutException) {
            return $this->isConfigured();
        } catch (Throwable $exception) {
            logger()->warning('dberp.oauth.signing_keys_generate_failed', [
                'exception_class' => $exception::class,
            ]);

            return false;
        } finally {
            self::$inProgress = false;
        }
    }

    private function environmentKeysArePartial(): bool
    {
        return $this->configuredValue('passport.private_key') !== $this->configuredValue('passport.public_key');
    }

    /**
     * 与 passport:keys 相同：phpseclib RSA 4096，写入 storage 密钥文件。禁止记录密钥内容。
     */
    private function writeGeneratedKeyPair(): bool
    {
        if (! class_exists(RSA::class)) {
            logger()->warning('dberp.oauth.signing_keys_generate_failed', [
                'exception_class' => 'phpseclib_rsa_missing',
            ]);

            return false;
        }

        $private = storage_path('oauth-private.key');
        $public = storage_path('oauth-public.key');
        $key = RSA::createKey(4096);
        $privatePem = (string) $key;
        $publicPem = (string) $key->getPublicKey();

        if ($privatePem === '' || $publicPem === '') {
            logger()->warning('dberp.oauth.signing_keys_generate_failed', [
                'exception_class' => 'empty_key_material',
            ]);

            return false;
        }

        if (file_put_contents($public, $publicPem) === false
            || file_put_contents($private, $privatePem) === false) {
            logger()->warning('dberp.oauth.signing_keys_generate_failed', [
                'exception_class' => 'key_file_write_failed',
            ]);

            return false;
        }

        $this->normalizeFilePermissions();

        return $this->isConfigured();
    }

    /**
     * 在 Passport 构造 CryptKey 之前调用。DBPanel 同步 / umask 会把密钥打成 644、664，
     * league/oauth2-server 的 E_USER_NOTICE 会被 Laravel 升级成 500，MCP（含 Sanctum PAT）全挂。
     */
    public function prepareForCryptKeyUse(): void
    {
        if (! class_exists(Passport::class)) {
            return;
        }

        $this->normalizeFilePermissions();

        // Windows ACL 无法表达 0600/0660；Linux 若 chmod 失败则关闭校验，避免 NOTICE 变 500。
        Passport::$validateKeyPermissions = PHP_OS_FAMILY !== 'Windows'
            && $this->filePermissionsAreLeagueAccepted();
    }

    /**
     * 把已有密钥文件收到 league/oauth2-server CryptKey 接受的模式。不读取、不记录密钥内容。
     */
    public function normalizeFilePermissions(): void
    {
        if (windows_os()) {
            return;
        }

        foreach ($this->keyFilePaths() as $path) {
            if (! is_file($path)) {
                continue;
            }

            if ($this->fileModeIsLeagueAccepted($path)) {
                continue;
            }

            $previous = $this->fileMode($path);
            @chmod($path, self::LINUX_KEY_FILE_MODE);
            clearstatcache(true, $path);

            if ($this->fileModeIsLeagueAccepted($path)) {
                continue;
            }

            logger()->warning('dberp.oauth.signing_key_permissions_unfixed', [
                'file' => basename($path),
                'mode' => $previous,
            ]);
        }
    }

    public function filePermissionsAreLeagueAccepted(): bool
    {
        if (windows_os()) {
            return true;
        }

        if ($this->configuredValue('passport.private_key') && $this->configuredValue('passport.public_key')) {
            return true;
        }

        foreach ($this->keyFilePaths() as $path) {
            if (is_file($path) && ! $this->fileModeIsLeagueAccepted($path)) {
                return false;
            }
        }

        return true;
    }

    /**
     * 半套密钥文件无法签发 token。删除残缺文件后才能无 --force 重新生成配对密钥；不得覆盖完整密钥对。
     */
    private function removeOrphanKeyFiles(): bool
    {
        $private = storage_path('oauth-private.key');
        $public = storage_path('oauth-public.key');
        $privateExists = is_file($private);
        $publicExists = is_file($public);

        if ($privateExists === $publicExists) {
            return true;
        }

        $orphan = $privateExists ? $private : $public;

        if (is_file($orphan) && ! @unlink($orphan) && is_file($orphan)) {
            logger()->warning('dberp.oauth.signing_keys_partial_remove_failed');

            return false;
        }

        logger()->warning('dberp.oauth.signing_keys_partial_removed');

        return true;
    }

    private function privateKeyIsConfigured(): bool
    {
        return $this->configuredValue('passport.private_key') || is_file(storage_path('oauth-private.key'));
    }

    private function publicKeyIsConfigured(): bool
    {
        return $this->configuredValue('passport.public_key') || is_file(storage_path('oauth-public.key'));
    }

    private function configuredValue(string $key): bool
    {
        return trim((string) config($key, '')) !== '';
    }

    /**
     * @return list<string>
     */
    private function keyFilePaths(): array
    {
        return [
            storage_path('oauth-private.key'),
            storage_path('oauth-public.key'),
        ];
    }

    private function fileModeIsLeagueAccepted(string $path): bool
    {
        return in_array($this->fileMode($path), self::LEAGUE_ACCEPTED_KEY_MODES, true);
    }

    private function fileMode(string $path): string
    {
        $perms = fileperms($path);

        if ($perms === false) {
            return '';
        }

        return decoct($perms & 0777);
    }
}
