<?php

/**
 * Copyright (c) 2026 PrestaShop SA
 *
 * This file is part of the PrestaShop AskAI solution.
 * For license terms, see LICENSE.md in this module.
 */

declare(strict_types=1);

namespace PrestaShop\Module\PsAskAi\Controller;

use NeuronAI\Chat\Messages\Stream\Chunks\ReasoningChunk;
use NeuronAI\Chat\Messages\Stream\Chunks\StreamChunk;
use NeuronAI\Chat\Messages\Stream\Chunks\TextChunk;
use NeuronAI\Chat\Messages\Stream\Chunks\ToolCallChunk;
use NeuronAI\Chat\Messages\Stream\Chunks\ToolResultChunk;
use NeuronAI\Workflow\Interrupt\Action;
use PrestaShop\Module\PsAskAi\Config\PsAskAiConfig;
use PrestaShop\Module\PsAskAi\Entity\PsAskAiConversation;
use PrestaShop\Module\PsAskAi\Exception\ApiKeyValidationException;
use PrestaShop\Module\PsAskAi\Service\ConversationService;
use PrestaShop\Module\PsAskAi\Service\McpServerService;
use PrestaShop\Module\PsAskAi\Service\NeuronAiService;
use PrestaShop\Module\PsAskAi\Service\PrepromptService;
use PrestaShop\Module\PsAskAi\Service\SegmentService;
use PrestaShop\Module\PsAskAi\Service\SuggestionsService;
use PrestaShop\Module\PsAskAi\Tools\ImageGenerationTool;
use PrestaShopBundle\Controller\Admin\FrameworkBundleAdminController;
use Psr\Log\LoggerInterface;
use Symfony\Component\HttpFoundation\File\UploadedFile;
use Symfony\Component\HttpFoundation\JsonResponse;
use Symfony\Component\HttpFoundation\Request;
use Symfony\Component\HttpFoundation\StreamedResponse;

/**
 * Back-office endpoints powering the production Ask AI feature. The frontend
 * is wired separately; here we only expose the request/stream surface.
 *
 * Each endpoint accepts an optional `model` / `api_key` in the request; when
 * either is missing, we fall back to the value stored in PrestaShop's
 * Configuration (set via the module's configure page). When the API key is
 * still empty after both lookups, NeuronAiService falls back to the matching
 * PS_ASK_AI__*_API_KEY env var.
 */
class AskAiController extends FrameworkBundleAdminController
{
    public function __construct(
        private readonly NeuronAiService $neuronAiService,
        private readonly ConversationService $conversationService,
        private readonly McpServerService $mcpServerService,
        private readonly PrepromptService $prepromptService,
        private readonly SuggestionsService $suggestionsService,
        private readonly ?LoggerInterface $logger = null,
        private readonly ?SegmentService $segmentService = null,
    ) {
    }

