<?php

namespace App\Infrastructure\AI\Gateways;

use App\Domain\Shared\Models\TeamProviderCredential;
use App\Infrastructure\AI\Contracts\AiGatewayInterface;
use App\Infrastructure\AI\DTOs\AiRequestDTO;
use App\Infrastructure\AI\DTOs\AiResponseDTO;
use App\Infrastructure\AI\Jobs\RunShadowComparisonJob;
use App\Infrastructure\AI\Services\CircuitBreaker;
use App\Infrastructure\AI\Services\EscalationStrategy;
use App\Infrastructure\AI\Services\ProviderRanker;
use App\Infrastructure\Telemetry\TracerProvider as FleetTracerProvider;
use Illuminate\Support\Facades\Log;
use OpenTelemetry\API\Trace\SpanInterface;
use OpenTelemetry\API\Trace\SpanKind;
use OpenTelemetry\API\Trace\StatusCode;
use Prism\Prism\Exceptions\PrismRateLimitedException;
use Throwable;

class FallbackAiGateway implements AiGatewayInterface
{
    /**
     * @param  array<string, list<array{provider: string, model: string}>>  $fallbackChains
     */
    public function __construct(
        private readonly PrismAiGateway $gateway,
        private readonly CircuitBreaker $circuitBreaker,
        private readonly array $fallbackChains = [],
        private readonly ?AiGatewayInterface $localGateway = null,
        private readonly ?LocalBridgeGateway $bridgeGateway = null,
        private readonly ?EscalationStrategy $escalationStrategy = null,
        private readonly ?ProviderRanker $ranker = null,
    ) {}

    public function complete(AiRequestDTO $request): AiResponseDTO
    {
        $tracer = app(FleetTracerProvider::class)->tracer('fleetq.ai');
        $span = $tracer->spanBuilder('ai.gateway.complete')
            ->setSpanKind(SpanKind::KIND_CLIENT)
            ->setAttribute('ai.gateway.requested_provider', $request->provider)
            ->setAttribute('ai.gateway.requested_model', $request->model)
            ->setAttribute('ai.gateway.team_id', (string) ($request->teamId ?? 'unknown'))
            ->setAttribute('ai.gateway.purpose', (string) ($request->purpose ?? 'unknown'))
            ->startSpan();
        $scope = $span->activate();

        try {
            $result = $this->completeWithFallback($request, $span);
            $span->setStatus(StatusCode::STATUS_OK);

            $this->maybeDispatchShadow($request, $result);

            return $result;
        } catch (Throwable $e) {
            $span->recordException($e);
            $span->setStatus(StatusCode::STATUS_ERROR, $e->getMessage());
            throw $e;
        } finally {
            $scope->detach();
            $span->end();
        }
    }

