<?php

namespace classes;

use Throwable;

class system_search_document_index
{
    public const TABLE = 'system_search_documents';

    private static bool $initialized = false;
    /**
     * @var array<string, array<int, string>>
     */
    private array $tableColumnsCache = [];

    public static function ensureTable(): void
    {
        if (self::$initialized) {
            return;
        }

        global $db;
        if (!is_object($db) || !method_exists($db, 'query')) {
            return;
        }

        $sql = "CREATE TABLE IF NOT EXISTS `" . self::TABLE . "` (
            `entity_type` VARCHAR(64) NOT NULL,
            `entity_id` VARCHAR(191) NOT NULL,
            `customer_number` INT NULL,
            `department_id` INT NULL,
            `title` TEXT NULL,
            `description` TEXT NULL,
            `search_text` MEDIUMTEXT NULL,
            `payload_json` LONGTEXT NULL,
            `created_at` DATETIME NULL,
            `updated_at` DATETIME NULL,
            `indexed_at` TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP,
            PRIMARY KEY (`entity_type`, `entity_id`),
            INDEX `idx_ssd_customer` (`customer_number`),
            INDEX `idx_ssd_department` (`department_id`),
            INDEX `idx_ssd_entity` (`entity_type`),
            FULLTEXT KEY `ft_ssd_text` (`title`, `description`, `search_text`)
        ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci";

        try {
            $db->query($sql);
            self::$initialized = true;
        } catch (Throwable) {
            // Search should stay available even if index bootstrap fails.
        }
    }

    /**
     * @param array<int, string> $types
     * @return array<string, int>
     */
    public static function refreshIndex(array $types = []): array
    {
        $instance = new self();
        return $instance->refresh($types);
    }

    /**
     * @param array<int, string> $types
     * @return array<string, int>
     */
    private function refresh(array $types = []): array
    {
        self::ensureTable();
        system_search_economic_customer_index::ensureTable();

        $stats = [
            'types' => 0,
            'documents' => 0,
            'errors' => 0,
        ];

        $targetTypes = array_values(array_unique(array_filter(array_map(
            static fn($type) => is_string($type) ? trim(mb_strtolower($type)) : '',
            $types
        ))));
        if (empty($targetTypes)) {
            $targetTypes = system_search_registry::indexedEntityTypes();
        } else {
            $targetTypes = array_values(array_intersect(system_search_registry::indexedEntityTypes(), $targetTypes));
        }

        foreach ($targetTypes as $entityType) {
            try {
                $documents = $this->buildDocumentsForType($entityType);
                $this->replaceDocumentsForType($entityType, $documents);
                $stats['types']++;
                $stats['documents'] += count($documents);
            } catch (Throwable) {
                $stats['errors']++;
            }
        }

        return $stats;
    }

    /**
     * @return array<int, array<string, mixed>>
     */
    private function buildDocumentsForType(string $entityType): array
    {
        return match ($entityType) {
            'customers' => $this->buildCustomerDocuments(),
            'employees' => $this->buildEmployeeDocuments(),
            'orders' => $this->buildOrderDocuments(),
            'order_items' => $this->buildOrderItemDocuments(),
            'invoices' => $this->buildInvoiceDocuments(),
            'vehicles' => $this->buildVehicleDocuments(),
            'customer_discounts' => $this->buildCustomerDiscountDocuments(),
            'customer_fixed_prices' => $this->buildCustomerFixedPriceDocuments(),
            'departments' => $this->buildSimpleTableDocuments('departments', 'departments', ['id', 'name', 'address', 'zip', 'city'], ['id', 'name', 'address', 'zip', 'city'], ['name', 'id'], ['address', 'city']),
            'roles' => $this->buildSimpleTableDocuments('roles', 'groups', ['id', 'name', 'description'], ['id', 'name', 'description'], ['name', 'id'], ['description']),
            'module_config' => $this->buildModuleConfigDocuments(),
            'objects' => $this->buildObjectAttachmentDocuments(),
            default => $this->buildGenericDocuments($entityType),
        };
    }