    public function streamAction(Request $request): StreamedResponse
    {
        $this->warmUpSessionDependentServices();

        $prompt = (string) $request->query->get('prompt', '');
        $requestedThreadId = $this->nullIfEmpty((string) $request->query->get('thread', ''));
        $urlContext = (bool) $request->query->getInt('url_context');
        [$modelKey, $apiKey] = $this->resolveModelAndKey(
            $this->nullIfEmpty((string) $request->query->get('model', '')),
            $this->nullIfEmpty((string) $request->query->get('api_key', '')),
        );
        // query->all($key) requires Symfony >= 5.1; on PS 8 (SF 4.4) the
        // argument is ignored and ALL query params come back. Index instead.
        $imageUrlsRaw = $request->query->all()['image_urls'] ?? [];
        $imageUrls = array_values(array_filter(array_map('strval', is_array($imageUrlsRaw) ? $imageUrlsRaw : [])));

        // Resolve and touch the conversation BEFORE the streamed response
        // begins emitting. Doctrine flush can lazily bootstrap PrestaShop's
        // session; once StreamedResponse has sent SSE headers, any subsequent
        // session_start() fails with "headers already sent". Doing it here
        // also means updated_at reflects when the user started the turn — a
        // turn that crashes mid-stream still surfaces in the recents list.
        $conversation = $prompt === ''
            ? null
            : $this->resolveConversationForCurrentEmployee($requestedThreadId);
        if ($conversation !== null) {
            $this->conversationService->touch($conversation);
        }

        return $this->buildStreamResponse(function (callable $emit) use ($prompt, $conversation, $urlContext, $modelKey, $apiKey, $imageUrls): void {
            if ($prompt === '') {
                $emit('error-event', ['message' => 'Empty prompt']);

                return;
            }
            if ($conversation === null) {
                $emit('error-event', ['message' => 'Conversation not found or not accessible.']);

                return;
            }
            $emit('thread', ['threadId' => $conversation->getThreadId()]);

            $this->neuronAiService->stream(
                $prompt,
                $conversation->getThreadId(),
                $this->prepromptService->get(),
                $this->buildChunkEmitter($emit),
                static function (array $meta) use ($emit): void {
                    $emit('meta', $meta);
                },
                $this->buildApprovalEmitter($emit, $conversation->getThreadId()),
                $urlContext,
                $modelKey,
                $apiKey,
                $imageUrls,
            );
        });
    }

    public function uploadAction(Request $request): JsonResponse
    {
        $file = $request->files->get('file');
        if (!$file instanceof UploadedFile || !$file->isValid()) {
            return new JsonResponse(['error' => 'No valid file uploaded'], 400);
        }

        $allowedMimes = ['image/jpeg', 'image/png', 'image/gif', 'image/webp'];
        $mimeType = $file->getMimeType() ?? '';
        if (!in_array($mimeType, $allowedMimes, true)) {
            return new JsonResponse(['error' => 'Only JPEG, PNG, GIF and WebP images are allowed'], 400);
        }

        if ($file->getSize() > 10 * 1024 * 1024) {
            return new JsonResponse(['error' => 'File exceeds 10 MB limit'], 400);
        }

        if (!defined('_PS_TMP_IMG_DIR_')) {
            return new JsonResponse(['error' => 'Image storage not available'], 500);
        }

        $dir = _PS_TMP_IMG_DIR_ . 'ps_ask_ai/';
        if (!is_dir($dir) && !@mkdir($dir, 0775, true) && !is_dir($dir)) {
            return new JsonResponse(['error' => 'Could not create upload directory'], 500);
        }

        // Purge files older than 30d on each upload (best-effort, ignore errors)
        foreach ((array) glob($dir . '*') as $old) {
            if (is_file((string) $old) && time() - filemtime((string) $old) > 86400 * 30) {
                @unlink((string) $old);
            }
        }

        $ext = match ($mimeType) {
            'image/jpeg' => 'jpg',
            'image/png' => 'png',
            'image/gif' => 'gif',
            default => 'webp',
        };

        $filename = bin2hex(random_bytes(16)) . '.' . $ext;
        $file->move($dir, $filename);

        $shopBaseUrl = rtrim(\Context::getContext()->link->getBaseLink(), '/');
        $publicUrl = $shopBaseUrl . '/img/tmp/ps_ask_ai/' . $filename;

        return new JsonResponse(['url' => $publicUrl]);
    }

    public function historyAction(Request $request): JsonResponse
    {
        $threadId = $this->nullIfEmpty((string) $request->query->get('thread', ''));
        if ($threadId === null) {
            return new JsonResponse(['error' => 'Missing thread'], 400);
        }

        $conversation = $this->resolveConversationForCurrentEmployee($threadId);
        if ($conversation === null) {
            return new JsonResponse(['error' => 'Conversation not found or not accessible.'], 404);
        }

        return new JsonResponse([
            'threadId' => $conversation->getThreadId(),
            'messages' => $this->shapeHistory($this->neuronAiService->getHistory($conversation->getThreadId())),
            'pendingApproval' => $this->neuronAiService->loadPendingApproval($conversation->getThreadId()),
        ]);
    }

