<?php

namespace App\Infrastructure\AI\Services;

use App\Domain\Agent\Models\Agent;
use App\Domain\Bridge\Models\BridgeConnection;
use App\Domain\Crew\Models\CrewMember;
use App\Domain\Shared\Enums\DataClassification;
use App\Domain\Shared\Models\Team;
use App\Domain\Shared\Models\TeamProviderCredential;
use App\Domain\Shared\Services\PlanEnforcer;
use App\Domain\Skill\Models\Skill;
use App\Infrastructure\AI\Exceptions\DataClassificationException;
use App\Infrastructure\AI\Exceptions\ModelNotAllowedException;
use App\Infrastructure\AI\Gateways\PortkeyGateway;
use App\Models\GlobalSetting;
use Illuminate\Support\Collection;
use Illuminate\Support\Facades\Log;
use Illuminate\Support\Str;

class ProviderResolver
{
    /**
     * Resolve provider and model using the hierarchy:
     * 1. Skill split-model override (if model_selection_mode === 'split')
     * 2. Skill unified override
     * 3. Agent-level override
     * 4. Team workspace default
     * 5. Platform default
     *
     * @param  string  $purpose  'run' (default, production) or 'build' (design/testing)
     * @return array{provider: string, model: string}
     */
    public function resolve(
        ?Skill $skill = null,
        ?Agent $agent = null,
        ?Team $team = null,
        string $purpose = 'run',
    ): array {
        $resolved = $this->resolveHierarchy($skill, $agent, $team, $purpose);
        $resolved = $this->enforceAllowedModels($resolved, $team);
        $resolved = $this->enforceAvailability($resolved, $team);
        $resolved = $this->enforceModelBelongsToProvider($resolved, $team);

        return $this->enforceDataClassification($resolved, $agent, $team);
    }

    /**
     * Sanity-check that the resolved provider is actually reachable.
     *
     * Bug class this guards against: a stale `GlobalSetting::default_llm_provider`
     * (e.g. left pointing at `bridge_agent` after the team's bridge agents
     * deregistered) would otherwise be returned as-is by `resolveHierarchy()`,
     * and the AI gateway would hang for the full timeout (e.g. 90s) trying to
     * reach a non-existent endpoint. Production incident 2026-05-12: Finance
     * sub-program OCR timed out because the GlobalSetting pointed at a bridge
     * with 0 active agents.
     *
     * Strategy: if the resolved provider is NOT in `availableProviders($team)`,
     * fall back to the first listed available provider (and its first listed
     * model). When no providers are available at all, return the original
     * resolution so the caller can fail loudly downstream with a clear error.
     *
     * @param  array{provider: string, model: string}  $resolved
     * @return array{provider: string, model: string}
     */
    private function enforceAvailability(array $resolved, ?Team $team): array
    {
        if ($team === null) {
            return $resolved;
        }

        try {
            $available = $this->availableProviders($team);
        } catch (\Throwable) {
            // Availability check itself failed (DB / config issue) — don't poison
            // the resolution path; return the original and let the caller fail
            // with whatever error the underlying gateway produces.
            return $resolved;
        }

        if (isset($available[$resolved['provider']])) {
            return $resolved;
        }

        if ($available === []) {
            // No providers configured for this team at all. Let the gateway
            // surface the InsufficientProvidersException downstream.
            return $resolved;
        }

        $firstProvider = array_key_first($available);
        $firstModel = array_key_first($available[$firstProvider]['models'] ?? []);

        if ($firstModel === null) {
            $firstModel = (string) config(
                "llm_pricing.default_models.{$firstProvider}",
                config('llm_pricing.default_model', 'claude-sonnet-4-5'),
            );
        }

        Log::warning('ProviderResolver: resolved provider is not available for team — falling back', [
            'team_id' => $team->id,
            'resolved' => $resolved,
            'fallback' => ['provider' => $firstProvider, 'model' => $firstModel],
            'available' => array_keys($available),
        ]);

        return [
            'provider' => $firstProvider,
            'model' => (string) $firstModel,
        ];
    }