    /**
     * @return array<int, array<string, mixed>>
     */
    private function buildCustomerDocuments(): array
    {
        $fromClause = 'users u';
        $selectFields = [
            'u.id AS entity_id',
            'u.customer_number',
            'u.display_name',
            'u.email',
            'u.phone',
            ...$this->joinTemporalSelectFields('users', 'u'),
        ];

        if ($this->tableExists(system_search_economic_customer_index::TABLE)) {
            $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',
            ];
        }

        $rows = $this->fetchRows(
            "SELECT " . implode(', ', $selectFields)
            . " FROM " . $fromClause
            . " WHERE u.customer_number IS NOT NULL AND u.customer_number <> 0"
        );

        $documents = [];
        foreach ($rows as $row) {
            $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'] ?? ''));
            }

            $documents[] = $this->makeDocument(
                'customers',
                (string)($row['entity_id'] ?? ''),
                $title,
                $description,
                $this->implodeSearchText([
                    $row['customer_number'] ?? null,
                    $row['display_name'] ?? null,
                    $row['email'] ?? null,
                    $row['phone'] ?? null,
                    $row['economic_name'] ?? null,
                    $row['economic_address'] ?? null,
                    $row['economic_city'] ?? null,
                    $row['economic_zip'] ?? null,
                    $row['economic_email'] ?? null,
                    $row['economic_cvr'] ?? null,
                    $row['economic_mobile_phone'] ?? null,
                    $row['search_text'] ?? null,
                ]),
                $this->toIntOrNull($row['customer_number'] ?? null),
                null,
                [
                    'id' => $this->toIntOrNull($row['entity_id'] ?? null),
                    'customer_number' => $this->toIntOrNull($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
            );
        }

        return $documents;
    }

    /**
     * @return array<int, array<string, mixed>>
     */
    private function buildEmployeeDocuments(): array
    {
        $temporalSelect = $this->joinTemporalSelectFields('users', 'u');
        $rows = $this->fetchRows(
            "SELECT DISTINCT u.id AS entity_id, u.customer_number, u.display_name, u.email, u.phone"
            . (!empty($temporalSelect) ? (', ' . implode(', ', $temporalSelect)) : '')
            . " FROM users u"
            . " INNER JOIN groups_permissions gp ON gp.group_id = u.group_id"
            . " WHERE gp.permission = 'employee_public_data'"
        );

        $documents = [];
        foreach ($rows as $row) {
            $documents[] = $this->makeDocument(
                'employees',
                (string)($row['entity_id'] ?? ''),
                (string)(($row['display_name'] ?? '') ?: ('Employee #' . ($row['entity_id'] ?? ''))),
                (string)($row['email'] ?? ''),
                $this->implodeSearchText([
                    $row['entity_id'] ?? null,
                    $row['customer_number'] ?? null,
                    $row['display_name'] ?? null,
                    $row['email'] ?? null,
                    $row['phone'] ?? null,
                ]),
                $this->toIntOrNull($row['customer_number'] ?? null),
                null,
                [
                    'id' => $this->toIntOrNull($row['entity_id'] ?? null),
                    'display_name' => $row['display_name'] ?? null,
                    'email' => $row['email'] ?? null,
                    'phone' => $row['phone'] ?? null,
                ],
                $row
            );
        }

        return $documents;
    }

    /**
     * @return array<int, array<string, mixed>>
     */
    private function buildOrderDocuments(): array
    {
        $rows = $this->fetchRows(
            "SELECT id AS entity_id, customer_id AS customer_number, reference, notes, reg_1, reg_2, reg_3, department_id, po, created_at, updated_at"
            . " FROM orders WHERE deleted_at IS NULL"
        );

        $documents = [];
        foreach ($rows as $row) {
            $documents[] = $this->makeDocument(
                'orders',
                (string)($row['entity_id'] ?? ''),
                'Order #' . (string)($row['entity_id'] ?? ''),
                (string)($row['reference'] ?? ''),
                $this->implodeSearchText([
                    $row['entity_id'] ?? null,
                    $row['customer_number'] ?? null,
                    $row['reference'] ?? null,
                    $row['notes'] ?? null,
                    $row['reg_1'] ?? null,
                    $row['reg_2'] ?? null,
                    $row['reg_3'] ?? null,
                    $row['po'] ?? null,
                ]),
                $this->toIntOrNull($row['customer_number'] ?? null),
                $this->toIntOrNull($row['department_id'] ?? null),
                [
                    'id' => $this->toIntOrNull($row['entity_id'] ?? null),
                    'customer_id' => $this->toIntOrNull($row['customer_number'] ?? null),
                    'reference' => $row['reference'] ?? null,
                    'notes' => $row['notes'] ?? null,
                    'reg_1' => $row['reg_1'] ?? null,
                    'reg_2' => $row['reg_2'] ?? null,
                    'reg_3' => $row['reg_3'] ?? null,
                    'po' => $row['po'] ?? null,
                    'department_id' => $this->toIntOrNull($row['department_id'] ?? null),
                ],
                $row
            );
        }

        return $documents;
    }