    public function modelsAction(): JsonResponse
    {
        $activeProvider = (string) \Configuration::get(PsAskAiConfig::CONFIG_ACTIVE_PROVIDER);
        if ($activeProvider === '' || !in_array($activeProvider, PsAskAiConfig::SUPPORTED_PROVIDERS, true)) {
            return new JsonResponse([
                'models' => [],
                'providers' => [],
                'default' => null,
                'current' => null,
            ]);
        }

        $models = [];
        foreach ($this->neuronAiService->getAvailableModels($activeProvider) as $key => $config) {
            $models[] = [
                'key' => $key,
                'label' => $config['label'],
                'provider' => $config['provider'],
            ];
        }

        $stored = (string) \Configuration::get(PsAskAiConfig::CONFIG_MODEL_KEY);
        $defaultKey = $this->neuronAiService->getDefaultModelKey($activeProvider);

        return new JsonResponse([
            'models' => $models,
            'providers' => [$activeProvider],
            'default' => $defaultKey,
            'current' => $stored !== '' ? $stored : $defaultKey,
        ]);
    }

    /**
     * Return the active conversations owned by the current employee + shop,
     * most recently updated first. The frontend uses this to restore the last
     * thread on drawer open and to render a conversation list.
     */
    public function listAction(): JsonResponse
    {
        $employee = \Context::getContext()->employee ?? null;
        $shop = \Context::getContext()->shop ?? null;
        if ($employee === null || $shop === null) {
            return new JsonResponse(['error' => 'Not authenticated'], 401);
        }

        $conversations = $this->conversationService->listForEmployee((int) $employee->id, (int) $shop->id);

        $payload = [];
        foreach ($conversations as $conversation) {
            $payload[] = [
                'threadId' => $conversation->getThreadId(),
                'title' => $conversation->getTitle(),
                'createdAt' => $conversation->getCreatedAt()->format(\DateTimeInterface::ATOM),
                'updatedAt' => $conversation->getUpdatedAt()->format(\DateTimeInterface::ATOM),
            ];
        }

        return new JsonResponse($payload);
    }

    public function selectModelAction(Request $request): JsonResponse
    {
        $key = $this->nullIfEmpty((string) $request->request->get('key', ''))
            ?? $this->nullIfEmpty((string) $request->query->get('key', ''));

        if ($key === null) {
            return new JsonResponse(['error' => 'Missing key'], 400);
        }

        $validKeys = array_keys($this->neuronAiService->getAvailableModels());
        if (!in_array($key, $validKeys, true)) {
            return new JsonResponse(['error' => 'Unknown model key'], 400);
        }

        \Configuration::updateValue(PsAskAiConfig::CONFIG_MODEL_KEY, $key);

        return new JsonResponse(['key' => $key]);
    }

    public function reportErrorAction(Request $request): JsonResponse
    {
        $type = $this->nullIfEmpty((string) $request->request->get('type', ''))
            ?? $this->nullIfEmpty((string) $request->query->get('type', ''));

        if ($type === 'invalidApiKey') {
            \Configuration::updateValue(PsAskAiConfig::CONFIG_API_KEY_ERROR, '1');
        } elseif ($type === 'providerQuota') {
            \Configuration::updateValue(PsAskAiConfig::CONFIG_PROVIDER_QUOTA_ERROR, '1');
        } else {
            return new JsonResponse(['error' => 'Invalid error type'], 400);
        }

        return new JsonResponse(null, 204);
    }

    public function deleteAction(Request $request): JsonResponse
    {
        $threadId = $this->nullIfEmpty((string) $request->query->get('thread', ''));
        if ($threadId === null) {
            return new JsonResponse(['error' => 'Missing thread'], 400);
        }

        $conversation = $this->resolveConversationForCurrentEmployee($threadId);
        if ($conversation === null) {
            return new JsonResponse(['error' => 'Conversation not found or not accessible.'], 404);
        }

        $this->conversationService->archive($conversation);

        return new JsonResponse(null, 204);
    }