    /**
     * Ensure the resolved model actually belongs to the resolved provider's catalog.
     *
     * Bug class this guards against: a cloud-only deployment whose agents/teams are
     * still configured with a foreign model name (e.g. an `anthropic` agent's
     * `claude-haiku-4-5` model ending up paired with the `openai` provider after a
     * team flip to cloud-only). The (provider, model) pair would pass
     * enforceAvailability() — the provider IS available — but the foreign model name
     * is then POSTed to that provider's endpoint, which rejects it with a 400
     * ("model 'claude-haiku-4-5' does not exist"). That 400 is NOT a provider outage;
     * it is a config error. Without this guard the FallbackAiGateway records it as a
     * CircuitBreaker failure, and a handful of such calls trips the breaker for the
     * only working cloud provider — short-circuiting every subsequent LLM call to an
     * empty completion (prod incident 2026-06-02, Barsy chatbot cloud-only flip).
     *
     * Strategy: only applies to providers that expose a STATIC model catalog in
     * config('llm_providers.{provider}.models') — i.e. cloud providers. Local, bridge
     * and http_local providers carry dynamic/empty catalogs and are left untouched.
     * When the resolved model is absent from that catalog, swap it for the team's
     * first available model for that provider (falling back to the first catalog
     * model), so the gateway never emits an invalid pair.
     *
     * @param  array{provider: string, model: string}  $resolved
     * @return array{provider: string, model: string}
     */
    private function enforceModelBelongsToProvider(array $resolved, ?Team $team): array
    {
        $provider = $resolved['provider'];

        // Local / bridge / http-local providers manage their own (often dynamic or
        // compound "agent:model") model names — never validate against a static catalog.
        if ($this->isLocalProvider($provider) || config("llm_providers.{$provider}.http_local")) {
            return $resolved;
        }

        $catalog = config("llm_providers.{$provider}.models");

        // No static catalog for this provider (e.g. custom endpoint) — nothing to validate.
        if (! is_array($catalog) || $catalog === []) {
            return $resolved;
        }

        if (isset($catalog[$resolved['model']])) {
            return $resolved;
        }

        // The model does not belong to this provider. Prefer a model the team can
        // actually use, then fall back to the provider's first catalog model.
        $replacement = null;
        if ($team !== null) {
            try {
                $available = $this->availableProviders($team);
                $replacement = array_key_first($available[$provider]['models'] ?? []);
            } catch (\Throwable) {
                $replacement = null;
            }
        }

        $replacement ??= array_key_first($catalog);

        Log::warning('ProviderResolver: resolved model does not belong to provider — swapping to provider default', [
            'team_id' => $team?->id,
            'provider' => $provider,
            'requested_model' => $resolved['model'],
            'replacement_model' => $replacement,
        ]);

        return [
            'provider' => $provider,
            'model' => (string) $replacement,
        ];
    }

    /**
     * Resolve provider and model for a crew member, honoring the per-role
     * model_override (config['model_override']) before falling through to
     * the agent/team/platform hierarchy. Override is still subjected to the
     * same allowed-models and data-classification enforcement that resolve()
     * runs, so a stale override that became disallowed will not slip through.
     *
     * @return array{provider: string, model: string}
     */
    public function forCrewRole(CrewMember $member, string $purpose = 'run'): array
    {
        $override = $member->model_override;
        $agent = $member->agent;
        $team = $agent?->team;

        if ($override !== null) {
            $resolved = $this->enforceAllowedModels($override, $team);

            return $this->enforceDataClassification($resolved, $agent, $team);
        }

        return $this->resolve(skill: null, agent: $agent, team: $team, purpose: $purpose);
    }

    /**
     * Resolve a PortkeyGateway instance for a team if Portkey is configured.
     *
     * Returns null when the team has no active Portkey credential.
     */
    public function resolvePortkeyGateway(?Team $team): ?PortkeyGateway
    {
        if (! $team) {
            return null;
        }

        $credential = TeamProviderCredential::where('team_id', $team->id)
            ->where('provider', 'portkey')
            ->where('is_active', true)
            ->first();

        if (! $credential) {
            return null;
        }

        $apiKey = $credential->credentials['api_key'] ?? null;

        if (! $apiKey) {
            return null;
        }

        $virtualKey = $credential->credentials['virtual_key'] ?? null;

        return new PortkeyGateway(apiKey: $apiKey, virtualKey: $virtualKey);
    }