    private function completeWithFallback(AiRequestDTO $request, SpanInterface $span): AiResponseDTO
    {
        // Route local agent requests directly — no fallback chain
        if ($this->isLocalProvider($request->provider)) {
            if (! $this->localGateway) {
                throw new \RuntimeException(
                    "Local agent provider '{$request->provider}' is not available. "
                    .'Enable local agents (LOCAL_AGENTS_ENABLED=true) and ensure the agent binary is installed.',
                );
            }

            $span->setAttribute('ai.gateway.route', 'local');

            return $this->localGateway->complete($request);
        }

        $chain = $this->getFallbackChain($request->provider, $request->model, $request->fallbackChain);
        $chain = $this->maybeRankChain($chain, $request->gatewaySort);

        $lastException = null;
        $firstException = null;

        foreach ($chain as $target) {
            $providerName = $target['provider'];
            $modelName = $target['model'];

            // Bridge provider — route through bridge daemon, fall through to next on failure
            if ($this->isBridgeProvider($providerName)) {
                if (! $this->bridgeGateway) {
                    Log::warning("AI Gateway: bridge provider '{$providerName}' not available, trying next in chain");

                    continue;
                }

                try {
                    $adjustedRequest = new AiRequestDTO(
                        provider: $providerName,
                        model: $modelName,
                        systemPrompt: $request->systemPrompt,
                        userPrompt: $request->userPrompt,
                        maxTokens: $request->maxTokens,
                        outputSchema: $request->outputSchema,
                        userId: $request->userId,
                        experimentId: $request->experimentId,
                        experimentStageId: $request->experimentStageId,
                        agentId: $request->agentId,
                        purpose: $request->purpose,
                        idempotencyKey: $request->idempotencyKey,
                        temperature: $request->temperature,
                        teamId: $request->teamId,
                        fallbackChain: $request->fallbackChain,
                        tools: $request->tools,
                        maxSteps: $request->maxSteps,
                        toolChoice: $request->toolChoice,
                        thinkingBudget: $request->thinkingBudget,
                        effort: $request->effort,
                        workingDirectory: $request->workingDirectory,
                        enablePromptCaching: $request->enablePromptCaching,
                        complexity: $request->complexity,
                        classifiedComplexity: $request->classifiedComplexity,
                        budgetPressureLevel: $request->budgetPressureLevel,
                        escalationAttempts: $request->escalationAttempts,
                        fastMode: $request->fastMode,
                        providerCredentialOverride: $request->providerCredentialOverride,
                        gatewaySort: $request->gatewaySort,
                    );

                    $span->setAttribute('ai.gateway.route', 'bridge');
                    $span->setAttribute('ai.gateway.final_provider', $providerName);
                    $span->setAttribute('ai.gateway.final_model', $modelName);

                    return $this->bridgeGateway->complete($adjustedRequest);
                } catch (Throwable $e) {
                    $firstException ??= $e;
                    $lastException = $e;
                    Log::warning("AI Gateway: bridge provider '{$providerName}' failed, trying next in chain", [
                        'error' => $e->getMessage(),
                    ]);

                    continue;
                }
            }

            if (! $this->providerHasApiKey($providerName, $request->teamId)) {
                Log::debug("AI Gateway: skipping {$providerName} (no API key configured)");

                continue;
            }

            if (! $this->circuitBreaker->isAvailable($providerName)) {
                Log::debug("CircuitBreaker: skipping {$providerName} (circuit open)");

                continue;
            }

            try {
                $adjustedRequest = new AiRequestDTO(
                    provider: $providerName,
                    model: $modelName,
                    systemPrompt: $request->systemPrompt,
                    userPrompt: $request->userPrompt,
                    maxTokens: $request->maxTokens,
                    outputSchema: $request->outputSchema,
                    userId: $request->userId,
                    experimentId: $request->experimentId,
                    experimentStageId: $request->experimentStageId,
                    agentId: $request->agentId,
                    purpose: $request->purpose,
                    idempotencyKey: $request->idempotencyKey,
                    temperature: $request->temperature,
                    teamId: $request->teamId,
                    fallbackChain: $request->fallbackChain,
                    tools: $request->tools,
                    maxSteps: $request->maxSteps,
                    toolChoice: $request->toolChoice,
                    providerCredentialOverride: $providerName === $request->provider
                        ? $request->providerCredentialOverride
                        : null,
                    gatewaySort: $request->gatewaySort,
                );

                $response = $this->gateway->complete($adjustedRequest);

                $this->circuitBreaker->recordSuccess($providerName);

                $span->setAttribute('ai.gateway.final_provider', $providerName);
                $span->setAttribute('ai.gateway.final_model', $modelName);
                $span->setAttribute('ai.gateway.fallback_used', $providerName !== $request->provider);

                return $response;
            } catch (PrismRateLimitedException $e) {
                // Rate limits are temporary — don't break the circuit
                $firstException ??= $e;
                $lastException = $e;
                Log::warning("AI Gateway: {$providerName}/{$modelName} rate limited (not recording CB failure)", [
                    'error' => $e->getMessage(),
                ]);
            } catch (\RuntimeException $e) {
                // Missing BYOK / plan restriction — config error, not a real provider failure.
                // Don't record a circuit breaker failure for this. Store as last but preserve first.
                $firstException ??= $e;
                $lastException = $e;
                Log::warning("AI Gateway fallback: {$providerName}/{$modelName} config error (not recording CB failure)", [
                    'error' => $e->getMessage(),
                ]);
            } catch (Throwable $e) {
                // Auth errors (401 / missing or revoked key) are config errors disguised
                // as provider failures — let the chain advance without breaking the
                // circuit. Mirrors the \RuntimeException branch above.
                if ($this->isAuthError($e)) {
                    $firstException ??= $e;
                    $lastException = $e;
                    Log::warning("AI Gateway fallback: {$providerName}/{$modelName} auth error (not recording CB failure)", [
                        'error' => $e->getMessage(),
                    ]);

                    continue;
                }

                $firstException ??= $e;
                $lastException = $e;
                $this->circuitBreaker->recordFailure($providerName);
                Log::warning("AI Gateway fallback: {$providerName}/{$modelName} failed", [
                    'error' => $e->getMessage(),
                ]);

                // Try model-tier escalation for quality failures before moving to next provider
                $escalatedResponse = $this->attemptEscalation($request, $e, $providerName, $modelName);
                if ($escalatedResponse !== null) {
                    return $escalatedResponse;
                }
            }
        }

        // Throw the first exception so the user sees the root cause (e.g. rate limit),
        // not a misleading error from a fallback provider they didn't configure.
        throw $firstException ?? new \RuntimeException('No available providers in fallback chain');
    }

