<?php

namespace App\Http\Controllers\Api\V1;

use App\Domain\Agent\Actions\ExecuteAgentAction;
use App\Domain\Agent\Models\Agent;
use App\Domain\Budget\Exceptions\InsufficientBudgetException;
use App\Domain\Crew\Models\Crew;
use App\Exceptions\ClientDisconnectedException;
use App\Http\Controllers\Controller;
use App\Infrastructure\AI\Contracts\AiGatewayInterface;
use App\Infrastructure\AI\Models\LlmRequestLog;
use App\Infrastructure\AI\Translators\OpenAiRequestTranslator;
use App\Infrastructure\AI\Translators\OpenAiResponseTranslator;
use Illuminate\Http\JsonResponse;
use Illuminate\Http\Request;
use Illuminate\Support\Str;
use Symfony\Component\HttpFoundation\StreamedResponse;

class OpenAiCompatibleController extends Controller
{
    public function __construct(
        private readonly AiGatewayInterface $gateway,
        private readonly OpenAiRequestTranslator $requestTranslator,
        private readonly OpenAiResponseTranslator $responseTranslator,
        private readonly ExecuteAgentAction $executeAgent,
    ) {}

    /**
     * GET /v1/models — List available models (agents + crews + raw providers).
     */
    /**
     * Resolve the team this request operates on.
     *
     * For partner / sub-program Sanctum tokens, ScopeTokenToTeam (pushed to the
     * `api` middleware group) rebinds the in-memory `currentTeam` relation to the
     * token's membership-checked `team:<uuid>` ability — WITHOUT mutating the
     * `users.current_team_id` column (the human owner's UI-active team). We must
     * read the token-scoped team here, not the column, otherwise an agent and its
     * AI credentials resolve against the owner's UI team instead of the team the
     * token (and the agent) actually belong to. Falls back to the column for
     * session (web) auth where no token ability is present.
     */
    private function resolveRequestTeamId(Request $request): ?string
    {
        return $request->user()->currentTeam?->getKey() ?? $request->user()->current_team_id;
    }

    /**
     * Aggregate the LLM token counts for an agent execution.
     *
     * `agent_executions` only stores cost_credits / step counters; the real
     * per-call prompt/completion tokens are persisted to `llm_request_logs`
     * by the gateway. We sum the rows that belong to this execution — matched
     * on agent_id within the execution's wall-clock window — so the OpenAI
     * usage block can return real numbers instead of zeros. The window comes
     * from the row's own created_at + duration_ms with a small floor, which is
     * tight enough for synchronous HTTP calls and tolerates multi-step tool
     * loops that produce several llm_request_logs rows for one execution.
     *
     * Team-scoped by `agent_id` (an agent belongs to exactly one team), so this
     * never crosses tenants.
     *
     * @return array{prompt: int, completion: int}
     */
    private function aggregateExecutionTokens(mixed $execution): array
    {
        if (! $execution || empty($execution->agent_id) || empty($execution->created_at)) {
            return ['prompt' => 0, 'completion' => 0];
        }

        // AgentExecution::create() runs AFTER the LLM call completes, so the row's
        // created_at marks the END of the call window. Walk backwards by the
        // recorded duration_ms (plus a small floor + a few seconds of slack for
        // tool-loop prefetch and clock skew) to capture every llm_request_logs
        // row produced by this execution.
        $durationMs = max(1000, (int) ($execution->duration_ms ?? 0));
        $windowStart = $execution->created_at->copy()
            ->subMilliseconds($durationMs)
            ->subSeconds(5);
        $windowEnd = $execution->created_at->copy()->addSeconds(2);

        $row = LlmRequestLog::withoutGlobalScopes()
            ->where('agent_id', $execution->agent_id)
            ->whereBetween('created_at', [$windowStart, $windowEnd])
            ->selectRaw('COALESCE(SUM(input_tokens), 0) AS p, COALESCE(SUM(output_tokens), 0) AS c')
            ->first();

        return [
            'prompt' => (int) ($row->p ?? 0),
            'completion' => (int) ($row->c ?? 0),
        ];
    }