    /**
     * Walk the provider hierarchy and return the first matching (provider, model) pair.
     *
     * @return array{provider: string, model: string}
     */
    private function resolveHierarchy(
        ?Skill $skill,
        ?Agent $agent,
        ?Team $team,
        string $purpose,
    ): array {
        // 1. Skill-level override
        if ($skill) {
            $config = $skill->configuration ?? [];

            // Split model: separate build/run models
            if (($config['model_selection_mode'] ?? 'unified') === 'split') {
                $key = $purpose === 'build' ? 'build_model' : 'run_model';
                $modelConfig = $config[$key] ?? null;

                if ($modelConfig && ! empty($modelConfig['provider']) && ! empty($modelConfig['model'])) {
                    return [
                        'provider' => $modelConfig['provider'],
                        'model' => $modelConfig['model'],
                    ];
                }
            }

            // Unified: single model for all executions
            if (! empty($config['provider']) && ! empty($config['model'])) {
                return [
                    'provider' => $config['provider'],
                    'model' => $config['model'],
                ];
            }
        }

        // 2. Agent-level override
        if ($agent && $agent->provider && $agent->model) {
            return [
                'provider' => $agent->provider,
                'model' => $agent->model,
            ];
        }

        // 3. Team workspace default
        if ($team) {
            $settings = $team->settings ?? [];
            if (! empty($settings['default_llm_provider']) && ! empty($settings['default_llm_model'])) {
                return [
                    'provider' => $settings['default_llm_provider'],
                    'model' => $settings['default_llm_model'],
                ];
            }
        }

        // 3.5. Stage-level model tier routing (after team, before platform default)
        if ($purpose && str_starts_with($purpose, 'stage:')) {
            $stageType = Str::after($purpose, 'stage:');
            $teamTiers = $team?->settings['stage_model_tiers'] ?? null;
            $tier = (is_array($teamTiers) ? ($teamTiers[$stageType] ?? null) : null)
                ?? config("experiments.stage_model_tiers.{$stageType}");

            if ($tier && $tier !== 'standard') {
                $tierModels = config("experiments.model_tiers.{$tier}");

                if ($tierModels && is_array($tierModels)) {
                    foreach ($tierModels as $provider => $model) {
                        if ($this->teamHasProvider($team, $provider)) {
                            return ['provider' => $provider, 'model' => $model];
                        }
                    }
                }
            }
        }

        // 4. GlobalSetting (configured via Settings page)
        $globalProvider = GlobalSetting::get('default_llm_provider');
        $globalModel = GlobalSetting::get('default_llm_model');
        if ($globalProvider && $globalModel) {
            return [
                'provider' => $globalProvider,
                'model' => $globalModel,
            ];
        }

        // 5. Platform default
        return [
            'provider' => config('llm_pricing.default_provider', 'anthropic'),
            'model' => config('llm_pricing.default_model', 'claude-sonnet-4-5'),
        ];
    }

    /**
     * Resolve provider and model with source attribution.
     *
     * Returns the same result as resolve() plus a 'source' key indicating
     * which level of the hierarchy provided the value:
     * 'skill_split', 'skill', 'agent', 'team', 'platform', or 'config'.
     *
     * @return array{provider: string, model: string, source: string}
     */
    public function resolveWithSource(
        ?Skill $skill = null,
        ?Agent $agent = null,
        ?Team $team = null,
        string $purpose = 'run',
    ): array {
        $result = $this->resolveWithSourceInternal($skill, $agent, $team, $purpose);
        $enforced = $this->enforceAllowedModels(
            ['provider' => $result['provider'], 'model' => $result['model']],
            $team,
        );

        return array_merge($result, $enforced);
    }

    private function resolveWithSourceInternal(
        ?Skill $skill,
        ?Agent $agent,
        ?Team $team,
        string $purpose,
    ): array {
        // 1. Skill-level override
        if ($skill) {
            $config = $skill->configuration ?? [];

            if (($config['model_selection_mode'] ?? 'unified') === 'split') {
                $key = $purpose === 'build' ? 'build_model' : 'run_model';
                $modelConfig = $config[$key] ?? null;

                if ($modelConfig && ! empty($modelConfig['provider']) && ! empty($modelConfig['model'])) {
                    return [
                        'provider' => $modelConfig['provider'],
                        'model' => $modelConfig['model'],
                        'source' => 'skill_split',
                    ];
                }
            }

            if (! empty($config['provider']) && ! empty($config['model'])) {
                return [
                    'provider' => $config['provider'],
                    'model' => $config['model'],
                    'source' => 'skill',
                ];
            }
        }

        // 2. Agent-level override
        if ($agent && $agent->provider && $agent->model) {
            return [
                'provider' => $agent->provider,
                'model' => $agent->model,
                'source' => 'agent',
            ];
        }

        // 3. Team workspace default
        if ($team) {
            $settings = $team->settings ?? [];
            if (! empty($settings['default_llm_provider']) && ! empty($settings['default_llm_model'])) {
                return [
                    'provider' => $settings['default_llm_provider'],
                    'model' => $settings['default_llm_model'],
                    'source' => 'team',
                ];
            }
        }

        // 3.5. Stage-level model tier routing
        if ($purpose && str_starts_with($purpose, 'stage:')) {
            $stageType = Str::after($purpose, 'stage:');
            $teamTiers = $team?->settings['stage_model_tiers'] ?? null;
            $tier = (is_array($teamTiers) ? ($teamTiers[$stageType] ?? null) : null)
                ?? config("experiments.stage_model_tiers.{$stageType}");

            if ($tier && $tier !== 'standard') {
                $tierModels = config("experiments.model_tiers.{$tier}");

                if ($tierModels && is_array($tierModels)) {
                    foreach ($tierModels as $provider => $model) {
                        if ($this->teamHasProvider($team, $provider)) {
                            return ['provider' => $provider, 'model' => $model, 'source' => 'stage_tier'];
                        }
                    }
                }
            }
        }

        // 4. GlobalSetting
        $globalProvider = GlobalSetting::get('default_llm_provider');
        $globalModel = GlobalSetting::get('default_llm_model');
        if ($globalProvider && $globalModel) {
            return [
                'provider' => $globalProvider,
                'model' => $globalModel,
                'source' => 'platform',
            ];
        }

        // 5. Config fallback
        return [
            'provider' => config('llm_pricing.default_provider', 'anthropic'),
            'model' => config('llm_pricing.default_model', 'claude-sonnet-4-5'),
            'source' => 'config',
        ];
    }