    public function titleAction(Request $request): JsonResponse
    {
        $threadId = $this->nullIfEmpty((string) $request->query->get('thread', ''));
        if ($threadId === null) {
            return new JsonResponse(['error' => 'Missing thread'], 400);
        }

        $conversation = $this->resolveConversationForCurrentEmployee($threadId);
        if ($conversation === null) {
            return new JsonResponse(['error' => 'Conversation not found or not accessible.'], 404);
        }

        if ($conversation->getTitle() !== null) {
            return new JsonResponse(['title' => $conversation->getTitle()]);
        }

        $messages = $this->neuronAiService->getHistory($threadId);
        if (empty($messages)) {
            return new JsonResponse(['error' => 'No messages to title'], 422);
        }

        [$modelKey, $apiKey] = $this->resolveModelAndKey(null, null);

        try {
            $title = $this->neuronAiService->generateTitle($messages, $modelKey, $apiKey);
        } catch (\Throwable $e) {
            $this->logger?->warning('Title generation failed: ' . $e->getMessage(), ['exception' => $e]);

            return new JsonResponse(['error' => 'Title generation failed'], 502);
        }

        if ($title === '') {
            return new JsonResponse(['error' => 'Empty title generated'], 422);
        }

        $this->conversationService->setTitle($conversation, $title);

        return new JsonResponse(['title' => $title]);
    }

    public function suggestionsAction(): JsonResponse
    {
        // Pick the catalog language from the logged-in employee, not the
        // browser. ISO code comes straight from PrestaShop's language table;
        // restrict to letters to keep the column-lookup key well-formed.
        $employee = \Context::getContext()->employee ?? null;
        $iso = $employee !== null ? \Language::getIsoById((int) $employee->id_lang) : false;
        $lang = is_string($iso) ? strtolower($iso) : 'en';
        if (!preg_match('/^[a-z]{2,3}$/', $lang)) {
            $lang = 'en';
        }
        try {
            return new JsonResponse($this->suggestionsService->get($lang));
        } catch (\Throwable $e) {
            $this->logger?->warning(
                'Suggestions fetch failed: ' . $e->getMessage(),
                ['exception' => $e],
            );

            return new JsonResponse(['error' => $e->getMessage()], 502);
        }
    }

    /**
     * Validate the configured (or request-supplied) API key against the
     * provider's models endpoint. Returns 200 {"valid": true} on success,
     * 400 with {"error": "..."} on invalid key or misconfiguration.
     *
     * Optional query params: `provider`, `api_key` — when absent, both fall
     * back to the stored Configuration values (same resolution as streamAction).
     */
    public function testKeyAction(Request $request): JsonResponse
    {
        $provider = $this->nullIfEmpty((string) $request->query->get('provider', ''))
            ?? $this->nullIfEmpty((string) \Configuration::get(PsAskAiConfig::CONFIG_ACTIVE_PROVIDER));

        if ($provider === null || !in_array($provider, PsAskAiConfig::SUPPORTED_PROVIDERS, true)) {
            return new JsonResponse(['error' => 'No provider configured or provider not supported.'], 400);
        }

        // Reaching test-key means the merchant submitted a key from the config
        // page (this route is only called from that form). Event 3.
        $this->segmentService?->track(
            PsAskAiConfig::SEGMENT_EVENT_API_KEY_SUBMITTED,
            ['provider' => $provider]
        );

        $apiKey = $this->nullIfEmpty((string) $request->query->get('api_key', ''))
            ?? $this->nullIfEmpty((string) \Configuration::get(PsAskAiConfig::CONFIG_API_KEY));

        try {
            $this->neuronAiService->validateApiKey($provider, $apiKey);
        } catch (\Throwable $e) {
            // The silent pre-save connection test failed → the config can't be
            // saved. Event 4, with a machine-readable error_type.
            $errorType = $e instanceof ApiKeyValidationException
                ? $e->errorType
                : ApiKeyValidationException::ERROR_UNKNOWN;
            $this->segmentService?->track(PsAskAiConfig::SEGMENT_EVENT_CONFIG_SAVE_FAILED, [
                'provider' => $provider,
                'model' => $this->resolveModelForTracking($provider),
                'error_type' => $errorType,
            ]);

            return new JsonResponse(['error' => $e->getMessage()], 400);
        }

        return new JsonResponse(['valid' => true]);
    }