    public function listModels(Request $request): JsonResponse
    {
        $user = $request->user();
        $teamId = $this->resolveRequestTeamId($request);

        $models = [];

        // Team's agents as models
        $agents = Agent::where('team_id', $teamId)->where('status', 'active')->get();
        foreach ($agents as $agent) {
            $models[] = [
                'id' => "agent/{$agent->slug}",
                'object' => 'model',
                'created' => $agent->created_at->timestamp,
                'owned_by' => 'fleetq',
            ];
        }

        // Team's crews as models
        $crews = Crew::where('team_id', $teamId)->where('status', 'active')->get();
        foreach ($crews as $crew) {
            $models[] = [
                'id' => "crew/{$crew->slug}",
                'object' => 'model',
                'created' => $crew->created_at->timestamp,
                'owned_by' => 'fleetq',
            ];
        }

        // Raw provider models (based on team's configured providers)
        foreach ($this->getConfiguredModels($teamId) as $modelId) {
            $models[] = [
                'id' => $modelId,
                'object' => 'model',
                'created' => 0,
                'owned_by' => explode('/', $modelId)[0] ?? 'unknown',
            ];
        }

        return response()->json([
            'object' => 'list',
            'data' => $models,
        ]);
    }

    /**
     * GET /v1/models/{model} — Retrieve a single model.
     */
    public function retrieveModel(Request $request, string $model): JsonResponse
    {
        $user = $request->user();
        $teamId = $this->resolveRequestTeamId($request);

        $resolved = $this->requestTranslator->resolveModel($model, $teamId);

        if ($resolved['type'] === 'not_found') {
            return response()->json(
                OpenAiResponseTranslator::formatError(
                    "Model '{$model}' not found.",
                    'invalid_request_error',
                    404,
                    'model_not_found',
                ),
                404,
            );
        }

        $created = 0;
        $ownedBy = 'fleetq';

        if ($resolved['entity'] instanceof Agent) {
            $created = $resolved['entity']->created_at->timestamp;
        } elseif ($resolved['entity'] instanceof Crew) {
            $created = $resolved['entity']->created_at->timestamp;
        } else {
            $ownedBy = $resolved['provider'];
        }

        return response()->json([
            'id' => $model,
            'object' => 'model',
            'created' => $created,
            'owned_by' => $ownedBy,
        ]);
    }

    /**
     * POST /v1/chat/completions — Chat completions (streaming + non-streaming).
     */
    public function chatCompletions(Request $request): JsonResponse|StreamedResponse
    {
        $validated = $request->validate([
            'model' => 'required|string|max:255',
            'messages' => 'required|array|min:1',
            'messages.*.role' => 'required|string|in:system,user,assistant,tool',
            'messages.*.content' => 'nullable|string',
            'messages.*.tool_calls' => 'nullable|array',
            'messages.*.tool_call_id' => 'nullable|string',
            'stream' => 'nullable|boolean',
            'temperature' => 'nullable|numeric|min:0|max:2',
            'max_tokens' => 'nullable|integer|min:1|max:65536',
            'stop' => 'nullable',
            'stream_options' => 'nullable|array',
            'stream_options.include_usage' => 'nullable|boolean',
        ]);

        $user = $request->user();
        $teamId = $this->resolveRequestTeamId($request);
        $modelId = $validated['model'];

        // Resolve model namespace
        $resolved = $this->requestTranslator->resolveModel($modelId, $teamId);

        if ($resolved['type'] === 'not_found') {
            return response()->json(
                OpenAiResponseTranslator::formatError(
                    "Model '{$modelId}' not found.",
                    'invalid_request_error',
                    404,
                    'model_not_found',
                ),
                404,
            );
        }

        $isStream = $validated['stream'] ?? false;
        // Force-include usage on the SSE final chunk for partner / sub-program
        // tokens (api_source is set by ScopeTokenToTeam only for real Sanctum
        // partner tokens). Partners need per-call cost_credits to mirror spend;
        // OpenAI clients that don't ask for it still get a backward-compatible
        // extra field.
        $includeUsage = ($validated['stream_options']['include_usage'] ?? false)
            || $request->attributes->has('api_source');

        try {
            // Route based on type
            if ($resolved['type'] === 'agent') {
                return $this->handleAgentRequest($resolved, $validated, $user, $teamId, $modelId, $isStream, $includeUsage);
            }

            // Passthrough to AI Gateway (raw model or crew)
            return $this->handlePassthroughRequest($resolved, $validated, $user, $teamId, $modelId, $isStream, $includeUsage);
        } catch (InsufficientBudgetException $e) {
            return response()->json(
                OpenAiResponseTranslator::formatError($e->getMessage(), 'insufficient_quota', 402, 'insufficient_quota'),
                402,
            );
        } catch (\Throwable $e) {
            return response()->json(
                OpenAiResponseTranslator::formatError(
                    app()->hasDebugModeEnabled() ? $e->getMessage() : 'An internal error occurred.',
                    'server_error',
                    500,
                ),
                500,
            );
        }
    }