    /**
     * Get all available providers and their models from config.
     * Local agents are only included when enabled and detected on the system.
     *
     * @return array<string, array{name: string, models: array}>
     */
    public function availableProviders(?Team $team = null): array
    {
        $providers = config('llm_providers', []);

        // Filter providers based on enabled flags and detection
        $localAgentsEnabled = config('local_agents.enabled');
        $localLlmEnabled = config('local_llm.enabled', false);
        $detected = null;

        // Pre-load team's active BYOK provider keys for cloud provider filtering
        $teamByokProviders = $team
            ? TeamProviderCredential::where('team_id', $team->id)
                ->where('is_active', true)
                ->whereNotIn('provider', ['custom_endpoint', 'ollama', 'openai_compatible', 'litellm_proxy'])
                ->pluck('provider')
                ->flip()
                ->all()
            : [];

        // Pre-load team's local LLM credentials — allows per-team override even when
        // local_llm.enabled is globally false (e.g. cloud edition force-disables it).
        $teamLocalLlmProviders = $team
            ? TeamProviderCredential::where('team_id', $team->id)
                ->where('is_active', true)
                ->whereIn('provider', ['ollama', 'openai_compatible'])
                ->pluck('provider')
                ->flip()
                ->all()
            : [];

        foreach ($providers as $key => $provider) {
            // HTTP-based local LLM providers (Ollama, OpenAI-compatible)
            if (! empty($provider['http_local'])) {
                $teamHasCredential = isset($teamLocalLlmProviders[$key]);
                if (! $localLlmEnabled && ! $teamHasCredential) {
                    unset($providers[$key]);
                } else {
                    // Replace static model list with live models fetched from the endpoint.
                    // For Ollama: queries /api/tags. For OpenAI-compat: queries /models.
                    // Returns empty array if the endpoint is unreachable — never shows
                    // hardcoded model names that may not actually be pulled/available.
                    $providers[$key]['models'] = $this->fetchHttpLocalModels($key, $team);
                }

                continue;
            }

            // CLI-based local agent providers (Codex, Claude Code)
            if (! empty($provider['local'])) {
                // VPS variant: MUST be checked before the global localAgentsEnabled
                // flag because the cloud edition forces that flag to false as a
                // safety net against the generic shell-execution local-agent path.
                // The VPS path has its own, stricter gates.
                if (! empty($provider['vps'])) {
                    $gate = app(ClaudeCodeVpsGate::class);
                    $user = auth()->user();

                    if (! $gate->isAllowedForUser($user, $team)) {
                        unset($providers[$key]);

                        continue;
                    }

                    if (! app(LocalAgentDiscovery::class)->isVpsAgentAvailable()) {
                        unset($providers[$key]);
                    }

                    continue;
                }

                // Allow per-team override: super-admins can enable bridge CLI agents
                // for specific teams even when the global kill-switch is off.
                $teamLocalAgentsAllowed = $team && ($team->settings['local_agents_allowed'] ?? false);
                if (! $localAgentsEnabled && ! $teamLocalAgentsAllowed) {
                    unset($providers[$key]);

                    continue;
                }

                // Lazy-detect once
                if ($detected === null) {
                    $detected = app(LocalAgentDiscovery::class)->detect();
                }

                $agentKey = $provider['agent_key'] ?? $key;

                if (! isset($detected[$agentKey])) {
                    unset($providers[$key]);
                }

                continue;
            }

            // Bridge-backed providers — populate models from active BridgeConnection
            if (! empty($provider['bridge'])) {
                if ($key === 'bridge_agent') {
                    $agents = $this->activeBridgeAgents();
                    if (empty($agents)) {
                        unset($providers[$key]);
                    } else {
                        $providers[$key]['models'] = $agents;
                    }
                } else {
                    // bridge_llm and any future bridge providers: remove if no active connection
                    try {
                        $hasActiveBridge = BridgeConnection::active()->exists();
                    } catch (\Throwable) {
                        $hasActiveBridge = false;
                    }
                    if (! $hasActiveBridge) {
                        unset($providers[$key]);
                    }
                }

                continue;
            }

            // Cloud providers: only show if a platform API key, a team BYOK key,
            // or (for sub-program teams) a dedicated sub-program key is configured.
            // Sub-program teams (finance, etc.) have an intentionally-empty platform
            // key — their spend resolves from services.sub_program_api_keys. Without
            // counting it, the provider is dropped and enforceAvailability() reroutes
            // the request to a different provider that returns an empty completion.
            $platformKey = config("services.platform_api_keys.{$key}");
            $subProgramKey = $this->teamHasSubProgramKey($team, $key);
            if (! $platformKey && ! $subProgramKey && ! isset($teamByokProviders[$key])) {
                unset($providers[$key]);
            }

            // Managed multi-model providers (OpenRouter, …): replace the static
            // config catalog with the live provider catalog when dynamic sync is
            // enabled and the provider survived the key check above.
            if (isset($providers[$key]) && ! empty($provider['dynamic_catalog'])) {
                $providers[$key]['models'] = $this->dynamicCatalogModels($key, $team, $providers[$key]['models'] ?? []);
            }
        }

        return $providers;
    }