    public function stream(AiRequestDTO $request, ?callable $onChunk = null): AiResponseDTO
    {
        $tracer = app(FleetTracerProvider::class)->tracer('fleetq.ai');
        $span = $tracer->spanBuilder('ai.gateway.stream')
            ->setSpanKind(SpanKind::KIND_CLIENT)
            ->setAttribute('ai.gateway.requested_provider', $request->provider)
            ->setAttribute('ai.gateway.requested_model', $request->model)
            ->setAttribute('ai.gateway.team_id', (string) ($request->teamId ?? 'unknown'))
            ->setAttribute('ai.gateway.purpose', (string) ($request->purpose ?? 'unknown'))
            ->startSpan();
        $scope = $span->activate();

        try {
            $result = $this->streamWithFallback($request, $onChunk, $span);
            $span->setStatus(StatusCode::STATUS_OK);

            return $result;
        } catch (Throwable $e) {
            $span->recordException($e);
            $span->setStatus(StatusCode::STATUS_ERROR, $e->getMessage());
            throw $e;
        } finally {
            $scope->detach();
            $span->end();
        }
    }

    private function streamWithFallback(AiRequestDTO $request, ?callable $onChunk, SpanInterface $span): AiResponseDTO
    {
        // Route local agent requests directly — no fallback chain
        if ($this->isLocalProvider($request->provider)) {
            if (! $this->localGateway) {
                throw new \RuntimeException(
                    "Local agent provider '{$request->provider}' is not available. "
                    .'Enable local agents (LOCAL_AGENTS_ENABLED=true) and ensure the agent binary is installed.',
                );
            }

            $span->setAttribute('ai.gateway.route', 'local');

            return $this->localGateway->stream($request, $onChunk);
        }

        $chain = $this->getFallbackChain($request->provider, $request->model, $request->fallbackChain);
        $chain = $this->maybeRankChain($chain, $request->gatewaySort);
        $firstException = null;
        $lastException = null;

        foreach ($chain as $target) {
            $providerName = $target['provider'];
            $modelName = $target['model'];

            // Bridge provider — route through bridge daemon, fall through to next on failure
            if ($this->isBridgeProvider($providerName)) {
                if (! $this->bridgeGateway) {
                    Log::warning("AI Gateway: bridge provider '{$providerName}' not available, trying next in chain");

                    continue;
                }

                try {
                    $adjustedRequest = new AiRequestDTO(
                        provider: $providerName,
                        model: $modelName,
                        systemPrompt: $request->systemPrompt,
                        userPrompt: $request->userPrompt,
                        maxTokens: $request->maxTokens,
                        outputSchema: $request->outputSchema,
                        userId: $request->userId,
                        experimentId: $request->experimentId,
                        experimentStageId: $request->experimentStageId,
                        agentId: $request->agentId,
                        purpose: $request->purpose,
                        idempotencyKey: $request->idempotencyKey,
                        temperature: $request->temperature,
                        teamId: $request->teamId,
                        fallbackChain: $request->fallbackChain,
                        tools: $request->tools,
                        maxSteps: $request->maxSteps,
                        toolChoice: $request->toolChoice,
                        thinkingBudget: $request->thinkingBudget,
                        effort: $request->effort,
                        workingDirectory: $request->workingDirectory,
                        enablePromptCaching: $request->enablePromptCaching,
                        complexity: $request->complexity,
                        classifiedComplexity: $request->classifiedComplexity,
                        budgetPressureLevel: $request->budgetPressureLevel,
                        escalationAttempts: $request->escalationAttempts,
                        fastMode: $request->fastMode,
                        providerCredentialOverride: $request->providerCredentialOverride,
                        gatewaySort: $request->gatewaySort,
                    );

                    $span->setAttribute('ai.gateway.route', 'bridge');
                    $span->setAttribute('ai.gateway.final_provider', $providerName);
                    $span->setAttribute('ai.gateway.final_model', $modelName);

                    return $this->bridgeGateway->stream($adjustedRequest, $onChunk);
                } catch (Throwable $e) {
                    $firstException ??= $e;
                    $lastException = $e;
                    Log::warning("AI Gateway: bridge provider '{$providerName}' failed, trying next in chain", [
                        'error' => $e->getMessage(),
                    ]);

                    continue;
                }
            }

            if (! $this->providerHasApiKey($providerName, $request->teamId)) {
                continue;
            }

            if (! $this->circuitBreaker->isAvailable($providerName)) {
                continue;
            }

            try {
                $adjustedRequest = new AiRequestDTO(
                    provider: $providerName,
                    model: $modelName,
                    systemPrompt: $request->systemPrompt,
                    userPrompt: $request->userPrompt,
                    maxTokens: $request->maxTokens,
                    outputSchema: $request->outputSchema,
                    userId: $request->userId,
                    experimentId: $request->experimentId,
                    experimentStageId: $request->experimentStageId,
                    agentId: $request->agentId,
                    purpose: $request->purpose,
                    idempotencyKey: $request->idempotencyKey,
                    temperature: $request->temperature,
                    teamId: $request->teamId,
                    fallbackChain: $request->fallbackChain,
                    tools: $request->tools,
                    maxSteps: $request->maxSteps,
                    toolChoice: $request->toolChoice,
                    providerCredentialOverride: $providerName === $request->provider
                        ? $request->providerCredentialOverride
                        : null,
                    gatewaySort: $request->gatewaySort,
                );

                $response = $this->gateway->stream($adjustedRequest, $onChunk);
                $this->circuitBreaker->recordSuccess($providerName);

                $span->setAttribute('ai.gateway.final_provider', $providerName);
                $span->setAttribute('ai.gateway.final_model', $modelName);
                $span->setAttribute('ai.gateway.fallback_used', $providerName !== $request->provider);

                return $response;
            } catch (\RuntimeException $e) {
                $firstException ??= $e;
                $lastException = $e;
                Log::warning("AI Gateway stream fallback: {$providerName}/{$modelName} config error", [
                    'error' => $e->getMessage(),
                ]);
            } catch (Throwable $e) {
                if ($this->isAuthError($e)) {
                    $firstException ??= $e;
                    $lastException = $e;
                    Log::warning("AI Gateway stream fallback: {$providerName}/{$modelName} auth error (not recording CB failure)", [
                        'error' => $e->getMessage(),
                    ]);

                    continue;
                }

                $firstException ??= $e;
                $lastException = $e;
                $this->circuitBreaker->recordFailure($providerName);

                // Try model-tier escalation for quality failures before moving to next provider
                $escalatedResponse = $this->attemptStreamEscalation($request, $e, $providerName, $modelName, $onChunk);
                if ($escalatedResponse !== null) {
                    return $escalatedResponse;
                }
            }
        }

        throw $firstException ?? new \RuntimeException('No available providers in fallback chain');
    }