    private function handleAgentRequest(
        array $resolved,
        array $validated,
        $user,
        string $teamId,
        string $modelId,
        bool $isStream,
        bool $includeUsage,
    ): JsonResponse|StreamedResponse {
        /** @var Agent $agent */
        $agent = $resolved['entity'];

        // The agent's own team is authoritative for execution and AI-credential
        // resolution (sub-program key lookup keys off teams.sub_program_slug).
        // resolveModel() already scoped the lookup to $teamId, so $agent->team_id
        // is guaranteed to equal the token-scoped team — never wider. Using the
        // agent's team_id directly ensures applyTeamCredentials resolves against
        // the team the agent belongs to, not the owner's UI-active team.
        $executionTeamId = $agent->team_id ?? $teamId;

        // Extract the partner-supplied system message(s) and prior turns from the
        // OpenAI-shaped `messages` array. Partner sub-program clients (Finance,
        // etc.) rely on the system slot for RAG / KPI context injection that
        // MUST reach the model. The last user message remains $task for
        // backward compatibility with workflow callers.
        $lastUserMessage = '';
        $incomingSystem = [];
        $priorMessages = []; // user/assistant/tool turns, excluding the trailing user message
        foreach ($validated['messages'] as $msg) {
            if (($msg['role'] ?? null) === 'system') {
                $content = (string) ($msg['content'] ?? '');
                if ($content !== '') {
                    $incomingSystem[] = $content;
                }

                continue;
            }
            $priorMessages[] = $msg;
        }
        // Pop the trailing user message into $lastUserMessage so it isn't double-injected.
        for ($i = count($priorMessages) - 1; $i >= 0; $i--) {
            if (($priorMessages[$i]['role'] ?? null) === 'user') {
                $lastUserMessage = (string) ($priorMessages[$i]['content'] ?? '');
                array_splice($priorMessages, $i, 1);
                break;
            }
        }

        $result = $this->executeAgent->execute(
            agent: $agent,
            input: [
                'task' => $lastUserMessage,
                'raw_messages' => $validated['messages'],
                'incoming_system' => $incomingSystem,
                'prior_messages' => $priorMessages,
            ],
            teamId: $executionTeamId,
            userId: $user->id,
        );

        $content = '';
        if (isset($result['output'])) {
            $content = is_array($result['output'])
                ? ($result['output']['content'] ?? json_encode($result['output']))
                : (string) $result['output'];
        }

        $execution = $result['execution'];
        // The `agent_executions` table doesn't store token counts (only cost +
        // tool/step counters); the real per-call tokens live in `llm_request_logs`.
        // Aggregate the rows that belong to this execution (matched by agent_id +
        // the execution's time window) so the usage block reports real numbers.
        $tokens = $this->aggregateExecutionTokens($execution);

        if ($isStream) {
            return $this->streamContent($modelId, $content, $includeUsage, $execution, $tokens);
        }

        $id = 'chatcmpl-'.Str::ulid();

        return response()->json([
            'id' => $id,
            'object' => 'chat.completion',
            'created' => time(),
            'model' => $modelId,
            'choices' => [
                [
                    'index' => 0,
                    'message' => ['role' => 'assistant', 'content' => $content],
                    'finish_reason' => 'stop',
                ],
            ],
            'usage' => [
                'prompt_tokens' => $tokens['prompt'],
                'completion_tokens' => $tokens['completion'],
                'total_tokens' => $tokens['prompt'] + $tokens['completion'],
                // Backward-compatible extension — agent path mirrors the
                // OpenAiResponseTranslator's cost_credits surface. AgentExecution
                // persists the same net cost (reservation minus excess release).
                'cost_credits' => (int) ($execution->cost_credits ?? 0),
            ],
            'system_fingerprint' => 'fleetq-v1',
        ]);
    }