    /**
     * Get models for a specific provider.
     * For HTTP-local providers (ollama, openai_compatible, litellm_proxy) returns only
     * dynamically discovered models from the endpoint — never the hardcoded static list,
     * because showing models that are not actually available is misleading.
     *
     * @return array<string, array{label: string, input_cost: float, output_cost: float}>
     */
    public function modelsForProvider(string $provider, ?Team $team = null): array
    {
        if (config("llm_providers.{$provider}.http_local")) {
            return $this->fetchHttpLocalModels($provider, $team);
        }

        $static = config("llm_providers.{$provider}.models", []);

        if (! empty(config("llm_providers.{$provider}.dynamic_catalog"))) {
            return $this->dynamicCatalogModels($provider, $team, $static);
        }

        return $static;
    }

    /**
     * Live model catalog for a managed multi-model provider, falling back to the
     * static config catalog when the feature is off or the endpoint is unreachable.
     *
     * @param  array<string, array{label: string, input_cost: float|int, output_cost: float|int}>  $staticModels
     * @return array<string, array{label: string, input_cost: float|int, output_cost: float|int}>
     */
    protected function dynamicCatalogModels(string $provider, ?Team $team, array $staticModels): array
    {
        if (! config('model_catalog.enabled')) {
            return $staticModels;
        }

        $entries = app(ManagedModelDiscovery::class)->discover(
            $provider,
            $this->resolveCatalogApiKey($provider, $team),
        );

        if ($entries === []) {
            return $staticModels;
        }

        $models = [];
        foreach ($entries as $entry) {
            $models[$entry->id] = $entry->toProviderModel();
        }

        return $models;
    }

    /**
     * Resolve the API key for a provider's /models endpoint. Returns null for
     * public endpoints (auth = 'none', e.g. OpenRouter). For bearer endpoints,
     * prefers the team BYOK key, then the platform key.
     */
    protected function resolveCatalogApiKey(string $provider, ?Team $team): ?string
    {
        if (config("llm_providers.{$provider}.models_endpoint_auth") !== 'bearer') {
            return null;
        }

        if ($team) {
            $credential = TeamProviderCredential::where('team_id', $team->id)
                ->where('provider', $provider)
                ->where('is_active', true)
                ->first();

            if ($credential !== null) {
                // `credentials` is a TeamEncryptedArray cast; larastan mis-types it
                // as string, so annotate the local to keep offset access type-safe.
                /** @var array<string, mixed> $creds */
                $creds = $credential->credentials;
                $key = $creds['api_key'] ?? null;
                if (is_string($key) && $key !== '') {
                    return $key;
                }
            }
        }

        $platform = config("services.platform_api_keys.{$provider}");

        return is_string($platform) && $platform !== '' ? $platform : null;
    }

    /**
     * Get active custom AI endpoints for a team.
     *
     * Returns credential records with name, masked key, and base_url.
     * These appear as selectable providers in agent/skill forms.
     *
     * @return Collection<int, TeamProviderCredential>
     */
    public function customEndpointsForTeam(?Team $team): Collection
    {
        if (! $team) {
            return collect();
        }

        return TeamProviderCredential::where('team_id', $team->id)
            ->where('provider', 'custom_endpoint')
            ->where('is_active', true)
            ->get();
    }

