<?php

namespace classes;

use Throwable;

class system_search_service
{
    private int $defaultEntityFetchLimit = 200;
    private int $defaultMaxResults = 50;
    private int $maxExpandedTerms = 24;
    private int $maxTermLength = 64;
    private int $recencyScoreTolerance = 12;
    private int $minimumEffectiveScore = 45;
    private int $minimumExplicitTypeScore = 20;
    private int $associationSeedScoreThreshold = 80;
    private array $rankingBoostByType = [
        'invoices' => 35,
        'orders' => 35,
        'order_bookings' => 35,
        'customers' => 35,
    ];
    private array $rankingPenaltyByType = [
        'xlvask_customers' => 90,
        'xlvask_usage_logs' => 90,
        'xlvask_vehicle_types' => 90,
        'motorapi_lookups' => 90,
        'customer_discounts' => 90,
        'department_selfserve_vehicle_conditions' => 90,
        'permissions' => 90,
        'branding' => 90,
        'order_items' => 90,
        'module_config' => 90,
    ];
    private array $tableColumnsCache = [];
    private array $customerContextCache = [];

    public function __construct()
    {
        try {
            system_search_economic_customer_index::ensureTable();
            system_search_document_index::ensureTable();
        } catch (Throwable) {
            // Search should work even if index bootstrap is temporarily unavailable.
        }
    }

    public function search(array $options): array
    {
        $query = trim((string)($options['query'] ?? ''));
        $includeTypes = $this->normalizeTypes((array)($options['include_types'] ?? []));
        $excludeTypes = $this->normalizeTypes((array)($options['exclude_types'] ?? []));
        $allowedTypes = $this->normalizeTypes((array)($options['allowed_types'] ?? []));
        $ownOnlyTypes = $this->normalizeTypes((array)($options['own_only_types'] ?? []));
        $ownCustomerNumber = isset($options['own_customer_number']) ? (int)$options['own_customer_number'] : null;
        $allowedDepartmentIds = array_values(array_unique(array_map('intval', (array)($options['allowed_department_ids'] ?? []))));
        $permissionsCatalogAll = (array)($options['permissions_catalog_all'] ?? []);
        $permissionsCatalogOwn = (array)($options['permissions_catalog_own'] ?? []);
        $moduleConfigVisibility = (array)($options['module_config_visibility'] ?? []);
        $includeAssociations = (bool)($options['include_associations'] ?? true);

        $maxResults = (int)($options['max_results'] ?? $this->defaultMaxResults);
        if ($maxResults < 1) {
            $maxResults = $this->defaultMaxResults;
        }
        if ($maxResults > $this->defaultMaxResults) {
            $maxResults = $this->defaultMaxResults;
        }

        $allTypes = $this->allEntityTypes();
        $activeTypes = empty($includeTypes) ? $allTypes : array_values(array_intersect($allTypes, $includeTypes));
        if (!empty($excludeTypes)) {
            $activeTypes = array_values(array_diff($activeTypes, $excludeTypes));
        }
        $activeTypes = array_values(array_intersect($activeTypes, $allowedTypes));
        $terms = $this->buildExpandedTerms($this->tokenize($query));
        $activeTypes = $this->selectSearchTypes($activeTypes, $includeTypes, $terms, $query);

        $baseMeta = [
            'query' => $query,
            'max_results' => $maxResults,
            'allowed_types' => $activeTypes,
            'cache' => ['hit' => false],
        ];

        if ($query === '' || empty($activeTypes)) {
            return [
                'results' => [],
                'grouped_results' => $this->groupResultsByType([]),
                'meta' => [
                    ...$baseMeta,
                    'returned' => 0,
                    'truncated' => false,
                ],
            ];
        }

        $queryCacheHash = md5(json_encode([
            'q' => $query,
            'include' => $includeTypes,
            'exclude' => $excludeTypes,
            'active' => $activeTypes,
            'max' => $maxResults,
            'own' => $ownCustomerNumber,
            'own_only' => $ownOnlyTypes,
            'dept' => $allowedDepartmentIds,
            'assoc' => $includeAssociations,
            'ctx' => $this->permissionContextFingerprint($permissionsCatalogAll, $permissionsCatalogOwn, $moduleConfigVisibility),
            'table_versions' => system_search_cache::tableVersionFingerprint($this->relevantSourceTables($activeTypes)),
            'v' => 13,
        ], JSON_UNESCAPED_UNICODE));

        $cached = system_search_cache::getQuery($queryCacheHash);
        if (is_array($cached) && isset($cached['results'], $cached['grouped_results'], $cached['meta'])) {
            $cached['meta']['cache'] = ['hit' => true];
            return $cached;
        }

        $entityBoost = [];
        $initialResults = $this->executeLexicalSearch(
            $activeTypes,
            $terms,
            $entityBoost,
            $ownOnlyTypes,
            $ownCustomerNumber,
            $permissionsCatalogAll,
            $permissionsCatalogOwn,
            $moduleConfigVisibility,
            $allowedDepartmentIds
        );
        $initialResults = $this->filterRelevantResults($initialResults, $includeTypes);

        if ($includeAssociations) {
            $customerNumbers = [];
            foreach ($initialResults as $result) {
                $customerNumber = $this->toIntOrNull($result['customer_number'] ?? null);
                if ($customerNumber === null || $customerNumber <= 0) {
                    continue;
                }
                if (!$this->shouldExpandAssociationsFromResult($result, $includeTypes)) {
                    continue;
                }
                $customerNumbers[] = $customerNumber;
            }
            $customerNumbers = array_values(array_unique(array_filter($customerNumbers)));
            if (count($customerNumbers) > 5) {
                $customerNumbers = array_slice($customerNumbers, 0, 5);
            }
            if (!empty($customerNumbers)) {
                $associationTypes = array_values(array_intersect(
                    $activeTypes,
                    $this->associationEntityTypes()
                ));
                $associationTypes = array_values(array_diff($associationTypes, $ownOnlyTypes));
                if (!empty($associationTypes)) {
                    foreach ($customerNumbers as $customerNumber) {
                        $associated = $this->executeLexicalSearch(
                            $associationTypes,
                            [(string)$customerNumber],
                            [],
                            $ownOnlyTypes,
                            $ownCustomerNumber,
                            $permissionsCatalogAll,
                            $permissionsCatalogOwn,
                            $moduleConfigVisibility,
                            $allowedDepartmentIds,
                            [$customerNumber]
                        );
                        foreach ($associated as &$item) {
                            if (!isset($item['association_reason'])) {
                                $item['association_reason'] = 'customer:' . $customerNumber;
                            }
                            $item['score'] = max((int)$item['score'], 35);
                        }
                        unset($item);
                        $associated = $this->filterRelevantResults($associated, $includeTypes);
                        $initialResults = $this->mergeResults($initialResults, $associated);
                    }
                }
            }
        }
        $initialResults = $this->filterRelevantResults($initialResults, $includeTypes);

        $preferRecency = $this->shouldPreferRecencySort($query, $terms);
        usort($initialResults, function (array $a, array $b) use ($preferRecency): int {
            $scoreA = (int)($a['score'] ?? 0);
            $scoreB = (int)($b['score'] ?? 0);
            $effectiveScoreA = $this->effectiveResultScore($a);
            $effectiveScoreB = $this->effectiveResultScore($b);
            $recencyA = $this->resultRecencyTimestamp($a);
            $recencyB = $this->resultRecencyTimestamp($b);
            $cancelledA = $this->isCancelledBookingResult($a);
            $cancelledB = $this->isCancelledBookingResult($b);

            // Cancelled bookings must never outrank active bookings.
            if ($cancelledA !== $cancelledB) {
                return $cancelledA ? 1 : -1;
            }

            if ($preferRecency && $recencyA !== $recencyB) {
                if ($effectiveScoreA === $effectiveScoreB || abs($effectiveScoreA - $effectiveScoreB) <= $this->recencyScoreTolerance) {
                    return $recencyB <=> $recencyA;
                }
            }

            if ($effectiveScoreA !== $effectiveScoreB) {
                return $effectiveScoreB <=> $effectiveScoreA;
            }
            if ($scoreA !== $scoreB) {
                return $scoreB <=> $scoreA;
            }
            if ($recencyA !== $recencyB) {
                return $recencyB <=> $recencyA;
            }
            return strcmp((string)$a['entity_type'] . ':' . (string)$a['entity_id'], (string)$b['entity_type'] . ':' . (string)$b['entity_id']);
        });

        $limited = array_slice($initialResults, 0, $maxResults);
        $grouped = $this->groupResultsByType($limited);

        $meta = [
            ...$baseMeta,
            'returned' => count($limited),
            'truncated' => count($initialResults) > count($limited),
        ];

        $payload = [
            'results' => $limited,
            'grouped_results' => $grouped,
            'meta' => $meta,
        ];
        system_search_cache::setQuery($queryCacheHash, $payload, 120);

        return $payload;
    }

    /**
     * @param array<int, string> $activeTypes
     * @param array<int, string> $includeTypes
     * @param array<int, string> $terms
     * @return array<int, string>
     */
    private function selectSearchTypes(array $activeTypes, array $includeTypes, array $terms, string $query): array
    {
        if (empty($activeTypes) || !empty($includeTypes)) {
            return $activeTypes;
        }

        $selected = array_values(array_intersect($activeTypes, $this->defaultSearchEntityTypes()));
        foreach ($activeTypes as $entityType) {
            if (in_array($entityType, $selected, true)) {
                continue;
            }
            if ($this->entityTypeMatchesQuery($entityType, $terms, $query)) {
                $selected[] = $entityType;
            }
        }

        return array_values(array_intersect($activeTypes, array_values(array_unique($selected))));
    }

    /**
     * @return array<int, string>
     */
    private function defaultSearchEntityTypes(): array
    {
        return [
            'customers',
            'users',
            'employees',
            'orders',
            'order_bookings',
            'bookings',
            'bookings_new',
            'invoices',
            'vehicles',
            'departments',
            'products',
            'objects',
        ];
    }

    /**
     * @param array<int, string> $terms
     */
    private function entityTypeMatchesQuery(string $entityType, array $terms, string $query): bool
    {
        $normalizedQuery = ' ' . trim(mb_strtolower($query)) . ' ';
        if (trim($normalizedQuery) === '') {
            return false;
        }

        $aliases = system_search_registry::taxonomyAliases()[$entityType] ?? [];
        $human = str_replace('_', ' ', $entityType);
        $aliases[] = $entityType;
        $aliases[] = $human;
        $aliases[] = rtrim($human, 's');
        $aliases = array_values(array_unique(array_filter($aliases, static fn($alias) => is_string($alias) && trim($alias) !== '')));

        foreach ($aliases as $alias) {
            $aliasTerms = $this->tokenize($alias);
            if (empty($aliasTerms)) {
                continue;
            }
            if (count($aliasTerms) === 1 && in_array($aliasTerms[0], $terms, true)) {
                return true;
            }
            if (count($aliasTerms) > 1 && empty(array_diff($aliasTerms, $terms))) {
                return true;
            }
            $aliasText = trim(mb_strtolower($alias));
            if ($aliasText !== '' && str_contains($normalizedQuery, ' ' . $aliasText . ' ')) {
                return true;
            }
        }

        return false;
    }

    /**
     * @param array<int, array<string, mixed>> $results
     * @param array<int, string> $includeTypes
     * @return array<int, array<string, mixed>>
     */
    private function filterRelevantResults(array $results, array $includeTypes): array
    {
        $explicitTypes = array_flip($includeTypes);
        $filtered = [];
        foreach ($results as $result) {
            $entityType = (string)($result['entity_type'] ?? '');
            $score = (int)($result['score'] ?? 0);
            if ($score <= 0) {
                continue;
            }
            if (isset($explicitTypes[$entityType])) {
                if ($score >= $this->minimumExplicitTypeScore) {
                    $filtered[] = $result;
                }
                continue;
            }
            if ($this->effectiveResultScore($result) >= $this->minimumEffectiveScore) {
                $filtered[] = $result;
            }
        }
        return $filtered;
    }

    private function shouldExpandAssociationsFromResult(array $result, array $includeTypes): bool
    {
        $entityType = trim(mb_strtolower((string)($result['entity_type'] ?? '')));
        if (!in_array($entityType, ['customers', 'users'], true)) {
            return false;
        }
        if (!empty($includeTypes) && !in_array($entityType, $includeTypes, true)) {
            return false;
        }
        return $this->effectiveResultScore($result) >= $this->associationSeedScoreThreshold;
    }