    /**
     * @return array<int, array<string, mixed>>
     */
    private function buildOrderItemDocuments(): array
    {
        $temporalSelect = $this->joinTemporalSelectFields('order_items', 'oi');
        $rows = $this->fetchRows(
            "SELECT oi.id AS entity_id, oi.order_id, oi.product_id, oi.reference, oi.notes, o.customer_id AS customer_number"
            . (!empty($temporalSelect) ? (', ' . implode(', ', $temporalSelect)) : '')
            . " FROM order_items oi"
            . " INNER JOIN orders o ON o.id = oi.order_id"
            . " WHERE o.deleted_at IS NULL"
        );

        $documents = [];
        foreach ($rows as $row) {
            $documents[] = $this->makeDocument(
                'order_items',
                (string)($row['entity_id'] ?? ''),
                'Order item #' . (string)($row['entity_id'] ?? ''),
                (string)($row['reference'] ?? ''),
                $this->implodeSearchText([
                    $row['entity_id'] ?? null,
                    $row['order_id'] ?? null,
                    $row['product_id'] ?? null,
                    $row['reference'] ?? null,
                    $row['notes'] ?? null,
                    $row['customer_number'] ?? null,
                ]),
                $this->toIntOrNull($row['customer_number'] ?? null),
                null,
                [
                    'id' => $this->toIntOrNull($row['entity_id'] ?? null),
                    'order_id' => $this->toIntOrNull($row['order_id'] ?? null),
                    'product_id' => $this->toIntOrNull($row['product_id'] ?? null),
                    'reference' => $row['reference'] ?? null,
                    'notes' => $row['notes'] ?? null,
                    'customer_number' => $this->toIntOrNull($row['customer_number'] ?? null),
                ],
                $row
            );
        }

        return $documents;
    }

    /**
     * @return array<int, array<string, mixed>>
     */
    private function buildInvoiceDocuments(): array
    {
        $rows = $this->fetchRows(
            "SELECT id AS entity_id, customer_number, name, notes, external_id, booked_invoice_id, po_number, created_at, updated_at, closed_at"
            . " FROM collected_order_invoices WHERE deleted_at IS NULL"
        );

        $documents = [];
        foreach ($rows as $row) {
            $title = $this->invoiceDocumentTitle(
                $row['name'] ?? null,
                $row['created_at'] ?? null,
                $row['closed_at'] ?? null,
                $row['entity_id'] ?? null
            );
            $documents[] = $this->makeDocument(
                'invoices',
                (string)($row['entity_id'] ?? ''),
                $title,
                (string)($row['external_id'] ?? ''),
                $this->implodeSearchText([
                    $row['entity_id'] ?? null,
                    $row['customer_number'] ?? null,
                    $row['name'] ?? null,
                    $row['notes'] ?? null,
                    $row['external_id'] ?? null,
                    $row['booked_invoice_id'] ?? null,
                    $row['po_number'] ?? null,
                ]),
                $this->toIntOrNull($row['customer_number'] ?? null),
                null,
                [
                    'id' => $this->toIntOrNull($row['entity_id'] ?? null),
                    'customer_number' => $this->toIntOrNull($row['customer_number'] ?? null),
                    'name' => $row['name'] ?? null,
                    'external_id' => $row['external_id'] ?? null,
                    'booked_invoice_id' => $row['booked_invoice_id'] ?? null,
                    'po_number' => $row['po_number'] ?? null,
                    'closed_at' => $row['closed_at'] ?? null,
                ],
                $row
            );
        }

        return $documents;
    }