    /**
     * Fetch the live model list for an HTTP-based local LLM provider (Ollama, OpenAI-compatible).
     *
     * Uses the team's configured TeamProviderCredential base_url when available,
     * falling back to the provider's config default_url. Results are cached for 60 s
     * via LocalLlmDiscovery so page renders stay fast.
     *
     * Returns an empty array when the endpoint is unreachable or returns no models —
     * never falls back to the hardcoded static list, because showing models that are
     * not actually pulled/available is misleading.
     *
     * @return array<string, array{label: string, input_cost: int, output_cost: int}>
     */
    private function fetchHttpLocalModels(string $provider, ?Team $team): array
    {
        $baseUrl = null;
        $apiKey = null;

        if ($team) {
            $credential = TeamProviderCredential::where('team_id', $team->id)
                ->where('provider', $provider)
                ->where('is_active', true)
                ->first();

            if ($credential) {
                $baseUrl = $credential->credentials['base_url'] ?? null;
                $apiKey = $credential->credentials['api_key'] ?? null;
            }
        }

        // Fall back to the provider's default URL from config (e.g. localhost:11434 for Ollama)
        $baseUrl ??= config("llm_providers.{$provider}.default_url");

        if (! $baseUrl) {
            return [];
        }

        $discovered = app(LocalLlmDiscovery::class)->discoverModels($provider, $baseUrl, $apiKey ?: null);

        return collect($discovered)
            ->mapWithKeys(fn (string $modelId) => [
                $modelId => ['label' => $modelId, 'input_cost' => 0, 'output_cost' => 0],
            ])
            ->all();
    }

    /**
     * Known model lists per bridge agent key.
     * Models are shown as "AgentName — Model Label" in the assistant panel.
     */
    private const BRIDGE_AGENT_MODELS = [
        'claude-code' => [
            'claude-sonnet-4-5' => 'Claude Code — Sonnet 4.5',
            'claude-haiku-4-5' => 'Claude Code — Haiku 4.5',
            'claude-opus-4-6' => 'Claude Code — Opus 4.6',
        ],
        'codex' => [
            'o4-mini' => 'Codex — o4-mini',
            'o3' => 'Codex — o3',
            'o1' => 'Codex — o1',
        ],
        'gemini' => [
            'gemini-2.5-flash' => 'Gemini CLI — 2.5 Flash',
            'gemini-2.5-pro' => 'Gemini CLI — 2.5 Pro',
        ],
        'aider' => [
            'claude-sonnet-4-5' => 'Aider — Sonnet 4.5',
            'claude-haiku-4-5' => 'Aider — Haiku 4.5',
            'gpt-4o' => 'Aider — GPT-4o',
            'gpt-4o-mini' => 'Aider — GPT-4o Mini',
            'gemini-2.5-flash' => 'Aider — Gemini 2.5 Flash',
        ],
    ];

    /**
     * Get models for the bridge_agent provider from the active BridgeConnection.
     *
     * Returns compound keys in the form "agent_key:model" (e.g. "claude-code:claude-sonnet-4-5")
     * for agents with known model lists. For unknown agents, falls back to a single entry
     * keyed by agent_key alone so the agent is still selectable.
     *
     * @return array<string, array{label: string, input_cost: int, output_cost: int}>
     */
    private function activeBridgeAgents(): array
    {
        try {
            // Aggregate agents from ALL active bridge connections (TeamScope auto-filters).
            $connections = BridgeConnection::active()
                ->orderByDesc('priority')
                ->orderByDesc('connected_at')
                ->get();
        } catch (\Throwable) {
            return [];
        }

        if ($connections->isEmpty()) {
            return [];
        }

        $models = [];
        $seenAgentKeys = [];

        foreach ($connections as $connection) {
            foreach ($connection->agents() as $agent) {
                if (! ($agent['found'] ?? false)) {
                    continue;
                }
                $key = $agent['key'];

                // Dedup: first bridge with this agent wins (highest priority / most recent)
                if (isset($seenAgentKeys[$key])) {
                    continue;
                }
                $seenAgentKeys[$key] = true;

                $agentName = $agent['name'] ?? $key;
                $knownModels = self::BRIDGE_AGENT_MODELS[$key] ?? null;

                if ($knownModels) {
                    foreach ($knownModels as $modelKey => $modelLabel) {
                        $models["{$key}:{$modelKey}"] = [
                            'label' => $modelLabel,
                            'input_cost' => 0,
                            'output_cost' => 0,
                        ];
                    }
                } else {
                    // Agent with no known model list (kiro, cursor, cline, opencode, …)
                    // Show as a single selectable option; the agent manages its own model.
                    $models[$key] = [
                        'label' => $agentName,
                        'input_cost' => 0,
                        'output_cost' => 0,
                    ];
                }
            }
        }

        return $models;
    }