    /**
     * @param array<int, string> $activeTypes
     * @param array<int, string> $terms
     * @param array<string, int> $entityBoost
     * @param array<int, string> $ownOnlyTypes
     * @param int|null $ownCustomerNumber
     * @param array<string, string> $permissionsCatalogAll
     * @param array<int, string> $permissionsCatalogOwn
     * @param array<string, bool> $moduleConfigVisibility
     * @param array<int, int> $allowedDepartmentIds
     * @param array<int, int> $forcedCustomerNumbers
     * @return array<int, array<string, mixed>>
     */
    protected function executeLexicalSearch(
        array $activeTypes,
        array $terms,
        array $entityBoost,
        array $ownOnlyTypes,
        ?int $ownCustomerNumber,
        array $permissionsCatalogAll,
        array $permissionsCatalogOwn,
        array $moduleConfigVisibility,
        array $allowedDepartmentIds = [],
        array $forcedCustomerNumbers = []
    ): array {
        $results = [];
        $dirtyTables = system_search_cache::peekDirtyTables();
        foreach ($activeTypes as $entityType) {
            $boost = (int)($entityBoost[$entityType] ?? 0);
            $ownOnly = in_array($entityType, $ownOnlyTypes, true);
            if ($ownOnly && $ownCustomerNumber === null && empty($forcedCustomerNumbers)) {
                continue;
            }
            if ($this->canUseIndexedSearch($entityType, $dirtyTables)) {
                $rows = $this->searchIndexedEntity(
                    $entityType,
                    $terms,
                    $boost,
                    $ownOnly,
                    $ownCustomerNumber,
                    $moduleConfigVisibility,
                    $allowedDepartmentIds,
                    $forcedCustomerNumbers
                );
            } else {
                $rows = $this->searchEntity(
                    $entityType,
                    $terms,
                    $boost,
                    $ownOnly,
                    $ownCustomerNumber,
                    $permissionsCatalogAll,
                    $permissionsCatalogOwn,
                    $moduleConfigVisibility,
                    $allowedDepartmentIds,
                    $forcedCustomerNumbers
                );
            }
            $results = $this->mergeResults($results, $rows);
        }
        return $results;
    }

    /**
     * @param array<int, string> $terms
     * @param array<string, string> $permissionsCatalogAll
     * @param array<int, string> $permissionsCatalogOwn
     * @param array<string, bool> $moduleConfigVisibility
     * @param array<int, int> $allowedDepartmentIds
     * @param array<int, int> $forcedCustomerNumbers
     * @return array<int, array<string, mixed>>
     */
    private function searchEntity(
        string $entityType,
        array $terms,
        int $entityBoost,
        bool $ownOnly,
        ?int $ownCustomerNumber,
        array $permissionsCatalogAll,
        array $permissionsCatalogOwn,
        array $moduleConfigVisibility,
        array $allowedDepartmentIds,
        array $forcedCustomerNumbers
    ): array {
        if ($this->isGenericEntityType($entityType)) {
            return $this->searchGenericEntity(
                $entityType,
                $terms,
                $entityBoost,
                $ownOnly,
                $ownCustomerNumber,
                $allowedDepartmentIds,
                $forcedCustomerNumbers
            );
        }

        return match ($entityType) {
            'customers' => $this->searchCustomers($terms, $entityBoost, $ownOnly, $ownCustomerNumber, $forcedCustomerNumbers),
            'employees' => $this->searchEmployees($terms, $entityBoost, $ownOnly, $ownCustomerNumber),
            'orders' => $this->searchOrders($terms, $entityBoost, $ownOnly, $ownCustomerNumber, $forcedCustomerNumbers),
            'order_items' => $this->searchOrderItems($terms, $entityBoost, $ownOnly, $ownCustomerNumber, $forcedCustomerNumbers),
            'invoices' => $this->searchInvoices($terms, $entityBoost, $ownOnly, $ownCustomerNumber, $forcedCustomerNumbers),
            'vehicles' => $this->searchVehicles($terms, $entityBoost, $ownOnly, $ownCustomerNumber, $forcedCustomerNumbers),
            'subusers' => $this->searchSubusers($terms, $entityBoost, $ownOnly, $ownCustomerNumber),
            'customer_discounts' => $this->searchCustomerDiscounts($terms, $entityBoost, $ownOnly, $ownCustomerNumber, $forcedCustomerNumbers),
            'customer_fixed_prices' => $this->searchCustomerFixedPrices($terms, $entityBoost, $ownOnly, $ownCustomerNumber, $forcedCustomerNumbers),
            'departments' => $this->searchDepartments($terms, $entityBoost),
            'roles' => $this->searchRoles($terms, $entityBoost),
            'permissions' => $this->searchPermissions($terms, $entityBoost, $ownOnly, $permissionsCatalogAll, $permissionsCatalogOwn),
            'module_config' => $this->searchModuleConfig($terms, $entityBoost, $moduleConfigVisibility),
            'objects' => $this->searchObjects($terms, $entityBoost, $ownOnly, $ownCustomerNumber),
            default => [],
        };
    }

    /**
     * @param array<int, string> $dirtyTables
     */
    private function canUseIndexedSearch(string $entityType, array $dirtyTables): bool
    {
        if (!$this->tableExists(system_search_document_index::TABLE)) {
            return false;
        }
        if (!in_array($entityType, system_search_registry::indexedEntityTypes(), true)) {
            return false;
        }

        $normalizedDirty = array_values(array_unique(array_filter(array_map(
            static fn($table) => is_string($table) ? trim($table, " `\t\n\r\0\x0B") : '',
            $dirtyTables
        ))));
        if (empty($normalizedDirty)) {
            return true;
        }

        return empty(array_intersect($normalizedDirty, system_search_registry::sourceTablesForEntityType($entityType)));
    }

    private function indexedEntitySupportsDepartmentFilter(string $entityType): bool
    {
        $entityType = trim(mb_strtolower($entityType));
        if (in_array($entityType, ['orders', 'objects'], true)) {
            return true;
        }

        $config = system_search_registry::genericEntityConfigs()[$entityType] ?? null;
        return is_array($config)
            && isset($config['department_field'])
            && is_string($config['department_field'])
            && trim($config['department_field']) !== '';
    }

    /**
     * @param array<int, string> $terms
     * @param array<string, bool> $moduleConfigVisibility
     * @param array<int, int> $allowedDepartmentIds
     * @param array<int, int> $forcedCustomerNumbers
     * @return array<int, array<string, mixed>>
     */
    private function searchIndexedEntity(
        string $entityType,
        array $terms,
        int $entityBoost,
        bool $ownOnly,
        ?int $ownCustomerNumber,
        array $moduleConfigVisibility,
        array $allowedDepartmentIds,
        array $forcedCustomerNumbers
    ): array {
        global $db;

        if (!$this->tableExists(system_search_document_index::TABLE) || empty($terms)) {
            return [];
        }

        $customerNumbers = !empty($forcedCustomerNumbers)
            ? $forcedCustomerNumbers
            : (($ownOnly && $ownCustomerNumber !== null) ? [$ownCustomerNumber] : []);
        if ($ownOnly && empty($customerNumbers)) {
            return [];
        }

        $wheres = [
            "`entity_type` = '" . $db->escape_string($entityType) . "'",
        ];
        if (!empty($customerNumbers)) {
            $wheres[] = "`customer_number` IN (" . implode(',', array_map('intval', $customerNumbers)) . ")";
        }
        if (!empty($allowedDepartmentIds) && $this->indexedEntitySupportsDepartmentFilter($entityType)) {
            $wheres[] = "`department_id` IN (" . implode(',', array_map('intval', $allowedDepartmentIds)) . ")";
        }

        $booleanQuery = $this->buildBooleanFullTextQuery($terms);
        $rows = [];
        if ($booleanQuery !== null) {
            $escapedBoolean = $db->escape_string($booleanQuery);
            $rows = $this->runSelectRows(
                "SELECT entity_id, customer_number, department_id, title, description, search_text, payload_json, created_at, updated_at, "
                . "MATCH(title, description, search_text) AGAINST ('" . $escapedBoolean . "' IN BOOLEAN MODE) AS indexed_score "
                . "FROM `" . system_search_document_index::TABLE . "` "
                . "WHERE " . implode(' AND ', $wheres)
                . " AND MATCH(title, description, search_text) AGAINST ('" . $escapedBoolean . "' IN BOOLEAN MODE)"
                . " ORDER BY indexed_score DESC LIMIT " . $this->defaultEntityFetchLimit
            );
        }

        if (empty($rows)) {
            $termClauses = [];
            foreach ($terms as $term) {
                $escaped = $db->escape_string($term);
                foreach (['title', 'description', 'search_text'] as $field) {
                    $termClauses[] = "`$field` LIKE '%$escaped%'";
                }
            }
            if (empty($termClauses)) {
                return [];
            }
            $rows = $this->runSelectRows(
                "SELECT entity_id, customer_number, department_id, title, description, search_text, payload_json, created_at, updated_at, 0 AS indexed_score "
                . "FROM `" . system_search_document_index::TABLE . "` "
                . "WHERE " . implode(' AND ', $wheres)
                . " AND (" . implode(' OR ', $termClauses) . ")"
                . " LIMIT " . $this->defaultEntityFetchLimit
            );
        }

        $this->primeCustomerContexts(array_values(array_unique(array_filter(
            array_map(fn(array $row): ?int => $this->toIntOrNull($row['customer_number'] ?? null), $rows),
            static fn(?int $value): bool => $value !== null && $value > 0
        ))));

        $invoiceTitleContexts = $entityType === 'invoices'
            ? $this->loadInvoiceTitleContexts(array_map(static fn(array $row): mixed => $row['entity_id'] ?? null, $rows))
            : [];

        $results = [];
        foreach ($rows as $row) {
            $payload = [];
            $payloadJson = $row['payload_json'] ?? null;
            if (is_string($payloadJson) && $payloadJson !== '') {
                $decoded = json_decode($payloadJson, true);
                if (is_array($decoded)) {
                    $payload = $decoded;
                }
            }

            $title = (string)($row['title'] ?? '');
            if ($entityType === 'invoices') {
                $invoiceContext = $invoiceTitleContexts[(string)($row['entity_id'] ?? '')] ?? [];
                foreach (['name', 'created_at', 'closed_at'] as $field) {
                    if (array_key_exists($field, $invoiceContext)) {
                        $payload[$field] = $invoiceContext[$field];
                    }
                }

                $storedTitle = trim($title);
                $storedName = null;
                if ($storedTitle !== '' && !str_starts_with($storedTitle, 'Invoice collection #')) {
                    $storedName = $storedTitle;
                }

                $title = $this->invoiceResultTitle(
                    $payload['name'] ?? $storedName,
                    $payload['created_at'] ?? ($row['created_at'] ?? null),
                    $payload['closed_at'] ?? null,
                    $row['entity_id'] ?? null
                );
            }
            if ($entityType === 'department_goals') {
                $title = $this->departmentGoalResultTitle(
                    $payload['criteria'] ?? null,
                    $row['entity_id'] ?? null,
                    $title
                );
            }

            if ($entityType === 'module_config') {
                $module = (string)($payload['module'] ?? '');
                if ($module !== '' && isset($moduleConfigVisibility[$module]) && !$moduleConfigVisibility[$module]) {
                    continue;
                }
                $variable = (string)($payload['variable'] ?? '');
                if ($variable !== '' && $this->looksSecretVariable($variable)) {
                    continue;
                }
            }

            $indexedBoost = (int)round(max(0.0, (float)($row['indexed_score'] ?? 0.0)) * 40);
            $score = $this->scoreRow([
                'title' => $title,
                'description' => $row['description'] ?? '',
                'search_text' => $row['search_text'] ?? '',
            ], ['title' => 4, 'description' => 2, 'search_text' => 1], $terms) + $indexedBoost + $entityBoost;
            if ($score <= 0) {
                continue;
            }

            $results[] = $this->decorateSearchResultWithCustomerContext([
                'entity_type' => $entityType,
                'entity_id' => (string)($row['entity_id'] ?? ''),
                'title' => $title,
                'description' => (string)($row['description'] ?? ''),
                'customer_number' => $this->toIntOrNull($row['customer_number'] ?? null),
                'department_id' => $this->toIntOrNull($row['department_id'] ?? null),
                'score' => $score,
                'payload' => $payload,
            ]);
        }

        return $results;
    }

    /**
     * @param array<int, mixed> $entityIds
     * @return array<string, array{name: ?string, created_at: ?string, closed_at: ?string}>
     */
    private function loadInvoiceTitleContexts(array $entityIds): array
    {
        if (!$this->tableExists('collected_order_invoices')) {
            return [];
        }

        $invoiceIds = array_values(array_unique(array_filter(array_map(
            fn(mixed $entityId): ?int => $this->toIntOrNull($entityId),
            $entityIds
        ), static fn(?int $invoiceId): bool => $invoiceId !== null && $invoiceId > 0)));
        if (empty($invoiceIds)) {
            return [];
        }

        $rows = $this->runSelectRows(
            "SELECT id, name, created_at, closed_at"
            . " FROM `collected_order_invoices`"
            . " WHERE `deleted_at` IS NULL"
            . " AND `id` IN (" . implode(',', $invoiceIds) . ")"
        );

        $contexts = [];
        foreach ($rows as $row) {
            if (!isset($row['id'])) {
                continue;
            }

            $contexts[(string)$row['id']] = [
                'name' => array_key_exists('name', $row) ? $row['name'] : null,
                'created_at' => array_key_exists('created_at', $row) ? $row['created_at'] : null,
                'closed_at' => array_key_exists('closed_at', $row) ? $row['closed_at'] : null,
            ];
        }

        return $contexts;
    }