    /**
     * Record a configuration-page interaction event. The only client-supplied
     * value honoured is the provider (validated against the supported list);
     * every other property is assembled server-side by SegmentService, so the
     * endpoint can't be abused to relay arbitrary analytics. Event 2.
     */
    public function trackAction(Request $request): JsonResponse
    {
        $event = (string) $request->request->get('event', '');
        if ($event !== 'provider_selected') {
            return new JsonResponse(['error' => 'Unknown event.'], 400);
        }

        $provider = (string) $request->request->get('provider', '');
        if (!in_array($provider, PsAskAiConfig::SUPPORTED_PROVIDERS, true)) {
            return new JsonResponse(['error' => 'Provider not supported.'], 400);
        }

        $this->segmentService?->track(
            PsAskAiConfig::SEGMENT_EVENT_PROVIDER_SELECTED,
            ['provider' => $provider]
        );

        return new JsonResponse(['ok' => true]);
    }

    /**
     * Best-effort model key for analytics: the configured model when set,
     * otherwise the provider's default. Never throws.
     */
    private function resolveModelForTracking(string $provider): string
    {
        $configured = $this->nullIfEmpty((string) \Configuration::get(PsAskAiConfig::CONFIG_MODEL_KEY));
        if ($configured !== null) {
            return $configured;
        }

        try {
            return $this->neuronAiService->getDefaultModelKey($provider);
        } catch (\Throwable $e) {
            return '';
        }
    }

    /**
     * Flatten NeuronAI Message objects into a UI-friendly transcript.
     * Each entry has { role: "user" | "assistant" | "tool", text, name?, images? }.
     * Tool-call/tool-result messages are tagged with the tool name; the
     * frontend renders them as historical tool.request bubbles.
     * User messages with attached images carry an images[] of URL strings.
     *
     * @param array<int, object> $messages
     *
     * @return array<int, array{role: string, text: string, name?: string, images?: string[]}>
     */
    private function shapeHistory(array $messages): array
    {
        $out = [];
        $messageList = array_values($messages);
        $total = count($messageList);
        for ($i = 0; $i < $total; ++$i) {
            $message = $messageList[$i];

            // Tool results are internal plumbing — skip them, they are not user-visible.
            if ($message instanceof \NeuronAI\Chat\Messages\ToolResultMessage) {
                continue;
            }

            $role = method_exists($message, 'getRole') ? (string) $message->getRole() : '';
            $text = $this->extractDisplayText($message);

            if ($message instanceof \NeuronAI\Chat\Messages\ToolCallMessage) {
                // A tool-call message can carry text alongside its calls, and
                // can hold several calls. Emit the text and one entry per tool
                // so nothing is lost on replay; only the silent image-gen tool
                // is filtered out (per call, never the whole message).
                if ($text !== '') {
                    $out[] = ['role' => $role, 'text' => $text];
                }

                // Build a map of tool name → result from the immediately following
                // ToolResultMessage so we can detect rejected tools (their result
                // starts with "TOOL NOT EXECUTED" set by ToolRejectionHandler).
                $resultMap = [];
                if (
                    isset($messageList[$i + 1])
                    && $messageList[$i + 1] instanceof \NeuronAI\Chat\Messages\ToolResultMessage
                ) {
                    foreach ($messageList[$i + 1]->getTools() as $rt) {
                        if (is_object($rt) && method_exists($rt, 'getName') && method_exists($rt, 'getResult')) {
                            $resultMap[(string) $rt->getName()] = (string) $rt->getResult();
                        }
                    }
                }

                foreach ($message->getTools() as $tool) {
                    if (!is_object($tool) || !method_exists($tool, 'getName')) {
                        continue;
                    }
                    $toolName = (string) $tool->getName();
                    if ($toolName === '' || $toolName === ImageGenerationTool::NAME) {
                        continue;
                    }
                    $approved = !str_starts_with($resultMap[$toolName] ?? '', 'TOOL NOT EXECUTED');
                    $out[] = ['role' => $role, 'text' => '', 'name' => $toolName, 'approved' => $approved];
                }
                continue;
            }

            $entry = ['role' => $role, 'text' => $text];

            if ($role === 'user' && method_exists($message, 'getContentBlocks')) {
                $imageUrls = [];
                foreach ($message->getContentBlocks() as $block) {
                    if (
                        $block instanceof \NeuronAI\Chat\Messages\ContentBlocks\ImageContent
                        && $block->sourceType === \NeuronAI\Chat\Enums\SourceType::URL
                    ) {
                        $imageUrls[] = $block->content;
                    }
                }
                if ($imageUrls !== []) {
                    $entry['images'] = $imageUrls;
                }
            }

            $out[] = $entry;
        }

        return $out;
    }