    /**
     * Enforce the team's model allowlist if one is configured.
     *
     * @param  array{provider: string, model: string}  $resolved
     * @return array{provider: string, model: string}
     *
     * @throws ModelNotAllowedException
     */
    private function enforceAllowedModels(array $resolved, ?Team $team): array
    {
        if (! $team) {
            return $resolved;
        }

        $allowedModels = $team->allowed_models;

        if (empty($allowedModels)) {
            return $resolved;
        }

        $key = $resolved['provider'].'/'.$resolved['model'];

        if (! in_array($key, $allowedModels, true)) {
            throw new ModelNotAllowedException(
                $resolved['provider'],
                $resolved['model'],
                $team->id,
            );
        }

        return $resolved;
    }

    /**
     * Enforce data classification policy on a resolved (provider, model) pair.
     *
     * Confidential and Restricted agents must run on a local provider (bridge_agent,
     * codex, claude_code, or local_agent). When the resolved provider is a cloud
     * provider and a local alternative is available, the local provider wins.
     * When no local provider exists the behaviour depends on plan entitlement:
     *   - Feature NOT available → silently allow (base / free plans unaffected)
     *   - Feature IS available  → throw DataClassificationException
     *
     * @param  array{provider: string, model: string}  $resolved
     * @return array{provider: string, model: string}
     *
     * @throws DataClassificationException
     */
    private function enforceDataClassification(array $resolved, ?Agent $agent, ?Team $team): array
    {
        $classification = $agent?->data_classification;

        // Only confidential and restricted classifications require local-only routing.
        if (
            $classification === null
            || ! in_array($classification, [DataClassification::Confidential, DataClassification::Restricted], true)
        ) {
            return $resolved;
        }

        // Already on a local provider — nothing to do.
        if ($this->isLocalProvider($resolved['provider'])) {
            return $resolved;
        }

        // Try to find an available local provider for this team.
        $localProvider = $this->resolveLocalProvider($agent, $team);

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

        // No local provider available. Check plan entitlement before blocking.
        // The feature is only enforced on plans that have it; base/open-source and
        // free plans silently allow cloud fallback so they are not broken.
        if (! $this->planEnforcerHasFeature('data_classification_routing')) {
            return $resolved;
        }

        throw new DataClassificationException($agent->id, $classification->value);
    }

    /**
     * Whether a sub-program team (finance, etc.) has a dedicated key configured
     * for this provider. Strictly gated to sub-program teams (sub_program_slug
     * != 'cloud') so it NEVER widens provider availability for a regular cloud
     * BYOK team.
     */
    protected function teamHasSubProgramKey(?Team $team, string $provider): bool
    {
        if (! $team || ($team->sub_program_slug ?? 'cloud') === 'cloud') {
            return false;
        }

        return (bool) config("services.sub_program_api_keys.{$provider}");
    }

    /**
     * Check whether a team has an active credential for a specific cloud provider.
     *
     * Also returns true when the platform has a global API key for the provider,
     * since the team can use it as a fallback.
     */
    protected function teamHasProvider(?Team $team, string $provider): bool
    {
        // Platform-level API key (available to all teams)
        if (config("services.platform_api_keys.{$provider}")) {
            return true;
        }

        if (! $team) {
            return false;
        }

        return TeamProviderCredential::where('team_id', $team->id)
            ->where('provider', $provider)
            ->where('is_active', true)
            ->exists();
    }