    public function estimateCost(AiRequestDTO $request): int
    {
        if ($this->isBridgeProvider($request->provider) || $this->isLocalProvider($request->provider)) {
            return 0;
        }

        return $this->gateway->estimateCost($request);
    }

    /**
     * @return list<array{provider: string, model: string}>
     */
    private function getFallbackChain(string $provider, string $model, ?array $requestChain = null): array
    {
        $chain = [['provider' => $provider, 'model' => $model]];

        // Per-request fallback chain takes priority over global chains
        if ($requestChain) {
            return array_merge($chain, $requestChain);
        }

        $key = "{$provider}/{$model}";

        if (isset($this->fallbackChains[$key])) {
            $chain = array_merge($chain, $this->fallbackChains[$key]);
        }

        return $chain;
    }

    /**
     * Reorder the fallback chain by 24h-rolling provider metric, when requested
     * and a ranker is wired up. Local/bridge entries pass through unchanged.
     *
     * @param  list<array{provider: string, model: string}>  $chain
     * @return list<array{provider: string, model: string}>
     */
    private function maybeRankChain(array $chain, ?string $sortBy): array
    {
        // A per-request gatewaySort wins; otherwise fall back to the platform
        // default (off unless AI_PROVIDER_RANKING_SORT is set). This makes
        // health/latency routing active fleet-wide rather than only when a
        // caller remembers to opt in per request.
        $sortBy ??= config('ai_routing.provider_ranking.default_sort');

        if ($sortBy === null || $this->ranker === null || count($chain) <= 1) {
            return $chain;
        }

        $cloud = [];
        $passthrough = [];

        foreach ($chain as $target) {
            if ($this->isBridgeProvider($target['provider']) || $this->isLocalProvider($target['provider'])) {
                $passthrough[] = $target;

                continue;
            }

            $cloud[] = $target;
        }

        $ranked = $this->ranker->rank($cloud, $sortBy);

        return array_merge($ranked, $passthrough);
    }