    /**
     * Pull only TextContent blocks out of a message — ReasoningContent is
     * dropped so restored conversations don't replay the model's chain of
     * thought as part of the visible answer. `Message::getContent()` is not
     * usable here because it concatenates reasoning into its returned string.
     */
    private function extractDisplayText(object $message): string
    {
        if (!method_exists($message, 'getContentBlocks')) {
            return '';
        }
        $buf = '';
        foreach ($message->getContentBlocks() as $block) {
            if ($block instanceof \NeuronAI\Chat\Messages\ContentBlocks\ReasoningContent) {
                continue;
            }
            if ($block instanceof \NeuronAI\Chat\Messages\ContentBlocks\TextContent) {
                $buf .= $block->content;
            }
        }

        return $buf;
    }

    public function streamResumeAction(Request $request): StreamedResponse
    {
        $this->warmUpSessionDependentServices();

        $workflowId = (string) $request->query->get('workflowId', '');
        $threadId = (string) $request->query->get('thread', '');
        // See streamAction: query->all($key) is SF >= 5.1 only.
        $decisionsRaw = $request->query->all()['decisions'] ?? [];
        [$modelKey, $apiKey] = $this->resolveModelAndKey(
            $this->nullIfEmpty((string) $request->query->get('model', '')),
            $this->nullIfEmpty((string) $request->query->get('api_key', '')),
        );

        // Same reasoning as streamAction: hit Doctrine before headers leave.
        $conversation = ($workflowId !== '' && $threadId !== '')
            ? $this->resolveConversationForCurrentEmployee($threadId)
            : null;
        if ($conversation !== null) {
            $this->conversationService->touch($conversation);
        }

        return $this->buildStreamResponse(function (callable $emit) use ($workflowId, $threadId, $conversation, $decisionsRaw, $modelKey, $apiKey): void {
            if ($workflowId === '') {
                $emit('error-event', ['message' => 'Missing workflowId']);

                return;
            }
            if ($threadId === '') {
                $emit('error-event', ['message' => 'Missing thread']);

                return;
            }
            if ($conversation === null) {
                $emit('error-event', ['message' => 'Conversation not found or not accessible.']);

                return;
            }

            $decisions = [];
            if (is_array($decisionsRaw)) {
                foreach ($decisionsRaw as $row) {
                    if (!is_array($row)) {
                        continue;
                    }
                    $decisions[] = [
                        'id' => (string) ($row['id'] ?? ''),
                        'decision' => (string) ($row['decision'] ?? 'reject'),
                        'feedback' => (string) ($row['feedback'] ?? ''),
                    ];
                }
            }

            // Delete the sidecar before resuming: if a second approval fires
            // during resume, buildApprovalEmitter will save a fresh sidecar.
            $this->neuronAiService->deletePendingApproval($threadId);
            $this->neuronAiService->streamResume(
                $workflowId,
                $decisions,
                $this->prepromptService->get(),
                $this->buildChunkEmitter($emit),
                static function (array $meta) use ($emit): void {
                    $emit('meta', $meta);
                },
                $this->buildApprovalEmitter($emit, $threadId),
                $modelKey,
                $apiKey,
                $conversation->getThreadId(),
            );
        });
    }