    /**
     * Resolve a provider/model for internal utility LLM calls (memory extraction,
     * context summarisation/compression, pre-execution scout, done-condition judge,
     * first-principles planning enrichment) that historically hard-coded
     * `anthropic/claude-haiku-4-5`.
     *
     * Honours BYOK availability: a team with no Anthropic key but an active
     * OpenAI/Google/OpenRouter key resolves to that provider's tier model instead
     * of dying with a 401. Without this, the hard-coded anthropic sub-calls throw
     * `x-api-key header is required` on no-Anthropic teams and abort the whole
     * experiment pipeline (prod incident 2026-05-30, team PriceX: every run died
     * before any stage was created).
     *
     * Resolution order:
     *   1. The team's configured default provider, when the tier has a model for it
     *      and the team holds a key (platform key or active BYOK credential).
     *   2. The first provider in the tier table the team has a key for.
     *   3. enforceAvailability() over the tier's first provider — picks from
     *      whatever the team genuinely has, or returns it unchanged so the caller
     *      fails loudly downstream instead of silently hanging.
     *
     * @param  'cheap'|'standard'|'expensive'  $tier
     * @return array{provider: string, model: string}
     */
    public function resolveInternal(?Team $team, string $tier = 'cheap'): array
    {
        /** @var array<string, string>|null $tierModels */
        $tierModels = config("experiments.model_tiers.{$tier}");

        // 'standard' tier (or an unknown tier) carries no concrete map — fall back
        // to the platform default, still subjected to availability enforcement.
        if (! is_array($tierModels) || $tierModels === []) {
            return $this->enforceAvailability([
                'provider' => (string) config('llm_pricing.default_provider', 'anthropic'),
                'model' => (string) config('llm_pricing.default_model', 'claude-sonnet-4-5'),
            ], $team);
        }

        // 1. Prefer the team's configured default provider when the tier has a
        //    model for it and the team actually holds a key.
        $teamProvider = is_array($team?->settings) ? ($team->settings['default_llm_provider'] ?? null) : null;
        if (
            is_string($teamProvider)
            && isset($tierModels[$teamProvider])
            && $this->teamHasProvider($team, $teamProvider)
        ) {
            return ['provider' => $teamProvider, 'model' => $tierModels[$teamProvider]];
        }

        // 2. First tier provider the team has a key for.
        foreach ($tierModels as $provider => $model) {
            if ($this->teamHasProvider($team, $provider)) {
                return ['provider' => $provider, 'model' => $model];
            }
        }

        // 3. Nothing in the tier table is available — let enforceAvailability pick
        //    from whatever the team genuinely has (or fail loudly downstream).
        $firstProvider = (string) array_key_first($tierModels);

        return $this->enforceAvailability([
            'provider' => $firstProvider,
            'model' => (string) $tierModels[$firstProvider],
        ], $team);
    }

    /**
     * Attempt to resolve a local provider for the given agent/team context.
     *
     * Returns a (provider, model) array when a local provider is available,
     * or null when none is configured/detected.
     *
     * @return array{provider: string, model: string}|null
     */
    private function resolveLocalProvider(?Agent $agent, ?Team $team): ?array
    {
        // Prefer bridge_agent when an active connection exists.
        try {
            $hasActiveBridge = BridgeConnection::active()->exists();
        } catch (\Throwable) {
            $hasActiveBridge = false;
        }

        if ($hasActiveBridge) {
            $agents = $this->activeBridgeAgents();

            if (! empty($agents)) {
                $firstKey = array_key_first($agents);

                // Compound keys are "agent_key:model" — split to populate both fields.
                if (str_contains($firstKey, ':')) {
                    [$agentKey, $model] = explode(':', $firstKey, 2);

                    return ['provider' => 'bridge_agent', 'model' => "{$agentKey}:{$model}"];
                }

                return ['provider' => 'bridge_agent', 'model' => $firstKey];
            }
        }

        // Fall back to CLI-based local agents (Codex, Claude Code).
        if (config('local_agents.enabled')) {
            try {
                $detected = app(LocalAgentDiscovery::class)->detect();
            } catch (\Throwable) {
                $detected = [];
            }

            if (! empty($detected)) {
                $agentKey = array_key_first($detected);
                $info = $detected[$agentKey];
                $provider = $info['provider'] ?? $agentKey;
                $model = $info['default_model'] ?? $agentKey;

                return ['provider' => $provider, 'model' => $model];
            }
        }

        return null;
    }

    /**
     * Determine whether a provider identifier refers to a local (non-cloud) provider.
     *
     * Local providers: bridge_agent, codex, claude_code, local_agent, and any provider
     * that starts with these prefixes (e.g. "bridge_agent" is always local).
     */
    private function isLocalProvider(string $provider): bool
    {
        $localProviders = ['bridge_agent', 'codex', 'claude_code', 'local_agent'];

        foreach ($localProviders as $local) {
            if ($provider === $local || str_starts_with($provider, $local)) {
                return true;
            }
        }

        // Also treat any provider flagged as local in config.
        return (bool) config("llm_providers.{$provider}.local")
            || (bool) config("llm_providers.{$provider}.bridge");
    }

    /**
     * Check whether the plan enforcer (cloud-only) has a specific feature enabled.
     *
     * The base / community edition does not ship PlanEnforcer, so this method
     * silently returns false when the service is not bound — preserving open-source
     * compatibility and ensuring free users are never unexpectedly blocked.
     */
    private function planEnforcerHasFeature(string $feature): bool
    {
        try {
            if (! app()->bound(PlanEnforcer::class)) {
                return false;
            }

            return app(PlanEnforcer::class)->hasFeature($feature);
        } catch (\Throwable) {
            return false;
        }
    }
}
