<?php

declare(strict_types=1);

namespace App\Agent;

use App\Commerce\Dto\ShopProductResult;
use App\Config\PromptBuilderConfig;
use App\Context\ContextService;
use App\Repository\SystemPromptRepository;
use App\Service\ModelGenerationConfigProvider;
use DateTimeImmutable;
use RuntimeException;

final readonly class PromptBuilder
{
    public function __construct(
        private ContextService $contextService,
        private SystemPromptRepository $systemPromptRepository,
        private ModelGenerationConfigProvider $modelGenerationConfigProvider,
        private PromptBuilderConfig $config,
    ) {
    }

    /**
     * Build the final prompt string for the LLM.
     *
     * @param string $prompt
     * @param string $userId
     * @param string $urlContent
     * @param string[] $knowledgeChunks
     * @param ShopProductResult[] $shopResults
     * @param bool|null $fullContext
     * @param string|null $swagFullOutPut
     */
    public function build(
        string $prompt,
        string $userId,
        string $urlContent,
        array $knowledgeChunks,
        array $shopResults = [],
        ?bool $fullContext = false,
        ?string $swagFullOutPut = '',
        bool $commerceSearchAttempted = false,
        bool $shopSearchHadSystemFailure = false,
        string $knowledgeEvidenceState = 'unknown'
    ): string {
        $prompt = $this->normalizeBlockText($prompt);
        $urlContent = $this->normalizeBlockText($urlContent);
        $swagFullOutPut = $this->normalizeNullableBlockText($swagFullOutPut);

        $hasShopResults = $shopResults !== [];
        $hasKnowledge = $knowledgeChunks !== [] || $urlContent !== '';
        $isTechnicalProductQuestion = $this->isLikelyTechnicalProductQuestion($prompt);
        $asksForAccessoryOrBundle = $this->asksForAccessoryOrBundle($prompt);
        $requestedProductRole = $this->resolveRequestedProductRole($prompt);
        $reliabilityState = $this->resolveReliabilityState(
            hasKnowledge: $hasKnowledge,
            hasShopResults: $hasShopResults,
            commerceSearchAttempted: $commerceSearchAttempted,
            shopSearchHadSystemFailure: $shopSearchHadSystemFailure,
            knowledgeEvidenceState: $knowledgeEvidenceState
        );

        $systemBlock = $this->buildSystemBlock();
        $shopBlock = $this->buildShopBlock($prompt, $shopResults, $swagFullOutPut, $requestedProductRole);
        $measurementEvidenceBlock = $this->buildMeasurementEvidenceBlock(
            prompt: $prompt,
            knowledgeChunks: $knowledgeChunks,
            urlContent: $urlContent,
            shopResults: $shopResults,
            requestedRole: $requestedProductRole
        );
        $outputPriorityBlock = $this->buildOutputPriorityBlock(
            hasShopResults: $hasShopResults,
            isTechnicalProductQuestion: $isTechnicalProductQuestion
        );
        $fallbackEscalationBlock = $this->buildFallbackEscalationBlock(
            reliabilityState: $reliabilityState,
            hasShopResults: $hasShopResults,
            commerceSearchAttempted: $commerceSearchAttempted,
            shopSearchHadSystemFailure: $shopSearchHadSystemFailure,
            isTechnicalProductQuestion: $isTechnicalProductQuestion
        );
        $responseFormatBlock = $this->buildResponseFormatBlock(
            hasShopResults: $hasShopResults,
            isTechnicalProductQuestion: $isTechnicalProductQuestion,
            asksForAccessoryOrBundle: $asksForAccessoryOrBundle
        );
        $knowledgeBlock = $this->buildKnowledgeBlock(
            knowledgeChunks: $knowledgeChunks,
            urlContent: $urlContent,
            hasShopResults: $hasShopResults,
            isTechnicalProductQuestion: $isTechnicalProductQuestion
        );
        $userBlock = $this->buildUserBlock($prompt);

        $fixedPrompt = $this->implodeBlocks([
            $systemBlock,
            $shopBlock,
            $measurementEvidenceBlock,
            $outputPriorityBlock,
            $fallbackEscalationBlock,
            $responseFormatBlock,
            $knowledgeBlock,
            $userBlock,
        ]);

        $contextBlock = $this->buildContextBlock(
            userId: $userId,
            fixedPrompt: $fixedPrompt,
            fullContext: (bool) $fullContext
        );

        return $this->implodeBlocks([
            $systemBlock,
            $shopBlock,
            $measurementEvidenceBlock,
            $outputPriorityBlock,
            $fallbackEscalationBlock,
            $responseFormatBlock,
            $knowledgeBlock,
            $contextBlock,
            $userBlock,
        ]);
    }

    private function buildSystemBlock(): string
    {
        $now = (new DateTimeImmutable())->format('Y-m-d H:i:s');

        $activePrompt = $this->systemPromptRepository->findActive();

        if (!$activePrompt) {
            throw new RuntimeException('No active system prompt configured.');
        }

        $activeSystemPrompt = str_replace('{% now %}', $now, $activePrompt->getContent());

        return $this->config->getSystemSectionLabel() . ":\n" . $this->normalizeBlockText($activeSystemPrompt);
    }

    private function buildUserBlock(string $prompt): string
    {
        return $this->config->getUserQuestionSectionLabel() . ":\n" . $prompt;
    }

    /**
     * Build the conversation block.
     *
     * If full context is requested, keep the previous behavior.
     * Otherwise, history only receives the remaining prompt budget.
     */
    private function buildContextBlock(string $userId, string $fixedPrompt, bool $fullContext): string
    {
        if ($fullContext) {
            $history = $this->contextService->buildUserContext(
                userId: $userId,
                full: true
            );
        } else {
            $historyBudgetChars = $this->resolveHistoryBudgetChars($fixedPrompt);

            if ($historyBudgetChars <= 0) {
                return '';
            }

            $history = $this->contextService->buildUserContextWithinBudget(
                userId: $userId,
                maxChars: $historyBudgetChars
            );
        }

        $history = $this->normalizeBlockText($history);

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

        return $this->implodeBlocks([
            $this->config->getConversationContextSectionLabel() . ':',
            $this->implodeLines($this->config->getConversationContextIntroLines()),
            $history,
        ]);
    }

    /**
     * Build the shop block.
     *
     * Shop data is the most current source for commercial details.
     * It should not override technical matching logic.
     */
    private function buildShopBlock(string $prompt, array $shopResults, ?string $swagFullOutPut, ?string $requestedProductRole = null): string
    {
        $parts = [];

        if ($swagFullOutPut !== null && $swagFullOutPut !== '') {
            $parts[] = $this->implodeBlocks([
                $this->config->getShopSearchQuerySectionLabel() . ':',
                $swagFullOutPut,
                $this->config->getShopSearchQuerySourceLine(),
            ]);
        }

        $normalizedShopResults = array_values(array_filter(
            $shopResults,
            static fn(mixed $product): bool => $product instanceof ShopProductResult
        ));

        if ($normalizedShopResults === []) {
            return $this->implodeBlocks($parts);
        }

        $totalCount = count($normalizedShopResults);
        $limitedShopResults = array_slice($normalizedShopResults, 0, $this->config->getMaxShopResultsInPrompt());
        $isDetailed = count($limitedShopResults) <= $this->config->getDetailedShopResultsMaxCount();
        $requestedRole = $requestedProductRole ?? $this->resolveRequestedProductRole($prompt);
        $measurementGuard = $this->resolveRequestedMeasurementGuard($prompt);
        $lines = [];

        foreach ($limitedShopResults as $i => $product) {
            $lines[] = $this->buildShopProductEntry(
                product: $product,
                index: $i + 1,
                isDetailed: $isDetailed,
                requestedRole: $requestedRole,
                measurementGuard: $measurementGuard
            );
        }

        if ($lines !== []) {
            $headerLines = $this->config->getLiveShopResultsHeaderLines();

            if ($totalCount > count($limitedShopResults)) {
                $headerLines[] = sprintf(
                    $this->config->getLiveShopResultsOverflowNoticeTemplate(),
                    count($limitedShopResults),
                    $totalCount
                );
            }

            $parts[] = $this->implodeBlocks([
                $this->implodeLines($headerLines),
                implode("\n\n", $lines),
            ]);
        }

        return $this->implodeBlocks($parts);
    }

    /**
     * Build a small priority block that tells the model what to surface first.
     */
    private function buildOutputPriorityBlock(bool $hasShopResults, bool $isTechnicalProductQuestion): string
    {
        $rules = [];

        if ($isTechnicalProductQuestion) {
            $rules = array_merge($rules, $this->config->getOutputPriorityTechnicalRules());
        }

        if ($hasShopResults) {
            $rules = array_merge($rules, $this->config->getOutputPriorityRules());
        }

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

        return $this->buildRuleBlock(
            $this->config->getOutputPrioritySectionLabel(),
            $rules
        );
    }

    private function resolveReliabilityState(
        bool $hasKnowledge,
        bool $hasShopResults,
        bool $commerceSearchAttempted,
        bool $shopSearchHadSystemFailure,
        string $knowledgeEvidenceState = 'unknown'
    ): string {
        $hasDirectKnowledgeEvidence = $knowledgeEvidenceState === 'direct' || $knowledgeEvidenceState === 'unknown' && $hasKnowledge;
        $hasWeakKnowledgeEvidence = $knowledgeEvidenceState === 'weak';

        if ($shopSearchHadSystemFailure && !$hasDirectKnowledgeEvidence) {
            return $hasWeakKnowledgeEvidence
                ? 'semantische_rag_treffer_kein_direkter_fachbeleg_shopdaten_nicht_verfuegbar'
                : 'shopdaten_nicht_verfuegbar';
        }

        if ($hasWeakKnowledgeEvidence && !$hasShopResults) {
            return 'semantische_rag_treffer_kein_direkter_fachbeleg';
        }

        if ($hasDirectKnowledgeEvidence && !$hasShopResults) {
            return 'sicher_beantwortbar';
        }

        if ($hasDirectKnowledgeEvidence && $hasShopResults) {
            return 'wahrscheinlich_beantwortbar';
        }

        if (!$hasDirectKnowledgeEvidence && $hasShopResults) {
            return 'nur_shop_treffer_kein_belastbares_fachwissen';
        }

        return 'keine_belastbaren_daten';
    }

    private function buildFallbackEscalationBlock(
        string $reliabilityState,
        bool $hasShopResults,
        bool $commerceSearchAttempted,
        bool $shopSearchHadSystemFailure,
        bool $isTechnicalProductQuestion
    ): string {
        $rules = [];
        $stateLineTemplate = $this->config->getFallbackEscalationStateLineTemplate();

        if ($stateLineTemplate !== '') {
            $rules[] = str_replace('{state}', $reliabilityState, $stateLineTemplate);
        }

        $rules = array_merge($rules, $this->config->getFallbackEscalationBaseRules());
        $rules = array_merge($rules, $this->config->getFallbackEscalationStateRules($reliabilityState));

        if ($isTechnicalProductQuestion && !$commerceSearchAttempted && !$shopSearchHadSystemFailure) {
            $rules = array_merge($rules, $this->config->getFallbackEscalationWithoutShopCheckRules());
        }

        if ($hasShopResults && !$commerceSearchAttempted) {
            $rules[] = '- Treat shop results as provided context only; do not imply that a live shop check was performed in this run.';
        }

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

        return $this->buildRuleBlock(
            $this->config->getFallbackEscalationSectionLabel(),
            $rules
        );
    }

    private function buildResponseFormatBlock(
        bool $hasShopResults,
        bool $isTechnicalProductQuestion,
        bool $asksForAccessoryOrBundle
    ): string {
        $rules = $this->config->getResponseFormatBaseRules();

        if ($hasShopResults) {
            $rules = array_merge($rules, $this->config->getResponseFormatWithShopRules());
        } else {
            $rules = array_merge($rules, $this->config->getResponseFormatWithoutShopRules());
        }

        if ($isTechnicalProductQuestion) {
            $rules = array_merge($rules, $this->config->getResponseFormatTechnicalRules());
        }

        if ($asksForAccessoryOrBundle) {
            $rules = array_merge($rules, $this->config->getResponseFormatAccessoryRules());
        }

        return $this->buildRuleBlock(
            $this->config->getResponseFormatSectionLabel(),
            $rules
        );
    }

    /**
     * Build the knowledge block.
     *
     * Retrieved knowledge remains the main source for technical matching and explanation.
     * Shop data is preferred for current commercial fields.
     *
     * @param string[] $knowledgeChunks
     */
    private function buildKnowledgeBlock(
        array $knowledgeChunks,
        string $urlContent,
        bool $hasShopResults,
        bool $isTechnicalProductQuestion
    ): string {
        $knowledgeParts = [];

        if ($knowledgeChunks !== []) {
            $lines = [];

            foreach ($knowledgeChunks as $i => $chunk) {
                $chunk = $this->normalizeBlockText((string) $chunk);

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

                $n = $i + 1;
                $lines[] = "[{$n}] RAG FACT RECORD\nRecord boundary: facts in this record must not be merged with accessory, indicator, reagent, price, URL, or product-number details from another record unless the same record explicitly connects them.\n" . $chunk;
            }

            if ($lines !== []) {
                $knowledgeParts[] = $this->implodeBlocks([
                    $this->buildRuleBlock(
                        $this->config->getLanguageRulesSectionLabel(),
                        $this->config->getLanguageRules()
                    ),
                    $this->buildRuleBlock(
                        $this->config->getFactGroundingRulesSectionLabel(),
                        $this->buildFactGroundingRules(
                            hasShopResults: $hasShopResults,
                            isTechnicalProductQuestion: $isTechnicalProductQuestion
                        )
                    ),
                    $this->implodeBlocks([
                        $this->config->getRetrievedKnowledgeSectionLabel() . ':',
                        $this->config->getRetrievedKnowledgeSourceLine(),
                        implode("\n\n", $lines),
                    ]),
                ]);
            }
        }

        if ($urlContent !== '') {
            $knowledgeParts[] = $this->implodeBlocks([
                $this->config->getUrlContentSectionLabel() . ':',
                $this->config->getUrlContentSourceLine(),
                $urlContent,
            ]);
        }

        return $this->implodeBlocks($knowledgeParts);
    }

    /**
     * Resolve how many characters may still be used by history.
     *
     * The active model num_ctx is converted into a conservative prompt budget.
     * Shop, knowledge and user question are fixed priority blocks.
     * History only receives the remaining space.
     */
    private function resolveHistoryBudgetChars(string $fixedPrompt): int
    {
        $numCtx = $this->modelGenerationConfigProvider->getActiveNumCtx();

        $outputReserveTokens = $this->clamp(
            (int) floor($numCtx * $this->config->getOutputReserveRatio()),
            $this->config->getOutputReserveMinTokens(),
            $this->config->getOutputReserveMaxTokens()
        );

        $safetyReserveTokens = $this->clamp(
            (int) floor($numCtx * $this->config->getSafetyReserveRatio()),
            $this->config->getSafetyReserveMinTokens(),
            $this->config->getSafetyReserveMaxTokens()
        );

        $promptBudgetTokens = max(
            $this->config->getMinPromptBudgetTokens(),
            $numCtx - $outputReserveTokens - $safetyReserveTokens
        );

        $promptBudgetChars = $promptBudgetTokens * $this->config->getCharsPerToken();

        $remaining = $promptBudgetChars
            - mb_strlen($fixedPrompt)
            - $this->config->getHistoryPaddingChars();

        return max(0, $remaining);
    }

    /**
     * @return string[]
     */
    private function buildFactGroundingRules(bool $hasShopResults, bool $isTechnicalProductQuestion): array
    {
        $rules = $this->config->getFactGroundingBaseRules();

        if ($hasShopResults) {
            $rules = array_merge($rules, $this->config->getFactGroundingWithShopRules());
        } else {
            $rules = array_merge($rules, $this->config->getFactGroundingWithoutShopRules());
        }

        if ($isTechnicalProductQuestion) {
            $rules = array_merge($rules, $this->config->getFactGroundingTechnicalRules());
        }

        return $rules;
    }

    private function buildShopProductEntry(
        ShopProductResult $product,
        int $index,
        bool $isDetailed,
        string $requestedRole,
        ?array $measurementGuard = null
    ): string {
        $productName = $this->normalizeBlockText($product->name);

        $inferredRole = $this->resolveShopProductRole($product);
        $roleCompatibility = $this->resolveShopRoleCompatibility($requestedRole, $inferredRole);

        $entryParts = [
            sprintf($this->config->getShopRecordHeaderTemplate(), $index),
            $this->config->getShopExactProductNameLabel() . ': ' . $productName,
            $this->config->getShopRequestedRoleLabel() . ': ' . $requestedRole,
            $this->config->getShopInferredRoleLabel() . ': ' . $inferredRole,
            $this->config->getShopRoleCompatibilityLabel() . ': ' . $roleCompatibility,
        ];

        foreach ($this->config->getShopAtomicRecordNoteLines() as $noteLine) {
            $noteLine = $this->normalizeBlockText($noteLine);

            if ($noteLine !== '') {
                $entryParts[] = $noteLine;
            }
        }

        $measurementEvidenceLine = $this->buildShopMeasurementEvidenceLine($product, $measurementGuard);
        if ($measurementEvidenceLine !== '') {
            $entryParts[] = $measurementEvidenceLine;
        }

        $suppressCommercialFields = $requestedRole === 'main_device'
            && $roleCompatibility === 'incompatible_accessory_for_main_device_request';

        if ($suppressCommercialFields) {
            $entryParts[] = $this->config->getShopRoleIncompatibleCommercialSuppressionNote();
        }

        if (!$suppressCommercialFields && $product->productNumber) {
            $entryParts[] = $this->config->getShopProductNumberLabel() . ': '
                . $this->normalizeBlockText($product->productNumber);
        }

        if (!$suppressCommercialFields && $product->manufacturer) {
            $entryParts[] = $this->config->getShopManufacturerLabel() . ': '
                . $this->normalizeBlockText($product->manufacturer);
        }

        if (!$suppressCommercialFields && $product->price) {
            $entryParts[] = $this->config->getShopPriceLabel() . ': '
                . $this->normalizeBlockText($product->price);
        }

        if (!$suppressCommercialFields && $product->available !== null) {
            $entryParts[] = $this->config->getShopAvailabilityLabel() . ': '
                . ($product->available
                    ? $this->config->getShopAvailabilityYesLabel()
                    : $this->config->getShopAvailabilityNoLabel());
        }

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

            if ($highlight !== '') {
                $entryParts[] = $this->config->getShopHighlightPrefix() . $highlight;
            }
        }

        if (!$suppressCommercialFields && $product->url) {
            $entryParts[] = $this->config->getShopUrlLabel() . ': '
                . $this->normalizeBlockText($product->url);
        }

        if (!$suppressCommercialFields && $product->productImage) {
            $entryParts[] = $this->config->getShopProductImageLabel() . ': '
                . $this->normalizeBlockText($product->productImage);
        }

        if (!$suppressCommercialFields && $isDetailed && $product->description) {
            $entryParts[] = $this->config->getShopDescriptionLabel() . ': '
                . $this->normalizeBlockText($product->description);
        }

        if (!$suppressCommercialFields && $product->customFields) {
            $entryParts[] = $this->config->getShopMetaInformationLabel() . ': '
                . $this->normalizeBlockText($product->customFields);
        }

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

    /**
     * @param string[] $knowledgeChunks
     * @param ShopProductResult[] $shopResults
     */
    private function buildMeasurementEvidenceBlock(
        string $prompt,
        array $knowledgeChunks,
        string $urlContent,
        array $shopResults,
        ?string $requestedRole = null
    ): string {
        $guard = $this->resolveRequestedMeasurementGuard($prompt);

        if ($guard === null) {
            return '';
        }

        $positiveTerms = $this->extractMeasurementGuardStringList($guard, 'positive_terms');
        $positiveContextTerms = $this->extractMeasurementGuardStringList($guard, 'positive_context_terms');
        $negativeContextTerms = $this->extractMeasurementGuardStringList($guard, 'negative_context_terms');
        $nonEquivalentTerms = $this->extractMeasurementGuardStringList($guard, 'non_equivalent_terms');
        $label = $this->normalizeBlockText((string) ($guard['label'] ?? 'requested measurement parameter'));
        $resolvedRequestedRole = $requestedRole ?? $this->resolveRequestedProductRole($prompt);
        $safeNoEvidenceAnswer = $this->normalizeBlockText((string) (
            $resolvedRequestedRole === 'accessory_or_consumable'
                ? ($guard['safe_no_accessory_evidence_answer_de'] ?? $guard['safe_no_evidence_answer_de'] ?? '')
                : ($guard['safe_no_evidence_answer_de'] ?? '')
        ));

        $knowledgeText = $this->normalizeBlockText(implode("\n\n", array_map('strval', $knowledgeChunks)) . "\n\n" . $urlContent);
        $knowledgeHasEvidence = $this->containsMeasurementPositiveEvidence($knowledgeText, $positiveTerms, $positiveContextTerms, $negativeContextTerms);

        $shopEvidenceLines = [];
        $shopHasEvidence = false;

        foreach (array_values($shopResults) as $index => $product) {
            if (!$product instanceof ShopProductResult) {
                continue;
            }

            $hasEvidence = $this->shopProductHasMeasurementEvidence($product, $positiveTerms, $positiveContextTerms, $negativeContextTerms);
            $productName = $this->normalizeBlockText($product->name);

            if ($hasEvidence) {
                $shopHasEvidence = true;
                $shopEvidenceLines[] = sprintf(
                    '- Shop record %d (%s): explicit positive evidence for %s is present in this same record.',
                    $index + 1,
                    $productName !== '' ? $productName : 'unnamed product',
                    $label
                );
            }
        }

        if ($shopEvidenceLines === []) {
            $shopEvidenceLines[] = sprintf(
                '- No shop product record shown to the model contains explicit positive evidence for %s in the same record.',
                $label
            );
        }

        $rules = $this->config->getMeasurementEvidenceIntroRules();
        $rules[] = '- User requested measurement parameter: ' . $label . '.';
        $rules[] = '- Positive parameter terms for this request: ' . implode(', ', $positiveTerms) . '.';
        if ($positiveContextTerms !== []) {
            $rules[] = '- These parameter terms count as suitability evidence only in a measurement-purpose context such as: ' . implode(', ', $positiveContextTerms) . '.';
        }
        if ($negativeContextTerms !== []) {
            $rules[] = '- These contexts are not suitability evidence by themselves: ' . implode(', ', $negativeContextTerms) . '.';
        }

        if ($nonEquivalentTerms !== []) {
            $rules[] = '- Terms that must NOT be treated as equivalent positive evidence: ' . implode(', ', $nonEquivalentTerms) . '.';
        }

        $rules[] = '- RAG/URL evidence scan for this exact parameter: ' . ($knowledgeHasEvidence ? 'explicit positive evidence found.' : 'no explicit positive evidence found.');
        $rules = array_merge($rules, $shopEvidenceLines);

        if (!$knowledgeHasEvidence && !$shopHasEvidence) {
            $rules[] = '- Mandatory answer behavior: do not recommend a product as suitable for this measurement parameter.';
            if ($safeNoEvidenceAnswer !== '') {
                $rules[] = '- Start the answer with this meaning in the user language: ' . $safeNoEvidenceAnswer;
            }
            if ($resolvedRequestedRole === 'accessory_or_consumable') {
                $rules[] = '- Do not recommend accessories for a different measurement parameter just because they are accessories. If only accessories for other parameters are present, say that only non-matching accessory hits were found.';
            } else {
                $rules[] = '- You may list exact shop hits only as commercial/search hits under a heading such as "Shop-Treffer (technische Eignung nicht sicher belegt)".';
            }
        }

        $rules[] = '- Do not output measurement ranges, methods, application areas, advantages, or alternative suitable models unless the same source record contains explicit positive evidence for the requested measurement parameter.';
        $rules[] = '- The generated shop search query, search intent, ranking position, and user question are not factual evidence for product suitability.';

        return $this->buildRuleBlock(
            $this->config->getMeasurementEvidenceSectionLabel(),
            $rules
        );
    }

    private function buildShopMeasurementEvidenceLine(ShopProductResult $product, ?array $guard): string
    {
        if ($guard === null) {
            return '';
        }

        $positiveTerms = $this->extractMeasurementGuardStringList($guard, 'positive_terms');
        $positiveContextTerms = $this->extractMeasurementGuardStringList($guard, 'positive_context_terms');
        $negativeContextTerms = $this->extractMeasurementGuardStringList($guard, 'negative_context_terms');
        $label = $this->normalizeBlockText((string) ($guard['label'] ?? 'requested measurement parameter'));

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

        if ($this->shopProductHasMeasurementEvidence($product, $positiveTerms, $positiveContextTerms, $negativeContextTerms)) {
            return sprintf(
                'Requested measurement evidence: explicit positive evidence for %s is present in this same SHOP PRODUCT RECORD.',
                $label
            );
        }

        return sprintf(
            'Requested measurement evidence: no explicit positive evidence for %s is present in this SHOP PRODUCT RECORD. Do not present this record as technically suitable for that measurement parameter.',
            $label
        );
    }

    private function resolveRequestedMeasurementGuard(string $prompt): ?array
    {
        $normalizedPrompt = $this->normalizeForMeasurementMatching($prompt);

        foreach ($this->config->getMeasurementEvidenceParameters() as $parameter) {
            $requestTerms = $this->extractMeasurementGuardStringList($parameter, 'request_terms');

            foreach ($requestTerms as $term) {
                if ($this->containsMeasurementTerm($normalizedPrompt, $term)) {
                    return $parameter;
                }
            }
        }

        return null;
    }

    /**
     * @return string[]
     */
    private function extractMeasurementGuardStringList(array $guard, string $key): array
    {
        $value = $guard[$key] ?? [];

        if (!is_array($value)) {
            return [];
        }

        $out = [];
        foreach ($value as $item) {
            if (!is_scalar($item)) {
                continue;
            }

            $item = $this->normalizeBlockText((string) $item);
            if ($item !== '' && !in_array($item, $out, true)) {
                $out[] = $item;
            }
        }

        return $out;
    }

    /**
     * @param string[] $positiveTerms
     * @param string[] $positiveContextTerms
     * @param string[] $negativeContextTerms
     */
    private function shopProductHasMeasurementEvidence(
        ShopProductResult $product,
        array $positiveTerms,
        array $positiveContextTerms,
        array $negativeContextTerms
    ): bool {
        foreach ($this->buildShopProductEvidenceFragments($product) as $fragment) {
            if ($this->containsMeasurementPositiveEvidence($fragment, $positiveTerms, $positiveContextTerms, $negativeContextTerms)) {
                return true;
            }
        }

        return false;
    }

    /**
     * @return string[]
     */
    private function buildShopProductEvidenceFragments(ShopProductResult $product): array
    {
        $fragments = array_filter([
            $product->name,
            $product->manufacturer,
            $product->url,
            implode(' ', array_map('strval', $product->highlights)),
            $product->description,
            $product->customFields,
        ], static fn($value): bool => is_scalar($value) && trim((string) $value) !== '');

        $out = [];
        foreach ($fragments as $fragment) {
            foreach ($this->splitMeasurementEvidenceFragments((string) $fragment) as $part) {
                if ($part !== '') {
                    $out[] = $part;
                }
            }
        }

        return $out;
    }

    /**
     * @param string[] $positiveTerms
     * @param string[] $positiveContextTerms
     * @param string[] $negativeContextTerms
     */
    private function containsMeasurementPositiveEvidence(
        string $text,
        array $positiveTerms,
        array $positiveContextTerms,
        array $negativeContextTerms
    ): bool {
        foreach ($this->splitMeasurementEvidenceFragments($text) as $fragment) {
            $normalizedFragment = $this->normalizeForMeasurementMatching($fragment);

            if ($normalizedFragment === '' || !$this->containsAnyMeasurementTerm($normalizedFragment, $positiveTerms)) {
                continue;
            }

            if ($negativeContextTerms !== [] && $this->containsAnyMeasurementTerm($normalizedFragment, $negativeContextTerms)) {
                continue;
            }

            if ($positiveContextTerms === [] || $this->containsAnyMeasurementTerm($normalizedFragment, $positiveContextTerms)) {
                return true;
            }
        }

        return false;
    }

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

        return false;
    }

    /**
     * @return string[]
     */
    private function splitMeasurementEvidenceFragments(string $text): array
    {
        $text = $this->normalizeBlockText($text);
        if ($text === '') {
            return [];
        }

        $parts = preg_split('/[\n.;|]+/u', $text) ?: [$text];

        return array_values(array_filter(
            array_map(fn(string $part): string => $this->normalizeBlockText($part), $parts),
            static fn(string $part): bool => $part !== ''
        ));
    }

    private function containsMeasurementTerm(string $normalizedText, string $term): bool
    {
        $normalizedTerm = $this->normalizeForMeasurementMatching($term);

        if ($normalizedText === '' || $normalizedTerm === '') {
            return false;
        }

        if (preg_match('/[\p{L}\p{N}]/u', $normalizedTerm) !== 1) {
            return str_contains($normalizedText, $normalizedTerm);
        }

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

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

    private function normalizeForMeasurementMatching(string $value): string
    {
        $value = mb_strtolower($this->normalizeBlockText($value), 'UTF-8');
        $value = str_replace(['‐', '‑', '‒', '–', '—'], '-', $value);
        $value = preg_replace('/<[^>]+>/u', ' ', $value) ?? $value;
        $value = preg_replace('/\s+/u', ' ', $value) ?? $value;

        return trim($value);
    }

    /**
     * @param string[] $rules
     */
    private function buildRuleBlock(string $sectionLabel, array $rules): string
    {
        $normalizedRules = array_values(array_filter(
            array_map(
                fn(string $rule): string => $this->normalizeBlockText($rule),
                $rules
            ),
            static fn(string $rule): bool => $rule !== ''
        ));

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

        return $sectionLabel . ":\n" . implode("\n", $normalizedRules);
    }

    /**
     * @param string[] $lines
     */
    private function implodeLines(array $lines): string
    {
        $normalizedLines = array_values(array_filter(
            array_map(
                fn(string $line): string => $this->normalizeBlockText($line),
                $lines
            ),
            static fn(string $line): bool => $line !== ''
        ));

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

    private function implodeBlocks(array $blocks): string
    {
        $filtered = array_values(array_filter(
            array_map(
                fn($block): string => is_string($block) ? $this->normalizeBlockText($block) : '',
                $blocks
            ),
            static fn(string $block): bool => $block !== ''
        ));

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

    private function resolveRequestedProductRole(string $prompt): string
    {
        $normalized = mb_strtolower($this->normalizeBlockText($prompt), 'UTF-8');
        $hasAccessoryIntent = $this->containsAnyPromptKeyword($normalized, $this->config->getAccessoryProductRoleKeywords());
        $hasMainDeviceIntent = $this->containsAnyPromptKeyword($normalized, $this->config->getMainDeviceRequestRoleKeywords());

        if ($hasAccessoryIntent && !$this->hasDirectMainDeviceRequest($normalized)) {
            return 'accessory_or_consumable';
        }

        if ($hasMainDeviceIntent) {
            return 'main_device';
        }

        if ($hasAccessoryIntent) {
            return 'accessory_or_consumable';
        }

        return 'unknown';
    }

    private function hasDirectMainDeviceRequest(string $normalizedPrompt): bool
    {
        $patterns = [
            '/\b(welcher|welches|welche)\s+[^?.!,;]{0,40}(testomat|messgerät|messgeraet|analysegerät|analysegeraet|gerät|geraet|analysator)\b/u',
            '/\b(suche|finde|empfiehl|empfehle)\s+[^?.!,;]{0,40}(testomat|messgerät|messgeraet|analysegerät|analysegeraet|gerät|geraet|analysator)\b/u',
            '/\b(testomat|messgerät|messgeraet|analysegerät|analysegeraet|gerät|geraet|analysator)\s+[^?.!,;]{0,40}(messen|misst|überwachen|ueberwachen|kann|für|fuer)\b/u',
        ];

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

        return false;
    }

    private function resolveShopProductRole(ShopProductResult $product): string
    {
        $primaryRole = $this->resolveShopPrimaryProductRole($product);

        if ($primaryRole !== 'unknown') {
            return $primaryRole;
        }

        $corpus = mb_strtolower(implode(' ', array_filter([
            $product->name,
            $product->productNumber,
            $product->manufacturer,
            implode(' ', $product->highlights),
            $product->description,
            $product->customFields,
            $product->url,
        ])), 'UTF-8');

        $isAccessory = $this->containsAnyPromptKeyword($corpus, $this->config->getAccessoryProductRoleKeywords());
        $isMainDevice = $this->containsAnyPromptKeyword($corpus, $this->config->getMainDeviceProductRoleKeywords());

        if ($isAccessory) {
            return 'accessory_or_consumable';
        }

        if ($isMainDevice) {
            return 'main_device';
        }

        return 'unknown';
    }

    private function resolveShopPrimaryProductRole(ShopProductResult $product): string
    {
        $primaryText = mb_strtolower(implode(' ', array_filter([
            $product->name,
            $product->url,
        ])), 'UTF-8');

        if ($this->normalizeBlockText($primaryText) === '') {
            return 'unknown';
        }

        $isAccessory = $this->containsAnyPromptKeyword($primaryText, $this->config->getAccessoryProductRoleKeywords());
        $isMainDevice = $this->containsAnyPromptKeyword($primaryText, $this->config->getMainDeviceProductRoleKeywords());

        if ($isAccessory) {
            return 'accessory_or_consumable';
        }

        if ($isMainDevice) {
            return 'main_device';
        }

        return 'unknown';
    }

    private function resolveShopRoleCompatibility(string $requestedRole, string $inferredRole): string
    {
        if ($requestedRole === 'unknown' || $inferredRole === 'unknown') {
            return 'unknown';
        }

        if ($requestedRole === 'main_device' && $inferredRole === 'accessory_or_consumable') {
            return 'incompatible_accessory_for_main_device_request';
        }

        if ($requestedRole === 'accessory_or_consumable' && $inferredRole === 'main_device') {
            return 'incompatible_main_device_for_accessory_request';
        }

        if ($inferredRole === 'ambiguous_mixed_role') {
            return 'ambiguous_keep_separate';
        }

        return 'compatible';
    }

    /**
     * @param string[] $keywords
     */
    private function containsAnyPromptKeyword(string $text, array $keywords): bool
    {
        foreach ($keywords as $keyword) {
            $keyword = mb_strtolower($this->normalizeBlockText((string) $keyword), 'UTF-8');

            if ($keyword !== '' && str_contains($text, $keyword)) {
                return true;
            }
        }

        return false;
    }

    private function normalizeNullableBlockText(?string $value): ?string
    {
        if ($value === null) {
            return null;
        }

        $normalized = $this->normalizeBlockText($value);

        return $normalized === '' ? null : $normalized;
    }

    private function normalizeBlockText(string $value): string
    {
        $value = str_replace(["\r\n", "\r"], "\n", $value);
        $value = str_replace("\u{00A0}", ' ', $value);
        $value = trim($value);

        $value = preg_replace("/\n{3,}/", "\n\n", $value) ?? $value;
        $value = preg_replace("/[ \t]+\n/", "\n", $value) ?? $value;
        $value = preg_replace("/[ \t]{2,}/", " ", $value) ?? $value;

        return $value;
    }

    private function isLikelyTechnicalProductQuestion(string $prompt): bool
    {
        $normalized = mb_strtolower($prompt, 'UTF-8');
        $matches = 0;

        foreach ($this->config->getTechnicalProductKeywords() as $keyword) {
            if (str_contains($normalized, $keyword)) {
                $matches++;
            }
        }

        if ($matches >= $this->config->getTechnicalProductKeywordMatchThreshold()) {
            return true;
        }

        return preg_match($this->config->getTechnicalProductModelPattern(), $prompt) === 1;
    }

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

        foreach ($this->config->getAccessoryRequestKeywords() as $keyword) {
            if (str_contains($normalized, $keyword)) {
                return true;
            }
        }

        return false;
    }

    private function clamp(int $value, int $min, int $max): int
    {
        return max($min, min($max, $value));
    }
}