    private function isBridgeProvider(string $provider): bool
    {
        return (bool) config("llm_providers.{$provider}.bridge");
    }

    private function isLocalProvider(string $provider): bool
    {
        // Explicit "local" provider name (generic alias)
        if ($provider === 'local') {
            return true;
        }

        return (bool) config("llm_providers.{$provider}.local");
    }

    /**
     * Whether a non-bridge / non-local provider has an API key configured.
     *
     * Bridge and local providers never need a key, so always return true for them.
     * For cloud providers (anthropic, openai, google, etc.) check both the
     * provider-specific config (config/ai.php) and the generic services config —
     * either is enough to satisfy Prism. An empty string is treated as missing.
     */
    private function providerHasApiKey(string $providerName, ?string $teamId = null): bool
    {
        if ($this->isBridgeProvider($providerName) || $this->isLocalProvider($providerName)) {
            return true;
        }

        // A team's active BYOK credential satisfies the gate: applyTeamCredentials()
        // injects it into the prism config before the actual provider call. Without
        // this, a BYOK-only provider (e.g. openrouter) that the platform has no key
        // for is skipped here, and the chain exhausts to "No available providers"
        // even though the team can reach the provider.
        // rescue(): a DB hiccup (or schema-less test context) must degrade to the
        // config-key gate below, not fail the whole provider chain.
        if ($teamId !== null && rescue(fn (): bool => TeamProviderCredential::where('team_id', $teamId)
            ->where('provider', $providerName)
            ->where('is_active', true)
            ->exists(), false, false)) {
            return true;
        }

        // config/ai.php declares providers as `'key' => env(...)`; keep `.api_key`
        // as a fallback for forks that already standardised on that field name.
        // Sub-program teams (finance, etc.) resolve their credentials from a
        // dedicated key in applyTeamCredentials(); count it here too, otherwise
        // this team-blind gate skips the provider before credentials are applied.
        $candidates = [
            config("ai.providers.{$providerName}.key"),
            config("ai.providers.{$providerName}.api_key"),
            config("services.{$providerName}.key"),
            config("services.{$providerName}"),
            config("services.sub_program_api_keys.{$providerName}"),
        ];

        foreach ($candidates as $candidate) {
            if (is_string($candidate) && $candidate !== '') {
                return true;
            }
        }

        return false;
    }