    private function handlePassthroughRequest(
        array $resolved,
        array $validated,
        $user,
        string $teamId,
        string $modelId,
        bool $isStream,
        bool $includeUsage,
    ): JsonResponse|StreamedResponse {
        $aiRequest = $this->requestTranslator->toAiRequest(
            validated: $validated,
            provider: $resolved['provider'],
            model: $resolved['model'],
            userId: $user->id,
            teamId: $teamId,
        );

        if ($isStream) {
            $streamId = 'chatcmpl-'.Str::ulid();
            $chunks = [];

            $response = $this->gateway->stream($aiRequest, function (string $delta) use (&$chunks) {
                $chunks[] = $delta;
            });

            return $this->buildStreamedResponse(function () use ($streamId, $modelId, $chunks, $response, $includeUsage) {
                try {
                    echo $this->responseTranslator->formatStreamStart($streamId, $modelId);
                    ob_flush();
                    flush();

                    foreach ($chunks as $chunk) {
                        if (connection_aborted()) {
                            throw new ClientDisconnectedException;
                        }
                        echo $this->responseTranslator->formatStreamDelta($streamId, $modelId, $chunk);
                        ob_flush();
                        flush();
                    }

                    echo $this->responseTranslator->formatStreamEnd($streamId, $modelId);
                    ob_flush();
                    flush();

                    if ($includeUsage) {
                        echo $this->responseTranslator->formatStreamUsage($streamId, $modelId, $response);
                        ob_flush();
                        flush();
                    }

                    echo $this->responseTranslator->formatStreamDone();
                    ob_flush();
                    flush();
                } catch (ClientDisconnectedException) {
                    // Client closed the connection; exit stream cleanly.
                }
            });
        }

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

        return response()->json($this->responseTranslator->toOpenAiResponse($response, $modelId));
    }

    /**
     * Stream agent content character by character (simulated streaming).
     *
     * @param  array{prompt: int, completion: int}  $tokens  Real token counts
     *                                                       aggregated from llm_request_logs (agent_executions doesn't store them).
     */
    private function streamContent(string $modelId, string $content, bool $includeUsage, $execution, array $tokens): StreamedResponse
    {
        $streamId = 'chatcmpl-'.Str::ulid();

        return $this->buildStreamedResponse(function () use ($streamId, $modelId, $content, $includeUsage, $execution, $tokens) {
            try {
                echo $this->responseTranslator->formatStreamStart($streamId, $modelId);
                ob_flush();
                flush();

                // Stream in word-sized chunks for natural pacing
                $words = preg_split('/(\s+)/', $content, -1, PREG_SPLIT_DELIM_CAPTURE);
                foreach ($words as $word) {
                    if ($word === '') {
                        continue;
                    }
                    if (connection_aborted()) {
                        throw new ClientDisconnectedException;
                    }
                    echo $this->responseTranslator->formatStreamDelta($streamId, $modelId, $word);
                    ob_flush();
                    flush();
                }

                echo $this->responseTranslator->formatStreamEnd($streamId, $modelId);
                ob_flush();
                flush();

                if ($includeUsage) {
                    $usageChunk = [
                        'id' => $streamId,
                        'object' => 'chat.completion.chunk',
                        'created' => time(),
                        'model' => $modelId,
                        'choices' => [],
                        'usage' => [
                            'prompt_tokens' => $tokens['prompt'],
                            'completion_tokens' => $tokens['completion'],
                            'total_tokens' => $tokens['prompt'] + $tokens['completion'],
                            'cost_credits' => (int) ($execution->cost_credits ?? 0),
                        ],
                    ];
                    echo 'data: '.json_encode($usageChunk)."\n\n";
                    ob_flush();
                    flush();
                }

                echo $this->responseTranslator->formatStreamDone();
                ob_flush();
                flush();
            } catch (ClientDisconnectedException) {
                // Client closed the connection; exit stream cleanly.
            }
        });
    }

    private function buildStreamedResponse(callable $callback): StreamedResponse
    {
        return new StreamedResponse($callback, 200, [
            'Content-Type' => 'text/event-stream',
            'Cache-Control' => 'no-cache',
            'Connection' => 'keep-alive',
            'X-Accel-Buffering' => 'no',
        ]);
    }

    /**
     * Get raw provider models available to the team.
     */
    private function getConfiguredModels(string $teamId): array
    {
        $models = [];

        $pricing = config('llm_pricing.providers', []);
        foreach ($pricing as $provider => $providerModels) {
            // Skip local/free providers
            if (in_array($provider, ['codex', 'claude-code', 'kiro', 'gemini-cli'], true)) {
                continue;
            }

            foreach (array_keys($providerModels) as $model) {
                $models[] = "{$provider}/{$model}";
            }
        }

        return $models;
    }
}
