<?php

declare(strict_types=1);

namespace App\Agent;

use App\Commerce\Dto\ShopProductResult;
use App\Commerce\SearchRepairService;
use App\Commerce\ShopSearchService;
use App\Config\AgentRunnerConfig;
use App\Context\ContextService;
use App\Context\UrlAnalyzer;
use App\Infrastructure\OllamaClient;
use App\Intent\CommerceIntentLite;
use App\Knowledge\Retrieval\RetrieverInterface;
use Generator;
use Psr\Log\LoggerInterface;
use Throwable;

final readonly class AgentRunner
{
    private bool $systemMsgOn;

    public function __construct(
        private PromptBuilder $promptBuilder,
        private ThinkSuppressor $thinkSuppressor,
        private ContextService $contextService,
        private UrlAnalyzer $urlAnalyzer,
        private RetrieverInterface $retriever,
        private ShopSearchService $shopSearchService,
        private SearchRepairService $searchRepairService,
        private CommerceIntentLite $commerceIntentLite,
        private OllamaClient $ollamaClient,
        private LoggerInterface $agentLogger,
        private AgentRunnerConfig $agentRunnerConfig,
        private bool $debug,
        private bool $logPrompt,
        private bool $logContext,
    ) {
        $this->systemMsgOn = true;
    }

    public function run(string $prompt, string $userId, bool $forceFullContext = false, string $requestContextHint = ''): Generator
    {
        $prompt = trim($prompt);

        if ($prompt === '') {
            yield $this->systemMsg($this->agentRunnerConfig->getEmptyPromptMessage(), 'err');
            return;
        }

        $shopResults = [];
        $primaryShopResults = [];
        $knowledgeChunks = [];
        $knowledgeEvidenceState = 'none';
        $sources = [];
        $optimizedShopQuery = '';
        $shopSearchQuery = '';
        $commerceHistoryContext = '';
        $attemptedShopRepair = false;
        $usedShopRepair = false;
        $shopRepairQueries = [];
        $shopSearchAttempted = false;
        $primaryShopSearchHadSystemFailure = false;
        $historyNotices = [];

        $this->agentLogger->info('Agent run started', [
            'userId' => $userId,
        ]);

        try {
            if ($forceFullContext) {
                // Full context mode is already passed to PromptBuilder.
                // Additional context strategies can be added here later.
            }

            yield $this->systemMsg(
                $this->buildProductionUiMetaMessage(
                    stageLabel: 'Antwort wird vorbereitet',
                    ragCount: null,
                    shopCount: null,
                    shopCountMode: 'not_requested',
                    sourceLabels: $sources,
                    confidenceLabel: 'Beleglage wird geprüft'
                ),
                'meta'
            );

            yield $this->systemMsg($this->agentRunnerConfig->getAnalyzeRequestMessage(), 'think');
            yield $this->systemMsg($this->agentRunnerConfig->getCheckInternetSourcesMessage(), 'think');

            $urlContent = $this->urlAnalyzer->extractContentFromPrompt($prompt);
            if ($urlContent !== '') {
                $this->addSource($sources, $this->agentRunnerConfig->getExternalUrlSourceLabel());
            }

            $commerceIntent = $this->detectCommerceIntent($prompt);

            yield $this->systemMsg($this->agentRunnerConfig->getRetrieveKnowledgeMessage(), 'think');

            $knowledgeRetrievalPrompt = $this->buildKnowledgeRetrievalPrompt(
                prompt: $prompt,
                userId: $userId,
                commerceIntent: $commerceIntent
            );
            $usedFollowUpRetrievalContext = $knowledgeRetrievalPrompt !== $prompt;

            $knowledgeChunks = $this->retriever->retrieve($knowledgeRetrievalPrompt);
            $knowledgeEvidenceState = $this->resolveKnowledgeEvidenceState($prompt, $knowledgeChunks, $urlContent);
            if ($knowledgeChunks !== []) {
                $this->addSource($sources, $this->agentRunnerConfig->getRagKnowledgeSourceLabel());
            }

            yield $this->systemMsg(
                $this->buildProductionUiMetaMessage(
                    stageLabel: 'RAG-Wissen wurde durchsucht',
                    ragCount: count($knowledgeChunks),
                    shopCount: null,
                    shopCountMode: 'not_requested',
                    sourceLabels: $sources,
                    confidenceLabel: $this->resolveRagEvidenceConfidenceLabel($knowledgeEvidenceState)
                ),
                'meta'
            );

            if ($usedFollowUpRetrievalContext) {
                $this->agentLogger->info('Knowledge retrieval used follow-up context', [
                    'userId' => $userId,
                    'prompt' => $prompt,
                    'knowledgeRetrievalPrompt' => $knowledgeRetrievalPrompt,
                    'commerceIntent' => $commerceIntent,
                ]);
            }

            if ($this->isCommerceIntent($commerceIntent)) {
                yield $this->systemMsg(
                    $this->buildProductionUiMetaMessage(
                        stageLabel: 'Shop-Suche wird vorbereitet',
                        ragCount: count($knowledgeChunks),
                        shopCount: null,
                        shopCountMode: 'loading',
                        sourceLabels: $sources,
                        confidenceLabel: $this->resolveRagEvidenceShopCheckConfidenceLabel($knowledgeEvidenceState)
                    ),
                    'meta'
                );

                yield $this->systemMsg($this->agentRunnerConfig->getOptimizeSearchMessage(), 'think');

                $commerceHistoryContext = $this->buildCommerceHistoryContext($userId, $requestContextHint);

                if ($commerceHistoryContext !== '') {
                    $this->addSource($sources, $this->agentRunnerConfig->getConversationHistorySourceLabel());
                }

                $optimizedShopQuery = yield from $this->buildOptimizedShopQuery(
                    $prompt,
                    $userId,
                    $commerceHistoryContext
                );

                $shopSearchQuery = $this->resolveShopSearchQuery(
                    prompt: $prompt,
                    optimizedShopQuery: $optimizedShopQuery,
                    commerceHistoryContext: $commerceHistoryContext,
                    userId: $userId
                );

                if ($shopSearchQuery === '') {
                    $this->agentLogger->info('Commerce search skipped because no concrete shop query could be resolved', [
                        'userId' => $userId,
                        'commerceIntent' => $commerceIntent,
                        'prompt' => $prompt,
                        'optimizedShopQuery' => $optimizedShopQuery,
                        'hasCommerceHistoryContext' => $commerceHistoryContext !== '',
                        'commerceHistoryContextLength' => mb_strlen($commerceHistoryContext),
                        'hasRequestContextHint' => trim($requestContextHint) !== '',
                    ]);

                    $noConcreteShopQueryMessage = $this->agentRunnerConfig->getNoConcreteShopQueryMessage();

                    yield $this->systemMsg(
                        $this->buildProductionUiMetaMessage(
                            stageLabel: 'Mehr Kontext nötig',
                            ragCount: count($knowledgeChunks),
                            shopCount: null,
                            shopCountMode: 'not_requested',
                            sourceLabels: $sources,
                            confidenceLabel: 'mehr Kontext nötig',
                            completed: true
                        ),
                        'meta'
                    );

                    yield $this->systemMsg(
                        $noConcreteShopQueryMessage,
                        'info'
                    );

                    $this->contextService->appendHistory(
                        $userId,
                        $prompt,
                        $this->plainTextFromHtml($noConcreteShopQueryMessage)
                    );

                    return;
                } else {
                    $shopQueryPreview = $this->shopSearchService->buildSearchQueryPreview(
                        $shopSearchQuery,
                        $commerceIntent,
                        $commerceHistoryContext
                    );

                    $shopSearchDisplayQuery = $shopQueryPreview->searchText !== ''
                        ? $shopQueryPreview->searchText
                        : $shopSearchQuery;
                    $shopSearchUsedOptimizedQuery = $optimizedShopQuery !== '';

                    yield $this->systemMsg(
                        $this->buildShopSearchMetaMessage(
                            query: $shopSearchDisplayQuery,
                            commerceIntent: $commerceIntent,
                            usedOptimizedQuery: $shopSearchUsedOptimizedQuery,
                            originalQuery: $shopSearchQuery
                        ),
                        'meta'
                    );

                    $this->agentLogger->info('Commerce search prepared', [
                        'userId' => $userId,
                        'commerceIntent' => $commerceIntent,
                        'usedOptimizedShopQuery' => $optimizedShopQuery !== '',
                        'optimizedShopQuery' => $optimizedShopQuery,
                        'shopSearchQuery' => $shopSearchQuery,
                        'hasCommerceHistoryContext' => $commerceHistoryContext !== '',
                        'commerceHistoryContextLength' => mb_strlen($commerceHistoryContext),
                    ]);

                    yield $this->systemMsg(
                        $this->buildProductionUiMetaMessage(
                            stageLabel: 'Shop wird durchsucht',
                            ragCount: count($knowledgeChunks),
                            shopCount: null,
                            shopCountMode: 'loading',
                            sourceLabels: $sources,
                            confidenceLabel: $this->resolveRagEvidenceShopCheckConfidenceLabel($knowledgeEvidenceState)
                        ),
                        'meta'
                    );

                    yield $this->systemMsg(
                        sprintf($this->agentRunnerConfig->getFetchSearchDataMessageTemplate(), $commerceIntent),
                        'think'
                    );

                    $shopSearchAttempted = true;
                    $primaryShopResults = $this->searchShop(
                        $shopSearchQuery,
                        $commerceIntent,
                        $userId,
                        $commerceHistoryContext
                    );
                    $primaryShopSearchHadSystemFailure = $this->shopSearchService->hadLastSearchSystemFailure();
                    $primaryShopSearchFailureReason = $this->shopSearchService->getLastSearchFailureReason();

                    if ($primaryShopSearchHadSystemFailure) {
                        $this->agentLogger->warning('Shop repair skipped after Store API system failure', [
                            'userId' => $userId,
                            'commerceIntent' => $commerceIntent,
                            'shopSearchQuery' => $shopSearchQuery,
                            'failureReason' => $primaryShopSearchFailureReason,
                        ]);

                        $shopUnavailableMessage = $this->buildShopUnavailableMessage($primaryShopSearchFailureReason);
                        yield $this->systemMsg(
                            $shopUnavailableMessage,
                            'err'
                        );
                        yield $this->systemMsg(
                            $this->buildShopSearchMetaMessage(
                                query: $shopSearchDisplayQuery !== '' ? $shopSearchDisplayQuery : $shopSearchQuery,
                                commerceIntent: $commerceIntent,
                                usedOptimizedQuery: $shopSearchUsedOptimizedQuery,
                                originalQuery: $shopSearchQuery,
                                completed: true,
                                unavailable: true
                            ),
                            'meta'
                        );
                        $historyNotices[] = $this->buildHistoryNotice(
                            'Shopdaten konnten nicht geladen werden',
                            $primaryShopSearchFailureReason
                        );

                        $repairPayload = [
                            'results' => $primaryShopResults,
                            'attemptedRepair' => false,
                            'usedRepair' => false,
                            'repairQueries' => [],
                        ];
                    } else {
                        yield $this->systemMsg('Erweiterte Shopsuche wird geprüft…', 'think');

                        $repairPayload = $this->repairShopResults(
                            prompt: $prompt,
                            userId: $userId,
                            commerceIntent: $commerceIntent,
                            commerceHistoryContext: $commerceHistoryContext,
                            primaryQuery: $shopSearchQuery,
                            primaryShopResults: $primaryShopResults,
                            knowledgeChunks: $knowledgeChunks
                        );
                    }
                }

                $shopResults = $repairPayload['results'];
                $attemptedShopRepair = $repairPayload['attemptedRepair'];
                $usedShopRepair = $repairPayload['usedRepair'];
                $shopRepairQueries = $repairPayload['repairQueries'];

                if (!$primaryShopSearchHadSystemFailure) {
                    yield $this->systemMsg(
                        $this->buildShopSearchMetaMessage(
                            query: $shopSearchDisplayQuery !== '' ? $shopSearchDisplayQuery : $shopSearchQuery,
                            commerceIntent: $commerceIntent,
                            usedOptimizedQuery: $shopSearchUsedOptimizedQuery,
                            originalQuery: $shopSearchQuery,
                            resultCount: count($shopResults),
                            completed: true,
                            attemptedRepair: $attemptedShopRepair,
                            usedRepair: $usedShopRepair
                        ),
                        'meta'
                    );
                }

                if ($shopResults !== []) {
                    $this->addSource($sources, $this->agentRunnerConfig->getShopSystemSourceLabel());
                }

                if ($attemptedShopRepair) {
                    $this->addSource($sources, $this->agentRunnerConfig->getExtendedShopSearchSourceLabel());
                }

                yield $this->systemMsg(
                    $this->buildProductionUiMetaMessage(
                        stageLabel: $primaryShopSearchHadSystemFailure ? 'Shopdaten nicht verfügbar' : 'Shop-Suche abgeschlossen',
                        ragCount: count($knowledgeChunks),
                        shopCount: $primaryShopSearchHadSystemFailure ? null : count($shopResults),
                        shopCountMode: $primaryShopSearchHadSystemFailure ? 'unavailable' : 'count',
                        sourceLabels: $sources,
                        confidenceLabel: $this->resolveProductionUiConfidenceLabel(
                            hasKnowledge: $this->isDirectKnowledgeEvidence($knowledgeEvidenceState),
                            isCommerceIntent: true,
                            shopSearchAttempted: $shopSearchAttempted,
                            hasShopResults: $shopResults !== [],
                            shopSearchHadSystemFailure: $primaryShopSearchHadSystemFailure
                        )
                    ),
                    'meta'
                );
            }

            if ($shopResults !== []) {
                $knowledgeChunks = $this->limitKnowledgeChunks($knowledgeChunks, $commerceIntent);
            }

            yield $this->systemMsg($this->agentRunnerConfig->getAnalyzeAllInformationMessage(), 'think');

            $finalPrompt = $this->promptBuilder->build(
                prompt: $prompt,
                userId: $userId,
                urlContent: $urlContent,
                knowledgeChunks: $knowledgeChunks,
                shopResults: $shopResults,
                fullContext: $forceFullContext,
                swagFullOutPut: $optimizedShopQuery,
                commerceSearchAttempted: $shopSearchAttempted,
                shopSearchHadSystemFailure: $primaryShopSearchHadSystemFailure,
                knowledgeEvidenceState: $knowledgeEvidenceState
            );

            if ($this->debug && $this->logPrompt) {
                $this->agentLogger->debug('Final prompt', [
                    'userId' => $userId,
                    'finalPrompt' => $finalPrompt,
                    'optimizedShopQuery' => $optimizedShopQuery,
                    'shopSearchQuery' => $shopSearchQuery,
                    'knowledgeRetrievalPrompt' => $knowledgeRetrievalPrompt,
                    'usedFollowUpRetrievalContext' => $usedFollowUpRetrievalContext,
                    'primaryShopResultsCount' => count($primaryShopResults),
                    'shopResultsCount' => count($shopResults),
                    'attemptedShopRepair' => $attemptedShopRepair,
                    'usedShopRepair' => $usedShopRepair,
                    'shopRepairQueries' => $shopRepairQueries,
                    'shopSearchAttempted' => $shopSearchAttempted,
                ]);
            }

            if ($this->debug && $this->logContext) {
                $this->agentLogger->debug('Conversation context snapshot', [
                    'userId' => $userId,
                    'context' => $this->contextService->buildUserContext(
                        $userId,
                        $forceFullContext
                    ),
                ]);
            }

            yield $this->systemMsg(
                $this->buildProductionUiMetaMessage(
                    stageLabel: 'Antwort wird generiert',
                    ragCount: count($knowledgeChunks),
                    shopCount: $primaryShopSearchHadSystemFailure ? null : ($shopSearchAttempted ? count($shopResults) : null),
                    shopCountMode: $primaryShopSearchHadSystemFailure ? 'unavailable' : ($shopSearchAttempted ? 'count' : 'not_requested'),
                    sourceLabels: $sources,
                    confidenceLabel: $this->resolveProductionUiConfidenceLabel(
                        hasKnowledge: $this->isDirectKnowledgeEvidence($knowledgeEvidenceState),
                        isCommerceIntent: $this->isCommerceIntent($commerceIntent),
                        shopSearchAttempted: $shopSearchAttempted,
                        hasShopResults: $shopResults !== [],
                        shopSearchHadSystemFailure: $primaryShopSearchHadSystemFailure
                    )
                ),
                'meta'
            );

            if ($sources !== []) {
                yield $this->emitSources(
                    $sources,
                    $this->agentRunnerConfig->getUsedSourcesPrefix()
                );
            }

            $noLlmFallbackAnswer = $this->buildNoLlmFallbackAnswer(
                prompt: $prompt,
                urlContent: $urlContent,
                knowledgeChunks: $knowledgeChunks,
                shopResults: $shopResults,
                commerceIntent: $commerceIntent,
                shopSearchAttempted: $shopSearchAttempted,
                shopSearchHadSystemFailure: $primaryShopSearchHadSystemFailure,
                shopSearchFailureReason: $primaryShopSearchFailureReason ?? null,
                knowledgeEvidenceState: $knowledgeEvidenceState
            );

            $fullOutput = yield from $this->streamFinalAnswer($finalPrompt, $noLlmFallbackAnswer);

            yield $this->systemMsg(
                $this->buildProductionUiMetaMessage(
                    stageLabel: 'Abgeschlossen',
                    ragCount: count($knowledgeChunks),
                    shopCount: $primaryShopSearchHadSystemFailure ? null : ($shopSearchAttempted ? count($shopResults) : null),
                    shopCountMode: $primaryShopSearchHadSystemFailure ? 'unavailable' : ($shopSearchAttempted ? 'count' : 'not_requested'),
                    sourceLabels: $sources,
                    confidenceLabel: $this->resolveProductionUiConfidenceLabel(
                        hasKnowledge: $this->isDirectKnowledgeEvidence($knowledgeEvidenceState),
                        isCommerceIntent: $this->isCommerceIntent($commerceIntent),
                        shopSearchAttempted: $shopSearchAttempted,
                        hasShopResults: $shopResults !== [],
                        shopSearchHadSystemFailure: $primaryShopSearchHadSystemFailure
                    ),
                    completed: true
                ),
                'meta'
            );

            if ($sources !== []) {
                yield $this->emitSources(
                    $sources,
                    $this->agentRunnerConfig->getSourcesPrefix()
                );
            }

            if ($this->debug) {
                yield $this->systemMsg($finalPrompt, 'debug');
            }

            $historyResponse = $this->buildHistoryResponse($fullOutput, $historyNotices);

            if ($historyResponse !== '') {
                $this->contextService->appendHistory(
                    $userId,
                    $prompt,
                    $historyResponse
                );
            }

            $this->agentLogger->info('Agent run finished', [
                'userId' => $userId,
                'outputLength' => mb_strlen($fullOutput),
                'contextMode' => $forceFullContext ? 'full' : 'recent',
                'commerceIntent' => $commerceIntent,
                'primaryShopResultsCount' => count($primaryShopResults),
                'shopResultsCount' => count($shopResults),
                'attemptedShopRepair' => $attemptedShopRepair,
                'usedShopRepair' => $usedShopRepair,
                'shopRepairQueries' => $shopRepairQueries,
                'shopSearchAttempted' => $shopSearchAttempted,
                'primaryShopSearchHadSystemFailure' => $primaryShopSearchHadSystemFailure,
                'primaryShopSearchFailureReason' => $primaryShopSearchFailureReason ?? null,
                'knowledgeChunkCount' => count($knowledgeChunks),
                'knowledgeRetrievalPrompt' => $knowledgeRetrievalPrompt,
                'usedFollowUpRetrievalContext' => $usedFollowUpRetrievalContext,
                'hasUrlContent' => $urlContent !== '',
                'usedOptimizedShopQuery' => $optimizedShopQuery !== '',
                'optimizedShopQuery' => $optimizedShopQuery,
                'shopSearchQuery' => $shopSearchQuery,
                'hasCommerceHistoryContext' => $commerceHistoryContext !== '',
                'commerceHistoryContextLength' => mb_strlen($commerceHistoryContext),
            ]);
        } catch (Throwable $e) {
            $this->agentLogger->error('Agent run failed', [
                'userId' => $userId,
                'exception' => $e,
            ]);

            $userErrorMessage = $this->buildUserErrorMessage($e);
            yield $this->systemMsg(
                $this->buildProductionUiMetaMessage(
                    stageLabel: 'Antwort wurde unterbrochen',
                    ragCount: count($knowledgeChunks),
                    shopCount: $primaryShopSearchHadSystemFailure ? null : ($shopSearchAttempted ? count($shopResults) : null),
                    shopCountMode: $primaryShopSearchHadSystemFailure ? 'unavailable' : ($shopSearchAttempted ? 'count' : 'not_requested'),
                    sourceLabels: $sources,
                    confidenceLabel: 'nicht abgeschlossen',
                    completed: true
                ),
                'meta'
            );
            yield $this->systemMsg($userErrorMessage, 'err');

            $historyResponse = $this->buildHistoryResponse('', array_merge(
                $historyNotices,
                [$this->buildHistoryNotice('Antwort konnte nicht abgeschlossen werden', $e->getMessage())]
            ));

            if ($historyResponse !== '') {
                $this->contextService->appendHistory($userId, $prompt, $historyResponse);
            }
        }
    }

    private function detectCommerceIntent(string $prompt): string
    {
        $commerceMeta = $this->commerceIntentLite->detect($prompt);

        return (string) ($commerceMeta['intent'] ?? CommerceIntentLite::NONE);
    }

    private function isCommerceIntent(string $commerceIntent): bool
    {
        return $commerceIntent === CommerceIntentLite::PRODUCT_SEARCH
            || $commerceIntent === CommerceIntentLite::ADVISORY_PRODUCT_SEARCH;
    }

    private function buildKnowledgeRetrievalPrompt(
        string $prompt,
        string $userId,
        string $commerceIntent
    ): string {
        if (!$this->shouldUseFollowUpContextForKnowledgeRetrieval($prompt, $commerceIntent)) {
            return $prompt;
        }

        $history = $this->contextService->buildUserContextWithinBudget($userId, 3000);
        $previousQuestions = $this->extractRecentUserQuestions($history, 2);
        $referenceAnchors = $this->extractLatestAssistantReferenceAnchors($history);

        if ($previousQuestions === [] && $referenceAnchors === []) {
            return $prompt;
        }

        $lines = [];

        foreach ($previousQuestions as $question) {
            $lines[] = 'Vorherige Nutzerfrage: ' . $question;
        }

        if ($referenceAnchors !== []) {
            $lines[] = 'Vorherige technische Referenzanker (nur zur Referenzauflösung, keine Faktenquelle): '
                . implode(' ', $referenceAnchors);
        }

        $lines[] = 'Aktuelle Folgefrage: ' . $prompt;

        return implode("\n", $lines);
    }

    private function shouldUseFollowUpContextForKnowledgeRetrieval(string $prompt, string $commerceIntent): bool
    {
        if ($this->isCommerceIntent($commerceIntent)) {
            return false;
        }

        $normalized = $this->normalizeFollowUpText($prompt);

        if ($normalized === '') {
            return false;
        }

        if ($this->containsExplicitCommercialFollowUpSignal($normalized)) {
            return false;
        }

        if (mb_strlen($normalized, 'UTF-8') > 180 && !$this->containsStrongFollowUpReference($normalized)) {
            return false;
        }

        return $this->containsStrongFollowUpReference($normalized);
    }

    private function containsStrongFollowUpReference(string $normalized): bool
    {
        $patterns = [
            '/\bder\s+wert\b/u',
            '/\bdieser\s+wert\b/u',
            '/\bdiesen\s+wert\b/u',
            '/\bdem\s+wert\b/u',
            '/\bmit\s+welche(?:m|n|r)?\b/u',
            '/\bwomit\b/u',
            '/\bdamit\b/u',
            '/\bdafuer\b/u',
            '/\bdafür\b/u',
            '/\bdazu\b/u',
            '/\bdaraus\b/u',
            '/\bwelche(?:r|s|m|n)?\s+indikator\b/u',
            '/\bwelche(?:r|s|m|n)?\s+indikatortyp\b/u',
            '/\bindikator\s+(?:dafuer|dafür|dazu|hierfuer|hierfür)\b/u',
            '/\bwelche(?:r|s|m|n)?\s+bereich\b/u',
            '/\bwelche(?:r|s|m|n)?\s+messbereich\b/u',
            '/\bwelche(?:r|s|m|n)?\s+grenzwert\b/u',
        ];

        foreach ($patterns as $pattern) {
            if (preg_match($pattern, $normalized) === 1) {
                return true;
            }
        }

        return false;
    }

    private function containsExplicitCommercialFollowUpSignal(string $normalized): bool
    {
        $commercialSignals = [
            'shop', 'preis', 'preise', 'kostet', 'kosten', 'kaufen', 'bestellen',
            'warenkorb', 'lieferzeit', 'verfuegbar', 'verfügbar', 'lager', 'url',
            'link', 'artikelnummer', 'sku', 'produktnummer',
        ];

        foreach ($commercialSignals as $signal) {
            if (str_contains($normalized, $signal)) {
                return true;
            }
        }

        return false;
    }

    /**
     * @return string[]
     */
    private function extractRecentUserQuestions(string $history, int $limit): array
    {
        $history = trim($history);

        if ($history === '' || $limit <= 0) {
            return [];
        }

        if (preg_match_all('/^Question:\s*(.+)$/mi', $history, $matches) !== 1) {
            return [];
        }

        $questions = array_values(array_filter(
            array_map(
                fn(string $question): string => $this->sanitizeHistoryQuestion($question),
                $matches[1] ?? []
            ),
            static fn(string $question): bool => $question !== ''
        ));

        if ($questions === []) {
            return [];
        }

        return array_slice($questions, -$limit);
    }

    /**
     * Extracts stable reference anchors from the latest assistant answer.
     *
     * These anchors are only used to resolve follow-up references such as
     * "der Wert" or "welcher Indikator". They are not factual evidence for
     * the final answer. To avoid propagating wrong earlier answers, only the
     * first explicit Testomat model reference and the first explicit °dH value
     * are kept. Indicator names, reagent codes, prices, URLs and product
     * numbers are intentionally ignored here.
     *
     * @return string[]
     */
    private function extractLatestAssistantReferenceAnchors(string $history): array
    {
        $turn = $this->extractLatestHistoryTurn($history);

        if ($turn === '') {
            return [];
        }

        $answer = preg_replace('/^Question:\s*.*(?:\R|$)/u', '', $turn, 1) ?? '';
        $answer = trim($answer);

        if ($answer === '') {
            return [];
        }

        $anchors = [];

        $model = $this->extractFirstTestomatModelAnchor($answer);
        if ($model !== '') {
            $anchors[] = $model;
        }

        $hardnessValue = $this->extractFirstHardnessValueAnchor($answer);
        if ($hardnessValue !== '') {
            $anchors[] = $hardnessValue;
        }

        return array_values(array_unique($anchors));
    }

    private function extractLatestHistoryTurn(string $history): string
    {
        $history = trim($history);

        if ($history === '') {
            return '';
        }

        $parts = preg_split('/(?=^Question:\s)/m', $history);

        if ($parts === false || $parts === []) {
            return '';
        }

        $turns = array_values(array_filter(
            array_map(static fn(string $part): string => trim($part), $parts),
            static fn(string $part): bool => $part !== ''
        ));

        if ($turns === []) {
            return '';
        }

        return (string) end($turns);
    }

    private function extractFirstTestomatModelAnchor(string $text): string
    {
        $pattern = '/\bTestomat(?:®)?\s+'
            . '(?:\d{3,4}|EVO(?:\s+[A-Z]{2,6})?|ECO(?:[-\s]?(?:PLUS|C))?|DUO(?:\s+\d{3,4})?|LAB(?:\s+[A-Z]{2,6})?)'
            . '\b/iu';

        if (preg_match($pattern, $text, $matches) !== 1) {
            return '';
        }

        $value = $this->sanitizeHistoryQuestion(($matches[0] ?? ''));
        $value = preg_replace('/\s+/u', ' ', $value) ?? $value;

        return trim(str_replace('®', '', $value));
    }

    private function extractFirstHardnessValueAnchor(string $text): string
    {
        if (preg_match('/\b\d+(?:[,.]\d+)?\s*°\s*dH\b/iu', $text, $matches) !== 1) {
            return '';
        }

        $value = preg_replace('/\s+/u', ' ', ($matches[0] ?? '')) ?? '';

        return trim($value);
    }

    private function sanitizeHistoryQuestion(string $question): string
    {
        $question = trim((string) preg_replace('/\s+/u', ' ', $question));

        if ($question === '') {
            return '';
        }

        if (mb_strlen($question, 'UTF-8') <= 500) {
            return $question;
        }

        return rtrim(mb_substr($question, 0, 497, 'UTF-8')) . '...';
    }

    private function normalizeFollowUpText(string $value): string
    {
        $value = mb_strtolower(trim($value), 'UTF-8');
        $value = str_replace(['-', '/', '_'], ' ', $value);
        $value = preg_replace('/[^\p{L}\p{N}\s]+/u', ' ', $value) ?? $value;
        $value = preg_replace('/\s+/u', ' ', $value) ?? $value;

        return trim($value);
    }

    /**
     * @return Generator<int, string, mixed, string>
     */
    private function buildOptimizedShopQuery(
        string $prompt,
        string $userId,
        string $commerceHistoryContext = ''
    ): Generator {
        $shopPrompt = trim($this->agentRunnerConfig->getShopPrompt(
            $prompt,
            $commerceHistoryContext
        ));

        if ($shopPrompt === '') {
            return '';
        }

        $optimizedQuery = '';
        $lastHeartbeatAt = time();
        $this->thinkSuppressor->reset();

        try {
            foreach ($this->ollamaClient->stream($shopPrompt) as $token) {
                if (!is_string($token)) {
                    continue;
                }

                if (time() - $lastHeartbeatAt >= 2) {
                    yield $this->systemMsg('Shop-Suchanfrage wird optimiert…', 'think');
                    $lastHeartbeatAt = time();
                }

                $cleanToken = $this->thinkSuppressor->filter($token);

                if ($cleanToken === '') {
                    continue;
                }

                $optimizedQuery .= $cleanToken;
            }
        } catch (Throwable $e) {
            $this->agentLogger->warning('Shop query optimization failed, falling back to original prompt', [
                'userId' => $userId,
                'exception' => $e,
            ]);

            return '';
        }

        return $this->sanitizeOptimizedShopQuery($optimizedQuery, $prompt, $commerceHistoryContext);
    }

    /**
     * @return array{
     *     results: array,
     *     attemptedRepair: bool,
     *     usedRepair: bool,
     *     repairQueries: string[]
     * }
     */
    private function repairShopResults(
        string $prompt,
        string $userId,
        string $commerceIntent,
        string $commerceHistoryContext,
        string $primaryQuery,
        array $primaryShopResults,
        array $knowledgeChunks
    ): array {
        try {
            return $this->searchRepairService->repair(
                prompt: $prompt,
                commerceIntent: $commerceIntent,
                commerceHistoryContext: $commerceHistoryContext,
                primaryQuery: $primaryQuery,
                primaryShopResults: $primaryShopResults,
                knowledgeChunks: $knowledgeChunks
            );
        } catch (Throwable $e) {
            $this->agentLogger->warning('Shop repair failed, continuing with primary shop results', [
                'userId' => $userId,
                'commerceIntent' => $commerceIntent,
                'primaryQuery' => $primaryQuery,
                'primaryShopResultsCount' => count($primaryShopResults),
                'exception' => $e,
            ]);

            return [
                'results' => $primaryShopResults,
                'attemptedRepair' => false,
                'usedRepair' => false,
                'repairQueries' => [],
            ];
        }
    }

    private function resolveShopSearchQuery(
        string $prompt,
        string $optimizedShopQuery,
        string $commerceHistoryContext,
        string $userId
    ): string {
        if ($optimizedShopQuery !== '' && !$this->isMetaOnlyShopQuery($optimizedShopQuery)) {
            return $optimizedShopQuery;
        }

        if (!$this->isMetaOnlyShopQuery($prompt)) {
            return $prompt;
        }

        $contextQuery = $this->extractContextualShopSearchQuery($commerceHistoryContext);

        if ($contextQuery !== '' && !$this->isMetaOnlyShopQuery($contextQuery)) {
            return $contextQuery;
        }

        $extendedHistoryBudget = $this->agentRunnerConfig->getShopQueryContextFallbackHistoryBudgetChars();

        if ($extendedHistoryBudget > mb_strlen($commerceHistoryContext, 'UTF-8')) {
            $extendedHistory = $this->contextService->buildUserContextWithinBudget($userId, $extendedHistoryBudget);
            $extendedContextQuery = $this->extractContextualShopSearchQuery($extendedHistory);

            if ($extendedContextQuery !== '' && !$this->isMetaOnlyShopQuery($extendedContextQuery)) {
                return $extendedContextQuery;
            }
        }

        if ($this->agentRunnerConfig->shouldUseFullHistoryForShopQueryContextFallback()) {
            $fullHistory = $this->contextService->buildUserContext($userId, true);
            $fullHistoryContextQuery = $this->extractContextualShopSearchQuery($fullHistory);

            if ($fullHistoryContextQuery !== '' && !$this->isMetaOnlyShopQuery($fullHistoryContextQuery)) {
                return $fullHistoryContextQuery;
            }
        }

        return '';
    }

    private function extractContextualShopSearchQuery(string $commerceHistoryContext): string
    {
        if (!$this->agentRunnerConfig->isShopQueryContextFallbackEnabled()) {
            return '';
        }

        $questions = $this->extractRecentUserQuestions(
            $commerceHistoryContext,
            $this->agentRunnerConfig->getShopQueryContextFallbackQuestionLimit()
        );

        for ($i = count($questions) - 1; $i >= 0; $i--) {
            $question = trim($questions[$i]);

            if ($question === '' || $this->isMetaOnlyShopQuery($question)) {
                continue;
            }

            $contextQuery = $this->buildContextFallbackShopQuery($question);

            if ($contextQuery !== '' && !$this->isMetaOnlyShopQuery($contextQuery)) {
                return $contextQuery;
            }
        }

        return '';
    }

    private function buildContextFallbackShopQuery(string $question): string
    {
        $tokens = $this->tokenizeShopQueryCandidate($question);

        if ($tokens === []) {
            return '';
        }

        $filterTerms = [];

        foreach (array_merge(
            $this->agentRunnerConfig->getShopQueryMetaOnlyTerms(),
            $this->agentRunnerConfig->getShopQueryContextFallbackFilterTerms()
        ) as $term) {
            foreach ($this->tokenizeShopQueryCandidate($term) as $token) {
                $filterTerms[$token] = true;
            }
        }

        $maxTerms = max(1, $this->agentRunnerConfig->getShopQueryContextFallbackMaxTerms());
        $out = [];

        foreach ($tokens as $token) {
            if (isset($filterTerms[$token])) {
                continue;
            }

            if (in_array($token, $out, true)) {
                continue;
            }

            $out[] = $token;

            if (count($out) >= $maxTerms) {
                break;
            }
        }

        return implode(' ', $out);
    }

    /**
     * @return string[]
     */
    private function tokenizeShopQueryCandidate(string $value): array
    {
        $value = mb_strtolower(trim($value), 'UTF-8');
        $value = str_replace(['-', '/', '_'], ' ', $value);

        if (preg_match_all('/\d+(?:[,.]\d+)?|[\p{L}\p{N}]+/u', $value, $matches) !== 1) {
            return [];
        }

        return array_values(array_filter(
            array_map(static fn(string $token): string => trim($token), $matches[0] ?? []),
            static fn(string $token): bool => $token !== ''
        ));
    }

    private function isMetaOnlyShopQuery(string $query): bool
    {
        if (!$this->agentRunnerConfig->isShopQueryMetaGuardEnabled()) {
            return false;
        }

        $tokens = $this->tokenizeMetaGuardText($query);

        if ($tokens === []) {
            return true;
        }

        $metaTerms = [];
        foreach ($this->agentRunnerConfig->getShopQueryMetaOnlyTerms() as $term) {
            foreach ($this->tokenizeMetaGuardText($term) as $token) {
                $metaTerms[$token] = true;
            }
        }

        if ($metaTerms === []) {
            return false;
        }

        foreach ($tokens as $token) {
            if (!isset($metaTerms[$token])) {
                return false;
            }
        }

        return true;
    }

    /**
     * @return string[]
     */
    private function tokenizeMetaGuardText(string $value): array
    {
        $value = mb_strtolower(trim($value), 'UTF-8');
        $value = str_replace(['-', '/', '_'], ' ', $value);
        $value = preg_replace('/[^\p{L}\p{N}]+/u', ' ', $value) ?? $value;
        $value = preg_replace('/\s+/u', ' ', $value) ?? $value;
        $value = trim($value);

        if ($value === '') {
            return [];
        }

        return array_values(array_filter(
            explode(' ', $value),
            static fn(string $token): bool => $token !== ''
        ));
    }

    private function searchShop(
        string $query,
        string $commerceIntent,
        string $userId,
        string $commerceHistoryContext = ''
    ): array {
        try {
            return $this->shopSearchService->search(
                $query,
                $commerceIntent,
                $commerceHistoryContext
            );
        } catch (Throwable $e) {
            $this->agentLogger->warning('Shop search failed, continuing without shop results', [
                'userId' => $userId,
                'commerceIntent' => $commerceIntent,
                'query' => $query,
                'hasCommerceHistoryContext' => $commerceHistoryContext !== '',
                'commerceHistoryContextLength' => mb_strlen($commerceHistoryContext),
                'exception' => $e,
            ]);

            return [];
        }
    }

    private function buildCommerceHistoryContext(string $userId, string $requestContextHint = ''): string
    {
        $history = $this->contextService->buildUserContextWithinBudget(
            $userId,
            $this->agentRunnerConfig->getCommerceHistoryBudgetChars()
        );

        $requestContextHint = $this->sanitizeRequestContextHintForCommerce($requestContextHint);

        if ($requestContextHint === '') {
            return $history;
        }

        if ($history === '') {
            return $requestContextHint;
        }

        return trim($history) . "\n\n" . $requestContextHint;
    }

    private function sanitizeRequestContextHintForCommerce(string $requestContextHint): string
    {
        $requestContextHint = str_replace(["\r\n", "\r"], "\n", $requestContextHint);
        $requestContextHint = preg_replace('/[\t ]+/u', ' ', $requestContextHint) ?? $requestContextHint;
        $requestContextHint = preg_replace('/\n{3,}/u', "\n\n", $requestContextHint) ?? $requestContextHint;
        $requestContextHint = trim($requestContextHint);

        if ($requestContextHint === '') {
            return '';
        }

        if (mb_strlen($requestContextHint, 'UTF-8') > 4000) {
            $requestContextHint = mb_substr($requestContextHint, 0, 4000, 'UTF-8');
        }

        return trim($requestContextHint);
    }

    private function limitKnowledgeChunks(array $knowledgeChunks, string $commerceIntent): array
    {
        return match ($commerceIntent) {
            CommerceIntentLite::PRODUCT_SEARCH => array_slice(
                $knowledgeChunks,
                0,
                $this->agentRunnerConfig->getProductSearchKnowledgeChunkLimit()
            ),
            CommerceIntentLite::ADVISORY_PRODUCT_SEARCH => array_slice(
                $knowledgeChunks,
                0,
                $this->agentRunnerConfig->getAdvisoryProductSearchKnowledgeChunkLimit()
            ),
            default => $knowledgeChunks,
        };
    }

    private function sanitizeOptimizedShopQuery(
        string $query,
        string $sourcePrompt = '',
        string $commerceHistoryContext = ''
    ): string {
        $query = trim($query);

        if ($query === '') {
            return '';
        }

        $query = preg_split('/\R+/u', $query, 2)[0] ?? $query;
        $query = preg_replace($this->agentRunnerConfig->getOptimizedShopQueryPrefixPattern(), '', $query) ?? $query;
        $query = trim($query, $this->agentRunnerConfig->getOptimizedShopQueryTrimCharacters());
        $query = preg_replace('/\s+/u', ' ', $query) ?? $query;
        $query = $this->preserveOptimizedShopQueryLanguage($query, $sourcePrompt);
        $query = $this->enrichReferentialShopQueryFromHistory($query, $sourcePrompt, $commerceHistoryContext);
        $query = preg_replace('/\s+/u', ' ', $query) ?? $query;

        return trim($query);
    }

    private function enrichReferentialShopQueryFromHistory(
        string $query,
        string $sourcePrompt,
        string $commerceHistoryContext
    ): string {
        if (!$this->agentRunnerConfig->isShopQueryContextAnchorEnrichmentEnabled()) {
            return $query;
        }

        if (trim($commerceHistoryContext) === '') {
            return $query;
        }

        $queryTokens = $this->tokenizeShopQueryCandidate($query);

        if ($queryTokens === []) {
            return $query;
        }

        $maxTerms = max(1, $this->agentRunnerConfig->getShopQueryContextAnchorEnrichmentMaxQueryTerms());
        if (count($queryTokens) > $maxTerms) {
            return $query;
        }

        if (!$this->containsConfiguredShopQueryAnchorTrigger(trim($query . ' ' . $sourcePrompt))) {
            return $query;
        }

        $anchor = $this->normalizeShopQueryAnchor(
            $this->extractLatestConfiguredShopQueryContextAnchor($commerceHistoryContext)
        );

        if ($anchor === '' || $this->queryAlreadyContainsAllAnchorTokens($query, $anchor)) {
            return $query;
        }

        $template = $this->agentRunnerConfig->getShopQueryContextAnchorEnrichmentTemplate();
        $enriched = str_replace(['{anchor}', '{query}'], [$anchor, $query], $template);
        $enriched = preg_replace('/\s+/u', ' ', $enriched) ?? $enriched;

        return trim($enriched) !== '' ? trim($enriched) : $query;
    }

    private function containsConfiguredShopQueryAnchorTrigger(string $text): bool
    {
        $tokens = $this->tokenizeShopQueryCandidate($text);

        if ($tokens === []) {
            return false;
        }

        $tokenSet = array_fill_keys($tokens, true);

        foreach ($this->agentRunnerConfig->getShopQueryContextAnchorEnrichmentTriggerTerms() as $term) {
            foreach ($this->tokenizeShopQueryCandidate($term) as $termToken) {
                if (isset($tokenSet[$termToken])) {
                    return true;
                }
            }
        }

        return false;
    }

    private function extractLatestConfiguredShopQueryContextAnchor(string $commerceHistoryContext): string
    {
        $latest = '';

        foreach ($this->agentRunnerConfig->getShopQueryContextAnchorEnrichmentPatterns() as $pattern) {
            if (@preg_match_all($pattern, $commerceHistoryContext, $matches, PREG_SET_ORDER) === false) {
                continue;
            }

            foreach ($matches as $match) {
                $candidate = trim((string) ($match[0] ?? ''));
                if ($candidate !== '') {
                    $latest = $candidate;
                }
            }
        }

        return $latest;
    }

    private function normalizeShopQueryAnchor(string $anchor): string
    {
        $anchor = str_replace('®', '', $anchor);
        $anchor = mb_strtolower(trim($anchor), 'UTF-8');
        $anchor = preg_replace('/[^\p{L}\p{N},.%°+\-\s]+/u', ' ', $anchor) ?? $anchor;
        $anchor = preg_replace('/\s+/u', ' ', $anchor) ?? $anchor;

        return trim($anchor);
    }

    private function queryAlreadyContainsAllAnchorTokens(string $query, string $anchor): bool
    {
        $queryTokens = array_fill_keys($this->tokenizeShopQueryCandidate($query), true);

        foreach ($this->tokenizeShopQueryCandidate($anchor) as $token) {
            if (!isset($queryTokens[$token])) {
                return false;
            }
        }

        return true;
    }

    private function preserveOptimizedShopQueryLanguage(string $query, string $sourcePrompt): string
    {
        if (!$this->agentRunnerConfig->isShopQueryLanguagePreservationEnabled()) {
            return $query;
        }

        $language = $this->detectConfiguredShopQueryLanguage($sourcePrompt);

        if ($language === null) {
            return $query;
        }

        $replacements = $this->agentRunnerConfig->getShopQueryTranslationReplacements($language);

        if ($replacements === []) {
            return $query;
        }

        foreach ($replacements as $source => $target) {
            $pattern = '/(?<![\\p{L}\\p{N}])' . preg_replace('/\\s+/u', '\\s+', preg_quote($source, '/')) . '(?![\\p{L}\\p{N}])/iu';
            $query = preg_replace($pattern, $target, $query) ?? $query;
        }

        return $query;
    }

    private function detectConfiguredShopQueryLanguage(string $sourcePrompt): ?string
    {
        $normalized = ' ' . strtolower($sourcePrompt) . ' ';
        $normalized = preg_replace('/[\\r\\n\\t]+/u', ' ', $normalized) ?? $normalized;
        $normalized = preg_replace('/\\s+/u', ' ', $normalized) ?? $normalized;

        foreach ($this->agentRunnerConfig->getShopQueryLanguageMarkers() as $language => $markers) {
            foreach ($markers as $marker) {
                if ($marker !== '' && str_contains($normalized, $marker)) {
                    return $language;
                }
            }
        }

        return null;
    }
    /**
     * @return Generator<int, string, mixed, string>
     */
    private function streamFinalAnswer(string $finalPrompt, string $noLlmFallbackAnswer = ''): Generator
    {
        $fullOutput = '';
        $thinkingNoticeShown = false;
        $chunker = new StreamChunker();

        $this->thinkSuppressor->reset();

        yield $this->systemMsg($this->agentRunnerConfig->getThinkingWhileStreamingMessage(), 'think');
        $thinkingNoticeShown = true;

        try {
            foreach ($this->ollamaClient->stream($finalPrompt) as $token) {
                if (!is_string($token)) {
                    continue;
                }

                $cleanToken = $this->thinkSuppressor->filter($token);

                if ($cleanToken === '') {
                    if (!$thinkingNoticeShown) {
                        yield $this->systemMsg($this->agentRunnerConfig->getThinkingWhileStreamingMessage(), 'think');
                        $thinkingNoticeShown = true;
                    }

                    continue;
                }

                $fullOutput .= $cleanToken;

                $chunk = $chunker->push($cleanToken);
                if ($chunk !== null) {
                    yield $this->systemMsg($chunk, 'answer');
                }
            }
        } catch (Throwable $e) {
            $noLlmFallbackAnswer = trim($noLlmFallbackAnswer);

            if ($noLlmFallbackAnswer === '' || $fullOutput !== '') {
                throw $e;
            }

            $this->agentLogger->warning('LLM stream failed before answer tokens, using deterministic no-LLM fallback answer', [
                'exception' => $e,
            ]);

            $fullOutput = $noLlmFallbackAnswer;
            yield $this->systemMsg($noLlmFallbackAnswer, 'answer');

            return $fullOutput;
        }

        $finalChunk = $chunker->flush();
        if ($finalChunk !== null) {
            yield $this->systemMsg($finalChunk, 'answer');
        } elseif ($fullOutput === '') {
            $noLlmFallbackAnswer = trim($noLlmFallbackAnswer);

            if ($noLlmFallbackAnswer !== '') {
                $fullOutput = $noLlmFallbackAnswer;
                yield $this->systemMsg($noLlmFallbackAnswer, 'answer');
            } else {
                yield $this->systemMsg($this->agentRunnerConfig->getNoLlmDataReceivedMessage(), 'err');
            }
        }

        return $fullOutput;
    }

    /**
     * Build a deterministic safety answer for environments where the LLM returns no tokens.
     *
     * This intentionally does not infer technical suitability from weak evidence. It only reports
     * reliable system state, shop hit metadata and the next safe action.
     *
     * @param string[] $knowledgeChunks
     * @param ShopProductResult[] $shopResults
     */
    private function buildNoLlmFallbackAnswer(
        string $prompt,
        string $urlContent,
        array $knowledgeChunks,
        array $shopResults,
        string $commerceIntent,
        bool $shopSearchAttempted,
        bool $shopSearchHadSystemFailure,
        ?string $shopSearchFailureReason,
        string $knowledgeEvidenceState = 'unknown'
    ): string {
        $hasKnowledge = $this->isDirectKnowledgeEvidence($knowledgeEvidenceState) || ($knowledgeEvidenceState === 'unknown' && ($knowledgeChunks !== [] || trim($urlContent) !== ''));
        $hasShopResults = $shopResults !== [];
        $isCommerceIntent = $this->isCommerceIntent($commerceIntent);

        if ($hasShopResults) {
            return $this->buildNoLlmShopFallbackAnswer(
                prompt: $prompt,
                hasKnowledge: $hasKnowledge,
                shopResults: $shopResults
            );
        }

        if ($shopSearchHadSystemFailure) {
            return $this->buildNoLlmShopUnavailableAnswer(
                hasKnowledge: $hasKnowledge,
                reason: $shopSearchFailureReason
            );
        }

        if ($isCommerceIntent && $shopSearchAttempted) {
            return $this->buildNoLlmNoShopResultsAnswer($hasKnowledge);
        }

        if ($hasKnowledge) {
            return $this->agentRunnerConfig->getNoLlmFallbackKnowledgeOnlyMessage();
        }

        return $this->agentRunnerConfig->getNoLlmFallbackNoDataMessage();
    }

    /**
     * @param ShopProductResult[] $shopResults
     */
    private function buildNoLlmShopFallbackAnswer(string $prompt, bool $hasKnowledge, array $shopResults): string
    {
        $intro = $hasKnowledge
            ? $this->agentRunnerConfig->getNoLlmFallbackShopWithKnowledgeMessage()
            : $this->agentRunnerConfig->getNoLlmFallbackShopOnlyMessage();
        $requestedProductRole = $this->resolveNoLlmRequestedProductRole($prompt);

        $lines = [$intro];

        if ($this->hasOnlyNoLlmAccessoryResultsForMainDeviceRequest($requestedProductRole, $shopResults)) {
            $lines[] = $this->agentRunnerConfig->getNoLlmFallbackAccessoryOnlyForMainDeviceMessage();
        }

        $lines[] = '';

        foreach ($this->buildNoLlmShopProductLines($shopResults, $requestedProductRole) as $line) {
            $lines[] = $line;
        }

        $escalation = trim($this->agentRunnerConfig->getNoLlmFallbackEscalationMessage());
        if ($escalation !== '') {
            $lines[] = '';
            $lines[] = $escalation;
        }

        return trim(implode("\n", $lines));
    }

    private function buildNoLlmShopUnavailableAnswer(bool $hasKnowledge, ?string $reason): string
    {
        $reason = $this->normalizeOneLine((string) $reason);
        $message = $hasKnowledge
            ? $this->agentRunnerConfig->getNoLlmFallbackShopUnavailableWithKnowledgeMessage()
            : $this->agentRunnerConfig->getNoLlmFallbackShopUnavailableNoKnowledgeMessage();

        if ($reason !== '') {
            $message .= ' Ursache: ' . $reason;
        }

        return trim($message);
    }

    private function buildNoLlmNoShopResultsAnswer(bool $hasKnowledge): string
    {
        return $hasKnowledge
            ? $this->agentRunnerConfig->getNoLlmFallbackNoShopResultsWithKnowledgeMessage()
            : $this->agentRunnerConfig->getNoLlmFallbackNoShopResultsNoKnowledgeMessage();
    }

    /**
     * @param ShopProductResult[] $shopResults
     * @return string[]
     */
    private function buildNoLlmShopProductLines(array $shopResults, string $requestedProductRole): array
    {
        $maxResults = max(1, $this->agentRunnerConfig->getNoLlmFallbackMaxShopResults());
        $lines = [];
        $index = 1;

        foreach ($shopResults as $product) {
            if (!$product instanceof ShopProductResult) {
                continue;
            }

            $lines[] = $this->formatNoLlmShopProductLine($product, $index, $requestedProductRole);
            $index++;

            if (count($lines) >= $maxResults) {
                break;
            }
        }

        if ($lines === []) {
            return ['- Es wurden Shop-Treffer übergeben, aber keine lesbaren Produktdaten gefunden.'];
        }

        return $lines;
    }

    private function formatNoLlmShopProductLine(ShopProductResult $product, int $index, string $requestedProductRole): string
    {
        $parts = [];
        $productRole = $this->resolveNoLlmShopProductRole($product);

        $name = $this->normalizeOneLine($product->name);
        $parts[] = $name !== '' ? $name : 'Unbenanntes Shop-Produkt';

        if ($product->productNumber !== null && trim($product->productNumber) !== '') {
            $parts[] = 'Art.-Nr. ' . $this->normalizeOneLine($product->productNumber);
        }

        if ($product->manufacturer !== null && trim($product->manufacturer) !== '') {
            $parts[] = 'Hersteller: ' . $this->normalizeOneLine($product->manufacturer);
        }

        if ($product->price !== null && trim($product->price) !== '') {
            $parts[] = 'Preis: ' . $this->normalizeOneLine($product->price);
        }

        if ($product->available !== null) {
            $parts[] = 'Verfügbar: ' . ($product->available ? 'ja' : 'nein');
        }

        if ($product->url !== null && trim($product->url) !== '') {
            $parts[] = 'URL: ' . $this->normalizeOneLine($product->url);
        }

        if ($requestedProductRole === 'main_device_or_system' && $productRole === 'accessory_or_consumable') {
            $parts[] = 'Hinweis: Zubehör/Verbrauchsartikel; nicht als Messanlage/Gerät bestätigt';
        }

        return sprintf('%d. %s', $index, implode(' | ', $parts));
    }

    /**
     * @param ShopProductResult[] $shopResults
     */
    private function hasOnlyNoLlmAccessoryResultsForMainDeviceRequest(string $requestedProductRole, array $shopResults): bool
    {
        if ($requestedProductRole !== 'main_device_or_system') {
            return false;
        }

        $seenProducts = 0;

        foreach ($shopResults as $product) {
            if (!$product instanceof ShopProductResult) {
                continue;
            }

            $seenProducts++;

            if ($this->resolveNoLlmShopProductRole($product) !== 'accessory_or_consumable') {
                return false;
            }
        }

        return $seenProducts > 0;
    }

    private function resolveNoLlmRequestedProductRole(string $prompt): string
    {
        $normalized = mb_strtolower($prompt, 'UTF-8');

        if ($this->containsAnyConfiguredTerm($normalized, $this->agentRunnerConfig->getNoLlmAccessoryProductRoleKeywords())) {
            return 'accessory_or_consumable';
        }

        if ($this->containsAnyConfiguredTerm($normalized, $this->agentRunnerConfig->getNoLlmMainDeviceRequestRoleKeywords())) {
            return 'main_device_or_system';
        }

        return 'unknown';
    }

    private function resolveNoLlmShopProductRole(ShopProductResult $product): string
    {
        $normalized = mb_strtolower($this->normalizeOneLine(implode(' ', [
            $product->name,
            (string) $product->description,
            (string) $product->customFields,
            implode(' ', $product->highlights),
        ])), 'UTF-8');

        if ($this->containsAnyConfiguredTerm($normalized, $this->agentRunnerConfig->getNoLlmAccessoryProductRoleKeywords())) {
            return 'accessory_or_consumable';
        }

        return 'unknown';
    }

    /**
     * @param string[] $terms
     */
    private function containsAnyConfiguredTerm(string $haystack, array $terms): bool
    {
        foreach ($terms as $term) {
            $term = mb_strtolower(trim($term), 'UTF-8');

            if ($term !== '' && str_contains($haystack, $term)) {
                return true;
            }
        }

        return false;
    }



    /**
     * Distinguish semantic nearest-neighbor retrieval hits from direct factual evidence.
     *
     * Vector retrieval can return useful context even when the essential user term is not
     * present. Those hits should stay visible as RAG hits, but they must not be counted as
     * "fachlich belegt" unless at least one salient request term or configured synonym
     * appears in the retrieved knowledge or user-provided URL content.
     *
     * @param string[] $knowledgeChunks
     */
    private function resolveKnowledgeEvidenceState(string $prompt, array $knowledgeChunks, string $urlContent): string
    {
        if ($knowledgeChunks === [] && trim($urlContent) === '') {
            return 'none';
        }

        if (trim($urlContent) !== '') {
            return 'direct';
        }

        $needles = $this->buildRagEvidenceNeedles($prompt);

        if ($needles === []) {
            // No meaningful term could be extracted. Preserve the previous behavior for
            // very short follow-ups instead of hiding potentially valid context.
            return 'direct';
        }

        $haystack = $this->normalizeRagEvidenceText(implode("\n\n", array_map('strval', $knowledgeChunks)));

        foreach ($needles as $needleGroup) {
            foreach ($needleGroup as $needle) {
                if ($this->containsRagEvidenceTerm($haystack, $needle)) {
                    return 'direct';
                }
            }
        }

        return 'weak';
    }

    private function isDirectKnowledgeEvidence(string $knowledgeEvidenceState): bool
    {
        return $knowledgeEvidenceState === 'direct';
    }

    private function resolveRagEvidenceConfidenceLabel(string $knowledgeEvidenceState): string
    {
        return match ($knowledgeEvidenceState) {
            'direct' => 'fachlich belegt',
            'weak' => 'RAG-Näherungstreffer, kein direkter Fachbeleg',
            default => 'noch keine belastbaren Treffer',
        };
    }

    private function resolveRagEvidenceShopCheckConfidenceLabel(string $knowledgeEvidenceState): string
    {
        return match ($knowledgeEvidenceState) {
            'direct' => 'fachlich belegt; Shopdaten werden geprüft',
            'weak' => 'RAG-Näherungstreffer; Shopdaten werden geprüft',
            default => 'Shopdaten werden geprüft',
        };
    }

    /**
     * @return array<int, string[]>
     */
    private function buildRagEvidenceNeedles(string $prompt): array
    {
        $normalizedPrompt = $this->normalizeRagEvidenceText($prompt);
        $stopTerms = [];

        foreach ($this->agentRunnerConfig->getRagEvidenceStopTerms() as $term) {
            $term = $this->normalizeRagEvidenceText($term);
            if ($term !== '') {
                $stopTerms[$term] = true;
            }
        }

        preg_match_all('/[\p{L}\p{N}][\p{L}\p{N}\-]{1,}/u', $normalizedPrompt, $matches);
        $tokens = $matches[0] ?? [];
        $groups = [];
        $synonyms = $this->normalizedRagEvidenceSynonyms();

        foreach ($tokens as $token) {
            $token = trim((string) $token);

            if ($token === '' || isset($stopTerms[$token]) || mb_strlen($token, 'UTF-8') < 3) {
                continue;
            }

            $group = $synonyms[$token] ?? [$token];
            $group = array_values(array_unique(array_filter(array_map(
                fn (string $item): string => $this->normalizeRagEvidenceText($item),
                $group
            ))));

            if ($group !== []) {
                $groups[] = $group;
            }
        }

        return $groups;
    }

    /**
     * @return array<string, string[]>
     */
    private function normalizedRagEvidenceSynonyms(): array
    {
        $out = [];

        foreach ($this->agentRunnerConfig->getRagEvidenceSynonyms() as $key => $items) {
            $key = $this->normalizeRagEvidenceText((string) $key);
            if ($key === '') {
                continue;
            }

            $terms = [];
            foreach ($items as $item) {
                $item = $this->normalizeRagEvidenceText((string) $item);
                if ($item !== '' && !in_array($item, $terms, true)) {
                    $terms[] = $item;
                }
            }

            if (!in_array($key, $terms, true)) {
                $terms[] = $key;
            }

            $out[$key] = $terms;
        }

        return $out;
    }

    private function containsRagEvidenceTerm(string $haystack, string $needle): bool
    {
        $needle = $this->normalizeRagEvidenceText($needle);

        if ($haystack === '' || $needle === '') {
            return false;
        }

        $pattern = '/(?<![\p{L}\p{N}])' . preg_quote($needle, '/') . '(?![\p{L}\p{N}])/u';

        return preg_match($pattern, $haystack) === 1;
    }

    private function normalizeRagEvidenceText(string $value): string
    {
        $value = html_entity_decode(strip_tags($value), ENT_QUOTES | ENT_SUBSTITUTE, 'UTF-8');
        $value = mb_strtolower($value, 'UTF-8');
        $value = str_replace(['‐', '‑', '‒', '–', '—'], '-', $value);
        $value = strtr($value, [
            'ä' => 'ae',
            'ö' => 'oe',
            'ü' => 'ue',
            'ß' => 'ss',
        ]);
        $value = preg_replace('/\s+/u', ' ', $value) ?? $value;

        return trim($value);
    }

    /**
     * @param string[] $sources
     */
    private function emitSources(array $sources, string $prefix): string
    {
        return $this->systemMsg($prefix . implode(' ', $sources), 'info');
    }

    /**
     * @param string[] $sources
     */
    private function addSource(array &$sources, string $label): void
    {
        $badge = $this->badge($label);

        if (!in_array($badge, $sources, true)) {
            $sources[] = $badge;
        }
    }

    /**
     * @param string[] $notices
     */
    private function buildHistoryResponse(string $fullOutput, array $notices): string
    {
        $parts = [];

        foreach ($notices as $notice) {
            $notice = trim($notice);

            if ($notice !== '') {
                $parts[] = $notice;
            }
        }

        $fullOutput = trim($fullOutput);

        if ($fullOutput !== '') {
            $parts[] = $fullOutput;
        } else {
            $noLlmMessage = $this->plainTextFromHtml($this->agentRunnerConfig->getNoLlmDataReceivedMessage());

            if ($noLlmMessage === '') {
                $noLlmMessage = 'Es wurden keine Daten vom LLM empfangen.';
            }

            $parts[] = 'Systemhinweis: ' . $noLlmMessage;
        }

        return trim(implode("\n\n", $parts));
    }

    private function buildHistoryNotice(string $title, ?string $detail): string
    {
        $title = $this->normalizeOneLine($this->plainTextFromHtml($title));
        $detail = $this->normalizeOneLine($this->plainTextFromHtml((string) $detail));

        if ($title === '') {
            $title = 'Systemhinweis';
        }

        if ($detail === '') {
            return 'Systemhinweis: ' . $title . '.';
        }

        if (mb_strlen($detail, 'UTF-8') > 500) {
            $detail = rtrim(mb_substr($detail, 0, 497, 'UTF-8')) . '...';
        }

        return 'Systemhinweis: ' . $title . '. Ursache: ' . $detail;
    }

    private function plainTextFromHtml(string $value): string
    {
        $value = html_entity_decode(strip_tags($value), ENT_QUOTES | ENT_SUBSTITUTE, 'UTF-8');
        $value = preg_replace('/\s+/u', ' ', $value) ?? $value;

        return trim($value);
    }

    /**
     * @param string[] $sourceLabels
     */
    private function buildProductionUiMetaMessage(
        string $stageLabel,
        ?int $ragCount,
        ?int $shopCount,
        string $shopCountMode,
        array $sourceLabels,
        string $confidenceLabel,
        bool $completed = false
    ): string {
        $state = $completed ? 'completed' : 'running';
        $ragLabel = $ragCount === null
            ? 'RAG-Treffer: wird geprüft'
            : 'RAG-Treffer: ' . max(0, $ragCount);
        $shopLabel = match ($shopCountMode) {
            'count' => 'Shop-Treffer: ' . max(0, (int) $shopCount),
            'loading' => 'Shop-Treffer: wird geladen',
            'unavailable' => 'Shop-Treffer: nicht verfügbar',
            default => 'Shop-Treffer: nicht angefragt',
        };
        $statusLabel = $completed ? 'Status: abgeschlossen' : 'Status: läuft';
        $sources = $this->formatProductionUiSourceLabels($sourceLabels);

        $html = '<div class="retriex-meta-card retriex-run-meta" data-retriex-meta-id="run-status" data-retriex-meta-state="'
            . htmlspecialchars($state, ENT_QUOTES | ENT_SUBSTITUTE, 'UTF-8')
            . '">'
            . '<div class="retriex-meta-card__eyebrow">RetrieX-Status</div>'
            . '<div class="retriex-meta-card__title">' . htmlspecialchars($stageLabel, ENT_QUOTES | ENT_SUBSTITUTE, 'UTF-8') . '</div>'
            . '<div class="retriex-meta-card__body">'
            . '<span class="retriex-meta-pill retriex-meta-pill--status">' . htmlspecialchars($statusLabel, ENT_QUOTES | ENT_SUBSTITUTE, 'UTF-8') . '</span>'
            . '<span class="retriex-meta-pill retriex-meta-pill--result">' . htmlspecialchars($ragLabel, ENT_QUOTES | ENT_SUBSTITUTE, 'UTF-8') . '</span>'
            . '<span class="retriex-meta-pill retriex-meta-pill--result">' . htmlspecialchars($shopLabel, ENT_QUOTES | ENT_SUBSTITUTE, 'UTF-8') . '</span>'
            . '<span class="retriex-meta-pill retriex-meta-pill--confidence">Beleglage: ' . htmlspecialchars($confidenceLabel, ENT_QUOTES | ENT_SUBSTITUTE, 'UTF-8') . '</span>'
            . '</div>';

        if ($sources !== []) {
            $html .= '<div class="retriex-source-overview"><span>Datenbasis</span><div class="retriex-source-overview__badges">';

            foreach ($sources as $source) {
                $html .= '<span class="retriex-source-chip">' . htmlspecialchars($source, ENT_QUOTES | ENT_SUBSTITUTE, 'UTF-8') . '</span>';
            }

            $html .= '</div></div>';
        } else {
            $emptySourceLabel = $completed ? 'keine belastbare Datenbasis' : 'wird geprüft';
            $html .= '<div class="retriex-source-overview"><span>Datenbasis</span><div class="retriex-source-overview__empty">'
                . htmlspecialchars($emptySourceLabel, ENT_QUOTES | ENT_SUBSTITUTE, 'UTF-8')
                . '</div></div>';
        }

        $html .= '</div>';

        return $html;
    }

    private function resolveProductionUiConfidenceLabel(
        bool $hasKnowledge,
        bool $isCommerceIntent,
        bool $shopSearchAttempted,
        bool $hasShopResults,
        bool $shopSearchHadSystemFailure
    ): string {
        if ($shopSearchHadSystemFailure) {
            return $hasKnowledge ? 'fachlich belegt; Shopdaten nicht verfügbar' : 'Shopdaten nicht verfügbar';
        }

        if ($hasKnowledge && $hasShopResults) {
            return 'RAG + Shopdaten';
        }

        if (!$hasKnowledge && $hasShopResults) {
            return 'nur Shopdaten';
        }

        if ($hasKnowledge && $shopSearchAttempted) {
            return 'RAG-Wissen, keine Shop-Treffer';
        }

        if ($hasKnowledge) {
            return 'fachlich belegt';
        }

        if ($isCommerceIntent || $shopSearchAttempted) {
            return 'keine belastbaren Daten';
        }

        return 'noch keine belastbaren Treffer';
    }

    /**
     * @param string[] $sourceLabels
     * @return string[]
     */
    private function formatProductionUiSourceLabels(array $sourceLabels): array
    {
        $labels = [];

        foreach ($sourceLabels as $label) {
            // Source labels are stored as badge HTML for the legacy "Genutzte Quellen" line.
            // The production UI data-basis chips must render plain labels, otherwise the
            // nested badge markup is escaped and shown as visible text.
            $label = $this->plainTextFromHtml((string) $label);

            if ($label === '') {
                continue;
            }

            if ($label === $this->plainTextFromHtml($this->agentRunnerConfig->getShopSystemSourceLabel())) {
                $label = 'Live-Shopdaten';
            }

            if (!in_array($label, $labels, true)) {
                $labels[] = $label;
            }
        }

        return $labels;
    }

    /**
     * @param ShopProductResult[] $shopResults
     */
    private function buildShopProductCardsMessage(array $shopResults, string $query, bool $usedRepair): string
    {
        $maxCards = 5;
        $visibleResults = array_slice($shopResults, 0, $maxCards);
        $totalCount = count($shopResults);
        $query = $this->normalizeOneLine($query);
        $summary = $totalCount . ' Shop-Treffer ausgewertet';

        if ($totalCount > $maxCards) {
            $summary .= ' · Top ' . $maxCards . ' angezeigt';
        }

        if ($usedRepair) {
            $summary .= ' · erweiterte Shopsuche genutzt';
        }

        $html = '<div class="retriex-meta-card retriex-product-results" data-retriex-meta-id="shop-results" data-retriex-meta-state="completed">'
            . '<div class="retriex-meta-card__eyebrow">Shop-Ergebnisse</div>'
            . '<div class="retriex-meta-card__title">Shop-Ergebnisse</div>'
            . '<div class="retriex-product-results__summary">' . htmlspecialchars($summary, ENT_QUOTES | ENT_SUBSTITUTE, 'UTF-8') . '</div>';

        if ($query !== '') {
            $html .= '<div class="retriex-meta-query retriex-meta-query--compact"><span>Ausgewertete Suchquery</span><code>'
                . htmlspecialchars($query, ENT_QUOTES | ENT_SUBSTITUTE, 'UTF-8')
                . '</code></div>';
        }

        $html .= '<div class="retriex-product-grid">';

        foreach ($visibleResults as $product) {
            if (!$product instanceof ShopProductResult) {
                continue;
            }

            $html .= $this->buildShopProductCard($product, $query);
        }

        $html .= '</div></div>';

        return $html;
    }

    private function buildShopProductCard(ShopProductResult $product, string $query): string
    {
        $name = $this->normalizeOneLine($product->name) ?: 'Unbenanntes Produkt';
        $productNumber = $this->normalizeOneLine((string) $product->productNumber);
        $manufacturer = $this->normalizeOneLine((string) $product->manufacturer);
        $price = $this->normalizeOneLine((string) $product->price);
        $url = $this->normalizeOneLine((string) $product->url);
        $availability = $this->formatProductAvailability($product->available);
        $relevance = $this->buildProductRelevanceLabel($product, $query);

        $html = '<article class="retriex-product-card">'
            . '<div class="retriex-product-card__title">';

        if ($url !== '') {
            $html .= '<a href="' . htmlspecialchars($url, ENT_QUOTES | ENT_SUBSTITUTE, 'UTF-8') . '">'
                . htmlspecialchars($name, ENT_QUOTES | ENT_SUBSTITUTE, 'UTF-8')
                . '</a>';
        } else {
            $html .= htmlspecialchars($name, ENT_QUOTES | ENT_SUBSTITUTE, 'UTF-8');
        }

        $html .= '</div><dl class="retriex-product-card__facts">';
        $html .= '<div><dt>Artikelnummer</dt><dd>' . htmlspecialchars($productNumber !== '' ? $productNumber : 'nicht übermittelt', ENT_QUOTES | ENT_SUBSTITUTE, 'UTF-8') . '</dd></div>';
        $html .= '<div><dt>Preis</dt><dd>' . htmlspecialchars($price !== '' ? $price : 'nicht übermittelt', ENT_QUOTES | ENT_SUBSTITUTE, 'UTF-8') . '</dd></div>';
        $html .= '<div><dt>Verfügbarkeit</dt><dd>' . htmlspecialchars($availability, ENT_QUOTES | ENT_SUBSTITUTE, 'UTF-8') . '</dd></div>';

        if ($manufacturer !== '') {
            $html .= '<div><dt>Hersteller</dt><dd>' . htmlspecialchars($manufacturer, ENT_QUOTES | ENT_SUBSTITUTE, 'UTF-8') . '</dd></div>';
        }

        $html .= '</dl>'
            . '<div class="retriex-product-card__relevance"><span>Relevanz</span>'
            . htmlspecialchars($relevance, ENT_QUOTES | ENT_SUBSTITUTE, 'UTF-8')
            . '</div>'
            . '</article>';

        return $html;
    }

    private function formatProductAvailability(?bool $available): string
    {
        return match ($available) {
            true => 'verfügbar',
            false => 'nicht verfügbar',
            default => 'Shopstatus nicht übermittelt',
        };
    }

    private function buildProductRelevanceLabel(ShopProductResult $product, string $query): string
    {
        $matchedQueries = [];

        foreach ($product->matchedQueries as $matchedQuery) {
            $matchedQuery = $this->normalizeOneLine((string) $matchedQuery);

            if ($matchedQuery !== '' && !in_array($matchedQuery, $matchedQueries, true)) {
                $matchedQueries[] = $matchedQuery;
            }
        }

        if ($matchedQueries !== []) {
            return 'Gefunden über: ' . implode(', ', array_slice($matchedQueries, 0, 3));
        }

        foreach ($product->highlights as $highlight) {
            $highlight = $this->normalizeOneLine($this->plainTextFromHtml((string) $highlight));

            if ($highlight !== '') {
                return 'Passender Shop-Hinweis: ' . mb_substr($highlight, 0, 140, 'UTF-8');
            }
        }

        $matchSource = $this->normalizeOneLine((string) $product->matchSource);

        if ($matchSource !== '') {
            return 'Trefferquelle: ' . $matchSource;
        }

        if ($query !== '') {
            return 'Passend zur Suchquery: ' . $query;
        }

        return 'Aus den Live-Shopdaten übernommen';
    }

    private function buildFollowUpActionsMessage(bool $isCommerceIntent, bool $hasShopResults, bool $hasKnowledge): string
    {
        if (!$isCommerceIntent && !$hasShopResults && !$hasKnowledge) {
            return '';
        }

        $actions = [];

        if ($isCommerceIntent || $hasShopResults) {
            $actions[] = ['Im Shop suchen', 'Suche die aktuelle Produktauswahl im Shop.'];
            $actions[] = ['Nur Zubehör anzeigen', 'Zeige aus der aktuellen Produktauswahl nur Zubehör.'];
            $actions[] = ['Nur Geräte anzeigen', 'Zeige aus der aktuellen Produktauswahl nur Geräte.'];
            $actions[] = ['Preis anzeigen', 'Zeige mir die Preise der aktuell relevanten Produkte.'];
        }

        if ($hasKnowledge || $hasShopResults) {
            $actions[] = ['Technische Details anzeigen', 'Zeige technische Details zur aktuellen Antwort.'];
        }

        if ($actions === []) {
            return '';
        }

        $html = '<div class="retriex-meta-card retriex-followup-actions" data-retriex-meta-id="followup-actions" data-retriex-meta-state="completed">'
            . '<div class="retriex-meta-card__eyebrow">Folgeaktionen</div>'
            . '<div class="retriex-meta-card__title">Was möchtest du als Nächstes tun?</div>'
            . '<div class="retriex-action-chip-row">';

        foreach ($actions as [$label, $actionPrompt]) {
            $html .= '<button type="button" class="retriex-action-chip" data-retriex-action-prompt="'
                . htmlspecialchars($actionPrompt, ENT_QUOTES | ENT_SUBSTITUTE, 'UTF-8')
                . '">'
                . htmlspecialchars($label, ENT_QUOTES | ENT_SUBSTITUTE, 'UTF-8')
                . '</button>';
        }

        $html .= '</div></div>';

        return $html;
    }

    private function buildShopSearchMetaMessage(
        string $query,
        string $commerceIntent,
        bool $usedOptimizedQuery,
        string $originalQuery,
        ?int $resultCount = null,
        bool $completed = false,
        bool $attemptedRepair = false,
        bool $usedRepair = false,
        bool $unavailable = false
    ): string {
        $query = $this->normalizeOneLine($query);
        $originalQuery = $this->normalizeOneLine($originalQuery);

        if ($query === '') {
            $query = $originalQuery !== '' ? $originalQuery : 'keine Suchquery ermittelt';
        }

        $queryModeLabel = $usedOptimizedQuery ? 'optimiert' : 'direkt';
        $intentLabel = $commerceIntent !== '' ? $commerceIntent : 'commerce';
        $title = $unavailable ? 'Shopdaten nicht verfügbar' : ($completed ? 'Shop-Suche abgeschlossen' : 'Shop-Suche wird ausgeführt');
        $statusLabel = $completed ? 'Status: abgeschlossen' : 'Status: läuft';
        $resultLabel = $unavailable
            ? 'Shoptreffer: nicht verfügbar'
            : ($resultCount === null
                ? 'Shoptreffer: wird geladen'
                : 'Shoptreffer: ' . max(0, $resultCount));
        $state = $completed ? 'completed' : 'running';
        $resultCountAttribute = $resultCount === null ? '' : (string) max(0, $resultCount);
        $repairLabel = '';

        if ($usedRepair) {
            $repairLabel = 'Erweiterte Suche: genutzt';
        } elseif ($attemptedRepair) {
            $repairLabel = 'Erweiterte Suche: geprüft';
        }

        $html = '<div class="retriex-meta-card retriex-shop-meta" data-retriex-meta-id="shop-search" data-retriex-meta-state="'
            . htmlspecialchars($state, ENT_QUOTES | ENT_SUBSTITUTE, 'UTF-8')
            . '" data-retriex-shop-result-count="'
            . htmlspecialchars($resultCountAttribute, ENT_QUOTES | ENT_SUBSTITUTE, 'UTF-8')
            . '">'
            . '<div class="retriex-meta-card__eyebrow">Shop-Suche</div>'
            . '<div class="retriex-meta-card__title">' . htmlspecialchars($title, ENT_QUOTES | ENT_SUBSTITUTE, 'UTF-8') . '</div>'
            . '<div class="retriex-meta-card__body">'
            . '<span class="retriex-meta-pill retriex-meta-pill--result">' . htmlspecialchars($resultLabel, ENT_QUOTES | ENT_SUBSTITUTE, 'UTF-8') . '</span>'
            . '<span class="retriex-meta-pill">' . htmlspecialchars($statusLabel, ENT_QUOTES | ENT_SUBSTITUTE, 'UTF-8') . '</span>'
            . '<span class="retriex-meta-pill">Query: ' . htmlspecialchars($queryModeLabel, ENT_QUOTES | ENT_SUBSTITUTE, 'UTF-8') . '</span>'
            . '<span class="retriex-meta-pill">Intent: ' . htmlspecialchars($intentLabel, ENT_QUOTES | ENT_SUBSTITUTE, 'UTF-8') . '</span>';

        if ($repairLabel !== '') {
            $html .= '<span class="retriex-meta-pill">' . htmlspecialchars($repairLabel, ENT_QUOTES | ENT_SUBSTITUTE, 'UTF-8') . '</span>';
        }

        $html .= '</div>'
            . '<div class="retriex-meta-query"><span>Gesendete Suchquery</span><code>'
            . htmlspecialchars($query, ENT_QUOTES | ENT_SUBSTITUTE, 'UTF-8')
            . '</code></div>'
            . '</div>';

        return $html;
    }

    private function buildShopUnavailableMessage(?string $reason): string
    {
        $reason = $this->normalizeOneLine((string) $reason);

        if ($reason === '') {
            $reason = 'Keine Detailmeldung vom Shopware-Server.';
        }

        if (mb_strlen($reason, 'UTF-8') > 320) {
            $reason = rtrim(mb_substr($reason, 0, 317, 'UTF-8')) . '...';
        }

        return '<div class="retriex-alert retriex-alert--warning">'
            . '<div class="retriex-alert__icon">⚠️</div>'
            . '<div class="retriex-alert__content">'
            . '<div class="retriex-alert__title">Shopdaten konnten nicht geladen werden</div>'
            . '<div class="retriex-alert__text">RetrieX antwortet ohne Live-Shopdaten weiter. Ursache: '
            . htmlspecialchars($reason, ENT_QUOTES | ENT_SUBSTITUTE, 'UTF-8')
            . '</div>'
            . '</div>'
            . '</div>';
    }

    private function normalizeOneLine(string $value): string
    {
        $value = trim($value);

        return preg_replace('/\s+/u', ' ', $value) ?? $value;
    }


    private function buildUserErrorMessage(Throwable $e): string
    {
        $message = trim($e->getMessage());

        if ($message === '') {
            $message = $e::class;
        }

        $safeMessage = htmlspecialchars($message, ENT_QUOTES | ENT_SUBSTITUTE, 'UTF-8');

        if (!$this->debug) {
            return $this->agentRunnerConfig->getGenericInternalErrorMessage()
                . '<br><small>Technischer Fehler: ' . $safeMessage . '</small>';
        }

        return $this->agentRunnerConfig->getDebugInternalErrorPrefix()
            . $safeMessage;
    }

    private function badge(string $label): string
    {
        return sprintf(
            $this->agentRunnerConfig->getSourceBadgeHtmlTemplate(),
            htmlspecialchars($label, ENT_QUOTES | ENT_SUBSTITUTE, 'UTF-8')
        );
    }

    private function systemMsg(string $msg, string $type = ''): string
    {
        if (!$this->systemMsgOn) {
            return '';
        }

        return match ($type) {
            'answer' => $msg,
            'err' => sprintf($this->agentRunnerConfig->getErrorHtmlTemplate(), $msg),
            'think' => sprintf($this->agentRunnerConfig->getThinkHtmlTemplate(), $msg),
            'info' => sprintf($this->agentRunnerConfig->getInfoHtmlTemplate(), $msg),
            'meta' => $msg,
            'debug' => sprintf(
                $this->agentRunnerConfig->getDebugHtmlTemplate(),
                htmlspecialchars($msg, ENT_QUOTES | ENT_SUBSTITUTE, 'UTF-8')
            ),
            default => $msg,
        };
    }
}