    private function searchCustomers(array $terms, int $entityBoost, bool $ownOnly, ?int $ownCustomerNumber, array $forcedCustomerNumbers): array
    {
        $customerNumbers = !empty($forcedCustomerNumbers)
            ? $forcedCustomerNumbers
            : (($ownOnly && $ownCustomerNumber) ? [$ownCustomerNumber] : []);
        $customerFilter = '';
        if (!empty($customerNumbers)) {
            $customerFilter = ' AND u.customer_number IN (' . implode(',', array_map('intval', $customerNumbers)) . ')';
        }

        $selectFields = [
            'u.id',
            'u.customer_number',
            'u.display_name',
            'u.email',
            'u.phone',
            ...$this->joinTemporalSelectFields('users', 'u'),
        ];
        $searchFields = ['u.id', 'u.customer_number', 'u.display_name', 'u.email', 'u.phone'];
        $fromClause = 'users u';

        if ($this->isEconomicCustomerIndexAvailable()) {
            $fromClause .= ' LEFT JOIN `' . system_search_economic_customer_index::TABLE . '` sci ON sci.customer_number = u.customer_number';
            $selectFields = [
                ...$selectFields,
                'sci.economic_name',
                'sci.economic_address',
                'sci.economic_city',
                'sci.economic_zip',
                'sci.economic_email',
                'sci.economic_cvr',
                'sci.economic_mobile_phone',
                'sci.search_text',
            ];
            $searchFields = [
                ...$searchFields,
                'sci.economic_name',
                'sci.economic_address',
                'sci.economic_city',
                'sci.economic_zip',
                'sci.economic_email',
                'sci.economic_cvr',
                'sci.economic_mobile_phone',
                'sci.search_text',
            ];
        }

        $rows = $this->searchTableWithJoin(
            'users',
            $fromClause,
            $selectFields,
            $searchFields,
            $terms,
            '1=1' . $customerFilter
        );

        return array_map(function (array $row) use ($terms, $entityBoost) {
            $title = trim((string)($row['economic_name'] ?? ''));
            if ($title === '') {
                $title = trim((string)($row['display_name'] ?? ''));
            }
            if ($title === '') {
                $title = 'Customer #' . (string)($row['customer_number'] ?? '');
            }

            $description = trim((string)($row['email'] ?? ''));
            if ($description === '') {
                $description = trim((string)($row['economic_email'] ?? ''));
            }

            return [
                'entity_type' => 'customers',
                'entity_id' => (string)$row['id'],
                'title' => $title,
                'description' => $description,
                'customer_number' => isset($row['customer_number']) ? (int)$row['customer_number'] : null,
                'score' => $this->scoreRow($row, [
                    'customer_number',
                    'display_name',
                    'email',
                    'phone',
                    'economic_name',
                    'economic_address',
                    'economic_city',
                    'economic_zip',
                    'economic_email',
                    'economic_cvr',
                    'economic_mobile_phone',
                    'search_text',
                ], $terms) + $entityBoost,
                'payload' => $this->augmentPayloadWithTemporal([
                    'id' => (int)$row['id'],
                    'customer_number' => isset($row['customer_number']) ? (int)$row['customer_number'] : null,
                    'display_name' => $row['display_name'] ?? null,
                    'email' => $row['email'] ?? null,
                    'phone' => $row['phone'] ?? null,
                    'economic_name' => $row['economic_name'] ?? null,
                    'economic_address' => $row['economic_address'] ?? null,
                    'economic_city' => $row['economic_city'] ?? null,
                    'economic_zip' => $row['economic_zip'] ?? null,
                    'economic_email' => $row['economic_email'] ?? null,
                    'economic_cvr' => $row['economic_cvr'] ?? null,
                    'economic_mobile_phone' => $row['economic_mobile_phone'] ?? null,
                ], $row),
            ];
        }, $rows);
    }

    private function searchEmployees(array $terms, int $entityBoost, bool $ownOnly, ?int $ownCustomerNumber): array
    {
        $rows = $this->searchTableWithJoin(
            'users',
            'users u INNER JOIN groups_permissions gp ON gp.group_id = u.group_id',
            ['u.id', 'u.customer_number', 'u.display_name', 'u.email', 'u.phone', ...$this->joinTemporalSelectFields('users', 'u')],
            ['u.id', 'u.customer_number', 'u.display_name', 'u.email', 'u.phone'],
            $terms,
            "gp.permission = 'employee_public_data'" . (($ownOnly && $ownCustomerNumber) ? (' AND u.customer_number = ' . (int)$ownCustomerNumber) : '')
        );

        return array_map(function (array $row) use ($terms, $entityBoost) {
            return [
                'entity_type' => 'employees',
                'entity_id' => (string)$row['id'],
                'title' => (string)($row['display_name'] ?: ('Employee #' . $row['id'])),
                'description' => (string)($row['email'] ?? ''),
                'customer_number' => isset($row['customer_number']) ? (int)$row['customer_number'] : null,
                'score' => $this->scoreRow($row, ['display_name', 'email', 'phone', 'customer_number'], $terms) + $entityBoost,
                'payload' => $this->augmentPayloadWithTemporal([
                    'id' => (int)$row['id'],
                    'display_name' => $row['display_name'] ?? null,
                    'email' => $row['email'] ?? null,
                    'phone' => $row['phone'] ?? null,
                ], $row),
            ];
        }, $rows);
    }

    private function searchOrders(array $terms, int $entityBoost, bool $ownOnly, ?int $ownCustomerNumber, array $forcedCustomerNumbers): array
    {
        $customerNumbers = !empty($forcedCustomerNumbers) ? $forcedCustomerNumbers : ($ownOnly && $ownCustomerNumber ? [$ownCustomerNumber] : []);
        $rows = $this->searchTable(
            'orders',
            ['id', 'customer_id', 'reference', 'notes', 'reg_1', 'reg_2', 'reg_3', 'department_id', 'po', 'deleted_at'],
            ['id', 'customer_id', 'reference', 'notes', 'reg_1', 'reg_2', 'reg_3', 'po'],
            $terms,
            $customerNumbers,
            'customer_id',
            'default',
            ['deleted_at' => null]
        );

        return array_map(function (array $row) use ($terms, $entityBoost) {
            return [
                'entity_type' => 'orders',
                'entity_id' => (string)$row['id'],
                'title' => 'Order #' . (string)$row['id'],
                'description' => (string)($row['reference'] ?? ''),
                'customer_number' => isset($row['customer_id']) ? (int)$row['customer_id'] : null,
                'department_id' => isset($row['department_id']) ? (int)$row['department_id'] : null,
                'score' => $this->scoreRow($row, ['id', 'customer_id', 'reference', 'notes', 'reg_1', 'reg_2', 'reg_3', 'po'], $terms) + $entityBoost,
                'payload' => $this->augmentPayloadWithTemporal([
                    'id' => (int)$row['id'],
                    'customer_id' => isset($row['customer_id']) ? (int)$row['customer_id'] : null,
                    'reference' => $row['reference'] ?? null,
                    'notes' => $row['notes'] ?? null,
                    'reg_1' => $row['reg_1'] ?? null,
                ], $row),
            ];
        }, $rows);
    }

    private function searchOrderItems(array $terms, int $entityBoost, bool $ownOnly, ?int $ownCustomerNumber, array $forcedCustomerNumbers): array
    {
        $customerClause = '';
        if (!empty($forcedCustomerNumbers)) {
            $customerClause = ' AND o.customer_id IN (' . implode(',', array_map('intval', $forcedCustomerNumbers)) . ')';
        } elseif ($ownOnly && $ownCustomerNumber) {
            $customerClause = ' AND o.customer_id = ' . (int)$ownCustomerNumber;
        }
        $baseWhere = 'o.id = oi.order_id' . $customerClause . ' AND o.deleted_at IS NULL';
        $rows = $this->searchTableWithJoin(
            'order_items',
            'order_items oi INNER JOIN orders o ON o.id = oi.order_id',
            [
                'oi.id',
                'oi.order_id',
                'oi.product_id',
                'oi.reference',
                'oi.notes',
                'o.customer_id AS customer_number',
                ...$this->joinTemporalSelectFields('order_items', 'oi'),
            ],
            ['oi.id', 'oi.order_id', 'oi.product_id', 'oi.reference', 'oi.notes', 'o.customer_id'],
            $terms,
            $baseWhere
        );

        return array_map(function (array $row) use ($terms, $entityBoost) {
            return [
                'entity_type' => 'order_items',
                'entity_id' => (string)$row['id'],
                'title' => 'Order item #' . (string)$row['id'],
                'description' => (string)($row['reference'] ?? ''),
                'customer_number' => isset($row['customer_number']) ? (int)$row['customer_number'] : null,
                'score' => $this->scoreRow($row, ['id', 'order_id', 'product_id', 'reference', 'notes', 'customer_number'], $terms) + $entityBoost,
                'payload' => $this->augmentPayloadWithTemporal([
                    'id' => (int)$row['id'],
                    'order_id' => isset($row['order_id']) ? (int)$row['order_id'] : null,
                    'product_id' => isset($row['product_id']) ? (int)$row['product_id'] : null,
                    'reference' => $row['reference'] ?? null,
                ], $row),
            ];
        }, $rows);
    }

    private function searchInvoices(array $terms, int $entityBoost, bool $ownOnly, ?int $ownCustomerNumber, array $forcedCustomerNumbers): array
    {
        $customerNumbers = !empty($forcedCustomerNumbers) ? $forcedCustomerNumbers : ($ownOnly && $ownCustomerNumber ? [$ownCustomerNumber] : []);
        $rows = $this->searchTable(
            'collected_order_invoices',
            ['id', 'customer_number', 'name', 'notes', 'external_id', 'booked_invoice_id', 'po_number', 'closed_at', 'deleted_at'],
            ['id', 'customer_number', 'name', 'notes', 'external_id', 'booked_invoice_id', 'po_number'],
            $terms,
            $customerNumbers,
            'customer_number',
            'default',
            ['deleted_at' => null]
        );

        return array_map(function (array $row) use ($terms, $entityBoost) {
            return [
                'entity_type' => 'invoices',
                'entity_id' => (string)$row['id'],
                'title' => $this->invoiceResultTitle(
                    $row['name'] ?? null,
                    $row['created_at'] ?? null,
                    $row['closed_at'] ?? null,
                    $row['id'] ?? null
                ),
                'description' => (string)($row['external_id'] ?? ''),
                'customer_number' => isset($row['customer_number']) ? (int)$row['customer_number'] : null,
                'score' => $this->scoreRow($row, ['id', 'customer_number', 'name', 'notes', 'external_id', 'booked_invoice_id', 'po_number'], $terms) + $entityBoost,
                'payload' => $this->augmentPayloadWithTemporal([
                    'id' => (int)$row['id'],
                    'customer_number' => isset($row['customer_number']) ? (int)$row['customer_number'] : null,
                    'name' => $row['name'] ?? null,
                    'external_id' => $row['external_id'] ?? null,
                    'closed_at' => $row['closed_at'] ?? null,
                ], $row),
            ];
        }, $rows);
    }

    private function invoiceResultTitle(mixed $name, mixed $fromDate, mixed $toDate, mixed $invoiceId): string
    {
        $resolvedName = trim((string)($name ?? ''));
        if ($resolvedName !== '') {
            return $resolvedName;
        }

        $dateRange = $this->invoiceDateRangeLabel($fromDate, $toDate);
        if ($dateRange !== '') {
            return $dateRange;
        }

        return 'Invoice collection #' . (string)$invoiceId;
    }

    private function invoiceDateRangeLabel(mixed $fromDate, mixed $toDate): string
    {
        $fromLabel = $this->invoiceDateLabel($fromDate);
        $toLabel = $this->invoiceDateLabel($toDate);

        if ($fromLabel !== '' && $toLabel !== '' && $fromLabel !== $toLabel) {
            return $fromLabel . ' - ' . $toLabel;
        }
        if ($fromLabel !== '') {
            return $fromLabel;
        }
        if ($toLabel !== '') {
            return $toLabel;
        }

        return '';
    }

    private function invoiceDateLabel(mixed $value): string
    {
        if ($value === null) {
            return '';
        }

        $raw = trim((string)$value);
        if ($raw === '' || $raw === '0000-00-00' || $raw === '0000-00-00 00:00:00') {
            return '';
        }

        $timestamp = strtotime($raw);
        if ($timestamp === false) {
            return '';
        }

        return date('Y-m-d', $timestamp);
    }

