<?php

declare(strict_types=1);

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

namespace App\Tools;

use App\Models\User;
use App\Support\DberpAuditCorrelationContext;
use App\Tools\Enums\ToolErrorCode;
use App\Tools\Exceptions\ToolInvalidInputException;
use App\Tools\Exceptions\ToolResourceNotFoundException;
use App\Tools\Support\ToolInvocationAuditor;
use App\Tools\Support\ToolJsonSchemaValidator;
use App\Tools\Support\ToolOutputGuard;
use App\Tools\Support\ToolPeriodPresetResolver;
use App\Tools\Support\ToolResultFactory;
use Illuminate\Support\Facades\Log;
use Illuminate\Support\Str;
use Throwable;

/**
 * Tool 唯一公开执行入口。负责解析、身份、READ_ONLY 风险、权限、校验、隔离、审计与安全错误映射。
 */
final class ToolExecutor
{
    public function __construct(
        private readonly ToolRegistry $registry,
        private readonly ToolJsonSchemaValidator $schemaValidator,
        private readonly ToolOutputGuard $outputGuard,
        private readonly ToolResultFactory $resultFactory,
        private readonly ToolAuthorizer $authorizer,
        private readonly ToolInvocationAuditor $invocationAuditor,
    ) {}

    /**
     * @param  array<string, mixed>  $input
     */
    public function execute(string $name, ToolExecutionContext $context, array $input = []): ToolResult
    {
        $startedAt = hrtime(true);
        $context = $this->ensureCorrelationId($context);
        $correlationId = $context->correlationId;
        $previousCorrelationId = DberpAuditCorrelationContext::id();
        $previousLocale = app()->getLocale();
        $metadata = null;
        $result = null;

        try {
            DberpAuditCorrelationContext::reset($correlationId);

            if ($context->locale !== null && trim($context->locale) !== '') {
                app()->setLocale(trim($context->locale));
            }

            $result = $this->runPipeline($name, $context, $input, $metadata);
        } catch (Throwable $exception) {
            $this->logInternalFailure($name, $correlationId, 'executor_exception', $exception->getMessage());
            $result = $this->resultFactory->failure(ToolErrorCode::ExecutionFailed, $correlationId);
        } finally {
            $result ??= $this->resultFactory->failure(ToolErrorCode::ExecutionFailed, $correlationId);
            $durationMs = (int) max(0, (hrtime(true) - $startedAt) / 1_000_000);
            $this->invocationAuditor->record($name, $context, $metadata, $result, $durationMs, $input);
            DberpAuditCorrelationContext::reset($previousCorrelationId);
            app()->setLocale($previousLocale);
        }

        return $result;
    }

    /**
     * @param  array<string, mixed>  $input
     */
    private function runPipeline(
        string $name,
        ToolExecutionContext $context,
        array $input,
        ?ToolMetadata &$metadata,
    ): ToolResult {
        $correlationId = $context->correlationId;
        $tool = $this->registry->resolveForExecution($name);

        if ($tool === null) {
            return $this->resultFactory->failure(ToolErrorCode::ToolNotFound, $correlationId);
        }

        $metadata = $tool->metadata();

        $contextError = $this->validateContext($context);

        if ($contextError !== null) {
            return $this->resultFactory->failure(
                ToolErrorCode::InvalidContext,
                $correlationId,
                ['reason' => $contextError],
            );
        }

        /** @var User $actor */
        $actor = $context->actor;

        // V1：非 READ_ONLY 由架构拒绝，先于授权与执行，避免探测写能力。
        if (! $metadata->risk->isReadOnly()) {
            return $this->resultFactory->failure(ToolErrorCode::RiskNotAllowed, $correlationId);
        }

        if (! $this->authorizer->allows($actor, $metadata)) {
            return $this->resultFactory->failure(ToolErrorCode::PermissionDenied, $correlationId);
        }

        $inputResult = $this->schemaValidator->validate($metadata->inputSchema, $input, applyDefaults: true);

        if (! $inputResult->passed) {
            return $this->resultFactory->failure(
                ToolErrorCode::InvalidInput,
                $correlationId,
                $inputResult->errors,
            );
        }

        try {
            $normalizedInput = ToolPeriodPresetResolver::applyToValidatedInput(
                $inputResult->data,
                $metadata->inputSchema,
                $context,
            );
            $data = $tool->execute($context, $normalizedInput);
        } catch (ToolInvalidInputException $exception) {
            return $this->resultFactory->failure(
                ToolErrorCode::InvalidInput,
                $correlationId,
                $exception->errors,
            );
        } catch (ToolResourceNotFoundException) {
            return $this->resultFactory->failure(ToolErrorCode::ResourceNotFound, $correlationId);
        } catch (Throwable $exception) {
            $this->logInternalFailure($name, $correlationId, 'tool_exception', $exception->getMessage());

            return $this->resultFactory->failure(ToolErrorCode::ExecutionFailed, $correlationId);
        }

        $forbiddenPath = $this->outputGuard->firstForbiddenPath($data);

        if ($forbiddenPath !== null) {
            $this->logInternalFailure($name, $correlationId, 'forbidden_output', $forbiddenPath);

            return $this->resultFactory->failure(ToolErrorCode::ExecutionFailed, $correlationId);
        }

        $outputResult = $this->schemaValidator->validate($metadata->outputSchema, $data, applyDefaults: false);

        if (! $outputResult->passed) {
            $this->logInternalFailure(
                $name,
                $correlationId,
                'invalid_output',
                implode('; ', $this->flattenErrors($outputResult->errors)),
            );

            return $this->resultFactory->failure(ToolErrorCode::ExecutionFailed, $correlationId);
        }

        return $this->resultFactory->success($outputResult->data, $correlationId);
    }

    private function ensureCorrelationId(ToolExecutionContext $context): ToolExecutionContext
    {
        if (trim($context->correlationId) !== '') {
            return $context;
        }

        return $context->withCorrelationId((string) Str::uuid());
    }

    private function validateContext(ToolExecutionContext $context): ?string
    {
        if (trim($context->correlationId) === '') {
            return 'correlation_id';
        }

        if (! $context->actor instanceof User) {
            return 'actor';
        }

        $actorId = $context->actor->getKey();

        if (! is_numeric($actorId) || (int) $actorId <= 0) {
            return 'actor';
        }

        // 必须是库中真实用户，禁止仅构造未持久化的 User 冒充执行身份。
        if (! User::query()->whereKey((int) $actorId)->exists()) {
            return 'actor';
        }

        if ($context->timezone !== null && $context->timezone !== '') {
            if (! in_array($context->timezone, timezone_identifiers_list(), true)) {
                return 'timezone';
            }
        }

        if ($context->locale !== null && trim($context->locale) === '') {
            return 'locale';
        }

        if ($context->currency !== null && trim($context->currency) === '') {
            return 'currency';
        }

        return null;
    }

    private function logInternalFailure(string $toolName, string $correlationId, string $reason, string $detail): void
    {
        Log::error('dberp.tools.execution_failed', [
            'tool' => $toolName,
            'correlation_id' => $correlationId,
            'reason' => $reason,
            'detail' => $detail,
        ]);
    }

    /**
     * @param  array<string, list<string>>  $errors
     * @return list<string>
     */
    private function flattenErrors(array $errors): array
    {
        $flat = [];

        foreach ($errors as $field => $messages) {
            foreach ($messages as $message) {
                $flat[] = $field === '' ? $message : $field.': '.$message;
            }
        }

        return $flat;
    }
}