    /**
     * Classify a provider exception as an authentication failure (revoked /
     * missing / wrong key). Provider SDKs throw various concrete classes for
     * 401s, so we sniff the message rather than the class hierarchy. Treated
     * the same as a config error: skip the provider without breaking the
     * circuit, advance the fallback chain.
     */
    private function isAuthError(Throwable $e): bool
    {
        $msg = $e->getMessage();

        return str_contains($msg, 'authentication_error')
            || str_contains($msg, 'x-api-key header is required')
            || str_contains($msg, 'Incorrect API key')
            || str_contains($msg, 'Invalid API Key')
            || str_contains($msg, 'API key not valid')
            || (str_contains($msg, '[401]') && str_contains($msg, 'Anthropic'))
            || (str_contains($msg, 'status code 401'));
    }

    /**
     * Attempt model-tier escalation for quality failures (complete path).
     */
    private function attemptEscalation(
        AiRequestDTO $request,
        Throwable $exception,
        string $providerName,
        string $modelName,
    ): ?AiResponseDTO {
        if (! $this->escalationStrategy) {
            return null;
        }

        $failureType = $this->escalationStrategy->classifyFailure($exception);

        if (! $failureType->shouldEscalateModel()) {
            return null;
        }

        $escalated = $this->escalationStrategy->getEscalatedModel($request, $providerName, $modelName);

        if ($escalated === null) {
            return null;
        }

        try {
            $escalatedRequest = $this->buildEscalatedRequest($request, $escalated['provider'], $escalated['model']);
            $response = $this->gateway->complete($escalatedRequest);
            $this->circuitBreaker->recordSuccess($escalated['provider']);

            return $response;
        } catch (Throwable $e) {
            Log::warning("AI Gateway: escalation to {$escalated['provider']}/{$escalated['model']} failed", [
                'error' => $e->getMessage(),
            ]);

            return null;
        }
    }

    /**
     * Attempt model-tier escalation for quality failures (stream path).
     */
    private function attemptStreamEscalation(
        AiRequestDTO $request,
        Throwable $exception,
        string $providerName,
        string $modelName,
        ?callable $onChunk,
    ): ?AiResponseDTO {
        if (! $this->escalationStrategy) {
            return null;
        }

        $failureType = $this->escalationStrategy->classifyFailure($exception);

        if (! $failureType->shouldEscalateModel()) {
            return null;
        }

        $escalated = $this->escalationStrategy->getEscalatedModel($request, $providerName, $modelName);

        if ($escalated === null) {
            return null;
        }

        try {
            $escalatedRequest = $this->buildEscalatedRequest($request, $escalated['provider'], $escalated['model']);
            $response = $this->gateway->stream($escalatedRequest, $onChunk);
            $this->circuitBreaker->recordSuccess($escalated['provider']);

            return $response;
        } catch (Throwable $e) {
            Log::warning("AI Gateway: stream escalation to {$escalated['provider']}/{$escalated['model']} failed", [
                'error' => $e->getMessage(),
            ]);

            return null;
        }
    }

