<?php

declare(strict_types=1);

namespace App\Commerce;

use App\Commerce\Dto\ShopProductResult;
use App\Config\SearchRepairConfig;
use Psr\Log\LoggerInterface;

final readonly class SearchRepairService
{
    public function __construct(
        private ShopSearchService $shopSearchService,
        private SearchRepairConfig $config,
        private LoggerInterface $logger,
    ) {
    }

    /**
     * @param ShopProductResult[] $primaryShopResults
     * @param array<mixed> $knowledgeChunks
     *
     * @return array{
     *     results: ShopProductResult[],
     *     attemptedRepair: bool,
     *     usedRepair: bool,
     *     repairQueries: string[]
     * }
     */
    public function repair(
        string $prompt,
        string $commerceIntent,
        string $commerceHistoryContext,
        string $primaryQuery,
        array $primaryShopResults,
        array $knowledgeChunks
    ): array {
        if (!$this->config->isEnabled()) {
            return $this->buildRepairResult(
                results: $primaryShopResults,
                attemptedRepair: false,
                usedRepair: false,
                repairQueries: []
            );
        }

        if (!$this->shouldAttemptRepair($prompt, $primaryQuery, $primaryShopResults)) {
            return $this->buildRepairResult(
                results: $primaryShopResults,
                attemptedRepair: false,
                usedRepair: false,
                repairQueries: []
            );
        }

        $repairQueries = $this->buildRepairQueries(
            prompt: $prompt,
            primaryQuery: $primaryQuery,
            primaryShopResults: $primaryShopResults,
            knowledgeChunks: $knowledgeChunks
        );

        if ($repairQueries === []) {
            return $this->buildRepairResult(
                results: $primaryShopResults,
                attemptedRepair: false,
                usedRepair: false,
                repairQueries: []
            );
        }

        $this->logger->info('Shop repair started', [
            'commerceIntent' => $commerceIntent,
            'primaryQuery' => $primaryQuery,
            'primaryResultsCount' => count($primaryShopResults),
            'repairQueries' => $repairQueries,
            'hasCommerceHistoryContext' => $commerceHistoryContext !== '',
            'commerceHistoryContextLength' => mb_strlen($commerceHistoryContext),
        ]);

        $repairResults = [];

        foreach ($repairQueries as $repairQuery) {
            $results = $this->shopSearchService->search($repairQuery, $commerceIntent, '');

            if ($this->shopSearchService->hadLastSearchSystemFailure()) {
                $this->logger->warning('Shop repair stopped after Store API system failure', [
                    'commerceIntent' => $commerceIntent,
                    'primaryQuery' => $primaryQuery,
                    'failedRepairQuery' => $repairQuery,
                    'failureReason' => $this->shopSearchService->getLastSearchFailureReason(),
                ]);

                break;
            }

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

            $repairResults = $this->mergeUniqueProducts($repairResults, $results);
        }

        if ($repairResults === []) {
            $this->logger->info('Shop repair finished without additional products', [
                'commerceIntent' => $commerceIntent,
                'primaryQuery' => $primaryQuery,
                'repairQueries' => $repairQueries,
            ]);

            return $this->buildRepairResult(
                results: $primaryShopResults,
                attemptedRepair: true,
                usedRepair: false,
                repairQueries: $repairQueries
            );
        }

        $mergedResults = $this->rankMergedResults(
            primaryResults: $primaryShopResults,
            repairResults: $repairResults,
            prompt: $prompt,
            primaryQuery: $primaryQuery,
            repairQueries: $repairQueries
        );

        $this->logger->info('Shop repair finished', [
            'commerceIntent' => $commerceIntent,
            'primaryQuery' => $primaryQuery,
            'primaryResultsCount' => count($primaryShopResults),
            'repairResultsCount' => count($repairResults),
            'mergedResultsCount' => count($mergedResults),
            'repairQueries' => $repairQueries,
            'topProducts' => array_map(
                static fn(ShopProductResult $product): array => [
                    'name' => $product->name,
                    'productNumber' => $product->productNumber,
                    'manufacturer' => $product->manufacturer,
                    'available' => $product->available,
                ],
                array_slice($mergedResults, 0, $this->config->getTopProductLogLimit())
            ),
        ]);

        return $this->buildRepairResult(
            results: $mergedResults,
            attemptedRepair: true,
            usedRepair: true,
            repairQueries: $repairQueries
        );
    }

    /**
     * @param ShopProductResult[] $primaryShopResults
     */
    private function shouldAttemptRepair(
        string $prompt,
        string $primaryQuery,
        array $primaryShopResults
    ): bool {
        $asksForBundle = $this->asksForBundleOrAccessory($prompt);
        $hasModelLikePrimaryQuery = $this->containsModelLikePhrase($primaryQuery);
        $primaryResultsCount = count($primaryShopResults);

        if ($primaryResultsCount === 0) {
            return true;
        }

        if ($asksForBundle) {
            return true;
        }

        if ($primaryResultsCount >= $this->config->getMinPrimaryResultsWithoutRepair()) {
            return false;
        }

        if ($hasModelLikePrimaryQuery && $primaryResultsCount > 0) {
            return false;
        }

        return $primaryResultsCount < $this->config->getMinPrimaryResultsWithoutRepair();
    }

    /**
     * @param ShopProductResult[] $primaryShopResults
     * @param array<mixed> $knowledgeChunks
     * @return string[]
     */
    private function buildRepairQueries(
        string $prompt,
        string $primaryQuery,
        array $primaryShopResults,
        array $knowledgeChunks
    ): array {
        $knowledgeText = $this->flattenToText($knowledgeChunks);
        $primaryResultText = $this->flattenPrimaryResults($primaryShopResults);
        $combinedText = trim($prompt . "\n" . $knowledgeText . "\n" . $primaryResultText);

        $modelCandidates = $this->extractModelCandidates($combinedText);
        $accessoryCandidates = $this->extractAccessoryCandidates($combinedText);
        $requestedAccessoryCodes = $this->extractRequestedAccessoryCodes($prompt . "\n" . $primaryQuery);

        if ($requestedAccessoryCodes !== []) {
            $accessoryCandidates = $this->filterAccessoryCandidatesByRequestedCodes(
                accessoryCandidates: $accessoryCandidates,
                requestedCodes: $requestedAccessoryCodes
            );
        }

        $topPrimaryName = $primaryShopResults[0]->name ?? '';
        $topPrimaryProductNumber = $primaryShopResults[0]->productNumber ?? null;
        $topPrimaryPhrase = trim($topPrimaryName . ' ' . ($topPrimaryProductNumber ?? ''));

        $queries = $this->buildFocusedModelAccessoryQueries(
            prompt: $prompt,
            primaryQuery: $primaryQuery,
            knowledgeText: $knowledgeText,
            modelCandidates: $modelCandidates,
            accessoryCandidates: $accessoryCandidates,
            requestedAccessoryCodes: $requestedAccessoryCodes
        );

        if ($requestedAccessoryCodes !== [] && $this->config->shouldRestrictRequestedAccessoryCodeRepair()) {
            foreach ($accessoryCandidates as $accessoryCandidate) {
                if ($this->candidateMatchesRequestedAccessoryCodes($accessoryCandidate, $requestedAccessoryCodes)) {
                    $queries[] = $accessoryCandidate;
                }
            }

            $queries = array_merge(
                $queries,
                $this->buildRequestedAccessoryFallbackQueries($requestedAccessoryCodes)
            );

            return $this->normalizeRepairQueries($queries, $primaryQuery);
        }

        if ($topPrimaryPhrase !== '' && $this->containsModelLikePhrase($topPrimaryPhrase)) {
            $queries[] = $topPrimaryPhrase;
        } elseif ($topPrimaryName !== '' && $this->containsModelLikePhrase($topPrimaryName)) {
            $queries[] = $topPrimaryName;
        }

        foreach ($modelCandidates as $candidate) {
            $queries[] = $candidate;
        }

        if ($this->asksForBundleOrAccessory($prompt)) {
            foreach ($accessoryCandidates as $accessoryCandidate) {
                if ($topPrimaryName !== '') {
                    $queries[] = trim($topPrimaryName . ' ' . $accessoryCandidate);
                }

                $queries[] = $accessoryCandidate;
            }
        }

        return $this->normalizeRepairQueries($queries, $primaryQuery);
    }

    /**
     * @param string[] $queries
     * @return string[]
     */
    private function normalizeRepairQueries(array $queries, string $primaryQuery): array
    {
        $queries = array_map(
            fn(string $query): string => $this->sanitizeQuery($query),
            $queries
        );

        $queries = array_values(array_filter(
            array_unique($queries),
            fn(string $query): bool => $query !== '' && !$this->isTooCloseToPrimaryQuery($query, $primaryQuery)
        ));

        return array_slice($queries, 0, max(1, $this->config->getMaxRepairQueries()));
    }

    /**
     * @param array<mixed> $value
     */
    private function flattenToText(array $value): string
    {
        $parts = [];

        $walker = function (mixed $item) use (&$parts, &$walker): void {
            if (is_array($item)) {
                foreach ($item as $subItem) {
                    $walker($subItem);
                }

                return;
            }

            if (is_scalar($item)) {
                $text = trim((string) $item);
                if ($text !== '') {
                    $parts[] = $text;
                }
            }
        };

        $walker($value);

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

    /**
     * @param ShopProductResult[] $products
     */
    private function flattenPrimaryResults(array $products): string
    {
        $parts = [];

        foreach ($products as $product) {
            $parts[] = trim(implode(' ', array_filter([
                $product->name,
                $product->productNumber,
                $product->manufacturer,
                $product->description,
                implode(' ', $product->highlights),
                $product->customFields,
            ])));
        }

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

    /**
     * @return string[]
     */
    private function extractModelCandidates(string $text): array
    {
        $candidates = [];

        preg_match_all(
            $this->config->getModelCandidatePattern(),
            $text,
            $matches
        );

        foreach ($matches[1] ?? [] as $candidate) {
            $candidate = $this->sanitizeQuery($candidate);
            $candidate = $this->reduceToSpecificModelCandidate($candidate);

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

            if ($this->looksTooGeneric($candidate)) {
                continue;
            }

            $candidates[] = $candidate;
        }

        return $this->sortCandidatesBySpecificity($candidates);
    }

    /**
     * @return string[]
     */
    private function extractAccessoryCandidates(string $text): array
    {
        $candidates = [];

        preg_match_all(
            $this->config->getAccessoryCandidatePattern(),
            $text,
            $matches
        );

        foreach ($matches[1] ?? [] as $candidate) {
            $candidate = $this->sanitizeQuery($candidate);

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

            $candidates[] = $candidate;
        }

        return $this->sortCandidatesBySpecificity($candidates);
    }

    /**
     * @param string[] $candidates
     * @return string[]
     */
    private function sortCandidatesBySpecificity(array $candidates): array
    {
        $decorated = [];

        foreach (array_values(array_unique($candidates)) as $candidate) {
            $decorated[] = [
                'candidate' => $candidate,
                'score' => $this->scoreCandidate($candidate),
            ];
        }

        usort($decorated, static function (array $a, array $b): int {
            return $b['score'] <=> $a['score'];
        });

        return array_values(array_map(
            static fn(array $row): string => $row['candidate'],
            $decorated
        ));
    }

    private function scoreCandidate(string $candidate): int
    {
        $score = 0;

        if (preg_match($this->config->getContainsDigitPattern(), $candidate) === 1) {
            $score += $this->config->getCandidateDigitScore();
        }

        $wordCount = count($this->tokenize($candidate));
        $score += min($wordCount, $this->config->getCandidateWordCountCap());

        if (preg_match($this->config->getSpecificityBoostPattern(), $candidate) === 1) {
            $score += $this->config->getSpecificityBoostScore();
        }

        return $score;
    }

    /**
     * @param string[] $modelCandidates
     * @param string[] $accessoryCandidates
     * @param string[] $requestedAccessoryCodes
     * @return string[]
     */
    private function buildFocusedModelAccessoryQueries(
        string $prompt,
        string $primaryQuery,
        string $knowledgeText,
        array $modelCandidates,
        array $accessoryCandidates,
        array $requestedAccessoryCodes
    ): array {
        if ($requestedAccessoryCodes === []) {
            return [];
        }

        $queries = [];
        $models = $this->filterModelCandidatesByRequestedAccessoryCodes(
            prompt: $prompt . "\n" . $primaryQuery,
            knowledgeText: $knowledgeText,
            modelCandidates: $modelCandidates,
            requestedCodes: $requestedAccessoryCodes
        );

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

        $accessories = $accessoryCandidates;
        if ($accessories === []) {
            $accessories = $this->buildRequestedAccessoryFallbackQueries($requestedAccessoryCodes);
        }

        foreach ($models as $model) {
            foreach ($accessories as $accessory) {
                if (!$this->candidateMatchesRequestedAccessoryCodes($accessory, $requestedAccessoryCodes)) {
                    continue;
                }

                $queries[] = trim($model . ' ' . $accessory);
            }
        }

        return array_values(array_unique(array_filter(
            array_map(fn(string $query): string => $this->sanitizeQuery($query), $queries),
            static fn(string $query): bool => $query !== ''
        )));
    }

    /**
     * @return string[]
     */
    private function extractRequestedAccessoryCodes(string $text): array
    {
        $codes = [];

        if (preg_match_all($this->config->getRequestedAccessoryCodePattern(), $text, $matches) !== false) {
            foreach ($matches[1] ?? [] as $code) {
                $normalized = $this->normalizeAccessoryCode((string) $code);
                if ($normalized !== '') {
                    $codes[$normalized] = $normalized;
                }
            }
        }

        return array_values($codes);
    }

    /**
     * @param string[] $requestedCodes
     * @return string[]
     */
    private function buildRequestedAccessoryFallbackQueries(array $requestedCodes): array
    {
        $queries = [];
        $templates = $this->config->getRequestedAccessoryCodeFallbackQueryTemplates();
        $terms = $this->config->getRequestedAccessoryCodeFallbackTerms();

        foreach ($requestedCodes as $code) {
            $normalizedCode = $this->normalizeAccessoryCode($code);
            if ($normalizedCode === '') {
                continue;
            }

            foreach ($templates as $template) {
                if (str_contains($template, '{term}')) {
                    foreach ($terms as $term) {
                        $queries[] = $this->applyRequestedAccessoryTemplate($template, $normalizedCode, $term);
                    }
                    continue;
                }

                $queries[] = $this->applyRequestedAccessoryTemplate($template, $normalizedCode, '');
            }
        }

        return array_values(array_unique(array_filter(
            array_map(fn(string $query): string => $this->sanitizeQuery($query), $queries),
            static fn(string $query): bool => $query !== ''
        )));
    }

    private function applyRequestedAccessoryTemplate(string $template, string $code, string $term): string
    {
        $query = str_replace(
            ['{code}', '{term}', '%code%', '%term%'],
            [$code, $term, $code, $term],
            $template
        );

        return $this->sanitizeQuery($query);
    }

    /**
     * @param string[] $accessoryCandidates
     * @param string[] $requestedCodes
     * @return string[]
     */
    private function filterAccessoryCandidatesByRequestedCodes(array $accessoryCandidates, array $requestedCodes): array
    {
        return array_values(array_filter(
            $accessoryCandidates,
            fn(string $candidate): bool => $this->candidateMatchesRequestedAccessoryCodes($candidate, $requestedCodes)
        ));
    }

    /**
     * @param string[] $modelCandidates
     * @param string[] $requestedCodes
     * @return string[]
     */
    private function filterModelCandidatesByRequestedAccessoryCodes(
        string $prompt,
        string $knowledgeText,
        array $modelCandidates,
        array $requestedCodes
    ): array {
        $promptAnchoredModels = [];
        $proximityModels = [];
        $normalizedPrompt = $this->normalizeForRepairMatching($prompt);

        foreach ($modelCandidates as $candidate) {
            $candidate = $this->reduceToSpecificModelCandidate($candidate);
            if ($candidate === '') {
                continue;
            }

            $normalizedCandidate = $this->normalizeForRepairMatching($candidate);
            $isPromptAnchored = $normalizedCandidate !== '' && str_contains($normalizedPrompt, $normalizedCandidate);

            if ($isPromptAnchored) {
                $promptAnchoredModels[$candidate] = $candidate;
                continue;
            }

            foreach ($requestedCodes as $code) {
                if ($this->modelAppearsNearAccessoryCode($knowledgeText, $candidate, $code)) {
                    $proximityModels[$candidate] = $candidate;
                    break;
                }
            }
        }

        if ($this->config->shouldPreferPromptAnchoredModelForRequestedAccessoryCode() && $promptAnchoredModels !== []) {
            return array_values($promptAnchoredModels);
        }

        return array_values($promptAnchoredModels + $proximityModels);
    }

    private function candidateMatchesRequestedAccessoryCodes(string $candidate, array $requestedCodes): bool
    {
        $normalizedCandidate = $this->normalizeForRepairMatching($candidate);
        $compactCandidate = preg_replace('/\s+/u', '', $normalizedCandidate) ?? $normalizedCandidate;

        foreach ($requestedCodes as $code) {
            $normalizedCode = $this->normalizeAccessoryCode($code);

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

            $pattern = '/\b' . preg_quote($normalizedCode, '/') . '\b/u';
            if (preg_match($pattern, $normalizedCandidate) === 1 || preg_match($pattern, $compactCandidate) === 1) {
                return true;
            }
        }

        return false;
    }

    private function modelAppearsNearAccessoryCode(string $knowledgeText, string $model, string $code): bool
    {
        $normalizedText = $this->normalizeForRepairMatching($knowledgeText);
        $normalizedModel = $this->normalizeForRepairMatching($model);
        $normalizedCode = $this->normalizeAccessoryCode($code);

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

        $modelPositions = $this->findNeedlePositions($normalizedText, $normalizedModel);
        if ($modelPositions === []) {
            return false;
        }

        $codeNeedles = [$normalizedCode];
        foreach ($this->config->getRequestedAccessoryCodeContextPrefixTerms() as $term) {
            $normalizedTerm = $this->normalizeForRepairMatching($term);
            if ($normalizedTerm !== '') {
                $codeNeedles[] = trim($normalizedTerm . ' ' . $normalizedCode);
            }
        }
        $codeNeedles = array_values(array_unique($codeNeedles));

        foreach ($codeNeedles as $needle) {
            foreach ($this->findNeedlePositions($normalizedText, $needle) as $codePos) {
                foreach ($modelPositions as $modelPos) {
                    if (abs($codePos - $modelPos) <= $this->config->getRequestedAccessoryCodeProximityWindow()) {
                        return true;
                    }
                }
            }
        }

        return false;
    }

    /**
     * @return int[]
     */
    private function findNeedlePositions(string $haystack, string $needle): array
    {
        if ($haystack === '' || $needle === '') {
            return [];
        }

        $positions = [];
        $offset = 0;

        while (($position = mb_strpos($haystack, $needle, $offset, 'UTF-8')) !== false) {
            $positions[] = $position;
            $offset = $position + max(1, strlen($needle));
        }

        return $positions;
    }

    private function reduceToSpecificModelCandidate(string $candidate): string
    {
        $candidate = $this->sanitizeQuery($candidate);

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

        $normalizedCandidate = $this->normalizeForRepairMatching($candidate);
        foreach ($this->config->getModelCandidateExcludeTerms() as $term) {
            $normalizedTerm = $this->normalizeForRepairMatching($term);
            if ($normalizedTerm !== '' && preg_match('/\b' . preg_quote($normalizedTerm, '/') . '\b/u', $normalizedCandidate) === 1) {
                return '';
            }
        }

        foreach ($this->config->getSpecificModelCandidatePatterns() as $pattern) {
            if (preg_match($pattern, $candidate, $matches) === 1) {
                return $this->sanitizeQuery((string) ($matches[1] ?? ''));
            }
        }

        return $candidate;
    }

    private function normalizeAccessoryCode(string $code): string
    {
        $code = $this->normalizeForRepairMatching($code);
        $code = preg_replace('/\s+/u', '', $code) ?? $code;

        return trim($code);
    }

    private function normalizeForRepairMatching(string $value): string
    {
        $value = mb_strtolower(trim($value), 'UTF-8');
        $value = str_replace('®', '', $value);
        $value = preg_replace('/[^\p{L}\p{N}]+/u', ' ', $value) ?? $value;
        $value = preg_replace($this->config->getWhitespaceCollapsePattern(), ' ', $value) ?? $value;

        return trim($value);
    }

    private function asksForBundleOrAccessory(string $prompt): bool
    {
        return preg_match($this->config->getAccessoryOrBundlePattern(), $prompt) === 1;
    }

    private function containsModelLikePhrase(string $text): bool
    {
        return preg_match($this->config->getModelLikePattern(), $text) === 1;
    }

    private function looksTooGeneric(string $candidate): bool
    {
        $normalized = mb_strtolower($candidate);

        foreach ($this->config->getGenericCandidateTokens() as $genericToken) {
            if ($normalized === $genericToken) {
                return true;
            }
        }

        return false;
    }

    private function sanitizeQuery(string $query): string
    {
        $query = trim($query);
        $query = preg_replace($this->config->getWhitespaceCollapsePattern(), ' ', $query) ?? $query;
        $query = trim($query, $this->config->getSanitizeTrimCharacters());

        return trim($query);
    }

    private function isTooCloseToPrimaryQuery(string $candidateQuery, string $primaryQuery): bool
    {
        $candidateTokens = $this->tokenize($candidateQuery);
        $primaryTokens = $this->tokenize($primaryQuery);

        if ($candidateTokens === [] || $primaryTokens === []) {
            return false;
        }

        $intersection = array_intersect($candidateTokens, $primaryTokens);
        $overlapRatio = count($intersection) / max(count($candidateTokens), count($primaryTokens));

        return $overlapRatio >= $this->config->getPrimaryQueryOverlapThreshold();
    }

    /**
     * @param ShopProductResult[] $existing
     * @param ShopProductResult[] $incoming
     * @return ShopProductResult[]
     */
    private function mergeUniqueProducts(array $existing, array $incoming): array
    {
        $merged = $existing;
        $seen = [];

        foreach ($existing as $product) {
            $seen[$this->buildProductKey($product)] = true;
        }

        foreach ($incoming as $product) {
            $key = $this->buildProductKey($product);

            if (isset($seen[$key])) {
                continue;
            }

            $seen[$key] = true;
            $merged[] = $product;
        }

        return $merged;
    }

    /**
     * @param ShopProductResult[] $primaryResults
     * @param ShopProductResult[] $repairResults
     * @param string[] $repairQueries
     * @return ShopProductResult[]
     */
    private function rankMergedResults(
        array $primaryResults,
        array $repairResults,
        string $prompt,
        string $primaryQuery,
        array $repairQueries
    ): array {
        $allResults = $this->mergeUniqueProducts($primaryResults, $repairResults);
        $repairSignal = trim(implode(' ', $repairQueries));

        $decorated = [];

        foreach ($allResults as $index => $product) {
            $score = 0;
            $score += $this->scoreProductAgainstText($product, $prompt) * $this->config->getPromptMatchWeight();
            $score += $this->scoreProductAgainstText($product, $primaryQuery) * $this->config->getPrimaryQueryMatchWeight();
            $score += $this->scoreProductAgainstText($product, $repairSignal) * $this->config->getRepairSignalMatchWeight();

            if ($index < count($primaryResults)) {
                $score += $this->config->getPrimaryResultOrderBonus();
            }

            $decorated[] = [
                'index' => $index,
                'score' => $score,
                'product' => $product,
            ];
        }

        usort($decorated, static function (array $a, array $b): int {
            if ($a['score'] === $b['score']) {
                return $a['index'] <=> $b['index'];
            }

            return $b['score'] <=> $a['score'];
        });

        return array_values(array_map(
            static fn(array $row): ShopProductResult => $row['product'],
            $decorated
        ));
    }

    private function scoreProductAgainstText(ShopProductResult $product, string $text): int
    {
        $queryTokens = $this->tokenize($text);
        if ($queryTokens === []) {
            return 0;
        }

        $productText = trim(implode(' ', array_filter([
            $product->name,
            $product->productNumber,
            $product->manufacturer,
            implode(' ', $product->highlights),
            $product->description,
            $product->customFields,
        ])));

        $productTokens = $this->tokenize($productText);
        if ($productTokens === []) {
            return 0;
        }

        $score = 0;
        $intersection = array_intersect($queryTokens, $productTokens);
        $score += count($intersection) * $this->config->getTokenIntersectionScore();

        foreach ($this->extractNumberTokens($queryTokens) as $numberToken) {
            if (in_array($numberToken, $productTokens, true)) {
                $score += $this->config->getNumericTokenMatchScore();
            }
        }

        return $score;
    }

    private function buildProductKey(ShopProductResult $product): string
    {
        return mb_strtolower(trim(implode($this->config->getProductKeySeparator(), [
            $product->id,
            $product->productNumber ?? '',
            $product->name,
            $product->url ?? '',
        ])));
    }

    /**
     * @return string[]
     */
    private function tokenize(string $text): array
    {
        $text = mb_strtolower($text);
        $text = preg_replace($this->config->getTokenizeCleanupPattern(), ' ', $text) ?? $text;
        $text = preg_replace($this->config->getWhitespaceCollapsePattern(), ' ', $text) ?? $text;
        $text = trim($text);

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

        return array_values(array_filter(explode(' ', $text)));
    }

    /**
     * @param string[] $tokens
     * @return string[]
     */
    private function extractNumberTokens(array $tokens): array
    {
        return array_values(array_filter(
            $tokens,
            fn(string $token): bool => preg_match($this->config->getContainsDigitPattern(), $token) === 1
        ));
    }

    /**
     * @param ShopProductResult[] $results
     * @param string[] $repairQueries
     * @return array{
     *     results: ShopProductResult[],
     *     attemptedRepair: bool,
     *     usedRepair: bool,
     *     repairQueries: string[]
     * }
     */
    private function buildRepairResult(
        array $results,
        bool $attemptedRepair,
        bool $usedRepair,
        array $repairQueries
    ): array {
        return [
            'results' => $results,
            'attemptedRepair' => $attemptedRepair,
            'usedRepair' => $usedRepair,
            'repairQueries' => $repairQueries,
        ];
    }
}