    private function departmentGoalResultTitle(mixed $criteria, mixed $entityId = null, string $fallback = ''): string
    {
        $label = $this->departmentGoalLabelFromCriteria($criteria);
        if ($label !== '') {
            return $label;
        }

        $trimmedFallback = trim($fallback);
        if ($trimmedFallback !== '') {
            return $trimmedFallback;
        }

        $resolvedId = $this->toIntOrNull($entityId);
        return $resolvedId !== null && $resolvedId > 0
            ? 'Department goal #' . $resolvedId
            : 'Department goal';
    }

    private function departmentGoalLabelFromCriteria(mixed $criteria): string
    {
        $decoded = null;
        if (is_array($criteria)) {
            $decoded = $criteria;
        } elseif (is_string($criteria) && trim($criteria) !== '') {
            $decodedValue = json_decode($criteria, true);
            if (is_array($decodedValue)) {
                $decoded = $decodedValue;
            }
        }

        if (!is_array($decoded)) {
            return '';
        }

        return trim((string)($decoded['label'] ?? ''));
    }

    private function searchVehicles(array $terms, int $entityBoost, bool $ownOnly, ?int $ownCustomerNumber, array $forcedCustomerNumbers): array
    {
        $customerNumbers = !empty($forcedCustomerNumbers) ? $forcedCustomerNumbers : ($ownOnly && $ownCustomerNumber ? [$ownCustomerNumber] : []);
        $rows = $this->searchTable(
            'customer_vehicles',
            ['id', 'customer_id', 'reg', 'reference', 'type', 'deleted_at'],
            ['id', 'customer_id', 'reg', 'reference', 'type'],
            $terms,
            $customerNumbers,
            'customer_id',
            'default',
            ['deleted_at' => null]
        );

        return array_map(function (array $row) use ($terms, $entityBoost) {
            return [
                'entity_type' => 'vehicles',
                'entity_id' => (string)$row['id'],
                'title' => (string)($row['reg'] ?: ('Vehicle #' . $row['id'])),
                'description' => (string)($row['reference'] ?? ''),
                'customer_number' => isset($row['customer_id']) ? (int)$row['customer_id'] : null,
                'score' => $this->scoreRow($row, ['id', 'customer_id', 'reg', 'reference', 'type'], $terms) + $entityBoost,
                'payload' => $this->augmentPayloadWithTemporal([
                    'id' => (int)$row['id'],
                    'customer_id' => isset($row['customer_id']) ? (int)$row['customer_id'] : null,
                    'reg' => $row['reg'] ?? null,
                    'reference' => $row['reference'] ?? null,
                ], $row),
            ];
        }, $rows);
    }

    private function searchSubusers(array $terms, int $entityBoost, bool $ownOnly, ?int $ownCustomerNumber): array
    {
        $where = '1=1';
        if ($ownOnly && $ownCustomerNumber) {
            $where .= ' AND sg.billing_customer_number = ' . (int)$ownCustomerNumber . ' AND sg.deleted_at IS NULL';
        }
        $rows = $this->searchTableWithJoin(
            'subusers',
            'subusers s LEFT JOIN subuser_grants sg ON sg.subuser = s.id',
            [
                's.id',
                's.username',
                's.name',
                's.email',
                's.phone_country_code',
                's.phone',
                ...$this->joinTemporalSelectFields('subusers', 's'),
            ],
            ['s.id', 's.username', 's.name', 's.email', 's.phone'],
            $terms,
            $where
        );

        return array_map(function (array $row) use ($terms, $entityBoost) {
            return [
                'entity_type' => 'subusers',
                'entity_id' => (string)$row['id'],
                'title' => (string)($row['name'] ?: ($row['username'] ?? ('Subuser #' . $row['id']))),
                'description' => (string)($row['email'] ?? ''),
                'score' => $this->scoreRow($row, ['id', 'username', 'name', 'email', 'phone'], $terms) + $entityBoost,
                'payload' => $this->augmentPayloadWithTemporal([
                    'id' => (int)$row['id'],
                    'username' => $row['username'] ?? null,
                    'name' => $row['name'] ?? null,
                    'email' => $row['email'] ?? null,
                ], $row),
            ];
        }, $rows);
    }

    private function searchCustomerDiscounts(array $terms, int $entityBoost, bool $ownOnly, ?int $ownCustomerNumber, array $forcedCustomerNumbers): array
    {
        price_overrides_schema_bootstrap::ensureColumns();
        $customerFilter = '';
        if (!empty($forcedCustomerNumbers)) {
            $customerFilter = ' AND u.customer_number IN (' . implode(',', array_map('intval', $forcedCustomerNumbers)) . ')';
        } elseif ($ownOnly && $ownCustomerNumber) {
            $customerFilter = ' AND u.customer_number = ' . (int)$ownCustomerNumber;
        }

        $fromClause = 'price_overrides po INNER JOIN users u ON u.id = po.user_id';
        $selectFields = [
            'po.id',
            'po.user_id',
            'po.is_category',
            'po.product_or_category_id',
            'po.percentage',
            'po.fixed_price',
            'u.customer_number',
            'u.display_name',
            ...$this->joinTemporalSelectFields('price_overrides', 'po'),
        ];
        $searchFields = ['po.id', 'po.user_id', 'po.product_or_category_id', 'po.percentage', 'po.fixed_price', 'u.customer_number', 'u.display_name'];

        if ($this->isEconomicCustomerIndexAvailable()) {
            $fromClause .= ' LEFT JOIN `' . system_search_economic_customer_index::TABLE . '` sci ON sci.customer_number = u.customer_number';
            $selectFields = [
                ...$selectFields,
                'sci.economic_name',
                'sci.economic_address',
                'sci.economic_city',
                'sci.economic_zip',
                'sci.economic_email',
                'sci.economic_cvr',
                'sci.economic_mobile_phone',
                'sci.search_text',
            ];
            $searchFields = [
                ...$searchFields,
                'sci.economic_name',
                'sci.economic_address',
                'sci.economic_city',
                'sci.economic_zip',
                'sci.economic_email',
                'sci.economic_cvr',
                'sci.economic_mobile_phone',
                'sci.search_text',
            ];
        }

        $rows = $this->searchTableWithJoin(
            'price_overrides',
            $fromClause,
            $selectFields,
            $searchFields,
            $terms,
            '1=1' . $customerFilter
        );

        return array_map(function (array $row) use ($terms, $entityBoost) {
            $customerDisplay = trim((string)($row['economic_name'] ?? ''));
            if ($customerDisplay === '') {
                $customerDisplay = trim((string)($row['display_name'] ?? ''));
            }
            return [
                'entity_type' => 'customer_discounts',
                'entity_id' => (string)$row['id'],
                'title' => 'Discount #' . (string)$row['id'],
                'description' => (string)('Customer ' . ($row['customer_number'] ?? '') . ' / ' . $customerDisplay),
                'customer_number' => isset($row['customer_number']) ? (int)$row['customer_number'] : null,
                'score' => $this->scoreRow($row, [
                    'id',
                    'customer_number',
                    'display_name',
                    'economic_name',
                    'economic_address',
                    'economic_city',
                    'economic_zip',
                    'economic_email',
                    'economic_cvr',
                    'economic_mobile_phone',
                    'search_text',
                    'product_or_category_id',
                    'percentage',
                    'fixed_price',
                    'user_id',
                ], $terms) + $entityBoost,
                'payload' => $this->augmentPayloadWithTemporal([
                    'id' => (int)$row['id'],
                    'customer_number' => isset($row['customer_number']) ? (int)$row['customer_number'] : null,
                    'product_or_category_id' => $row['product_or_category_id'] ?? null,
                    'percentage' => isset($row['percentage']) ? (int)$row['percentage'] : null,
                    'fixed_price' => isset($row['fixed_price']) ? (int)$row['fixed_price'] : null,
                    'economic_name' => $row['economic_name'] ?? null,
                    'economic_cvr' => $row['economic_cvr'] ?? null,
                ], $row),
            ];
        }, $rows);
    }

    private function searchCustomerFixedPrices(array $terms, int $entityBoost, bool $ownOnly, ?int $ownCustomerNumber, array $forcedCustomerNumbers): array
    {
        $customerNumbers = !empty($forcedCustomerNumbers)
            ? $forcedCustomerNumbers
            : (($ownOnly && $ownCustomerNumber) ? [$ownCustomerNumber] : []);
        $customerFilter = '';
        if (!empty($customerNumbers)) {
            $customerFilter = ' AND cfp.customer_number IN (' . implode(',', array_map('intval', $customerNumbers)) . ')';
        }

        $fromClause = 'customer_fixed_pricing cfp';
        $selectFields = [
            'cfp.id',
            'cfp.customer_number',
            'cfp.price',
            'cfp.description',
            ...$this->joinTemporalSelectFields('customer_fixed_pricing', 'cfp'),
        ];
        $searchFields = ['cfp.id', 'cfp.customer_number', 'cfp.price', 'cfp.description'];

        if ($this->isEconomicCustomerIndexAvailable()) {
            $fromClause .= ' LEFT JOIN `' . system_search_economic_customer_index::TABLE . '` sci ON sci.customer_number = cfp.customer_number';
            $selectFields = [
                ...$selectFields,
                'sci.economic_name',
                'sci.economic_address',
                'sci.economic_city',
                'sci.economic_zip',
                'sci.economic_email',
                'sci.economic_cvr',
                'sci.economic_mobile_phone',
                'sci.search_text',
            ];
            $searchFields = [
                ...$searchFields,
                'sci.economic_name',
                'sci.economic_address',
                'sci.economic_city',
                'sci.economic_zip',
                'sci.economic_email',
                'sci.economic_cvr',
                'sci.economic_mobile_phone',
                'sci.search_text',
            ];
        }

        $rows = $this->searchTableWithJoin(
            'customer_fixed_pricing',
            $fromClause,
            $selectFields,
            $searchFields,
            $terms,
            '1=1' . $customerFilter
        );

        return array_map(function (array $row) use ($terms, $entityBoost) {
            $description = trim((string)($row['description'] ?? ''));
            if ($description === '') {
                $description = trim((string)($row['economic_name'] ?? ''));
            }
            return [
                'entity_type' => 'customer_fixed_prices',
                'entity_id' => (string)$row['id'],
                'title' => 'Fixed pricing #' . (string)$row['id'],
                'description' => $description,
                'customer_number' => isset($row['customer_number']) ? (int)$row['customer_number'] : null,
                'score' => $this->scoreRow($row, [
                    'id',
                    'customer_number',
                    'price',
                    'description',
                    'economic_name',
                    'economic_address',
                    'economic_city',
                    'economic_zip',
                    'economic_email',
                    'economic_cvr',
                    'economic_mobile_phone',
                    'search_text',
                ], $terms) + $entityBoost,
                'payload' => $this->augmentPayloadWithTemporal([
                    'id' => (int)$row['id'],
                    'customer_number' => isset($row['customer_number']) ? (int)$row['customer_number'] : null,
                    'price' => isset($row['price']) ? (int)$row['price'] : null,
                    'description' => $row['description'] ?? null,
                    'economic_name' => $row['economic_name'] ?? null,
                    'economic_cvr' => $row['economic_cvr'] ?? null,
                ], $row),
            ];
        }, $rows);
    }

    private function searchDepartments(array $terms, int $entityBoost): array
    {
        $rows = $this->searchTable(
            'departments',
            ['id', 'name', 'address', 'zip', 'city'],
            ['id', 'name', 'address', 'zip', 'city'],
            $terms
        );

        return array_map(function (array $row) use ($terms, $entityBoost) {
            return [
                'entity_type' => 'departments',
                'entity_id' => (string)$row['id'],
                'title' => (string)($row['name'] ?: ('Department #' . $row['id'])),
                'description' => trim((string)(($row['address'] ?? '') . ' ' . ($row['city'] ?? ''))),
                'score' => $this->scoreRow($row, ['id', 'name', 'address', 'zip', 'city'], $terms) + $entityBoost,
                'payload' => $this->augmentPayloadWithTemporal([
                    'id' => (int)$row['id'],
                    'name' => $row['name'] ?? null,
                    'address' => $row['address'] ?? null,
                    'zip' => $row['zip'] ?? null,
                    'city' => $row['city'] ?? null,
                ], $row),
            ];
        }, $rows);
    }

    private function searchRoles(array $terms, int $entityBoost): array
    {
        $rows = $this->searchTable(
            'groups',
            ['id', 'name', 'description'],
            ['id', 'name', 'description'],
            $terms
        );

        return array_map(function (array $row) use ($terms, $entityBoost) {
            return [
                'entity_type' => 'roles',
                'entity_id' => (string)$row['id'],
                'title' => (string)($row['name'] ?: ('Role #' . $row['id'])),
                'description' => (string)($row['description'] ?? ''),
                'score' => $this->scoreRow($row, ['id', 'name', 'description'], $terms) + $entityBoost,
                'payload' => $this->augmentPayloadWithTemporal([
                    'id' => (int)$row['id'],
                    'name' => $row['name'] ?? null,
                    'description' => $row['description'] ?? null,
                ], $row),
            ];
        }, $rows);
    }

