<?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\Config\LanguageCleanupConfig;
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 LanguageCleanupConfig $languageCleanupConfig,
        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
    {
        $originalPrompt = trim($prompt);
        $prompt = $originalPrompt;
        $routingPrompt = $prompt;

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

        $shopResults = [];
        $primaryShopResults = [];
        $knowledgeChunks = [];
        $knowledgeEvidenceState = 'none';
        $sources = [];
        $optimizedShopQuery = '';
        $shopSearchQuery = '';
        $commerceHistoryContext = '';
        $shopQueryHistoryContext = '';
        $attemptedShopRepair = false;
        $usedShopRepair = false;
        $shopRepairQueries = [];
        $shopSearchAttempted = false;
        $primaryShopSearchHadSystemFailure = false;
        $historyNotices = [];
        $commerceIntent = CommerceIntentLite::NONE;
        $shopSearchSkippedBecauseNoQuery = false;

        $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: $this->agentRunnerConfig->getProductionUiStageLabel('preparing_answer'),
                    ragCount: null,
                    shopCount: null,
                    shopCountMode: $this->resolveShopCountModeForMeta(
                        commerceIntent: $commerceIntent,
                        shopSearchAttempted: $shopSearchAttempted,
                        shopSearchHadSystemFailure: $primaryShopSearchHadSystemFailure,
                        shopSearchSkippedBecauseNoQuery: $shopSearchSkippedBecauseNoQuery
                    ),
                    sourceLabels: $sources,
                    confidenceLabel: $this->agentRunnerConfig->getProductionUiConfidenceLabel('checking_evidence')
                ),
                'meta'
            );

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

            $normalizedPrompt = yield from $this->normalizePromptForRouting($prompt, $userId);
            if ($normalizedPrompt !== $prompt) {
                $this->agentLogger->info('Prompt normalized before routing', [
                    'userId' => $userId,
                    'originalPrompt' => $prompt,
                    'normalizedPrompt' => $normalizedPrompt,
                ]);
                $routingPrompt = $normalizedPrompt;
            }

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

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

            $commerceIntent = $this->detectCommerceIntentForRouting(
                $routingPrompt,
                $userId,
                $requestContextHint
            );
            $originalCommerceIntent = $this->detectCommerceIntentForRouting(
                $originalPrompt,
                $userId,
                $requestContextHint
            );

            if (!$this->isCommerceIntent($commerceIntent) && $this->isCommerceIntent($originalCommerceIntent)) {
                $this->agentLogger->info('Promoted normalized routing back to explicit original commerce intent', [
                    'userId' => $userId,
                    'originalPrompt' => $originalPrompt,
                    'routingPrompt' => $routingPrompt,
                    'originalCommerceIntent' => $originalCommerceIntent,
                ]);
                $commerceIntent = $originalCommerceIntent;
            }

            if ($this->isCommerceIntent($commerceIntent)) {
                yield $this->systemMsg(
                    $this->buildProductionUiMetaMessage(
                        stageLabel: $this->agentRunnerConfig->getProductionUiStageLabel('shop_routing_detected'),
                        ragCount: null,
                        shopCount: null,
                        shopCountMode: $this->resolveShopCountModeForMeta(
                            commerceIntent: $commerceIntent,
                            shopSearchAttempted: $shopSearchAttempted,
                            shopSearchHadSystemFailure: $primaryShopSearchHadSystemFailure,
                            shopSearchSkippedBecauseNoQuery: $shopSearchSkippedBecauseNoQuery
                        ),
                        sourceLabels: $sources,
                        confidenceLabel: $this->agentRunnerConfig->getProductionUiConfidenceLabel('checking_shop_data')
                    ),
                    'meta'
                );
            }

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

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

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

            yield $this->systemMsg(
                $this->buildProductionUiMetaMessage(
                    stageLabel: $this->agentRunnerConfig->getProductionUiStageLabel('rag_searched'),
                    ragCount: count($knowledgeChunks),
                    shopCount: null,
                    shopCountMode: $this->resolveShopCountModeForMeta(
                        commerceIntent: $commerceIntent,
                        shopSearchAttempted: $shopSearchAttempted,
                        shopSearchHadSystemFailure: $primaryShopSearchHadSystemFailure,
                        shopSearchSkippedBecauseNoQuery: $shopSearchSkippedBecauseNoQuery
                    ),
                    sourceLabels: $sources,
                    confidenceLabel: $this->resolveRagEvidenceConfidenceLabel($knowledgeEvidenceState)
                ),
                'meta'
            );

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

            if ($this->isCommerceIntent($commerceIntent)) {
                yield $this->systemMsg(
                    $this->buildProductionUiMetaMessage(
                        stageLabel: $this->agentRunnerConfig->getProductionUiStageLabel('shop_search_preparing'),
                        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);
                $isStandaloneShopQuery = $this->shouldIsolateStandaloneShopQueryFromHistory($originalPrompt);
                $shopQueryHistoryContext = $isStandaloneShopQuery
                    ? ''
                    : $this->resolveShopQueryHistoryContext(
                        prompt: $originalPrompt,
                        commerceHistoryContext: $commerceHistoryContext
                    );

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

                if ($commerceHistoryContext !== '' && $shopQueryHistoryContext === '') {
                    $this->agentLogger->info('Ignored commerce history for standalone shop query', [
                        'userId' => $userId,
                        'prompt' => $prompt,
                        'routingPrompt' => $routingPrompt,
                        'originalPrompt' => $originalPrompt,
                        'commerceHistoryContextLength' => mb_strlen($commerceHistoryContext),
                        'standaloneShopQueryIsolated' => $isStandaloneShopQuery,
                    ]);
                }

                if ($isStandaloneShopQuery) {
                    $optimizedShopQuery = '';
                    $shopSearchQuery = $this->guardFinalStandaloneShopSearchQuery(
                        prompt: $originalPrompt,
                        shopSearchQuery: $routingPrompt
                    );

                    if ($shopSearchQuery === '') {
                        $shopSearchQuery = $originalPrompt;
                    }

                    $this->agentLogger->info('Using deterministic standalone shop query without LLM optimizer history', [
                        'userId' => $userId,
                        'prompt' => $prompt,
                        'routingPrompt' => $routingPrompt,
                        'originalPrompt' => $originalPrompt,
                        'shopSearchQuery' => $shopSearchQuery,
                    ]);
                } else {
                    $optimizedShopQuery = yield from $this->buildOptimizedShopQuery(
                        $routingPrompt,
                        $userId,
                        $shopQueryHistoryContext
                    );

                    $shopSearchQuery = $this->resolveShopSearchQuery(
                        prompt: $originalPrompt,
                        optimizedShopQuery: $optimizedShopQuery,
                        commerceHistoryContext: $shopQueryHistoryContext,
                        userId: $userId,
                        currentPromptFallback: $routingPrompt
                    );
                }

                $guardedShopSearchQuery = $this->guardFinalStandaloneShopSearchQuery(
                    prompt: $originalPrompt,
                    shopSearchQuery: $shopSearchQuery
                );

                if ($guardedShopSearchQuery !== $shopSearchQuery) {
                    $this->agentLogger->info('Replaced standalone shop search query after final guard', [
                        'userId' => $userId,
                        'prompt' => $prompt,
                        'routingPrompt' => $routingPrompt,
                        'optimizedShopQuery' => $optimizedShopQuery,
                        'unsafeShopSearchQuery' => $shopSearchQuery,
                        'guardedShopSearchQuery' => $guardedShopSearchQuery,
                    ]);

                    $shopSearchQuery = $guardedShopSearchQuery;
                    $optimizedShopQuery = '';
                }

                $ragAnchoredShopSearchQuery = $this->enrichShopSearchQueryWithRagAnchor(
                    prompt: $originalPrompt,
                    shopSearchQuery: $shopSearchQuery,
                    knowledgeChunks: $knowledgeChunks
                );

                if ($ragAnchoredShopSearchQuery !== $shopSearchQuery) {
                    $this->agentLogger->info('Enriched shop search query with RAG product anchor', [
                        'userId' => $userId,
                        'prompt' => $prompt,
                        'routingPrompt' => $routingPrompt,
                        'optimizedShopQuery' => $optimizedShopQuery,
                        'shopSearchQuery' => $shopSearchQuery,
                        'ragAnchoredShopSearchQuery' => $ragAnchoredShopSearchQuery,
                    ]);

                    $shopSearchQuery = $ragAnchoredShopSearchQuery;
                    $optimizedShopQuery = '';
                }

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

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

                    yield $this->systemMsg(
                        $this->buildProductionUiMetaMessage(
                            stageLabel: $this->agentRunnerConfig->getProductionUiStageLabel('more_context_needed'),
                            ragCount: count($knowledgeChunks),
                            shopCount: null,
                            shopCountMode: $this->resolveShopCountModeForMeta(
                                commerceIntent: $commerceIntent,
                                shopSearchAttempted: $shopSearchAttempted,
                                shopSearchHadSystemFailure: $primaryShopSearchHadSystemFailure,
                                shopSearchSkippedBecauseNoQuery: $shopSearchSkippedBecauseNoQuery
                            ),
                            sourceLabels: $sources,
                            confidenceLabel: $this->agentRunnerConfig->getProductionUiConfidenceLabel('more_context_needed'),
                            completed: true
                        ),
                        'meta'
                    );

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

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

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

                    $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: $this->agentRunnerConfig->getProductionUiStageLabel('shop_search_running'),
                            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,
                        $shopQueryHistoryContext
                    );
                    $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(
                            $this->agentRunnerConfig->getProductionUiText('history_notice_shop_unavailable_title'),
                            $primaryShopSearchFailureReason
                        );

                        $repairPayload = [
                            'results' => $primaryShopResults,
                            'attemptedRepair' => false,
                            'usedRepair' => false,
                            'repairQueries' => [],
                        ];
                    } else {
                        yield $this->systemMsg($this->agentRunnerConfig->getShopRepairCheckMessage(), 'think');

                        $repairPayload = $this->repairShopResults(
                            prompt: $prompt,
                            userId: $userId,
                            commerceIntent: $commerceIntent,
                            commerceHistoryContext: $shopQueryHistoryContext,
                            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 ? $this->agentRunnerConfig->getProductionUiStageLabel('shop_unavailable') : $this->agentRunnerConfig->getProductionUiStageLabel('shop_completed'),
                        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,
                            knowledgeEvidenceState: $knowledgeEvidenceState
                        )
                    ),
                    '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: $this->agentRunnerConfig->getProductionUiStageLabel('answer_generating'),
                    ragCount: count($knowledgeChunks),
                    shopCount: $primaryShopSearchHadSystemFailure ? null : ($shopSearchAttempted ? count($shopResults) : null),
                    shopCountMode: $this->resolveShopCountModeForMeta(
                        commerceIntent: $commerceIntent,
                        shopSearchAttempted: $shopSearchAttempted,
                        shopSearchHadSystemFailure: $primaryShopSearchHadSystemFailure,
                        shopSearchSkippedBecauseNoQuery: $shopSearchSkippedBecauseNoQuery
                    ),
                    sourceLabels: $sources,
                    confidenceLabel: $this->resolveProductionUiConfidenceLabel(
                        hasKnowledge: $this->isDirectKnowledgeEvidence($knowledgeEvidenceState),
                        isCommerceIntent: $this->isCommerceIntent($commerceIntent),
                        shopSearchAttempted: $shopSearchAttempted,
                        hasShopResults: $shopResults !== [],
                        shopSearchHadSystemFailure: $primaryShopSearchHadSystemFailure,
                        knowledgeEvidenceState: $knowledgeEvidenceState
                    )
                ),
                '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: $this->agentRunnerConfig->getProductionUiStageLabel('completed'),
                    ragCount: count($knowledgeChunks),
                    shopCount: $primaryShopSearchHadSystemFailure ? null : ($shopSearchAttempted ? count($shopResults) : null),
                    shopCountMode: $this->resolveShopCountModeForMeta(
                        commerceIntent: $commerceIntent,
                        shopSearchAttempted: $shopSearchAttempted,
                        shopSearchHadSystemFailure: $primaryShopSearchHadSystemFailure,
                        shopSearchSkippedBecauseNoQuery: $shopSearchSkippedBecauseNoQuery
                    ),
                    sourceLabels: $sources,
                    confidenceLabel: $this->resolveProductionUiConfidenceLabel(
                        hasKnowledge: $this->isDirectKnowledgeEvidence($knowledgeEvidenceState),
                        isCommerceIntent: $this->isCommerceIntent($commerceIntent),
                        shopSearchAttempted: $shopSearchAttempted,
                        hasShopResults: $shopResults !== [],
                        shopSearchHadSystemFailure: $primaryShopSearchHadSystemFailure,
                        knowledgeEvidenceState: $knowledgeEvidenceState
                    ),
                    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,
                    $originalPrompt,
                    $historyResponse
                );
            }

            $this->agentLogger->info('Agent run finished', [
                'userId' => $userId,
                'outputLength' => mb_strlen($fullOutput),
                'contextMode' => $forceFullContext ? 'full' : 'recent',
                'commerceIntent' => $commerceIntent,
                'originalPrompt' => $originalPrompt,
                'effectivePrompt' => $prompt,
                'routingPrompt' => $routingPrompt,
                'promptWasNormalized' => $routingPrompt !== $originalPrompt,
                '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: $this->agentRunnerConfig->getProductionUiStageLabel('interrupted'),
                    ragCount: count($knowledgeChunks),
                    shopCount: $primaryShopSearchHadSystemFailure ? null : ($shopSearchAttempted ? count($shopResults) : null),
                    shopCountMode: $this->resolveShopCountModeForMeta(
                        commerceIntent: $commerceIntent,
                        shopSearchAttempted: $shopSearchAttempted,
                        shopSearchHadSystemFailure: $primaryShopSearchHadSystemFailure,
                        shopSearchSkippedBecauseNoQuery: $shopSearchSkippedBecauseNoQuery
                    ),
                    sourceLabels: $sources,
                    confidenceLabel: $this->agentRunnerConfig->getProductionUiConfidenceLabel('interrupted'),
                    completed: true
                ),
                'meta'
            );
            yield $this->systemMsg($userErrorMessage, 'err');

            $historyResponse = $this->buildHistoryResponse('', array_merge(
                $historyNotices,
                [$this->buildHistoryNotice($this->agentRunnerConfig->getProductionUiText('history_notice_answer_incomplete_title'), $e->getMessage())]
            ));

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

    /**
     * @return Generator<int, string, mixed, string>
     */
    private function normalizePromptForRouting(string $prompt, string $userId): Generator
    {
        if (!$this->agentRunnerConfig->isInputNormalizationEnabled()) {
            return $prompt;
        }

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

        $normalizationPrompt = trim($this->agentRunnerConfig->getInputNormalizationPrompt($prompt));
        if ($normalizationPrompt === '') {
            return $prompt;
        }

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

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

                if (time() - $lastHeartbeatAt >= 2) {
                    yield $this->systemMsg($this->agentRunnerConfig->getInputNormalizationHeartbeatMessage(), 'think');
                    $lastHeartbeatAt = time();
                }

                $cleanToken = $this->thinkSuppressor->filter($token);
                if ($cleanToken === '') {
                    continue;
                }

                $candidate .= $cleanToken;
            }
        } catch (Throwable $e) {
            $this->agentLogger->warning('Prompt normalization failed, falling back to fuzzy routing-signal normalization', [
                'userId' => $userId,
                'exception' => $e,
            ]);

            return $this->applyFuzzyRoutingSignalNormalization($prompt, $prompt);
        }

        $normalized = $this->sanitizeNormalizedPromptForRouting($candidate, $prompt);

        return $this->applyFuzzyRoutingSignalNormalization($normalized, $prompt);
    }

    private function shouldSkipInputNormalization(string $prompt): bool
    {
        if (mb_strlen($prompt, 'UTF-8') > $this->agentRunnerConfig->getInputNormalizationMaxInputChars()) {
            return true;
        }

        foreach ($this->agentRunnerConfig->getInputNormalizationSkipPatterns() as $pattern) {
            if (@preg_match($pattern, $prompt) === 1) {
                return true;
            }
        }

        return false;
    }

    private function sanitizeNormalizedPromptForRouting(string $candidate, string $originalPrompt): string
    {
        $candidate = trim($candidate);
        if ($candidate === '') {
            return $originalPrompt;
        }

        $candidate = preg_split('/\R{2,}/u', $candidate, 2)[0] ?? $candidate;
        $candidate = trim($candidate);
        $candidate = preg_replace($this->agentRunnerConfig->getInputNormalizationOutputPrefixPattern(), '', $candidate) ?? $candidate;
        $candidate = trim($candidate, $this->agentRunnerConfig->getOptimizedShopQueryTrimCharacters());
        $candidate = preg_replace('/\s+/u', ' ', $candidate) ?? $candidate;
        $candidate = trim($candidate);

        if ($candidate === '') {
            return $originalPrompt;
        }

        if ($this->isInputNormalizationPlaceholderOutput($candidate)) {
            return $originalPrompt;
        }

        if (mb_strlen($candidate, 'UTF-8') > $this->agentRunnerConfig->getInputNormalizationMaxOutputChars()) {
            return $originalPrompt;
        }

        if ($this->normalizeRoutingComparisonText($candidate) === $this->normalizeRoutingComparisonText($originalPrompt)) {
            return $originalPrompt;
        }

        if (!$this->isSafeNormalizedPromptCandidate($candidate, $originalPrompt)) {
            return $originalPrompt;
        }

        return $candidate;
    }

    private function applyFuzzyRoutingSignalNormalization(string $candidate, string $originalPrompt): string
    {
        if (!$this->agentRunnerConfig->isInputNormalizationFuzzyRoutingEnabled()) {
            return $candidate;
        }

        $terms = $this->buildFuzzyRoutingTermIndex();
        if ($terms === []) {
            return $candidate;
        }

        $minLength = $this->agentRunnerConfig->getInputNormalizationFuzzyRoutingMinTokenLength();
        $changed = false;

        $normalized = preg_replace_callback(
            '/(?<![\p{L}\p{N}])[\p{L}][\p{L}\p{N}\-]{' . max(0, $minLength - 1) . ',}(?![\p{L}\p{N}])/u',
            function (array $matches) use ($terms, &$changed): string {
                $token = (string) ($matches[0] ?? '');
                $replacement = $this->resolveFuzzyRoutingTokenReplacement($token, $terms);

                if ($replacement === null || $replacement === $token) {
                    return $token;
                }

                $changed = true;

                return $replacement;
            },
            $candidate
        );

        if (!is_string($normalized) || !$changed) {
            return $candidate;
        }

        $normalized = preg_replace('/\s+/u', ' ', trim($normalized)) ?? trim($normalized);
        if ($normalized === '' || $this->normalizeRoutingComparisonText($normalized) === $this->normalizeRoutingComparisonText($candidate)) {
            return $candidate;
        }

        if (!$this->isSafeNormalizedPromptCandidate($normalized, $originalPrompt)) {
            return $candidate;
        }

        return $normalized;
    }

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

        foreach ($this->agentRunnerConfig->getInputNormalizationFuzzyRoutingTerms() as $term) {
            $term = trim($term);
            if ($term === '') {
                continue;
            }

            $normalized = $this->normalizeFuzzyRoutingToken($term);
            if ($normalized === '') {
                continue;
            }

            $terms[$normalized] ??= mb_strtolower($term, 'UTF-8');
        }

        return $terms;
    }

    /**
     * @param array<string, string> $terms
     */
    private function resolveFuzzyRoutingTokenReplacement(string $token, array $terms): ?string
    {
        $normalizedToken = $this->normalizeFuzzyRoutingToken($token);
        if ($normalizedToken === '' || isset($terms[$normalizedToken])) {
            return null;
        }

        $bestTerm = null;
        $bestDistance = PHP_INT_MAX;
        $ambiguous = false;
        $tokenLength = max(1, strlen($normalizedToken));

        foreach ($terms as $normalizedTerm => $term) {
            $termLength = strlen($normalizedTerm);
            if (abs($tokenLength - $termLength) > $this->resolveFuzzyRoutingMaxDistance(max($tokenLength, $termLength))) {
                continue;
            }

            $distance = $this->calculateFuzzyRoutingDistance($normalizedToken, $normalizedTerm);
            $maxLength = max($tokenLength, $termLength);
            $maxDistance = $this->resolveFuzzyRoutingMaxDistance($maxLength);
            if ($distance > $maxDistance) {
                continue;
            }

            $similarityPercent = (int) round((1 - ($distance / max(1, $maxLength))) * 100);
            if ($similarityPercent < $this->agentRunnerConfig->getInputNormalizationFuzzyRoutingMinSimilarityPercent()) {
                continue;
            }

            if ($distance < $bestDistance) {
                $bestDistance = $distance;
                $bestTerm = $term;
                $ambiguous = false;
                continue;
            }

            if ($distance === $bestDistance && $term !== $bestTerm) {
                $ambiguous = true;
            }
        }

        if ($bestTerm === null || $ambiguous) {
            return null;
        }

        return $bestTerm;
    }

    private function calculateFuzzyRoutingDistance(string $left, string $right): int
    {
        $leftLength = strlen($left);
        $rightLength = strlen($right);

        if ($leftLength === 0) {
            return $rightLength;
        }

        if ($rightLength === 0) {
            return $leftLength;
        }

        $distance = [];
        for ($i = 0; $i <= $leftLength; $i++) {
            $distance[$i] = [$i];
        }

        for ($j = 0; $j <= $rightLength; $j++) {
            $distance[0][$j] = $j;
        }

        for ($i = 1; $i <= $leftLength; $i++) {
            for ($j = 1; $j <= $rightLength; $j++) {
                $cost = $left[$i - 1] === $right[$j - 1] ? 0 : 1;
                $distance[$i][$j] = min(
                    $distance[$i - 1][$j] + 1,
                    $distance[$i][$j - 1] + 1,
                    $distance[$i - 1][$j - 1] + $cost
                );

                if (
                    $i > 1
                    && $j > 1
                    && $left[$i - 1] === $right[$j - 2]
                    && $left[$i - 2] === $right[$j - 1]
                ) {
                    $distance[$i][$j] = min($distance[$i][$j], $distance[$i - 2][$j - 2] + 1);
                }
            }
        }

        return $distance[$leftLength][$rightLength];
    }

    private function resolveFuzzyRoutingMaxDistance(int $tokenLength): int
    {
        if ($tokenLength >= $this->agentRunnerConfig->getInputNormalizationFuzzyRoutingLongTokenLength()) {
            return $this->agentRunnerConfig->getInputNormalizationFuzzyRoutingMaxDistanceLong();
        }

        if ($tokenLength >= $this->agentRunnerConfig->getInputNormalizationFuzzyRoutingMediumTokenLength()) {
            return $this->agentRunnerConfig->getInputNormalizationFuzzyRoutingMaxDistanceMedium();
        }

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

    private function normalizeFuzzyRoutingToken(string $token): string
    {
        $token = mb_strtolower(trim($token), 'UTF-8');
        $token = $this->languageCleanupConfig->transliterateToAscii($token);
        $token = preg_replace('/[^a-z0-9]+/u', '', $token) ?? $token;

        return trim($token);
    }

    private function isSafeNormalizedPromptCandidate(string $candidate, string $originalPrompt): bool
    {
        $originalLength = max(1, mb_strlen($originalPrompt, 'UTF-8'));
        $candidateLength = mb_strlen($candidate, 'UTF-8');
        $maxLength = (int) ceil($originalLength * ($this->agentRunnerConfig->getInputNormalizationMaxLengthRatioPercent() / 100));

        if ($candidateLength > $maxLength) {
            return false;
        }

        $originalTokens = $this->tokenizeInputNormalizationGuardText($originalPrompt);
        $candidateTokens = $this->tokenizeInputNormalizationGuardText($candidate);
        $maxAddedTokens = $this->agentRunnerConfig->getInputNormalizationMaxAddedTokens();

        if (count($candidateTokens) > count($originalTokens) + $maxAddedTokens) {
            return false;
        }

        $originalNumbers = $this->extractInputNormalizationNumbers($originalPrompt);
        foreach ($this->extractInputNormalizationNumbers($candidate) as $number) {
            if (!in_array($number, $originalNumbers, true)) {
                return false;
            }
        }

        return true;
    }

    private function isInputNormalizationPlaceholderOutput(string $candidate): bool
    {
        $normalized = $this->normalizeRoutingComparisonText($candidate);

        return in_array($normalized, array_map(
            fn (string $placeholder): string => $this->normalizeRoutingComparisonText($placeholder),
            $this->agentRunnerConfig->getInputNormalizationPlaceholderOutputs()
        ), true);
    }

    private function normalizeRoutingComparisonText(string $value): string
    {
        $value = mb_strtolower(trim($value), 'UTF-8');
        $value = preg_replace('/\s+/u', ' ', $value) ?? $value;

        return trim($value);
    }

    /**
     * @return string[]
     */
    private function tokenizeInputNormalizationGuardText(string $value): array
    {
        if (preg_match_all('/\d+(?:[,.]\d+)?|[\p{L}\p{N}]+/u', mb_strtolower($value, 'UTF-8'), $matches) === false) {
            return [];
        }

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

    /**
     * @return string[]
     */
    private function extractInputNormalizationNumbers(string $value): array
    {
        if (preg_match_all('/\d+(?:[,.]\d+)?/u', $value, $matches) === false) {
            return [];
        }

        return array_values(array_unique(array_map(
            static fn(string $number): string => str_replace(',', '.', $number),
            $matches[0] ?? []
        )));
    }

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

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

    private function detectCommerceIntentForRouting(
        string $prompt,
        string $userId,
        string $requestContextHint
    ): string {
        $commerceIntent = $this->detectCommerceIntent($prompt);

        if ($this->isCommerceIntent($commerceIntent)) {
            return $commerceIntent;
        }

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

        $this->agentLogger->info('Promoted commercial table follow-up to shop intent', [
            'userId' => $userId,
            'prompt' => $prompt,
            'hasRequestContextHint' => trim($requestContextHint) !== '',
        ]);

        return CommerceIntentLite::PRODUCT_SEARCH;
    }

    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[] = $this->renderAgentTemplate($this->agentRunnerConfig->getFollowUpContextPreviousUserQuestionTemplate(), ['question' => $question]);
        }

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

        $lines[] = $this->renderAgentTemplate($this->agentRunnerConfig->getFollowUpContextCurrentQuestionTemplate(), ['question' => $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
    {
        foreach ($this->agentRunnerConfig->getFollowUpStrongReferencePatterns() as $pattern) {
            if (preg_match($pattern, $normalized) === 1) {
                return true;
            }
        }

        return false;
    }

    private function containsExplicitCommercialFollowUpSignal(string $normalized): bool
    {
        foreach ($this->agentRunnerConfig->getFollowUpExplicitCommercialSignalTerms() as $signal) {
            if (str_contains($normalized, mb_strtolower($signal, 'UTF-8'))) {
                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($this->agentRunnerConfig->getFollowUpHistoryQuestionPattern(), $history, $matches) === false) {
            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 product-model reference and the first explicit measurement 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($this->agentRunnerConfig->getFollowUpHistoryQuestionStripPattern(), '', $turn, 1) ?? '';
        $answer = trim($answer);

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

        $anchors = [];

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

        $hardnessValue = $this->extractFirstMeasurementValueAnchor($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($this->agentRunnerConfig->getFollowUpHistoryTurnSplitPattern(), $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);
    }

    /**
     * @return string[]
     */
    private function extractHistoryTurnsNewestFirst(string $history): array
    {
        $history = trim($history);

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

        $parts = preg_split($this->agentRunnerConfig->getFollowUpHistoryTurnSplitPattern(), $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 !== ''
        ));

        return array_reverse($turns);
    }

    private function extractFirstProductModelAnchor(string $text): string
    {
        if (preg_match($this->agentRunnerConfig->getFollowUpReferenceAnchorProductModelPattern(), $text, $matches) !== 1) {
            return '';
        }

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

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

    private function extractFirstMeasurementValueAnchor(string $text): string
    {
        if (preg_match($this->agentRunnerConfig->getFollowUpReferenceAnchorMeasurementValuePattern(), $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 = $this->languageCleanupConfig->replaceWordSeparatorsWithSpace($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($this->agentRunnerConfig->getShopQueryOptimizationHeartbeatMessage(), '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 resolveShopQueryHistoryContext(string $prompt, string $commerceHistoryContext): string
    {
        $commerceHistoryContext = trim($commerceHistoryContext);

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

        if ($this->shouldUseCommerceHistoryForShopQuery($prompt)) {
            return $commerceHistoryContext;
        }

        return '';
    }

    private function shouldUseCommerceHistoryForShopQuery(string $prompt): bool
    {
        $prompt = trim($prompt);

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

        if ($this->isCommercialTableFollowUpPrompt($prompt)) {
            return true;
        }

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

        if ($this->extractFirstProductModelAnchor($prompt) !== '') {
            return false;
        }

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

        if ($this->containsConfiguredShopQueryAnchorTrigger($normalizedPrompt)) {
            return !$this->containsNumericShopQueryToken($normalizedPrompt);
        }

        return $this->containsReferentialShopQueryMarker($normalizedPrompt);
    }

    private function containsNumericShopQueryToken(string $text): bool
    {
        return preg_match('/\d/u', $text) === 1;
    }

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

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

        $tokenSet = array_fill_keys($tokens, true);

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

        return false;
    }

    private function shouldIsolateStandaloneShopQueryFromHistory(string $prompt): bool
    {
        $prompt = trim($prompt);

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

        if ($this->isCommercialTableFollowUpPrompt($prompt) || $this->isMetaOnlyShopQuery($prompt)) {
            return false;
        }

        $normalizedPrompt = $this->normalizeFollowUpText($prompt);
        $usesReferenceLanguage = $this->containsReferentialShopQueryMarker($normalizedPrompt)
            || $this->containsConfiguredShopQueryAnchorTrigger($normalizedPrompt);

        if (!$usesReferenceLanguage) {
            return true;
        }

        return $this->hasStandaloneConcreteShopSubject($prompt);
    }

    private function hasStandaloneConcreteShopSubject(string $prompt): bool
    {
        if ($this->extractFirstProductModelAnchor($prompt) !== '') {
            return true;
        }

        $contextFallbackQuery = $this->buildContextFallbackShopQuery($prompt);
        $tokens = $this->tokenizeShopQueryCandidate($contextFallbackQuery);

        if (count($tokens) >= 2) {
            return true;
        }

        foreach ($tokens as $token) {
            if (preg_match('/\d/u', $token) === 1) {
                return true;
            }
        }

        return false;
    }

    private function shouldUseDeterministicStandaloneShopQuery(string $prompt, string $shopQueryHistoryContext): bool
    {
        $prompt = trim($prompt);

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

        if (trim($shopQueryHistoryContext) !== '') {
            return false;
        }

        if ($this->isCommercialTableFollowUpPrompt($prompt)) {
            return false;
        }

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

        return true;
    }

    private function guardStandaloneOptimizedShopQuery(string $prompt, string $optimizedShopQuery): string
    {
        if ($this->shouldUseCommerceHistoryForShopQuery($prompt)) {
            return $optimizedShopQuery;
        }

        if ($this->standaloneOptimizedShopQueryIntroducesUnsupportedContext($prompt, $optimizedShopQuery)) {
            $this->agentLogger->info('Ignored optimized shop query because it introduced unsupported standalone context', [
                'prompt' => $prompt,
                'optimizedShopQuery' => $optimizedShopQuery,
            ]);

            return $prompt;
        }

        if ($this->extractFirstProductModelAnchor($prompt) === '') {
            return $optimizedShopQuery;
        }

        if (!$this->containsConfiguredShopQueryAnchorTrigger($optimizedShopQuery)) {
            return $optimizedShopQuery;
        }

        if ($this->containsConfiguredShopQueryAnchorTrigger($prompt)) {
            return $optimizedShopQuery;
        }

        $this->agentLogger->info('Ignored optimized shop query because it added an unsupported context anchor', [
            'prompt' => $prompt,
            'optimizedShopQuery' => $optimizedShopQuery,
        ]);

        return $prompt;
    }

    private function guardFinalStandaloneShopSearchQuery(string $prompt, string $shopSearchQuery): string
    {
        $shopSearchQuery = trim($shopSearchQuery);

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

        $guardedQuery = $this->guardStandaloneOptimizedShopQuery($prompt, $shopSearchQuery);
        $query = $guardedQuery !== $shopSearchQuery
            ? $this->preserveCurrentInputShopQueryTerms($prompt, $guardedQuery)
            : $this->preserveCurrentInputShopQueryTerms($prompt, $shopSearchQuery);

        return $this->cleanupDirectProductAttributeShopQuery($prompt, $query);
    }

    private function cleanupDirectProductAttributeShopQuery(string $prompt, string $shopSearchQuery): string
    {
        $shopSearchQuery = trim($shopSearchQuery);

        if (
            $shopSearchQuery === ''
            || !$this->agentRunnerConfig->isShopQueryProductAttributeCleanupEnabled()
        ) {
            return $shopSearchQuery;
        }

        $combined = trim($prompt . ' ' . $shopSearchQuery);
        if (!$this->containsAnyShopQueryTerm($combined, $this->agentRunnerConfig->getShopQueryProductAttributeCleanupProductTypeTerms())) {
            return $shopSearchQuery;
        }

        $constraintTokens = $this->extractConfiguredShopQueryConstraintTokens(
            $combined,
            $this->agentRunnerConfig->getShopQueryProductAttributeCleanupComparativeConstraintPatterns()
        );

        if ($constraintTokens === []) {
            return $shopSearchQuery;
        }

        $removeTokens = array_fill_keys($constraintTokens, true);
        foreach ($this->agentRunnerConfig->getShopQueryProductAttributeCleanupStopTerms() as $term) {
            foreach ($this->tokenizeShopQueryCandidate($term) as $token) {
                $removeTokens[$token] = true;
            }
        }

        $kept = [];
        foreach ($this->tokenizeShopQueryCandidate($shopSearchQuery) as $token) {
            if (isset($removeTokens[$token]) || isset($kept[$token])) {
                continue;
            }

            $kept[$token] = $token;
        }

        if (count($kept) < max(1, $this->agentRunnerConfig->getShopQueryProductAttributeCleanupMinTokens())) {
            return $shopSearchQuery;
        }

        $cleaned = implode(' ', array_values($kept));

        return $cleaned !== '' ? $cleaned : $shopSearchQuery;
    }

    /**
     * @param string[] $terms
     */
    private function containsAnyShopQueryTerm(string $text, array $terms): bool
    {
        $tokens = array_fill_keys($this->tokenizeShopQueryCandidate($text), true);

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

        foreach ($terms as $term) {
            $termTokens = $this->tokenizeShopQueryCandidate($term);
            if ($termTokens === []) {
                continue;
            }

            $matches = true;
            foreach ($termTokens as $termToken) {
                if (!isset($tokens[$termToken])) {
                    $matches = false;
                    break;
                }
            }

            if ($matches) {
                return true;
            }
        }

        return false;
    }

    /**
     * @param string[] $patterns
     * @return string[]
     */
    private function extractConfiguredShopQueryConstraintTokens(string $text, array $patterns): array
    {
        $tokens = [];

        foreach ($patterns as $pattern) {
            if (@preg_match_all($pattern, $text, $matches, PREG_SET_ORDER) === false) {
                continue;
            }

            foreach ($matches as $match) {
                $value = $match['value'] ?? ($match[1] ?? '');
                if (!is_scalar($value)) {
                    continue;
                }

                foreach ($this->tokenizeShopQueryCandidate((string) $value) as $token) {
                    $tokens[$token] = $token;
                }
            }
        }

        return array_values($tokens);
    }

    private function preserveCurrentInputShopQueryTerms(string $prompt, string $shopSearchQuery): string
    {
        $shopSearchQuery = trim($shopSearchQuery);

        if ($shopSearchQuery === '' || !$this->agentRunnerConfig->isShopQueryCurrentInputPreservationEnabled()) {
            return $shopSearchQuery;
        }

        $promptTokens = array_fill_keys($this->tokenizeShopQueryCandidate($prompt), true);
        $queryTokens = array_fill_keys($this->tokenizeShopQueryCandidate($shopSearchQuery), true);

        if ($promptTokens === [] || $queryTokens === []) {
            return $shopSearchQuery;
        }

        $appendTokens = [];

        $preservationTerms = $this->mergeUniqueStrings(
            $this->languageCleanupConfig->getProtectedTerms(),
            $this->agentRunnerConfig->getShopQueryCurrentInputPreservationTerms()
        );

        foreach ($preservationTerms as $term) {
            $termTokens = $this->tokenizeShopQueryCandidate($term);

            if ($termTokens === []) {
                continue;
            }

            foreach ($termTokens as $termToken) {
                if (!isset($promptTokens[$termToken]) || isset($queryTokens[$termToken])) {
                    continue;
                }

                $appendTokens[$termToken] = $termToken;
                $queryTokens[$termToken] = true;
            }
        }

        if ($appendTokens === []) {
            return $shopSearchQuery;
        }

        return trim($shopSearchQuery . ' ' . implode(' ', array_values($appendTokens)));
    }

    /**
     * @param string[] $knowledgeChunks
     */
    private function enrichShopSearchQueryWithRagAnchor(
        string $prompt,
        string $shopSearchQuery,
        array $knowledgeChunks
    ): string {
        $shopSearchQuery = trim($shopSearchQuery);

        if (
            $shopSearchQuery === ''
            || $knowledgeChunks === []
            || !$this->agentRunnerConfig->isShopQueryRagAnchorEnrichmentEnabled()
        ) {
            return $shopSearchQuery;
        }

        $focuses = $this->extractShopQueryNumericFocuses($prompt);
        if ($focuses === []) {
            return $shopSearchQuery;
        }

        $anchor = $this->resolveBestRagShopQueryAnchor($knowledgeChunks, $focuses);
        if ($anchor === '') {
            return $shopSearchQuery;
        }

        $queryTokens = array_fill_keys($this->tokenizeShopQueryCandidate($shopSearchQuery), true);
        $anchorTokens = $this->tokenizeShopQueryCandidate($anchor);

        if ($anchorTokens === []) {
            return $shopSearchQuery;
        }

        $missingAnchorToken = false;
        foreach ($anchorTokens as $anchorToken) {
            if (!isset($queryTokens[$anchorToken])) {
                $missingAnchorToken = true;
                break;
            }
        }

        if (!$missingAnchorToken) {
            return $shopSearchQuery;
        }

        $subject = $this->extractRagAnchorSubjectTerms($prompt, $shopSearchQuery);
        $rendered = strtr($this->agentRunnerConfig->getShopQueryRagAnchorEnrichmentTemplate(), [
            '{anchor}' => $anchor,
            '{query}' => $shopSearchQuery,
            '{subject}' => $subject,
        ]);

        $enrichedQuery = $this->limitShopQueryTerms(
            $rendered,
            $this->agentRunnerConfig->getShopQueryRagAnchorEnrichmentMaxQueryTerms()
        );

        return $enrichedQuery !== '' ? $enrichedQuery : $shopSearchQuery;
    }

    /**
     * @return array<int, array{value:string, unit:string}>
     */
    private function extractShopQueryNumericFocuses(string $prompt): array
    {
        $focuses = [];
        $seen = [];

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

            foreach ($matches as $match) {
                $rawValue = $match['value'] ?? ($match[1] ?? '');
                $rawUnit = $match['unit'] ?? ($match[2] ?? '');

                if (!is_scalar($rawValue) || !is_scalar($rawUnit)) {
                    continue;
                }

                $value = $this->normalizeShopQueryNumericFocusValue((string) $rawValue);
                $unit = $this->normalizeShopQueryNumericFocusUnit((string) $rawUnit);

                if ($value === '' || $unit === '') {
                    continue;
                }

                $key = $value . '|' . $unit;
                if (isset($seen[$key])) {
                    continue;
                }

                $seen[$key] = true;
                $focuses[] = [
                    'value' => $value,
                    'unit' => $unit,
                ];
            }
        }

        return $focuses;
    }

    /**
     * @param string[] $knowledgeChunks
     * @param array<int, array{value:string, unit:string}> $focuses
     */
    private function resolveBestRagShopQueryAnchor(array $knowledgeChunks, array $focuses): string
    {
        $bestAnchor = '';
        $bestScore = 0;
        $minScore = $this->agentRunnerConfig->getShopQueryRagAnchorEnrichmentMinScore();
        $earlyBonusMax = max(0, $this->agentRunnerConfig->getShopQueryRagAnchorEnrichmentEarlyChunkBonusMax());

        foreach (array_values($knowledgeChunks) as $index => $chunk) {
            $chunk = (string) $chunk;
            $anchor = $this->extractRagProductTitleAnchor($chunk);

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

            $score = $this->scoreRagChunkForShopQueryNumericFocus($chunk, $focuses);
            if ($score <= 0) {
                continue;
            }

            if ($this->ragAnchorMatchesAnyBonusPattern($anchor)) {
                $score += $this->agentRunnerConfig->getShopQueryRagAnchorEnrichmentAnchorBonusScore();
            }

            if ($earlyBonusMax > 0) {
                $score += max(0, $earlyBonusMax - min($earlyBonusMax, $index));
            }

            if ($score < $minScore || $score <= $bestScore) {
                continue;
            }

            $bestScore = $score;
            $bestAnchor = $anchor;
        }

        return $bestAnchor;
    }

    private function extractRagProductTitleAnchor(string $chunk): string
    {
        foreach ($this->agentRunnerConfig->getShopQueryRagAnchorEnrichmentProductTitlePatterns() as $pattern) {
            if (@preg_match($pattern, $chunk, $matches) !== 1) {
                continue;
            }

            $title = $matches['title'] ?? ($matches[1] ?? '');
            if (!is_scalar($title)) {
                continue;
            }

            $title = trim(preg_replace('/\s+/u', ' ', str_replace('®', '', (string) $title)) ?? '');
            if ($title !== '') {
                return $title;
            }
        }

        return '';
    }

    /**
     * @param array<int, array{value:string, unit:string}> $focuses
     */
    private function scoreRagChunkForShopQueryNumericFocus(string $chunk, array $focuses): int
    {
        $normalizedChunk = $this->normalizeShopQueryNumericFocusSearchText($chunk);
        if ($normalizedChunk === '') {
            return 0;
        }

        $score = 0;
        foreach ($focuses as $focus) {
            $hasValue = $focus['value'] !== '' && str_contains($normalizedChunk, $focus['value']);
            $hasUnit = $focus['unit'] === '' || str_contains($normalizedChunk, $focus['unit']);

            if ($hasValue && $hasUnit) {
                $score += $this->agentRunnerConfig->getShopQueryRagAnchorEnrichmentExactValueUnitScore();
                continue;
            }

            if ($hasValue) {
                $score += $this->agentRunnerConfig->getShopQueryRagAnchorEnrichmentExactValueScore();
            }
        }

        return $score;
    }

    private function ragAnchorMatchesAnyBonusPattern(string $anchor): bool
    {
        foreach ($this->agentRunnerConfig->getShopQueryRagAnchorEnrichmentAnchorBonusPatterns() as $pattern) {
            if (@preg_match($pattern, $anchor) === 1) {
                return true;
            }
        }

        return false;
    }

    private function extractRagAnchorSubjectTerms(string $prompt, string $shopSearchQuery): string
    {
        $promptTokens = array_fill_keys($this->tokenizeShopQueryCandidate($prompt), true);
        $queryTokens = array_fill_keys($this->tokenizeShopQueryCandidate($shopSearchQuery), true);
        $subjectTerms = [];

        foreach ($this->agentRunnerConfig->getShopQueryRagAnchorEnrichmentSubjectTerms() as $term) {
            $termTokens = $this->tokenizeShopQueryCandidate($term);
            if ($termTokens === []) {
                continue;
            }

            $allPresent = true;
            foreach ($termTokens as $termToken) {
                if (!isset($promptTokens[$termToken])) {
                    $allPresent = false;
                    break;
                }
            }

            if (!$allPresent) {
                continue;
            }

            $alreadyInQuery = true;
            foreach ($termTokens as $termToken) {
                if (!isset($queryTokens[$termToken])) {
                    $alreadyInQuery = false;
                    break;
                }
            }

            if (!$alreadyInQuery) {
                $subjectTerms[] = $term;
            }
        }

        return implode(' ', array_values(array_unique($subjectTerms)));
    }

    private function limitShopQueryTerms(string $query, int $maxTerms): string
    {
        $maxTerms = max(1, $maxTerms);
        $tokens = [];

        foreach ($this->tokenizeShopQueryCandidate($query) as $token) {
            if (isset($tokens[$token])) {
                continue;
            }

            $tokens[$token] = $token;

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

        return implode(' ', array_values($tokens));
    }

    private function normalizeShopQueryNumericFocusValue(string $value): string
    {
        $value = $this->normalizeShopQueryNumericFocusSearchText($value);
        $value = preg_replace('/[^0-9,]+/u', '', $value) ?? $value;

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

    private function normalizeShopQueryNumericFocusUnit(string $unit): string
    {
        $unit = $this->normalizeShopQueryNumericFocusSearchText($unit);
        $unit = preg_replace('/[^\p{L}]+/u', '', $unit) ?? $unit;

        return $unit;
    }

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

        return trim($value);
    }

    private function standaloneOptimizedShopQueryIntroducesUnsupportedContext(
        string $prompt,
        string $optimizedShopQuery
    ): bool {
        $promptTokens = array_fill_keys($this->tokenizeShopQueryCandidate($prompt), true);
        $optimizedTokens = $this->tokenizeShopQueryCandidate($optimizedShopQuery);

        if ($optimizedTokens === [] || $promptTokens === []) {
            return false;
        }

        $overlap = 0;

        foreach ($optimizedTokens as $token) {
            if (isset($promptTokens[$token])) {
                $overlap++;
                continue;
            }

            // A standalone query optimizer may remove words, but it must not add
            // model numbers or article-like numbers that are absent from the
            // current user input. Otherwise old context can leak into new shop
            // searches, for example "Anschlusskabel pH/Redox" -> "testomat 808".
            if (preg_match('/\d/u', $token) === 1) {
                return true;
            }
        }

        // If the optimized query has no token overlap with the current standalone
        // input, it is not a safe optimization but a context substitution.
        return $overlap === 0 && !$this->isMetaOnlyShopQuery($prompt);
    }

    private function resolveShopSearchQuery(
        string $prompt,
        string $optimizedShopQuery,
        string $commerceHistoryContext,
        string $userId,
        string $currentPromptFallback = ''
    ): string {
        if ($this->isCommercialTableFollowUpPrompt($prompt)) {
            foreach ($this->buildCommercialTableFollowUpContextCandidates($commerceHistoryContext, $userId) as $contextCandidate) {
                $commercialTableContextQuery = $this->extractCommercialTableFollowUpShopQuery($contextCandidate);

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

        if ($optimizedShopQuery !== '' && !$this->isMetaOnlyShopQuery($optimizedShopQuery)) {
            return $this->guardStandaloneOptimizedShopQuery($prompt, $optimizedShopQuery);
        }

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

        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 '';
    }

    /**
     * @return string[]
     */
    private function buildCommercialTableFollowUpContextCandidates(string $commerceHistoryContext, string $userId): array
    {
        $candidates = [];

        $commerceHistoryContext = trim($commerceHistoryContext);
        if ($commerceHistoryContext !== '') {
            $candidates[] = $commerceHistoryContext;
        }

        $extendedHistoryBudget = $this->agentRunnerConfig->getShopQueryContextFallbackHistoryBudgetChars();
        if ($extendedHistoryBudget > mb_strlen($commerceHistoryContext, 'UTF-8')) {
            $extendedHistory = trim($this->contextService->buildUserContextWithinBudget($userId, $extendedHistoryBudget));
            if ($extendedHistory !== '') {
                $candidates[] = $extendedHistory;
            }
        }

        if ($this->agentRunnerConfig->shouldUseFullHistoryForShopQueryContextFallback()) {
            $fullHistory = trim($this->contextService->buildUserContext($userId, true));
            if ($fullHistory !== '') {
                $candidates[] = $fullHistory;
            }
        }

        return array_values(array_unique($candidates));
    }

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

        $fallbackWithoutModel = '';

        foreach ($this->extractHistoryTurnsNewestFirst($commerceHistoryContext) as $turn) {
            if (!$this->matchesAnyConfiguredPattern(
                $turn,
                $this->agentRunnerConfig->getCommercialTableFollowUpIndicatorMarkerPatterns()
            )) {
                continue;
            }

            $model = $this->extractFirstProductModelAnchor($turn);

            if ($model !== '') {
                $query = str_replace(
                    '{model}',
                    $model,
                    $this->agentRunnerConfig->getCommercialTableFollowUpQueryTemplateWithModel()
                );

                return trim((string) preg_replace('/\s+/u', ' ', $query));
            }

            $fallbackWithoutModel = trim($this->agentRunnerConfig->getCommercialTableFollowUpQueryTemplateWithoutModel());
        }

        return $fallbackWithoutModel;
    }

    private function isCommercialTableFollowUpPrompt(string $prompt): bool
    {
        if (!$this->agentRunnerConfig->isCommercialTableFollowUpEnabled()) {
            return false;
        }

        return $this->matchesAnyConfiguredPattern(
            $this->normalizeFollowUpText($prompt),
            $this->agentRunnerConfig->getCommercialTableFollowUpPromptPatterns()
        );
    }

    /**
     * @param string[] $patterns
     */
    private function matchesAnyConfiguredPattern(string $text, array $patterns): bool
    {
        foreach ($patterns as $pattern) {
            if (preg_match($pattern, $text) === 1) {
                return true;
            }
        }

        return false;
    }

    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 ($this->isUsableContextualShopQuery($contextQuery)) {
                return $contextQuery;
            }
        }

        return $this->extractContextualShopSearchQueryFromHistoryTurns($commerceHistoryContext);
    }

    private function extractContextualShopSearchQueryFromHistoryTurns(string $commerceHistoryContext): string
    {
        foreach ($this->extractHistoryTurnsNewestFirst($commerceHistoryContext) as $turn) {
            $question = $this->extractQuestionFromHistoryTurn($turn);

            if ($question !== '' && !$this->isMetaOnlyShopQuery($question)) {
                $contextQuery = $this->buildContextFallbackShopQuery($question);

                if ($this->isUsableContextualShopQuery($contextQuery)) {
                    return $contextQuery;
                }
            }

            $modelAnchor = $this->extractFirstProductModelAnchor($turn);

            if ($modelAnchor !== '' && !$this->isMetaOnlyShopQuery($modelAnchor)) {
                return mb_strtolower($modelAnchor, 'UTF-8');
            }
        }

        return '';
    }

    private function extractQuestionFromHistoryTurn(string $turn): string
    {
        if (preg_match($this->agentRunnerConfig->getFollowUpHistoryQuestionPattern(), $turn, $matches) !== 1) {
            return '';
        }

        return $this->sanitizeHistoryQuestion((string) ($matches[1] ?? ''));
    }

    private function isUsableContextualShopQuery(string $query): bool
    {
        $query = trim($query);

        if ($query === '' || $this->isMetaOnlyShopQuery($query)) {
            return false;
        }

        return $this->tokenizeShopQueryCandidate($query) !== [];
    }

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

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

        $filterTerms = [];

        foreach ($this->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 getShopQueryContextFallbackFilterTerms(): array
    {
        $profileName = $this->agentRunnerConfig->getShopQueryContextFallbackCleanupProfile();

        return $this->mergeUniqueStrings(
            $this->mergeUniqueStrings(
                $this->languageCleanupConfig->getStopWordsForProfile($profileName),
                $this->languageCleanupConfig->getPhrasesForProfile($profileName)
            ),
            $this->mergeUniqueStrings(
                $this->languageCleanupConfig->getMetaTermsForProfile($profileName),
                $this->mergeUniqueStrings(
                    $this->agentRunnerConfig->getShopQueryMetaOnlyTerms(),
                    $this->agentRunnerConfig->getShopQueryContextFallbackFilterTerms()
                )
            )
        );
    }

    /** @return string[] */
    private function getShopQueryMetaGuardTerms(): array
    {
        $profileName = $this->agentRunnerConfig->getShopQueryContextFallbackCleanupProfile();

        return $this->mergeUniqueStrings(
            $this->mergeUniqueStrings(
                $this->languageCleanupConfig->getStopWordsForProfile($profileName),
                $this->languageCleanupConfig->getPhrasesForProfile($profileName)
            ),
            $this->mergeUniqueStrings(
                $this->languageCleanupConfig->getMetaTermsForProfile($profileName),
                $this->agentRunnerConfig->getShopQueryMetaOnlyTerms()
            )
        );
    }

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

        if (preg_match_all('/\d+(?:[,.]\d+)?|[\p{L}\p{N}]+/u', $value, $matches) === false) {
            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->getShopQueryMetaGuardTerms() 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 = $this->languageCleanupConfig->replaceWordSeparatorsWithSpace($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 = $this->renderAgentTemplate($template, [
            'anchor' => $anchor,
            'query' => $query,
        ]);
        $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 = $this->renderAgentTemplate($this->agentRunnerConfig->getNoLlmProductField('unavailable_reason_template'), [
                'message' => $message,
                'reason' => $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 [$this->agentRunnerConfig->getNoLlmProductField('unreadable_results_message')];
        }

        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 : $this->agentRunnerConfig->getNoLlmProductField('unnamed_product');

        if ($product->productNumber !== null && trim($product->productNumber) !== '') {
            $parts[] = $this->renderAgentTemplate($this->agentRunnerConfig->getNoLlmProductField('product_number_template'), ['value' => $this->normalizeOneLine($product->productNumber)]);
        }

        if ($product->manufacturer !== null && trim($product->manufacturer) !== '') {
            $parts[] = $this->renderAgentTemplate($this->agentRunnerConfig->getNoLlmProductField('manufacturer_template'), ['value' => $this->normalizeOneLine($product->manufacturer)]);
        }

        if ($product->price !== null && trim($product->price) !== '') {
            $parts[] = $this->renderAgentTemplate($this->agentRunnerConfig->getNoLlmProductField('price_template'), ['value' => $this->normalizeOneLine($product->price)]);
        }

        if ($product->available !== null) {
            $parts[] = $this->renderAgentTemplate($this->agentRunnerConfig->getNoLlmProductField('availability_template'), ['value' => $product->available ? $this->agentRunnerConfig->getNoLlmProductField('availability_yes') : $this->agentRunnerConfig->getNoLlmProductField('availability_no')]);
        }

        if ($product->url !== null && trim($product->url) !== '') {
            $parts[] = $this->renderAgentTemplate($this->agentRunnerConfig->getNoLlmProductField('url_template'), ['value' => $this->normalizeOneLine($product->url)]);
        }

        if ($requestedProductRole === 'main_device_or_system' && $productRole === 'accessory_or_consumable') {
            $parts[] = $this->agentRunnerConfig->getNoLlmProductField('incompatible_role_note');
        }

        return $this->renderAgentTemplate($this->agentRunnerConfig->getNoLlmProductField('line_template'), [
            'index' => (string) $index,
            'parts' => implode($this->agentRunnerConfig->getNoLlmProductField('separator'), $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)));
        $isAggregateQuery = $this->isAggregateRagEvidenceQuery($prompt);

        if (
            $isAggregateQuery
            && !$this->containsAnyRagEvidencePattern($haystack, $this->agentRunnerConfig->getRagEvidenceAggregateAnswerEvidencePatterns())
        ) {
            return 'aggregate_missing';
        }

        if (
            $isAggregateQuery
            && !$this->containsAnyRagEvidenceTerm($haystack, $this->agentRunnerConfig->getRagEvidenceAggregateEvidenceTerms())
        ) {
            return 'aggregate_missing';
        }

        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' => $this->agentRunnerConfig->getProductionUiConfidenceLabel('direct'),
            'aggregate_missing' => $this->agentRunnerConfig->getProductionUiConfidenceLabel('aggregate_missing'),
            'weak' => $this->agentRunnerConfig->getProductionUiConfidenceLabel('weak'),
            default => $this->agentRunnerConfig->getProductionUiConfidenceLabel('default'),
        };
    }

    private function resolveRagEvidenceShopCheckConfidenceLabel(string $knowledgeEvidenceState): string
    {
        return match ($knowledgeEvidenceState) {
            'direct' => $this->agentRunnerConfig->getProductionUiConfidenceLabel('direct_shop_check'),
            'aggregate_missing' => $this->agentRunnerConfig->getProductionUiConfidenceLabel('aggregate_missing_shop_check'),
            'weak' => $this->agentRunnerConfig->getProductionUiConfidenceLabel('weak_shop_check'),
            default => $this->agentRunnerConfig->getProductionUiConfidenceLabel('default_shop_check'),
        };
    }

    private function isAggregateRagEvidenceQuery(string $prompt): bool
    {
        $normalizedPrompt = $this->normalizeRagEvidenceText($prompt);

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

        foreach ($this->agentRunnerConfig->getRagEvidenceAggregateQueryPatterns() as $pattern) {
            if (@preg_match($pattern, $normalizedPrompt) === 1) {
                return true;
            }
        }

        return false;
    }

    /**
     * @param string[] $terms
     */
    private function containsAnyRagEvidenceTerm(string $haystack, array $terms): bool
    {
        foreach ($terms as $term) {
            if ($this->containsRagEvidenceTerm($haystack, $term)) {
                return true;
            }
        }

        return false;
    }

    /**
     * @param string[] $patterns
     */
    private function containsAnyRagEvidencePattern(string $haystack, array $patterns): bool
    {
        foreach ($patterns as $pattern) {
            if (@preg_match($pattern, $haystack) === 1) {
                return true;
            }
        }

        return false;
    }

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

        foreach ($this->getRagEvidenceCleanupStopTerms() 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 string[] */
    private function getRagEvidenceCleanupStopTerms(): array
    {
        return $this->mergeUniqueStrings(
            $this->languageCleanupConfig->getStopWordsForProfile($this->agentRunnerConfig->getRagEvidenceCleanupProfile()),
            $this->agentRunnerConfig->getRagEvidenceStopTerms()
        );
    }

    /**
     * @param string[] $left
     * @param string[] $right
     * @return string[]
     */
    private function mergeUniqueStrings(array $left, array $right): array
    {
        $out = [];
        foreach (array_merge($left, $right) as $item) {
            $item = trim((string) $item);
            if ($item === '' || isset($out[$item])) {
                continue;
            }

            $out[$item] = $item;
        }

        return array_values($out);
    }

    /**
     * @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 = $this->languageCleanupConfig->normalizeDashEquivalents($value);
        $value = $this->languageCleanupConfig->transliterateToAscii($value);
        $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 = $this->agentRunnerConfig->getProductionUiText('no_llm_history_default');
            }

            $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 = $this->agentRunnerConfig->getProductionUiText('history_notice_default_title');
        }

        if ($detail === '') {
            return $this->renderAgentTemplate($this->agentRunnerConfig->getProductionUiTemplate('history_notice_without_detail'), ['title' => $title]);
        }

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

        return $this->renderAgentTemplate($this->agentRunnerConfig->getProductionUiTemplate('history_notice_with_detail'), [
            'title' => $title,
            'detail' => $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);
    }

    private function resolveShopCountModeForMeta(
        string $commerceIntent,
        bool $shopSearchAttempted,
        bool $shopSearchHadSystemFailure,
        bool $shopSearchSkippedBecauseNoQuery = false
    ): string {
        if ($shopSearchHadSystemFailure) {
            return 'unavailable';
        }

        if ($shopSearchAttempted) {
            return 'count';
        }

        if ($shopSearchSkippedBecauseNoQuery) {
            return 'not_resolved';
        }

        if ($this->isCommerceIntent($commerceIntent)) {
            return 'loading';
        }

        return 'not_requested';
    }

    /**
     * @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
            ? $this->agentRunnerConfig->getProductionUiText('rag_hits_checking')
            : $this->renderAgentTemplate($this->agentRunnerConfig->getProductionUiTemplate('rag_hits_count'), ['count' => (string) max(0, $ragCount)]);
        $shopLabel = match ($shopCountMode) {
            'count' => $this->renderAgentTemplate($this->agentRunnerConfig->getProductionUiTemplate('shop_hits_count'), ['count' => (string) max(0, (int) $shopCount)]),
            'loading' => $this->agentRunnerConfig->getProductionUiText('shop_hits_loading'),
            'unavailable' => $this->agentRunnerConfig->getProductionUiText('shop_hits_unavailable'),
            'not_resolved' => $this->agentRunnerConfig->getProductionUiText('shop_hits_no_query'),
            default => $this->agentRunnerConfig->getProductionUiText('shop_hits_not_requested'),
        };
        $statusLabel = $completed
            ? $this->agentRunnerConfig->getProductionUiText('status_completed')
            : $this->agentRunnerConfig->getProductionUiText('status_running');
        $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">' . htmlspecialchars($this->agentRunnerConfig->getProductionUiText('run_status_eyebrow'), ENT_QUOTES | ENT_SUBSTITUTE, 'UTF-8') . '</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">' . htmlspecialchars($this->agentRunnerConfig->getProductionUiText('evidence_prefix'), ENT_QUOTES | ENT_SUBSTITUTE, 'UTF-8') . '' . htmlspecialchars($confidenceLabel, ENT_QUOTES | ENT_SUBSTITUTE, 'UTF-8') . '</span>'
            . '</div>';

        if ($sources !== []) {
            $html .= '<div class="retriex-source-overview"><span>' . htmlspecialchars($this->agentRunnerConfig->getProductionUiText('data_basis_label'), ENT_QUOTES | ENT_SUBSTITUTE, 'UTF-8') . '</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
                ? $this->agentRunnerConfig->getProductionUiText('data_basis_empty_completed')
                : $this->agentRunnerConfig->getProductionUiText('data_basis_empty_running');
            $html .= '<div class="retriex-source-overview"><span>' . htmlspecialchars($this->agentRunnerConfig->getProductionUiText('data_basis_label'), ENT_QUOTES | ENT_SUBSTITUTE, 'UTF-8') . '</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 $knowledgeEvidenceState = 'unknown'
    ): string {
        if ($knowledgeEvidenceState === 'aggregate_missing' && !$hasShopResults) {
            return $shopSearchHadSystemFailure
                ? $this->agentRunnerConfig->getProductionUiConfidenceLabel('aggregate_missing_shop_unavailable')
                : $this->agentRunnerConfig->getProductionUiConfidenceLabel('aggregate_missing_no_count');
        }

        if ($shopSearchHadSystemFailure) {
            return $hasKnowledge
                ? $this->agentRunnerConfig->getProductionUiConfidenceLabel('shop_unavailable_with_knowledge')
                : $this->agentRunnerConfig->getProductionUiConfidenceLabel('shop_unavailable');
        }

        if ($hasKnowledge && $hasShopResults) {
            return $this->agentRunnerConfig->getProductionUiConfidenceLabel('rag_and_shop');
        }

        if (!$hasKnowledge && $hasShopResults) {
            return $this->agentRunnerConfig->getProductionUiConfidenceLabel('shop_only');
        }

        if ($hasKnowledge && $shopSearchAttempted) {
            return $this->agentRunnerConfig->getProductionUiConfidenceLabel('rag_no_shop_hits');
        }

        if ($hasKnowledge) {
            return $this->agentRunnerConfig->getProductionUiConfidenceLabel('direct');
        }

        if ($isCommerceIntent || $shopSearchAttempted) {
            return $this->agentRunnerConfig->getProductionUiConfidenceLabel('no_reliable_data');
        }

        return $this->agentRunnerConfig->getProductionUiConfidenceLabel('no_reliable_hits');
    }

    /**
     * @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 = $this->agentRunnerConfig->getProductionUiText('live_shop_source_plain_label');
            }

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

        return $labels;
    }

    /**
     * @param ShopProductResult[] $shopResults
     */
    private function buildShopProductCardsMessage(array $shopResults, string $query, bool $usedRepair): string
    {
        $maxCards = max(1, $this->agentRunnerConfig->getProductionUiShopResultsMaxCards());
        $visibleResults = array_slice($shopResults, 0, $maxCards);
        $totalCount = count($shopResults);
        $query = $this->normalizeOneLine($query);
        $summary = $this->renderAgentTemplate($this->agentRunnerConfig->getProductionUiTemplate('shop_results_summary'), ['count' => (string) $totalCount]);

        if ($totalCount > $maxCards) {
            $summary .= $this->renderAgentTemplate($this->agentRunnerConfig->getProductionUiTemplate('shop_results_top_displayed_suffix'), ['max' => (string) $maxCards]);
        }

        if ($usedRepair) {
            $summary .= $this->agentRunnerConfig->getProductionUiTemplate('shop_results_repair_suffix');
        }

        $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">' . htmlspecialchars($this->agentRunnerConfig->getProductionUiText('shop_results_eyebrow'), ENT_QUOTES | ENT_SUBSTITUTE, 'UTF-8') . '</div>'
            . '<div class="retriex-meta-card__title">' . htmlspecialchars($this->agentRunnerConfig->getProductionUiText('shop_results_title'), ENT_QUOTES | ENT_SUBSTITUTE, 'UTF-8') . '</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>' . htmlspecialchars($this->agentRunnerConfig->getProductionUiText('evaluated_query_label'), ENT_QUOTES | ENT_SUBSTITUTE, 'UTF-8') . '</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) ?: $this->agentRunnerConfig->getProductionUiText('unnamed_product');
        $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>' . htmlspecialchars($this->agentRunnerConfig->getProductionUiText('product_number_label'), ENT_QUOTES | ENT_SUBSTITUTE, 'UTF-8') . '</dt><dd>' . htmlspecialchars($productNumber !== '' ? $productNumber : $this->agentRunnerConfig->getProductionUiText('field_not_provided'), ENT_QUOTES | ENT_SUBSTITUTE, 'UTF-8') . '</dd></div>';
        $html .= '<div><dt>' . htmlspecialchars($this->agentRunnerConfig->getProductionUiText('price_label'), ENT_QUOTES | ENT_SUBSTITUTE, 'UTF-8') . '</dt><dd>' . htmlspecialchars($price !== '' ? $price : $this->agentRunnerConfig->getProductionUiText('field_not_provided'), ENT_QUOTES | ENT_SUBSTITUTE, 'UTF-8') . '</dd></div>';
        $html .= '<div><dt>' . htmlspecialchars($this->agentRunnerConfig->getProductionUiText('availability_label'), ENT_QUOTES | ENT_SUBSTITUTE, 'UTF-8') . '</dt><dd>' . htmlspecialchars($availability, ENT_QUOTES | ENT_SUBSTITUTE, 'UTF-8') . '</dd></div>';

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

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

        return $html;
    }

    private function formatProductAvailability(?bool $available): string
    {
        return match ($available) {
            true => $this->agentRunnerConfig->getProductionUiText('availability_yes'),
            false => $this->agentRunnerConfig->getProductionUiText('availability_no'),
            default => $this->agentRunnerConfig->getProductionUiText('availability_unknown'),
        };
    }

    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 $this->renderAgentTemplate($this->agentRunnerConfig->getProductionUiTemplate('relevance_matched_queries'), [
                'queries' => implode(', ', array_slice($matchedQueries, 0, 3)),
            ]);
        }

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

            if ($highlight !== '') {
                return $this->renderAgentTemplate($this->agentRunnerConfig->getProductionUiTemplate('relevance_highlight'), [
                    'highlight' => mb_substr($highlight, 0, 140, 'UTF-8'),
                ]);
            }
        }

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

        if ($matchSource !== '') {
            return $this->renderAgentTemplate($this->agentRunnerConfig->getProductionUiTemplate('relevance_match_source'), ['source' => $matchSource]);
        }

        if ($query !== '') {
            return $this->renderAgentTemplate($this->agentRunnerConfig->getProductionUiTemplate('relevance_query'), ['query' => $query]);
        }

        return $this->agentRunnerConfig->getProductionUiTemplate('relevance_default');
    }

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

        $actions = [];

        if ($isCommerceIntent || $hasShopResults) {
            $actions = array_merge($actions, $this->agentRunnerConfig->getProductionUiFollowUpActions('commerce'));
        }

        if ($hasKnowledge || $hasShopResults) {
            $actions = array_merge($actions, $this->agentRunnerConfig->getProductionUiFollowUpActions('knowledge'));
        }

        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">' . htmlspecialchars($this->agentRunnerConfig->getProductionUiText('followup_eyebrow'), ENT_QUOTES | ENT_SUBSTITUTE, 'UTF-8') . '</div>'
            . '<div class="retriex-meta-card__title">' . htmlspecialchars($this->agentRunnerConfig->getProductionUiText('followup_title'), ENT_QUOTES | ENT_SUBSTITUTE, 'UTF-8') . '</div>'
            . '<div class="retriex-action-chip-row">';

        foreach ($actions as $action) {
            $label = (string) ($action['label'] ?? '');
            $actionPrompt = (string) ($action['prompt'] ?? '');
            if ($label === '' || $actionPrompt === '') {
                continue;
            }
            $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 : $this->agentRunnerConfig->getProductionUiText('shop_meta_fallback_query');
        }

        $queryModeLabel = $usedOptimizedQuery ? $this->agentRunnerConfig->getProductionUiText('shop_meta_query_mode_optimized') : $this->agentRunnerConfig->getProductionUiText('shop_meta_query_mode_direct');
        $intentLabel = $commerceIntent !== '' ? $commerceIntent : $this->agentRunnerConfig->getProductionUiText('shop_meta_default_intent');
        $title = $unavailable
            ? $this->agentRunnerConfig->getProductionUiText('shop_meta_title_unavailable')
            : ($completed
                ? $this->agentRunnerConfig->getProductionUiText('shop_meta_title_completed')
                : $this->agentRunnerConfig->getProductionUiText('shop_meta_title_running'));
        $statusLabel = $completed
            ? $this->agentRunnerConfig->getProductionUiText('shop_meta_status_completed')
            : $this->agentRunnerConfig->getProductionUiText('shop_meta_status_running');
        $resultLabel = $unavailable
            ? $this->agentRunnerConfig->getProductionUiText('shop_meta_result_unavailable')
            : ($resultCount === null
                ? $this->agentRunnerConfig->getProductionUiText('shop_meta_result_loading')
                : $this->renderAgentTemplate($this->agentRunnerConfig->getProductionUiTemplate('shop_meta_result_count'), ['count' => (string) max(0, $resultCount)]));
        $state = $completed ? 'completed' : 'running';
        $resultCountAttribute = $resultCount === null ? '' : (string) max(0, $resultCount);
        $repairLabel = '';

        if ($usedRepair) {
            $repairLabel = $this->agentRunnerConfig->getProductionUiText('shop_meta_repair_used');
        } elseif ($attemptedRepair) {
            $repairLabel = $this->agentRunnerConfig->getProductionUiText('shop_meta_repair_checked');
        }

        $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">' . htmlspecialchars($this->agentRunnerConfig->getProductionUiText('shop_meta_eyebrow'), ENT_QUOTES | ENT_SUBSTITUTE, 'UTF-8') . '</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">' . htmlspecialchars($this->agentRunnerConfig->getProductionUiText('shop_meta_query_prefix'), ENT_QUOTES | ENT_SUBSTITUTE, 'UTF-8') . htmlspecialchars($queryModeLabel, ENT_QUOTES | ENT_SUBSTITUTE, 'UTF-8') . '</span>'
            . '<span class="retriex-meta-pill">' . htmlspecialchars($this->agentRunnerConfig->getProductionUiText('shop_meta_intent_prefix'), ENT_QUOTES | ENT_SUBSTITUTE, 'UTF-8') . 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>' . htmlspecialchars($this->agentRunnerConfig->getProductionUiText('shop_meta_query_label'), ENT_QUOTES | ENT_SUBSTITUTE, 'UTF-8') . '</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 = $this->agentRunnerConfig->getProductionUiText('shop_unavailable_default_reason');
        }

        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">' . htmlspecialchars($this->agentRunnerConfig->getProductionUiText('shop_unavailable_title'), ENT_QUOTES | ENT_SUBSTITUTE, 'UTF-8') . '</div>'
            . '<div class="retriex-alert__text">' . htmlspecialchars($this->agentRunnerConfig->getProductionUiText('shop_unavailable_text_prefix'), ENT_QUOTES | ENT_SUBSTITUTE, 'UTF-8') . ''
            . htmlspecialchars($reason, ENT_QUOTES | ENT_SUBSTITUTE, 'UTF-8')
            . '</div>'
            . '</div>'
            . '</div>';
    }


    /**
     * @param array<string, string> $values
     */
    private function renderAgentTemplate(string $template, array $values): string
    {
        foreach ($values as $key => $value) {
            $template = str_replace('{' . $key . '}', $value, $template);
        }

        return $template;
    }

    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,
        };
    }
}