    /**
     * Flag the current turn of a thread to stop. Runs in its own request while
     * the SSE worker is mid-stream; the streaming loop polls the flag and
     * breaks, keeping any partial answer (or dropping the unanswered prompt).
     * Ownership is enforced the same way as the streaming endpoints.
     */
    public function stopAction(Request $request): JsonResponse
    {
        $threadId = $this->nullIfEmpty((string) $request->query->get('thread', ''));
        if ($threadId === null) {
            return new JsonResponse(['error' => 'Missing thread'], 400);
        }

        $conversation = $this->resolveConversationForCurrentEmployee($threadId);
        if ($conversation === null) {
            return new JsonResponse(['error' => 'Conversation not found or not accessible.'], 404);
        }

        $this->neuronAiService->requestStop($conversation->getThreadId());

        return new JsonResponse(['ok' => true]);
    }

    /**
     * Pre-load services that read from the session, before we return a
     * StreamedResponse. Once the StreamedResponse begins sendContent(),
     * Symfony's SessionListener has already committed and closed the
     * session at kernel.response — any later code that calls
     * Session::get(...) (e.g. ps_mbo's AddonsUser, lazy-loaded via
     * McpServerService → ModuleManagerBuilder) tries to re-start the
     * session, which fails because SSE headers are already on the wire.
     *
     * Calling McpServerService::getMcpServerUrl() here triggers the DI
     * chain (ModuleManagerBuilder → AddonsUrlSourceRetriever →
     * AddonsDataProvider → AddonsUser) while the session is still open;
     * subsequent calls during the stream reuse the cached instances and
     * don't touch the session again.
     */
    private function warmUpSessionDependentServices(): void
    {
        try {
            $this->mcpServerService->getMcpServerUrl();
        } catch (\Throwable $e) {
            $this->logger?->warning(
                'AskAiController warm-up failed: ' . $e->getMessage(),
                ['exception' => $e],
            );
        }
    }

    /**
     * Look up the requested conversation (or create a fresh one when null) and
     * enforce that it belongs to the currently authenticated employee + shop.
     * Returns null when a thread id was supplied but does not match an active
     * conversation owned by the caller — the streaming endpoint surfaces this
     * as an error to the client.
     */
    private function resolveConversationForCurrentEmployee(?string $threadId): ?PsAskAiConversation
    {
        $employee = \Context::getContext()->employee ?? null;
        $shop = \Context::getContext()->shop ?? null;
        if ($employee === null || $shop === null) {
            return null;
        }
        $idEmployee = (int) $employee->id;
        $idShop = (int) $shop->id;

        if ($threadId === null) {
            return $this->conversationService->create($idEmployee, $idShop);
        }

        $conversation = $this->conversationService->getByThreadId($threadId);
        if ($conversation === null
            || $conversation->getIdEmployee() !== $idEmployee
            || $conversation->getIdShop() !== $idShop
            || $conversation->getArchivedAt() !== null
        ) {
            return null;
        }

        return $conversation;
    }

    /**
     * Resolve final (modelKey, apiKey) for a request. Request-supplied values
     * win; otherwise we fall back to the Configuration stored by the configure
     * form. Returning null for either lets NeuronAiService apply its own
     * defaults (default model, env-based key).
     *
     * @return array{0: ?string, 1: ?string}
     */
    private function resolveModelAndKey(?string $reqModel, ?string $reqKey): array
    {
        $modelKey = $reqModel;
        if ($modelKey === null) {
            $stored = (string) \Configuration::get(PsAskAiConfig::CONFIG_MODEL_KEY);
            if ($stored !== '') {
                $modelKey = $stored;
            } else {
                $activeProvider = (string) \Configuration::get(PsAskAiConfig::CONFIG_ACTIVE_PROVIDER);
                if ($activeProvider !== '') {
                    $modelKey = $this->neuronAiService->getDefaultModelKey($activeProvider);
                }
            }
        }

        if ($reqKey !== null) {
            return [$modelKey, $reqKey];
        }

        $stored = (string) \Configuration::get(PsAskAiConfig::CONFIG_API_KEY);

        return [$modelKey, $stored !== '' ? $stored : null];
    }