    private function searchPermissions(array $terms, int $entityBoost, bool $ownOnly, array $permissionsCatalogAll, array $permissionsCatalogOwn): array
    {
        $rows = [];
        if ($ownOnly) {
            foreach ($permissionsCatalogOwn as $permission) {
                $rows[] = ['permission' => (string)$permission, 'description' => (string)$permission];
            }
        } else {
            foreach ($permissionsCatalogAll as $permission => $description) {
                $rows[] = ['permission' => (string)$permission, 'description' => (string)$description];
            }
        }

        $filtered = [];
        foreach ($rows as $row) {
            $score = $this->scoreRow($row, ['permission', 'description'], $terms) + $entityBoost;
            if ($score <= 0) {
                continue;
            }
            $filtered[] = [
                'entity_type' => 'permissions',
                'entity_id' => (string)$row['permission'],
                'title' => (string)$row['permission'],
                'description' => (string)$row['description'],
                'score' => $score,
                'payload' => $row,
            ];
        }
        return $filtered;
    }

    private function searchModuleConfig(array $terms, int $entityBoost, array $moduleConfigVisibility): array
    {
        $rows = $this->searchTable(
            'module_config',
            ['module', 'variable', 'type', 'value'],
            ['module', 'variable', 'type'],
            $terms
        );

        $filtered = [];
        foreach ($rows as $row) {
            $module = (string)($row['module'] ?? '');
            if ($module !== '' && isset($moduleConfigVisibility[$module]) && !$moduleConfigVisibility[$module]) {
                continue;
            }
            $variable = (string)($row['variable'] ?? '');
            if ($this->looksSecretVariable($variable)) {
                continue;
            }
            $score = $this->scoreRow($row, ['module', 'variable', 'type'], $terms) + $entityBoost;
            if ($score <= 0) {
                continue;
            }
            $filtered[] = [
                'entity_type' => 'module_config',
                'entity_id' => $module . ':' . $variable,
                'title' => $module . '.' . $variable,
                'description' => (string)($row['type'] ?? ''),
                'score' => $score,
                'payload' => $this->augmentPayloadWithTemporal([
                    'module' => $module,
                    'variable' => $variable,
                    'type' => $row['type'] ?? null,
                ], $row),
            ];
        }
        return $filtered;
    }

    private function searchObjects(array $terms, int $entityBoost, bool $ownOnly, ?int $ownCustomerNumber): array
    {
        global $db;

        if ($ownOnly && $ownCustomerNumber === null) {
            return [];
        }

        $taskColumns = $this->getColumns('department_selfserve_tasks');
        $taskSelectFields = [];
        $taskSearchFields = [];
        if (in_array('task', $taskColumns, true)) {
            $taskSelectFields[] = 'dst.task AS task_title';
            $taskSearchFields[] = 'dst.task';
        }
        if (in_array('description', $taskColumns, true)) {
            $taskSelectFields[] = 'dst.description AS task_description';
            $taskSearchFields[] = 'dst.description';
        }
        if (in_array('department', $taskColumns, true)) {
            $taskSelectFields[] = 'dst.department AS task_department';
        }

        $taskDepartmentJoin = '';
        if ($this->tableExists('departments') && in_array('department', $taskColumns, true) && in_array('name', $this->getColumns('departments'), true)) {
            $taskDepartmentJoin = ' LEFT JOIN departments d ON d.id = dst.department';
            $taskSelectFields[] = 'd.name AS task_department_name';
            $taskSearchFields[] = 'd.name';
        }

        $customerJoin = '';
        $customerSelectFields = [];
        $customerSearchFields = ['o.customer_id', 'o.reference'];
        if ($this->tableExists(system_search_economic_customer_index::TABLE)) {
            $customerJoin = " LEFT JOIN `" . system_search_economic_customer_index::TABLE . "` sci ON sci.customer_number = o.customer_id";
            $customerSelectFields = [
                "COALESCE(sci.economic_name, sci.local_display_name) AS customer_name",
                "COALESCE(sci.economic_email, sci.local_email) AS customer_email",
                "COALESCE(sci.economic_mobile_phone, sci.local_phone) AS customer_phone",
                "sci.economic_cvr AS customer_cvr",
                "sci.economic_barred AS customer_barred",
            ];
            $customerSearchFields = [
                ...$customerSearchFields,
                'sci.economic_name',
                'sci.local_display_name',
                'sci.economic_email',
                'sci.local_email',
                'sci.economic_mobile_phone',
                'sci.local_phone',
                'sci.economic_cvr',
            ];
        }

        $termClauses = [];
        foreach ($terms as $term) {
            $escaped = $db->escape_string($term);
            foreach (array_values(array_unique([
                'oa.id',
                'oa.object_type',
                'oa.object_id',
                'oa.content',
                ...$customerSearchFields,
                ...$taskSearchFields,
            ])) as $field) {
                $termClauses[] = $field . " LIKE '%$escaped%'";
            }
        }
        if (empty($termClauses)) {
            return [];
        }

        $scopeClauses = [];
        if ($ownOnly) {
            $scopeClauses[] = "(oa.object_type = 'orders' AND o.customer_id = " . (int)$ownCustomerNumber . ")";
        } else {
            $scopeClauses[] = "(oa.object_type = 'orders' AND o.id IS NOT NULL)";
            $scopeClauses[] = "(oa.object_type = 'department_selfserve_tasks' AND dst.id IS NOT NULL)";
        }

        $rows = $this->runSelectRows(
            "SELECT oa.id, oa.object_type, oa.object_id, oa.content, oa.created_at, oa.updated_at,"
            . " o.customer_id AS customer_number, o.department_id, o.reference AS order_reference,"
            . (!empty($taskSelectFields) ? (' ' . ', ' . implode(', ', array_values(array_unique($taskSelectFields)))) : '')
            . (!empty($customerSelectFields) ? (' ' . ', ' . implode(', ', $customerSelectFields)) : '')
            . " FROM object_attachments oa"
            . " LEFT JOIN orders o ON oa.object_type = 'orders' AND o.id = oa.object_id AND o.deleted_at IS NULL"
            . " LEFT JOIN department_selfserve_tasks dst ON oa.object_type = 'department_selfserve_tasks' AND dst.id = oa.object_id AND dst.deleted_at IS NULL"
            . $taskDepartmentJoin
            . $customerJoin
            . " WHERE oa.deleted_at IS NULL"
            . " AND (" . implode(' OR ', $scopeClauses) . ")"
            . " AND (" . implode(' OR ', $termClauses) . ")"
            . " LIMIT " . $this->defaultEntityFetchLimit
        );

        $this->primeCustomerContexts(array_values(array_unique(array_filter(
            array_map(fn(array $row): ?int => $this->toIntOrNull($row['customer_number'] ?? null), $rows),
            static fn(?int $value): bool => $value !== null && $value > 0
        ))));

        return array_map(
            fn(array $row): array => $this->buildObjectSearchResult($row, $terms, $entityBoost),
            $rows
        );
    }

    private function isGenericEntityType(string $entityType): bool
    {
        $configs = $this->genericEntityConfigs();
        return isset($configs[$entityType]);
    }

    /**
     * @return array<int, string>
     */
    private function associationEntityTypes(): array
    {
        $types = ['orders', 'order_items', 'invoices', 'vehicles', 'customer_discounts', 'customer_fixed_prices'];
        foreach ($this->genericEntityConfigs() as $entityType => $config) {
            if (isset($config['customer_field']) && is_string($config['customer_field']) && $config['customer_field'] !== '') {
                $types[] = $entityType;
            }
        }
        return array_values(array_unique($types));
    }

    /**
     * @param array<int, string> $terms
     * @param array<int, int> $allowedDepartmentIds
     * @param array<int, int> $forcedCustomerNumbers
     * @return array<int, array<string, mixed>>
     */
    private function searchGenericEntity(
        string $entityType,
        array $terms,
        int $entityBoost,
        bool $ownOnly,
        ?int $ownCustomerNumber,
        array $allowedDepartmentIds = [],
        array $forcedCustomerNumbers = []
    ): array {
        if (empty($terms)) {
            return [];
        }

        $config = $this->genericEntityConfigs()[$entityType] ?? null;
        if (!is_array($config)) {
            return [];
        }

        $table = trim((string)($config['table'] ?? ''));
        if ($table === '' || !$this->tableExists($table)) {
            return [];
        }

        $columns = $this->getColumns($table);
        if (empty($columns)) {
            return [];
        }

        $idField = (string)($config['id_field'] ?? (in_array('id', $columns, true) ? 'id' : $columns[0]));
        if (!in_array($idField, $columns, true)) {
            return [];
        }

        $customerField = null;
        if (isset($config['customer_field']) && is_string($config['customer_field']) && in_array($config['customer_field'], $columns, true)) {
            $customerField = $config['customer_field'];
        }
        $customerFieldMode = isset($config['customer_field_mode']) && is_string($config['customer_field_mode'])
            ? trim(mb_strtolower($config['customer_field_mode']))
            : 'default';
        if ($customerFieldMode === '') {
            $customerFieldMode = 'default';
        }

        if ($ownOnly) {
            if ($customerField === null) {
                return [];
            }
            if (empty($forcedCustomerNumbers) && $ownCustomerNumber === null) {
                return [];
            }
        }

        $departmentField = null;
        if (isset($config['department_field']) && is_string($config['department_field']) && in_array($config['department_field'], $columns, true)) {
            $departmentField = $config['department_field'];
        }

        $excludedColumns = [];
        if (isset($config['exclude_columns']) && is_array($config['exclude_columns'])) {
            $excludedColumns = array_values(array_filter($config['exclude_columns'], static fn($v) => is_string($v) && $v !== ''));
        }

        $searchable = [];
        if (isset($config['search_fields']) && is_array($config['search_fields']) && !empty($config['search_fields'])) {
            $configured = array_values(array_filter($config['search_fields'], static fn($v) => is_string($v) && $v !== ''));
            $configured = array_values(array_intersect($configured, $columns));
            $searchable = $this->sanitizeGenericSearchFields($configured, $excludedColumns);
        }
        if (empty($searchable)) {
            $searchable = $this->sanitizeGenericSearchFields($columns, $excludedColumns);
        }
        if (empty($searchable)) {
            return [];
        }

        $selectFields = array_values(array_unique(array_filter([
            $idField,
            $customerField,
            $departmentField,
            ...$searchable,
        ], static fn($value) => is_string($value) && $value !== '')));
        if (count($selectFields) > 24) {
            $selectFields = array_slice($selectFields, 0, 24);
        }
        $searchable = array_values(array_intersect($searchable, $selectFields));

        $fixedConditions = [];
        if (isset($config['fixed_conditions']) && is_array($config['fixed_conditions'])) {
            foreach ($config['fixed_conditions'] as $column => $value) {
                if (!is_string($column) || !in_array($column, $columns, true)) {
                    continue;
                }
                $fixedConditions[$column] = $value;
            }
        }
        if (!array_key_exists('deleted_at', $fixedConditions) && in_array('deleted_at', $columns, true)) {
            $fixedConditions['deleted_at'] = null;
        }

        $customerNumbers = !empty($forcedCustomerNumbers)
            ? $forcedCustomerNumbers
            : (($ownOnly && $ownCustomerNumber !== null && $customerField !== null) ? [$ownCustomerNumber] : []);

        $rows = $this->searchTable(
            $table,
            $selectFields,
            $searchable,
            $terms,
            $customerNumbers,
            $customerField,
            $customerFieldMode,
            $fixedConditions,
            $allowedDepartmentIds,
            $departmentField
        );

        $this->primeCustomerContexts(array_values(array_unique(array_filter(
            array_map(
                fn(array $row): ?int => ($customerField !== null && array_key_exists($customerField, $row))
                    ? $this->resolveConfiguredCustomerNumber($row[$customerField], $customerFieldMode)
                    : null,
                $rows
            ),
            static fn(?int $value): bool => $value !== null && $value > 0
        ))));

        $titleFields = [];
        if (isset($config['title_fields']) && is_array($config['title_fields'])) {
            $titleFields = array_values(array_filter($config['title_fields'], static fn($v) => is_string($v) && in_array($v, $selectFields, true)));
        }
        if (empty($titleFields)) {
            $titleFields = array_values(array_intersect(
                ['name', 'title', 'display_name', 'reference', 'reference_number', 'reg', 'reg_1', 'plate', 'module', 'customer_number', 'id'],
                $selectFields
            ));
        }

        $descriptionFields = [];
        if (isset($config['description_fields']) && is_array($config['description_fields'])) {
            $descriptionFields = array_values(array_filter($config['description_fields'], static fn($v) => is_string($v) && in_array($v, $selectFields, true)));
        }
        if (empty($descriptionFields)) {
            $descriptionFields = array_values(array_intersect(
                ['description', 'note', 'notes', 'email', 'status', 'type', 'city', 'address', 'action', 'message', 'customer_id'],
                $selectFields
            ));
        }

        $entityLabel = ucfirst(str_replace('_', ' ', $entityType));
        $results = [];
        foreach ($rows as $row) {
            $entityId = isset($row[$idField]) ? (string)$row[$idField] : md5(json_encode($row, JSON_UNESCAPED_UNICODE));

            $title = '';
            foreach ($titleFields as $field) {
                $value = trim((string)($row[$field] ?? ''));
                if ($value !== '') {
                    $title = $value;
                    break;
                }
            }
            if ($entityType === 'department_goals') {
                $title = $this->departmentGoalResultTitle($row['criteria'] ?? null, $entityId, $title);
            }
            if ($title === '') {
                $title = $entityLabel . ' #' . $entityId;
            }

            $descriptionParts = [];
            foreach ($descriptionFields as $field) {
                $value = trim((string)($row[$field] ?? ''));
                if ($value === '') {
                    continue;
                }
                $descriptionParts[] = $value;
                if (count($descriptionParts) >= 2) {
                    break;
                }
            }
            $description = implode(' / ', $descriptionParts);

            $results[] = $this->decorateSearchResultWithCustomerContext([
                'entity_type' => $entityType,
                'entity_id' => $entityId,
                'title' => $title,
                'description' => $description,
                'customer_number' => ($customerField !== null && array_key_exists($customerField, $row))
                    ? $this->resolveConfiguredCustomerNumber($row[$customerField], $customerFieldMode)
                    : null,
                'department_id' => ($departmentField !== null && isset($row[$departmentField])) ? $this->toIntOrNull($row[$departmentField]) : null,
                'score' => $this->scoreRow($row, $searchable, $terms) + $entityBoost,
                'payload' => $this->augmentPayloadWithTemporal(
                    array_intersect_key($row, array_flip([...$selectFields, 'updated_at', 'created_at'])),
                    $row
                ),
            ]);
        }

        return $results;
    }