    /**
     * Build a new AiRequestDTO with escalated provider/model and incremented attempts.
     */
    private function buildEscalatedRequest(AiRequestDTO $request, string $provider, string $model): AiRequestDTO
    {
        return new AiRequestDTO(
            provider: $provider,
            model: $model,
            systemPrompt: $request->systemPrompt,
            userPrompt: $request->userPrompt,
            maxTokens: $request->maxTokens,
            outputSchema: $request->outputSchema,
            userId: $request->userId,
            teamId: $request->teamId,
            experimentId: $request->experimentId,
            experimentStageId: $request->experimentStageId,
            agentId: $request->agentId,
            purpose: $request->purpose,
            idempotencyKey: $request->idempotencyKey,
            temperature: $request->temperature,
            fallbackChain: $request->fallbackChain,
            tools: $request->tools,
            maxSteps: $request->maxSteps,
            toolChoice: $request->toolChoice,
            providerName: $request->providerName,
            thinkingBudget: $request->thinkingBudget,
            workingDirectory: $request->workingDirectory,
            enablePromptCaching: $request->enablePromptCaching,
            complexity: $request->complexity,
            classifiedComplexity: $request->classifiedComplexity,
            budgetPressureLevel: $request->budgetPressureLevel,
            escalationAttempts: $request->escalationAttempts + 1,
            providerCredentialOverride: $provider === $request->provider
                ? $request->providerCredentialOverride
                : null,
            gatewaySort: $request->gatewaySort,
        );
    }

    /**
     * Mirror a completed request to a candidate model on a background queue for
     * offline A/B. Fully guarded — any failure here must never affect the primary
     * response that has already been produced. Skips structured/tool requests and
     * never recurses (the shadow leg's purpose ends in ':shadow').
     */
    private function maybeDispatchShadow(AiRequestDTO $request, AiResponseDTO $result): void
    {
        try {
            if (! (bool) config('ai_routing.shadow_traffic.enabled', false)) {
                return;
            }

            $shadowProvider = (string) config('ai_routing.shadow_traffic.provider', '');
            $shadowModel = (string) config('ai_routing.shadow_traffic.model', '');
            if ($shadowProvider === '' || $shadowModel === '') {
                return;
            }

            // Never mirror structured/tool calls; never recurse on the shadow leg.
            if ($request->isStructured() || $request->hasTools() || str_ends_with((string) $request->purpose, ':shadow')) {
                return;
            }

            // Don't shadow the same model against itself.
            if ($shadowProvider === $result->provider && $shadowModel === $result->model) {
                return;
            }

            $rate = (float) config('ai_routing.shadow_traffic.sample_rate', 0.0);
            if ($rate <= 0.0 || (mt_rand() / mt_getrandmax()) >= $rate) {
                return;
            }

            RunShadowComparisonJob::dispatch(
                $request->systemPrompt,
                $request->userPrompt,
                $request->maxTokens,
                $request->temperature,
                $request->teamId,
                $request->purpose,
                $result->provider,
                $result->model,
                $result->latencyMs,
                $result->usage->costCredits,
                $result->content,
                $shadowProvider,
                $shadowModel,
                (bool) config('ai_routing.shadow_traffic.store_snippets', false),
                (int) config('ai_routing.shadow_traffic.snippet_chars', 2000),
            )->onQueue((string) config('ai_routing.shadow_traffic.queue', 'metrics'));
        } catch (Throwable $e) {
            Log::warning('Shadow traffic dispatch failed (primary unaffected)', [
                'error' => $e->getMessage(),
            ]);
        }
    }
}