    private function nullIfEmpty(string $value): ?string
    {
        return $value === '' ? null : $value;
    }

    /**
     * Wrap a stream-producing closure in a configured SSE response. The
     * closure receives an `emit(eventName, data)` helper and is responsible
     * for calling the service. Errors get caught, surfaced as `error-event`,
     * and the stream always closes with a `done` event.
     */
    private function buildStreamResponse(callable $producer): StreamedResponse
    {
        $response = new StreamedResponse();
        $response->headers->set('Content-Type', 'text/event-stream');
        $response->headers->set('Cache-Control', 'no-cache');
        $response->headers->set('Connection', 'keep-alive');
        $response->headers->set('X-Accel-Buffering', 'no');

        $response->setCallback(function () use ($producer): void {
            // Finish the turn even if the client disconnects (drawer closed,
            // page navigation): aborting mid-run would leave the chat history
            // without the assistant's answer — the exchange would silently
            // vanish from the thread on the next reload.
            ignore_user_abort(true);

            while (ob_get_level() > 0) {
                ob_end_flush();
            }
            @ini_set('zlib.output_compression', '0');

            $emit = static function (string $event, array $data = []): void {
                echo "event: {$event}\n";
                echo 'data: ' . json_encode($data) . "\n\n";
                flush();
            };

            try {
                $producer($emit);
            } catch (\Throwable $e) {
                $this->logger?->error(
                    'AskAiController stream error: ' . $e->getMessage(),
                    ['exception' => $e, 'trace' => $e->getTraceAsString()],
                );
                $emit('error-event', ['message' => $e->getMessage()]);
            }

            $emit('done');
        });

        return $response;
    }

    /**
     * @return callable(StreamChunk): void
     */
    private function buildChunkEmitter(callable $emit): callable
    {
        return static function (StreamChunk $chunk) use ($emit): void {
            if ($chunk instanceof ReasoningChunk) {
                $emit('reasoning', ['content' => $chunk->content]);

                return;
            }
            if ($chunk instanceof TextChunk) {
                $emit('text', ['content' => $chunk->content]);

                return;
            }
            if ($chunk instanceof ToolCallChunk) {
                if ($chunk->tool->getName() === ImageGenerationTool::NAME) {
                    return;
                }
                $emit('tool-call', [
                    'id' => $chunk->tool->getCallId(),
                    'name' => $chunk->tool->getName(),
                ]);

                return;
            }
            if ($chunk instanceof ToolResultChunk) {
                if ($chunk->tool->getName() === ImageGenerationTool::NAME) {
                    return;
                }
                $emit('tool-result', [
                    'id' => $chunk->tool->getCallId(),
                    'name' => $chunk->tool->getName(),
                ]);
            }
        };
    }

    /**
     * @return callable(string, Action[]): void
     */
    private function buildApprovalEmitter(callable $emit, string $threadId): callable
    {
        return function (string $workflowId, array $actions) use ($emit, $threadId): void {
            $payload = [];
            $sidecar = [];
            foreach ($actions as $action) {
                $payload[] = [
                    'id' => $action->id,
                    'name' => $action->name,
                    'description' => $action->description,
                ];
                $sidecar[] = ['id' => $action->id, 'name' => $action->name];
            }
            $this->neuronAiService->savePendingApproval($threadId, $workflowId, $sidecar);
            $emit('approval-needed', [
                'workflowId' => $workflowId,
                'actions' => $payload,
            ]);
        };
    }
}