    /**
     * @param array<int, string> $columns
     * @param array<int, string> $excludeColumns
     * @return array<int, string>
     */
    private function sanitizeGenericSearchFields(array $columns, array $excludeColumns = []): array
    {
        $excluded = array_values(array_unique(array_map(static fn($v) => mb_strtolower((string)$v), $excludeColumns)));
        $filtered = [];
        foreach ($columns as $column) {
            if (!is_string($column) || $column === '') {
                continue;
            }
            $lower = mb_strtolower($column);
            if (in_array($lower, $excluded, true)) {
                continue;
            }
            if (in_array($lower, ['created_at', 'updated_at'], true)) {
                continue;
            }
            if (preg_match('/(?:^|_)(password|token|secret|api_key|apikey|private|credential|passkey|session|hash|salt|client_secret|refresh_token|access_token)(?:_|$)/i', $lower)) {
                continue;
            }
            if (in_array($lower, ['data', 'content', 'payload', 'config', 'permissions', 'washitems', 'client_secret'], true)) {
                continue;
            }
            $filtered[] = $column;
        }
        return array_values(array_unique($filtered));
    }

    /**
     * @return array<string, array<string, mixed>>
     */
    private function genericEntityConfigs(): array
    {
        return system_search_registry::genericEntityConfigs();
    }

    private function toIntOrNull(mixed $value): ?int
    {
        if (is_int($value)) {
            return $value;
        }
        if (is_string($value) && preg_match('/^-?\d+$/', $value)) {
            return (int)$value;
        }
        if (is_float($value)) {
            return (int)$value;
        }
        return null;
    }

    private function resolveConfiguredCustomerNumber(mixed $value, string $mode = 'default'): ?int
    {
        if ($mode !== 'digits_only') {
            return $this->toIntOrNull($value);
        }

        if (is_int($value)) {
            return $value > 0 ? $value : null;
        }

        if (!is_string($value)) {
            return null;
        }

        $trimmed = trim($value);
        if ($trimmed === '' || !preg_match('/^\d+$/', $trimmed)) {
            return null;
        }

        $resolved = (int)$trimmed;
        return $resolved > 0 ? $resolved : null;
    }

    /**
     * @param array<int, int> $customerNumbers
     */
    private function customerFieldFilterClause(string $customerField, array $customerNumbers, string $customerFieldMode = 'default'): string
    {
        $normalizedNumbers = array_values(array_unique(array_filter(
            array_map('intval', $customerNumbers),
            static fn(int $value): bool => $value > 0
        )));
        if (empty($normalizedNumbers)) {
            return '';
        }

        if ($customerFieldMode === 'digits_only') {
            return "TRIM(`$customerField`) REGEXP '^[0-9]+$' AND CAST(TRIM(`$customerField`) AS UNSIGNED) IN ("
                . implode(',', $normalizedNumbers)
                . ")";
        }

        return "`$customerField` IN (" . implode(',', $normalizedNumbers) . ")";
    }

    /**
     * Generic table search helper.
     *
     * @param array<int, string> $candidateFields
     * @param array<int, string> $searchFields
     * @param array<int, string> $terms
     * @param array<int, int> $customerNumbers
     * @param string|null $customerField
     * @param string $customerFieldMode
     * @param array<string, mixed> $fixedConditions
     * @param array<int, int> $departmentIds
     * @param string|null $departmentField
     * @return array<int, array<string, mixed>>
     */
    private function searchTable(
        string $table,
        array $candidateFields,
        array $searchFields,
        array $terms,
        array $customerNumbers = [],
        ?string $customerField = null,
        string $customerFieldMode = 'default',
        array $fixedConditions = [],
        array $departmentIds = [],
        ?string $departmentField = null
    ): array {
        global $db;

        if (!$this->tableExists($table)) {
            return [];
        }
        $fields = $this->intersectExistingColumns($table, $candidateFields);
        if (empty($fields)) {
            return [];
        }
        $fields = $this->appendTemporalColumns($table, $fields);
        $searchable = array_values(array_intersect($searchFields, $fields));
        if (empty($searchable)) {
            return [];
        }

        $wheres = [];
        foreach ($fixedConditions as $column => $value) {
            if (!in_array($column, $fields, true)) {
                continue;
            }
            if ($value === null) {
                $wheres[] = "`$column` IS NULL";
            } else {
                $wheres[] = "`$column` = '" . $db->escape_string((string)$value) . "'";
            }
        }

        if (!empty($customerNumbers) && $customerField !== null && in_array($customerField, $fields, true)) {
            $customerFilterClause = $this->customerFieldFilterClause($customerField, $customerNumbers, $customerFieldMode);
            if ($customerFilterClause !== '') {
                $wheres[] = $customerFilterClause;
            }
        }

        if (!empty($departmentIds) && $departmentField !== null && in_array($departmentField, $fields, true)) {
            $wheres[] = "`$departmentField` IN (" . implode(',', array_map('intval', $departmentIds)) . ")";
        }

        $termClauses = [];
        foreach ($terms as $term) {
            $escaped = $db->escape_string($term);
            foreach ($searchable as $field) {
                $termClauses[] = "`$field` LIKE '%$escaped%'";
            }
        }
        if (!empty($termClauses)) {
            $wheres[] = '(' . implode(' OR ', $termClauses) . ')';
        }

        if (empty($wheres)) {
            return [];
        }

        $sql = "SELECT " . implode(', ', array_map(fn($f) => "`$f`", $fields))
            . " FROM `$table`"
            . " WHERE " . implode(' AND ', $wheres)
            . " LIMIT " . $this->defaultEntityFetchLimit;
        return $this->runSelectRows($sql);
    }

    /**
     * Generic join search helper.
     *
     * @param array<int, string> $selectFields
     * @param array<int, string> $searchFields
     * @param array<int, string> $terms
     * @return array<int, array<string, mixed>>
     */
    private function searchTableWithJoin(
        string $table,
        string $fromClause,
        array $selectFields,
        array $searchFields,
        array $terms,
        string $baseWhere
    ): array {
        global $db;
        if (!$this->tableExists($table)) {
            return [];
        }
        if (empty($terms)) {
            return [];
        }
        $termClauses = [];
        foreach ($terms as $term) {
            $escaped = $db->escape_string($term);
            foreach ($searchFields as $field) {
                $termClauses[] = "$field LIKE '%$escaped%'";
            }
        }
        if (empty($termClauses)) {
            return [];
        }
        $sql = "SELECT " . implode(', ', $selectFields)
            . " FROM " . $fromClause
            . " WHERE " . $baseWhere
            . " AND (" . implode(' OR ', $termClauses) . ")"
            . " LIMIT " . $this->defaultEntityFetchLimit;
        return $this->runSelectRows($sql);
    }

    /**
     * @return array<int, array<string, mixed>>
     */
    protected function runSelectRows(string $sql): array
    {
        global $db;
        try {
            $result = $db->query($sql);
            if (!($result instanceof \mysqli_result)) {
                return [];
            }
            return $db->fetch_all($result);
        } catch (Throwable) {
            return [];
        }
    }

    private function scoreRow(array $row, array $fields, array $terms): int
    {
        $fieldWeights = [];
        foreach ($fields as $key => $value) {
            if (is_string($key)) {
                $fieldWeights[$key] = max(1, (int)$value);
                continue;
            }
            if (is_string($value)) {
                $fieldWeights[$value] = 1;
            }
        }
        if (empty($fieldWeights) || empty($terms)) {
            return 0;
        }

        $contentTerms = $this->contentTerms($terms);
        $matchedTerms = [];
        $score = 0;
        foreach ($terms as $term) {
            $termLower = mb_strtolower($term);
            $bestScore = 0;
            foreach ($fieldWeights as $field => $weight) {
                if (!array_key_exists($field, $row) || $row[$field] === null) {
                    continue;
                }
                $value = trim((string)$row[$field]);
                if ($value === '') {
                    continue;
                }
                $valueLower = mb_strtolower($value);
                $baseScore = 0;
                if ($valueLower === $termLower) {
                    $baseScore = 100;
                } elseif (str_starts_with($valueLower, $termLower)) {
                    $baseScore = 60;
                } elseif (str_contains($valueLower, $termLower)) {
                    $baseScore = 30;
                } elseif (strlen($termLower) >= 4 && strlen($valueLower) <= 64) {
                    $distance = levenshtein($termLower, $valueLower);
                    if ($distance <= 2) {
                        $baseScore = 20 - ($distance * 5);
                    }
                }
                if ($baseScore <= 0) {
                    continue;
                }
                $bestScore = max($bestScore, $baseScore * $weight);
            }

            if ($bestScore > 0) {
                $score += $bestScore;
                if (in_array($termLower, $contentTerms, true)) {
                    $matchedTerms[$termLower] = true;
                }
            }
        }

        if (count($contentTerms) > 1) {
            $requiredMatches = $this->minimumTermMatches($contentTerms);
            if (count($matchedTerms) < $requiredMatches) {
                return 0;
            }
        }

        return $score;
    }

    /**
     * @param array<int, string> $terms
     * @return array<int, string>
     */
    private function contentTerms(array $terms): array
    {
        $stopwords = [
            'a', 'an', 'and', 'at', 'between', 'find', 'for', 'fra', 'from', 'har', 'have',
            'hvilke', 'hvor', 'i', 'med', 'need', 'of', 'og', 'or', 'search', 'show', 'som',
            'the', 'til', 'uden', 'want', 'where', 'which', 'with', 'without',
            'booking', 'bookings', 'customer', 'customers', 'discount', 'discounts', 'faktura',
            'invoice', 'invoices', 'kunde', 'kunder', 'order', 'orders', 'rabat', 'user', 'users',
            'vehicle', 'vehicles',
        ];

        $filtered = [];
        foreach ($terms as $term) {
            $normalized = trim(mb_strtolower((string)$term));
            if ($normalized === '' || in_array($normalized, $stopwords, true)) {
                continue;
            }
            $filtered[] = $normalized;
        }

        $filtered = array_values(array_unique($filtered));
        if (!empty($filtered)) {
            return $filtered;
        }

        return array_values(array_unique(array_map(static fn($term) => mb_strtolower((string)$term), $terms)));
    }

    /**
     * @param array<int, string> $terms
     */
    private function minimumTermMatches(array $terms): int
    {
        $count = count($terms);
        if ($count <= 1) {
            return 1;
        }
        if ($count === 2) {
            return 2;
        }
        return min(3, max(2, (int)ceil($count / 2)));
    }

    private function tableExists(string $table): bool
    {
        return !empty($this->getColumns($table));
    }

    private function intersectExistingColumns(string $table, array $candidateFields): array
    {
        $columns = $this->getColumns($table);
        if (empty($columns)) {
            return [];
        }
        return array_values(array_intersect($candidateFields, $columns));
    }

    private function getColumns(string $table): array
    {
        if (isset($this->tableColumnsCache[$table])) {
            return $this->tableColumnsCache[$table];
        }
        global $db;
        try {
            $result = $db->query("SHOW COLUMNS FROM `$table`");
            if (!($result instanceof \mysqli_result)) {
                $this->tableColumnsCache[$table] = [];
                return [];
            }
            $rows = $db->fetch_all($result);
            $columns = array_values(array_map(static fn($row) => (string)$row['Field'], $rows));
            $this->tableColumnsCache[$table] = $columns;
            return $columns;
        } catch (Throwable) {
            $this->tableColumnsCache[$table] = [];
            return [];
        }
    }