    private function invoiceDocumentTitle(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);
    }

    /**
     * @return array<int, array<string, mixed>>
     */
    private function buildVehicleDocuments(): array
    {
        $rows = $this->fetchRows(
            "SELECT id AS entity_id, customer_id AS customer_number, reg, reference, type, created_at, updated_at"
            . " FROM customer_vehicles WHERE deleted_at IS NULL"
        );

        $documents = [];
        foreach ($rows as $row) {
            $documents[] = $this->makeDocument(
                'vehicles',
                (string)($row['entity_id'] ?? ''),
                (string)(($row['reg'] ?? '') ?: ('Vehicle #' . ($row['entity_id'] ?? ''))),
                (string)($row['reference'] ?? ''),
                $this->implodeSearchText([
                    $row['entity_id'] ?? null,
                    $row['customer_number'] ?? null,
                    $row['reg'] ?? null,
                    $row['reference'] ?? null,
                    $row['type'] ?? null,
                ]),
                $this->toIntOrNull($row['customer_number'] ?? null),
                null,
                [
                    'id' => $this->toIntOrNull($row['entity_id'] ?? null),
                    'customer_id' => $this->toIntOrNull($row['customer_number'] ?? null),
                    'reg' => $row['reg'] ?? null,
                    'reference' => $row['reference'] ?? null,
                    'type' => $row['type'] ?? null,
                ],
                $row
            );
        }

        return $documents;
    }

    /**
     * @return array<int, array<string, mixed>>
     */
    private function buildCustomerDiscountDocuments(): array
    {
        price_overrides_schema_bootstrap::ensureColumns();
        $fromClause = 'price_overrides po INNER JOIN users u ON u.id = po.user_id';
        $selectFields = [
            'po.id AS entity_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'),
        ];

        if ($this->tableExists(system_search_economic_customer_index::TABLE)) {
            $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',
            ];
        }

        $rows = $this->fetchRows("SELECT " . implode(', ', $selectFields) . " FROM " . $fromClause);

        $documents = [];
        foreach ($rows as $row) {
            $customerDisplay = trim((string)($row['economic_name'] ?? ''));
            if ($customerDisplay === '') {
                $customerDisplay = trim((string)($row['display_name'] ?? ''));
            }

            $documents[] = $this->makeDocument(
                'customer_discounts',
                (string)($row['entity_id'] ?? ''),
                'Discount #' . (string)($row['entity_id'] ?? ''),
                (string)('Customer ' . ($row['customer_number'] ?? '') . ' / ' . $customerDisplay),
                $this->implodeSearchText([
                    $row['entity_id'] ?? null,
                    $row['customer_number'] ?? null,
                    $row['display_name'] ?? null,
                    $row['economic_name'] ?? null,
                    $row['economic_address'] ?? null,
                    $row['economic_city'] ?? null,
                    $row['economic_zip'] ?? null,
                    $row['economic_email'] ?? null,
                    $row['economic_cvr'] ?? null,
                    $row['economic_mobile_phone'] ?? null,
                    $row['search_text'] ?? null,
                    $row['product_or_category_id'] ?? null,
                    $row['percentage'] ?? null,
                    $row['fixed_price'] ?? null,
                    $row['user_id'] ?? null,
                ]),
                $this->toIntOrNull($row['customer_number'] ?? null),
                null,
                [
                    'id' => $this->toIntOrNull($row['entity_id'] ?? null),
                    'user_id' => $this->toIntOrNull($row['user_id'] ?? null),
                    'customer_number' => $this->toIntOrNull($row['customer_number'] ?? null),
                    'product_or_category_id' => $row['product_or_category_id'] ?? null,
                    'percentage' => $this->toIntOrNull($row['percentage'] ?? null),
                    'fixed_price' => $this->toIntOrNull($row['fixed_price'] ?? null),
                    'economic_name' => $row['economic_name'] ?? null,
                    'economic_cvr' => $row['economic_cvr'] ?? null,
                    'is_category' => $row['is_category'] ?? null,
                ],
                $row
            );
        }

        return $documents;
    }

    /**
     * @return array<int, array<string, mixed>>
     */
    private function buildCustomerFixedPriceDocuments(): array
    {
        $fromClause = 'customer_fixed_pricing cfp';
        $selectFields = [
            'cfp.id AS entity_id',
            'cfp.customer_number',
            'cfp.price',
            'cfp.description',
            ...$this->joinTemporalSelectFields('customer_fixed_pricing', 'cfp'),
        ];

        if ($this->tableExists(system_search_economic_customer_index::TABLE)) {
            $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',
            ];
        }

        $rows = $this->fetchRows("SELECT " . implode(', ', $selectFields) . " FROM " . $fromClause);

        $documents = [];
        foreach ($rows as $row) {
            $description = trim((string)($row['description'] ?? ''));
            if ($description === '') {
                $description = trim((string)($row['economic_name'] ?? ''));
            }

            $documents[] = $this->makeDocument(
                'customer_fixed_prices',
                (string)($row['entity_id'] ?? ''),
                'Fixed pricing #' . (string)($row['entity_id'] ?? ''),
                $description,
                $this->implodeSearchText([
                    $row['entity_id'] ?? null,
                    $row['customer_number'] ?? null,
                    $row['price'] ?? null,
                    $row['description'] ?? null,
                    $row['economic_name'] ?? null,
                    $row['economic_address'] ?? null,
                    $row['economic_city'] ?? null,
                    $row['economic_zip'] ?? null,
                    $row['economic_email'] ?? null,
                    $row['economic_cvr'] ?? null,
                    $row['economic_mobile_phone'] ?? null,
                    $row['search_text'] ?? null,
                ]),
                $this->toIntOrNull($row['customer_number'] ?? null),
                null,
                [
                    'id' => $this->toIntOrNull($row['entity_id'] ?? null),
                    'customer_number' => $this->toIntOrNull($row['customer_number'] ?? null),
                    'price' => $this->toIntOrNull($row['price'] ?? null),
                    'description' => $row['description'] ?? null,
                    'economic_name' => $row['economic_name'] ?? null,
                    'economic_cvr' => $row['economic_cvr'] ?? null,
                ],
                $row
            );
        }

        return $documents;
    }

    /**
     * @return array<int, array<string, mixed>>
     */
    private function buildModuleConfigDocuments(): array
    {
        $rows = $this->fetchRows(
            "SELECT module, variable, type, created_at, updated_at"
            . " FROM module_config"
        );

        $documents = [];
        foreach ($rows as $row) {
            $variable = (string)($row['variable'] ?? '');
            if ($variable === '' || $this->looksSecretVariable($variable)) {
                continue;
            }

            $module = (string)($row['module'] ?? '');
            $entityId = $module . ':' . $variable;
            $documents[] = $this->makeDocument(
                'module_config',
                $entityId,
                $module . '.' . $variable,
                (string)($row['type'] ?? ''),
                $this->implodeSearchText([$module, $variable, $row['type'] ?? null]),
                null,
                null,
                [
                    'module' => $module,
                    'variable' => $variable,
                    'type' => $row['type'] ?? null,
                ],
                $row
            );
        }

        return $documents;
    }

    /**
     * @return array<int, array<string, mixed>>
     */
    private function buildObjectAttachmentDocuments(): array
    {
        $temporalSelect = $this->joinTemporalSelectFields('object_attachments', 'oa');
        $taskSelect = [];
        $taskColumns = $this->getColumns('department_selfserve_tasks');
        foreach (['task', 'description', 'department'] as $column) {
            if (in_array($column, $taskColumns, true)) {
                $taskSelect[] = 'dst.' . $column . ' AS task_' . $column;
            }
        }

        $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';
            $taskSelect[] = 'd.name AS task_department_name';
        }

        $customerSelect = [];
        $customerJoin = '';
        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';
            $customerSelect = [
                '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',
            ];
        }

        $rows = $this->fetchRows(
            "SELECT oa.id AS entity_id, oa.object_type, oa.object_id, oa.content"
            . (!empty($temporalSelect) ? (', ' . implode(', ', $temporalSelect)) : '')
            . ", o.customer_id AS customer_number, o.department_id, o.reference AS order_reference"
            . (!empty($taskSelect) ? (', ' . implode(', ', $taskSelect)) : '')
            . (!empty($customerSelect) ? (', ' . implode(', ', $customerSelect)) : '')
            . " 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"
        );

        $documents = [];
        foreach ($rows as $row) {
            $content = $row['content'] ?? null;
            $contentText = is_string($content) ? $content : json_encode($content, JSON_UNESCAPED_UNICODE);
            $attachmentName = '';
            $decodedContent = is_string($content) ? json_decode($content, true) : null;
            if (is_array($decodedContent)) {
                $attachmentName = trim((string)($decodedContent['other'] ?? ''));
            }

            $title = trim($attachmentName);
            if ($title === '') {
                if (($row['object_type'] ?? '') === 'orders') {
                    $title = 'Order attachment #' . (string)($row['object_id'] ?? '');
                } elseif (($row['object_type'] ?? '') === 'department_selfserve_tasks') {
                    $title = 'Task attachment #' . (string)($row['object_id'] ?? '');
                } else {
                    $title = 'Attachment #' . (string)($row['entity_id'] ?? '');
                }
            }

            $descriptionParts = [];
            if (!empty($row['order_reference'])) {
                $descriptionParts[] = 'Order ref ' . (string)$row['order_reference'];
            }
            if (!empty($row['customer_name'])) {
                $descriptionParts[] = (string)$row['customer_name'];
            }
            if (!empty($row['task_task'])) {
                $descriptionParts[] = (string)$row['task_task'];
            }
            if (!empty($row['task_department_name'])) {
                $descriptionParts[] = (string)$row['task_department_name'];
            }
            if (!empty($row['task_description'])) {
                $descriptionParts[] = (string)$row['task_description'];
            }

            $documents[] = $this->makeDocument(
                'objects',
                (string)($row['entity_id'] ?? ''),
                $title,
                implode(' / ', array_slice($descriptionParts, 0, 2)),
                $this->implodeSearchText([
                    $row['entity_id'] ?? null,
                    $row['object_type'] ?? null,
                    $row['object_id'] ?? null,
                    $attachmentName,
                    $contentText,
                    $row['customer_number'] ?? null,
                    $row['customer_name'] ?? null,
                    $row['customer_email'] ?? null,
                    $row['customer_phone'] ?? null,
                    $row['customer_cvr'] ?? null,
                    $row['order_reference'] ?? null,
                    $row['task_task'] ?? null,
                    $row['task_department_name'] ?? null,
                    $row['task_description'] ?? null,
                ]),
                $this->toIntOrNull($row['customer_number'] ?? null),
                $this->toIntOrNull($row['department_id'] ?? ($row['task_department'] ?? null)),
                [
                    'id' => $this->toIntOrNull($row['entity_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),
                    'customer_name' => $row['customer_name'] ?? null,
                    'customer_email' => $row['customer_email'] ?? null,
                    'customer_phone' => $row['customer_phone'] ?? null,
                    'customer_cvr' => $row['customer_cvr'] ?? null,
                    'customer_barred' => isset($row['customer_barred']) ? ((int)$row['customer_barred'] === 1) : null,
                    'department_id' => $this->toIntOrNull($row['department_id'] ?? ($row['task_department'] ?? null)),
                    'order_reference' => $row['order_reference'] ?? null,
                    'task_title' => $row['task_task'] ?? null,
                    'task_description' => $row['task_description'] ?? null,
                    'task_department' => $this->toIntOrNull($row['task_department'] ?? null),
                    'task_department_name' => $row['task_department_name'] ?? null,
                ],
                $row
            );
        }

        return $documents;
    }

    /**
     * @return array<int, array<string, mixed>>
     */
    private function buildGenericDocuments(string $entityType): array
    {
        $config = system_search_registry::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';
        }

        $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($value) => is_string($value) && $value !== ''));
        }

        $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($value) => is_string($value) && $value !== ''));
            $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 !== '')));
        $selectFields = $this->appendTemporalColumns($table, $selectFields);
        if (count($selectFields) > 32) {
            $selectFields = array_slice($selectFields, 0, 32);
        }

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

        $whereClauses = [];
        foreach ($fixedConditions as $column => $value) {
            if ($value === null) {
                $whereClauses[] = "`$column` IS NULL";
            } else {
                $whereClauses[] = "`$column` = " . $this->sqlString((string)$value);
            }
        }

        $rows = $this->fetchRows(
            "SELECT " . implode(', ', array_map(static fn($field) => "`$field`", $selectFields))
            . " FROM `$table`"
            . (!empty($whereClauses) ? (' WHERE ' . implode(' AND ', $whereClauses)) : '')
        );

        $titleFields = [];
        if (isset($config['title_fields']) && is_array($config['title_fields'])) {
            $titleFields = array_values(array_filter($config['title_fields'], static fn($value) => is_string($value) && in_array($value, $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($value) => is_string($value) && in_array($value, $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));
        $documents = [];
        foreach ($rows as $row) {
            $entityId = isset($row[$idField]) ? (string)$row[$idField] : '';
            if ($entityId === '') {
                continue;
            }

            $title = '';
            foreach ($titleFields as $field) {
                $value = trim((string)($row[$field] ?? ''));
                if ($value === '') {
                    continue;
                }
                $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;
                }
            }

            $payload = array_intersect_key($row, array_flip($selectFields));
            $documents[] = $this->makeDocument(
                $entityType,
                $entityId,
                $title,
                implode(' / ', $descriptionParts),
                $this->implodeSearchText(array_map(static fn($field) => $row[$field] ?? null, $searchable)),
                $customerField !== null ? $this->resolveConfiguredCustomerNumber($row[$customerField] ?? null, $customerFieldMode) : null,
                $departmentField !== null ? $this->toIntOrNull($row[$departmentField] ?? null) : null,
                $payload,
                $row
            );
        }

        return $documents;
    }

    /**
     * @param array<int, string> $candidateFields
     * @param array<int, string> $searchFields
     * @param array<int, string> $titleFields
     * @param array<int, string> $descriptionFields
     * @return array<int, array<string, mixed>>
     */
    private function buildSimpleTableDocuments(
        string $entityType,
        string $table,
        array $candidateFields,
        array $searchFields,
        array $titleFields,
        array $descriptionFields
    ): array {
        if (!$this->tableExists($table)) {
            return [];
        }

        $fields = $this->appendTemporalColumns($table, $this->intersectExistingColumns($table, $candidateFields));
        if (empty($fields) || !in_array('id', $fields, true)) {
            return [];
        }

        $rows = $this->fetchRows(
            "SELECT " . implode(', ', array_map(static fn($field) => "`$field`", $fields))
            . " FROM `$table`"
        );

        $documents = [];
        foreach ($rows as $row) {
            $entityId = isset($row['id']) ? (string)$row['id'] : '';
            if ($entityId === '') {
                continue;
            }

            $title = '';
            foreach ($titleFields as $field) {
                $value = trim((string)($row[$field] ?? ''));
                if ($value === '') {
                    continue;
                }
                $title = $value;
                break;
            }
            if ($title === '') {
                $title = ucfirst(rtrim(str_replace('_', ' ', $entityType), 's')) . ' #' . $entityId;
            }

            $descriptionParts = [];
            foreach ($descriptionFields as $field) {
                $value = trim((string)($row[$field] ?? ''));
                if ($value === '') {
                    continue;
                }
                $descriptionParts[] = $value;
            }

            $documents[] = $this->makeDocument(
                $entityType,
                $entityId,
                $title,
                implode(' / ', array_slice($descriptionParts, 0, 2)),
                $this->implodeSearchText(array_map(static fn($field) => $row[$field] ?? null, $searchFields)),
                null,
                null,
                array_intersect_key($row, array_flip($fields)),
                $row
            );
        }

        return $documents;
    }

    /**
     * @param array<int, array<string, mixed>> $documents
     */
    private function replaceDocumentsForType(string $entityType, array $documents): void
    {
        global $db;
        if (!is_object($db) || !method_exists($db, 'query')) {
            return;
        }

        $escapedType = $db->escape_string($entityType);
        $db->query("DELETE FROM `" . self::TABLE . "` WHERE `entity_type` = '" . $escapedType . "'");

        if (empty($documents)) {
            return;
        }

        foreach (array_chunk($documents, 100) as $chunk) {
            $values = [];
            foreach ($chunk as $document) {
                $values[] = '('
                    . $this->sqlString((string)$document['entity_type']) . ', '
                    . $this->sqlString((string)$document['entity_id']) . ', '
                    . $this->sqlNullableInt($document['customer_number'] ?? null) . ', '
                    . $this->sqlNullableInt($document['department_id'] ?? null) . ', '
                    . $this->sqlNullableString($document['title'] ?? null) . ', '
                    . $this->sqlNullableString($document['description'] ?? null) . ', '
                    . $this->sqlNullableString($document['search_text'] ?? null) . ', '
                    . $this->sqlNullableString($document['payload_json'] ?? null) . ', '
                    . $this->sqlNullableString($document['created_at'] ?? null) . ', '
                    . $this->sqlNullableString($document['updated_at'] ?? null)
                    . ')';
            }

            $db->query(
                "INSERT INTO `" . self::TABLE . "` "
                . "(`entity_type`, `entity_id`, `customer_number`, `department_id`, `title`, `description`, `search_text`, `payload_json`, `created_at`, `updated_at`) VALUES "
                . implode(', ', $values)
            );
        }
    }

    /**
     * @param array<string, mixed> $payload
     * @param array<string, mixed> $row
     * @return array<string, mixed>
     */
    private function makeDocument(
        string $entityType,
        string $entityId,
        string $title,
        string $description,
        string $searchText,
        ?int $customerNumber,
        ?int $departmentId,
        array $payload,
        array $row
    ): array {
        foreach (['created_at', 'updated_at'] as $column) {
            if (array_key_exists($column, $row)) {
                $payload[$column] = $row[$column];
            }
        }

        return [
            'entity_type' => $entityType,
            'entity_id' => $entityId,
            'customer_number' => $customerNumber,
            'department_id' => $departmentId,
            'title' => trim($title),
            'description' => trim($description),
            'search_text' => trim($searchText),
            'payload_json' => json_encode($payload, JSON_UNESCAPED_UNICODE),
            'created_at' => isset($row['created_at']) ? (string)$row['created_at'] : null,
            'updated_at' => isset($row['updated_at']) ? (string)$row['updated_at'] : null,
        ];
    }

    /**
     * @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<int, mixed> $parts
     */
    private function implodeSearchText(array $parts): string
    {
        return trim(implode(' ', array_values(array_filter(array_map(
            static function ($value): ?string {
                if ($value === null) {
                    return null;
                }
                $string = trim((string)$value);
                return $string === '' ? null : $string;
            },
            $parts
        )))));
    }

    /**
     * @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($value) => mb_strtolower((string)$value), $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<int, array<string, mixed>>
     */
    private function fetchRows(string $sql): array
    {
        global $db;
        if (!is_object($db) || !method_exists($db, 'query')) {
            return [];
        }

        try {
            $result = $db->query($sql);
            if (!($result instanceof \mysqli_result)) {
                return [];
            }
            return $db->fetch_all($result);
        } catch (Throwable) {
            return [];
        }
    }

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

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

    /**
     * @return array<int, string>
     */
    private function getColumns(string $table): array
    {
        if (isset($this->tableColumnsCache[$table])) {
            return $this->tableColumnsCache[$table];
        }

        global $db;
        try {
            if (!is_object($db) || !method_exists($db, 'query')) {
                $this->tableColumnsCache[$table] = [];
                return [];
            }
            $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 sqlString(string $value): string
    {
        global $db;
        return "'" . $db->escape_string($value) . "'";
    }

    private function sqlNullableString(?string $value): string
    {
        if ($value === null || trim($value) === '') {
            return 'NULL';
        }
        return $this->sqlString($value);
    }

    private function sqlNullableInt(mixed $value): string
    {
        $intValue = $this->toIntOrNull($value);
        return $intValue === null ? 'NULL' : (string)$intValue;
    }

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

    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'] ?? ''));
    }
}