    private function looksSecretVariable(string $variable): bool
    {
        $variable = mb_strtolower($variable);
        return str_contains($variable, 'api_key')
            || str_contains($variable, 'secret')
            || str_contains($variable, 'password')
            || str_contains($variable, 'token')
            || str_contains($variable, 'private_key');
    }

    private function mergeResults(array $base, array $incoming): array
    {
        $indexed = [];
        foreach ($base as $item) {
            $key = (string)$item['entity_type'] . ':' . (string)$item['entity_id'];
            $indexed[$key] = $item;
        }
        foreach ($incoming as $item) {
            $key = (string)$item['entity_type'] . ':' . (string)$item['entity_id'];
            if (!isset($indexed[$key])) {
                $indexed[$key] = $item;
                continue;
            }
            if ((int)$item['score'] > (int)$indexed[$key]['score']) {
                $indexed[$key]['score'] = (int)$item['score'];
            }
            if (!isset($indexed[$key]['association_reason']) && isset($item['association_reason'])) {
                $indexed[$key]['association_reason'] = $item['association_reason'];
            }
        }
        return array_values($indexed);
    }

    /**
     * @param array<int, string> $fields
     * @return array<int, string>
     */
    private function appendTemporalColumns(string $table, array $fields): array
    {
        $columns = $this->getColumns($table);
        foreach (['updated_at', 'created_at'] as $column) {
            if (in_array($column, $columns, true) && !in_array($column, $fields, true)) {
                $fields[] = $column;
            }
        }
        return array_values(array_unique($fields));
    }

    /**
     * @return array<int, string>
     */
    private function joinTemporalSelectFields(string $table, string $alias): array
    {
        $fields = [];
        $columns = $this->getColumns($table);
        $aliasPrefix = trim($alias) === '' ? '' : (trim($alias) . '.');
        foreach (['updated_at', 'created_at'] as $column) {
            if (!in_array($column, $columns, true)) {
                continue;
            }
            $fields[] = $aliasPrefix . $column . ' AS ' . $column;
        }
        return $fields;
    }

    /**
     * @param array<string, mixed> $payload
     * @param array<string, mixed> $row
     * @return array<string, mixed>
     */
    private function augmentPayloadWithTemporal(array $payload, array $row): array
    {
        foreach (['updated_at', 'created_at'] as $column) {
            if (array_key_exists($column, $row)) {
                $payload[$column] = $row[$column];
            }
        }
        return $payload;
    }

    /**
     * @param array<string, mixed> $row
     * @param array<int, string> $terms
     * @return array<string, mixed>
     */
    private function buildObjectSearchResult(array $row, array $terms, int $entityBoost): array
    {
        $context = $this->resolveObjectSearchContext($row);
        $result = [
            'entity_type' => 'objects',
            'entity_id' => (string)($row['id'] ?? ''),
            'title' => (string)$context['title'],
            'description' => (string)$context['description'],
            'customer_number' => $context['customer_number'],
            'department_id' => $context['department_id'],
            'score' => $this->scoreRow($row, [
                'id' => 3,
                'object_type' => 2,
                'object_id' => 2,
                'content' => 1,
                'customer_number' => 3,
                'customer_name' => 4,
                'customer_email' => 2,
                'customer_cvr' => 2,
                'order_reference' => 3,
                'task_title' => 3,
                'task_description' => 2,
                'task_department_name' => 2,
            ], $terms) + $entityBoost,
            'payload' => $this->augmentPayloadWithTemporal((array)$context['payload'], $row),
        ];

        return $this->decorateSearchResultWithCustomerContext($result);
    }

    /**
     * @param array<string, mixed> $row
     * @return array{title: string, description: string, customer_number: ?int, department_id: ?int, payload: array<string,mixed>}
     */
    private function resolveObjectSearchContext(array $row): array
    {
        return match (trim(mb_strtolower((string)($row['object_type'] ?? '')))) {
            'orders' => $this->resolveOrderObjectSearchContext($row),
            'department_selfserve_tasks' => $this->resolveTaskObjectSearchContext($row),
            default => $this->resolveGenericObjectSearchContext($row),
        };
    }

    /**
     * @param array<string, mixed> $row
     * @return array{title: string, description: string, customer_number: ?int, department_id: ?int, payload: array<string,mixed>}
     */
    private function resolveOrderObjectSearchContext(array $row): array
    {
        $attachmentName = $this->attachmentNameFromContent($row['content'] ?? null);
        $title = $attachmentName !== '' ? $attachmentName : ('Order attachment #' . (string)($row['object_id'] ?? ''));
        $customerNumber = $this->toIntOrNull($row['customer_number'] ?? null);
        $departmentId = $this->toIntOrNull($row['department_id'] ?? null);
        $customerName = trim((string)($row['customer_name'] ?? ''));

        $descriptionParts = [];
        $orderReference = trim((string)($row['order_reference'] ?? ''));
        if ($orderReference !== '') {
            $descriptionParts[] = $orderReference;
        }
        if ($customerName !== '') {
            $descriptionParts[] = $customerName;
        }

        return [
            'title' => $title,
            'description' => implode(' / ', array_slice($descriptionParts, 0, 2)),
            'customer_number' => $customerNumber,
            'department_id' => $departmentId,
            'payload' => [
                'id' => $this->toIntOrNull($row['id'] ?? null),
                'object_type' => $row['object_type'] ?? null,
                'object_id' => $this->toIntOrNull($row['object_id'] ?? null),
                'linked_entity_type' => 'orders',
                'linked_entity_id' => $this->toIntOrNull($row['object_id'] ?? null),
                'attachment_name' => $attachmentName !== '' ? $attachmentName : null,
                'customer_number' => $customerNumber,
                'department_id' => $departmentId,
                'order_reference' => $row['order_reference'] ?? null,
            ],
        ];
    }

    /**
     * @param array<string, mixed> $row
     * @return array{title: string, description: string, customer_number: ?int, department_id: ?int, payload: array<string,mixed>}
     */
    private function resolveTaskObjectSearchContext(array $row): array
    {
        $attachmentName = $this->attachmentNameFromContent($row['content'] ?? null);
        $title = $attachmentName !== '' ? $attachmentName : ('Task attachment #' . (string)($row['object_id'] ?? ''));
        $departmentId = $this->toIntOrNull($row['task_department'] ?? null);

        $descriptionParts = [];
        foreach (['task_title', 'task_department_name', 'task_description'] as $field) {
            $value = trim((string)($row[$field] ?? ''));
            if ($value === '') {
                continue;
            }
            $descriptionParts[] = $value;
            if (count($descriptionParts) >= 2) {
                break;
            }
        }

        return [
            'title' => $title,
            'description' => implode(' / ', $descriptionParts),
            'customer_number' => null,
            'department_id' => $departmentId,
            'payload' => [
                'id' => $this->toIntOrNull($row['id'] ?? null),
                'object_type' => $row['object_type'] ?? null,
                'object_id' => $this->toIntOrNull($row['object_id'] ?? null),
                'linked_entity_type' => 'department_selfserve_tasks',
                'linked_entity_id' => $this->toIntOrNull($row['object_id'] ?? null),
                'attachment_name' => $attachmentName !== '' ? $attachmentName : null,
                'department_id' => $departmentId,
                'task_title' => $row['task_title'] ?? null,
                'task_description' => $row['task_description'] ?? null,
                'task_department' => $departmentId,
                'task_department_name' => $row['task_department_name'] ?? null,
            ],
        ];
    }

    /**
     * @param array<string, mixed> $row
     * @return array{title: string, description: string, customer_number: ?int, department_id: ?int, payload: array<string,mixed>}
     */
    private function resolveGenericObjectSearchContext(array $row): array
    {
        $attachmentName = $this->attachmentNameFromContent($row['content'] ?? null);
        $title = $attachmentName !== ''
            ? $attachmentName
            : ((string)($row['object_type'] ?? 'object_attachment') . '#' . (string)($row['object_id'] ?? ''));

        return [
            'title' => $title,
            'description' => '',
            'customer_number' => $this->toIntOrNull($row['customer_number'] ?? null),
            'department_id' => $this->toIntOrNull($row['department_id'] ?? ($row['task_department'] ?? null)),
            'payload' => [
                'id' => $this->toIntOrNull($row['id'] ?? null),
                'object_type' => $row['object_type'] ?? null,
                'object_id' => $this->toIntOrNull($row['object_id'] ?? null),
                'linked_entity_type' => $row['object_type'] ?? null,
                'linked_entity_id' => $this->toIntOrNull($row['object_id'] ?? null),
                'attachment_name' => $attachmentName !== '' ? $attachmentName : null,
                'customer_number' => $this->toIntOrNull($row['customer_number'] ?? null),
                'department_id' => $this->toIntOrNull($row['department_id'] ?? ($row['task_department'] ?? null)),
            ],
        ];
    }

    private function attachmentNameFromContent(mixed $content): string
    {
        $decodedContent = is_string($content) ? json_decode($content, true) : null;
        if (!is_array($decodedContent)) {
            return '';
        }
        return trim((string)($decodedContent['other'] ?? ''));
    }

    /**
     * @param array<int, int> $customerNumbers
     */
    private function primeCustomerContexts(array $customerNumbers): void
    {
        $missing = [];
        foreach ($customerNumbers as $customerNumber) {
            $normalized = (int)$customerNumber;
            if ($normalized <= 0 || array_key_exists($normalized, $this->customerContextCache)) {
                continue;
            }
            $missing[] = $normalized;
        }
        if (empty($missing)) {
            return;
        }

        $loaded = $this->loadCustomerContexts($missing);
        foreach ($missing as $customerNumber) {
            $this->customerContextCache[$customerNumber] = $loaded[$customerNumber] ?? null;
        }
    }

    /**
     * @param array<int, int> $customerNumbers
     * @return array<int, array<string, mixed>>
     */
    protected function loadCustomerContexts(array $customerNumbers): array
    {
        $contexts = system_search_economic_customer_index::fetchContexts($customerNumbers);
        foreach ($customerNumbers as $customerNumber) {
            $normalized = (int)$customerNumber;
            if ($normalized <= 0 || isset($contexts[$normalized])) {
                continue;
            }

            $fallback = $this->loadFallbackCustomerContext($normalized);
            if ($fallback !== null) {
                $contexts[$normalized] = $fallback;
            }
        }

        return $contexts;
    }

    /**
     * @return array<string, mixed>|null
     */
    private function loadFallbackCustomerContext(int $customerNumber): ?array
    {
        if ($customerNumber <= 0) {
            return null;
        }

        try {
            $users = new \objects\users_o();
            $name = $users->getCustomerName($customerNumber);
            $userId = null;
            try {
                $resolvedUserId = $users->getUserIdFromEconomic($customerNumber);
                $userId = $resolvedUserId > 0 ? $resolvedUserId : null;
            } catch (Throwable) {
                $userId = null;
            }

            $resolved = ($name !== null && trim($name) !== '') || $userId !== null;
            $barred = $resolved ? $users->isCustomerBarred($customerNumber) : null;

            return [
                'customer_number' => $customerNumber,
                'user_id' => $userId,
                'name' => is_string($name) && trim($name) !== '' ? trim($name) : null,
                'barred' => $barred,
                'status' => $this->customerBarredStatus($barred),
                'email' => null,
                'phone' => null,
                'cvr' => null,
                'address' => null,
                'city' => null,
                'zip' => null,
            ];
        } catch (Throwable) {
            return null;
        }
    }

    /**
     * @return array<string, mixed>|null
     */
    private function customerContext(?int $customerNumber): ?array
    {
        if ($customerNumber === null || $customerNumber <= 0) {
            return null;
        }
        $this->primeCustomerContexts([$customerNumber]);
        return $this->customerContextCache[$customerNumber] ?? null;
    }

    /**
     * @param array<string, mixed> $result
     * @return array<string, mixed>
     */
    private function decorateSearchResultWithCustomerContext(array $result): array
    {
        $payload = is_array($result['payload'] ?? null) ? $result['payload'] : [];
        $customerNumber = $this->toIntOrNull($result['customer_number'] ?? ($payload['customer_number'] ?? null));
        if ($customerNumber !== null) {
            $result['customer_number'] = $customerNumber;
        }

        $context = $this->customerContext($customerNumber);
        $result['payload'] = $this->enrichPayloadWithCustomerContext($payload, $customerNumber, $context);
        if ($context !== null) {
            $result['customer_name'] = $context['name'] ?? null;
            $result['customer_barred'] = $context['barred'] ?? null;
            $result['customer_status'] = $context['status'] ?? $this->customerBarredStatus($context['barred'] ?? null);
            $result['title'] = $this->overrideUnnamedUserTitleWithCustomerName($result, $context);
        }

        return $result;
    }

    /**
     * @param array<string, mixed> $result
     * @param array<string, mixed> $context
     */
    private function overrideUnnamedUserTitleWithCustomerName(array $result, array $context): string
    {
        $title = trim((string)($result['title'] ?? ''));
        if (trim(mb_strtolower((string)($result['entity_type'] ?? ''))) !== 'users') {
            return $title;
        }
        if (trim(mb_strtolower($title)) !== 'unnamed') {
            return $title;
        }

        $customerName = trim((string)($context['name'] ?? ''));
        return $customerName !== '' ? $customerName : $title;
    }

    /**
     * @param array<string, mixed> $payload
     * @param array<string, mixed>|null $context
     * @return array<string, mixed>
     */
    private function enrichPayloadWithCustomerContext(array $payload, ?int $customerNumber, ?array $context = null): array
    {
        if ($customerNumber !== null) {
            $payload['customer_number'] = $customerNumber;
        }
        if ($context === null) {
            $context = $this->customerContext($customerNumber);
        }
        if ($context === null) {
            return $payload;
        }

        $payload['customer_context'] = $context;
        $payload['customer_name'] = $context['name'] ?? null;
        $payload['customer_barred'] = $context['barred'] ?? null;
        $payload['customer_status'] = $context['status'] ?? $this->customerBarredStatus($context['barred'] ?? null);
        foreach (['email', 'phone', 'cvr', 'address', 'city', 'zip', 'user_id'] as $key) {
            if (array_key_exists($key, $context)) {
                $payload['customer_' . $key] = $context[$key];
            }
        }

        return $payload;
    }

    private function customerBarredStatus(?bool $barred): string
    {
        return match ($barred) {
            true => 'barred',
            false => 'active',
            default => 'unknown',
        };
    }

    private function resultRecencyTimestamp(array $result): int
    {
        $timestamps = [];
        foreach (['updated_at', 'created_at', 'date', 'timestamp'] as $key) {
            if (array_key_exists($key, $result)) {
                $timestamps[] = $this->normalizeTimestamp($result[$key]);
            }
        }

        $payload = $result['payload'] ?? null;
        if (is_array($payload)) {
            foreach (['updated_at', 'created_at', 'date', 'timestamp'] as $key) {
                if (!array_key_exists($key, $payload)) {
                    continue;
                }
                $timestamps[] = $this->normalizeTimestamp($payload[$key]);
            }
        }

        $timestamps = array_values(array_filter($timestamps, static fn($v) => is_int($v) && $v > 0));
        if (empty($timestamps)) {
            return 0;
        }
        return max($timestamps);
    }

    private function isCancelledBookingResult(array $result): bool
    {
        $entityType = trim(mb_strtolower((string)($result['entity_type'] ?? '')));
        if (!in_array($entityType, ['bookings', 'bookings_new', 'order_bookings'], true)) {
            return false;
        }

        $sources = [$result];
        $payload = $result['payload'] ?? null;
        if (is_array($payload)) {
            $sources[] = $payload;
        }

        foreach ($sources as $source) {
            foreach (['is_cancelled', 'is_canceled', 'cancelled', 'canceled'] as $flag) {
                if (!array_key_exists($flag, $source)) {
                    continue;
                }
                if ($this->boolishTrue($source[$flag])) {
                    return true;
                }
            }

            foreach (['cancelled_at', 'canceled_at', 'deleted_at'] as $timestampField) {
                if (!array_key_exists($timestampField, $source)) {
                    continue;
                }
                if ($this->normalizeTimestamp($source[$timestampField]) > 0) {
                    return true;
                }
            }

            foreach (['status', 'booking_status', 'state'] as $stateField) {
                if (!array_key_exists($stateField, $source)) {
                    continue;
                }
                $state = trim(mb_strtolower((string)$source[$stateField]));
                if ($state === '') {
                    continue;
                }
                if (preg_match('/\b(cancelled?|canceled|aflyst|annulleret|void|voided|cancel)\b/u', $state)) {
                    return true;
                }
            }
        }

        return false;
    }

    private function rankingBoost(array $result): int
    {
        $entityType = trim(mb_strtolower((string)($result['entity_type'] ?? '')));
        if ($entityType === '') {
            return 0;
        }
        return (int)($this->rankingBoostByType[$entityType] ?? 0);
    }

    private function rankingPenalty(array $result): int
    {
        $entityType = trim(mb_strtolower((string)($result['entity_type'] ?? '')));
        if ($entityType === '') {
            return 0;
        }
        return (int)($this->rankingPenaltyByType[$entityType] ?? 0);
    }

    private function effectiveResultScore(array $result): int
    {
        return (int)($result['score'] ?? 0) + $this->rankingBoost($result) - $this->rankingPenalty($result);
    }

    private function boolishTrue(mixed $value): bool
    {
        if (is_bool($value)) {
            return $value;
        }
        if (is_int($value) || is_float($value)) {
            return (float)$value > 0;
        }
        if (!is_string($value)) {
            return false;
        }
        $normalized = trim(mb_strtolower($value));
        if ($normalized === '') {
            return false;
        }
        return in_array($normalized, ['1', 'true', 'yes', 'y', 'on'], true);
    }

    private function normalizeTimestamp(mixed $value): int
    {
        if ($value === null) {
            return 0;
        }
        if (is_int($value)) {
            if ($value > 2000000000) {
                return (int)floor($value / 1000);
            }
            return max(0, $value);
        }
        if (is_float($value)) {
            return $this->normalizeTimestamp((int)$value);
        }
        if (is_string($value)) {
            $trimmed = trim($value);
            if ($trimmed === '') {
                return 0;
            }
            if (preg_match('/^\d+$/', $trimmed)) {
                return $this->normalizeTimestamp((int)$trimmed);
            }
            $parsed = strtotime($trimmed);
            return $parsed !== false ? max(0, (int)$parsed) : 0;
        }
        return 0;
    }

    /**
     * @param array<int, string> $terms
     */
    private function shouldPreferRecencySort(string $query, array $terms): bool
    {
        if (empty($terms)) {
            return false;
        }
        $normalized = trim(mb_strtolower($query));
        if ($normalized === '') {
            return false;
        }

        // Explicit identifiers (order numbers, customer numbers, emails, etc.) imply exact matches.
        if ($this->queryHasExplicitIdentifier($normalized)) {
            return false;
        }
        return true;
    }

    /**
     * @param array<int, string> $terms
     * @return array<int, string>
     */
    private function buildExpandedTerms(array $terms): array
    {
        $base = $this->limitTerms($terms);
        if (empty($base)) {
            return [];
        }
        return $this->limitTerms([
            ...$base,
            ...$this->expandLexicalSynonyms($base),
        ]);
    }

    private function queryHasExplicitIdentifier(string $normalizedQuery): bool
    {
        if ($normalizedQuery === '') {
            return false;
        }
        if (str_contains($normalizedQuery, '@')) {
            return true;
        }
        if (preg_match('/(?:^|[\s#])(order|invoice|booking|customer|kunde|vehicle|subuser|user)[\s:#-]*\d{3,}/iu', $normalizedQuery)) {
            return true;
        }
        if (preg_match('/\b\d{5,}\b/', $normalizedQuery)) {
            return true;
        }
        if (preg_match('/\b[a-f0-9]{8}-[a-f0-9]{4}-[1-5][a-f0-9]{3}-[89ab][a-f0-9]{3}-[a-f0-9]{12}\b/i', $normalizedQuery)) {
            return true;
        }
        return false;
    }

    /**
     * @param array<int, string> $terms
     */
    private function buildBooleanFullTextQuery(array $terms): ?string
    {
        $parts = [];
        foreach ($terms as $term) {
            $normalized = trim(mb_strtolower((string)$term));
            if ($normalized === '' || mb_strlen($normalized) < 3) {
                return null;
            }
            $sanitized = preg_replace('/[^\p{L}\p{N}_]+/u', '', $normalized);
            if (!is_string($sanitized) || $sanitized === '' || mb_strlen($sanitized) < 3) {
                return null;
            }
            $parts[] = '+' . $sanitized . '*';
        }
        return empty($parts) ? null : implode(' ', array_values(array_unique($parts)));
    }

    /**
     * @param array<int, string> $terms
     * @return array<int, string>
     */
    private function expandLexicalSynonyms(array $terms): array
    {
        $synonyms = [
            'rabat' => ['discount', 'discounts'],
            'rabatordning' => ['discount'],
            'rabatter' => ['discounts', 'discount'],
            'discount' => ['rabat'],
            'discounts' => ['rabat'],
            'kunde' => ['customer', 'customers'],
            'kunder' => ['customer', 'customers'],
            'faktura' => ['invoice', 'invoices'],
            'fakturaer' => ['invoice', 'invoices'],
        ];

        $expanded = [];
        foreach ($terms as $term) {
            $term = trim(mb_strtolower((string)$term));
            if ($term === '' || !isset($synonyms[$term])) {
                continue;
            }
            foreach ($synonyms[$term] as $synonym) {
                $expanded[] = $synonym;
            }
        }
        return array_values(array_unique($expanded));
    }

    private function isEconomicCustomerIndexAvailable(): bool
    {
        return $this->tableExists(system_search_economic_customer_index::TABLE);
    }

    private function tokenize(string $query): array
    {
        $query = trim(mb_strtolower($query));
        if ($query === '') {
            return [];
        }
        $parts = preg_split('/[^\p{L}\p{N}_]+/u', $query) ?: [];
        $parts = array_values(array_filter(array_map('trim', $parts), static fn($p) => $p !== '' && mb_strlen($p) >= 2));
        return array_values(array_unique($parts));
    }

    private function limitTerms(array $terms): array
    {
        $normalized = [];
        foreach ($terms as $term) {
            if (!is_string($term)) {
                continue;
            }
            $value = trim(mb_strtolower($term));
            if ($value === '' || mb_strlen($value) < 2) {
                continue;
            }
            if (mb_strlen($value) > $this->maxTermLength) {
                $value = mb_substr($value, 0, $this->maxTermLength);
            }
            $normalized[] = $value;
            if (count($normalized) >= $this->maxExpandedTerms) {
                break;
            }
        }
        return array_values(array_unique($normalized));
    }

    private function permissionContextFingerprint(
        array $permissionsCatalogAll,
        array $permissionsCatalogOwn,
        array $moduleConfigVisibility
    ): string {
        $all = [];
        foreach ($permissionsCatalogAll as $permission => $description) {
            if (!is_string($permission) || $permission === '') {
                continue;
            }
            $all[$permission] = is_string($description) ? $description : (string)$description;
        }
        ksort($all);

        $own = array_values(array_unique(array_filter(array_map(static fn($v) => is_string($v) ? trim($v) : '', $permissionsCatalogOwn))));
        sort($own);

        $visibility = [];
        foreach ($moduleConfigVisibility as $module => $visible) {
            if (!is_string($module) || $module === '') {
                continue;
            }
            $visibility[$module] = (bool)$visible;
        }
        ksort($visibility);

        return md5(json_encode([
            'all' => $all,
            'own' => $own,
            'visibility' => $visibility,
            'v' => 1,
        ], JSON_UNESCAPED_UNICODE));
    }

    /**
     * @param array<int, string> $activeTypes
     * @return array<int, string>
     */
    private function relevantSourceTables(array $activeTypes): array
    {
        $tables = [];
        foreach ($activeTypes as $entityType) {
            $tables = [...$tables, ...system_search_registry::sourceTablesForEntityType($entityType)];
            if ($this->entityTypeUsesCustomerContext($entityType)) {
                $tables[] = system_search_economic_customer_index::TABLE;
            }
        }
        return array_values(array_unique(array_filter($tables, static fn($table) => is_string($table) && $table !== '')));
    }

    private function entityTypeUsesCustomerContext(string $entityType): bool
    {
        $entityType = trim(mb_strtolower($entityType));
        if (in_array($entityType, ['objects', 'orders', 'order_items', 'invoices', 'vehicles', 'customers', 'employees', 'customer_discounts', 'customer_fixed_prices'], true)) {
            return true;
        }

        $config = $this->genericEntityConfigs()[$entityType] ?? null;
        return is_array($config)
            && isset($config['customer_field'])
            && is_string($config['customer_field'])
            && trim($config['customer_field']) !== '';
    }

    private function normalizeTypes(array $types): array
    {
        $normalized = [];
        foreach ($types as $type) {
            if (!is_string($type)) {
                continue;
            }
            $t = trim(mb_strtolower($type));
            if ($t === '') {
                continue;
            }
            $normalized[] = $t;
        }
        return array_values(array_unique($normalized));
    }

    private function allEntityTypes(): array
    {
        return system_search_registry::allEntityTypes();
    }

    private function groupResultsByType(array $results): array
    {
        $grouped = [];
        foreach ($results as $item) {
            $type = (string)$item['entity_type'];
            if (!isset($grouped[$type])) {
                $grouped[$type] = [];
            }
            $grouped[$type][] = $item;
        }
        return $grouped;
    }
}
