<?php

app_require('classes/system_search_cache.php');
app_require('classes/system_search_document_index.php');
app_require('classes/system_search_economic_customer_index.php');
app_require('classes/system_search_registry.php');
app_require('classes/system_search_service.php');

use classes\system_search_cache;
use classes\system_search_economic_customer_index;
use classes\system_search_service;

if (!class_exists('SystemSearchTestRedisAdapter')) {
    class SystemSearchTestRedisAdapter
    {
        private array $store = [];

        public function get(string $key): mixed
        {
            return $this->store[$key] ?? null;
        }

        public function set(string $key, string $value): void
        {
            $this->store[$key] = $value;
        }

        public function setEx(string $key, string $value, int $ttl): void
        {
            $this->store[$key] = $value;
        }

        public function delete(string $key): void
        {
            unset($this->store[$key]);
        }

        public function expire(string $key, int $ttl): void
        {
            // TTL is not simulated in unit tests.
        }

        public function set_array(string $key, array $value): void
        {
            $this->store[$key] = json_encode($value, JSON_UNESCAPED_UNICODE);
        }

        public function get_array(string $key): ?array
        {
            $value = $this->store[$key] ?? null;
            if (!is_string($value)) {
                return null;
            }
            $decoded = json_decode($value, true);
            return is_array($decoded) ? $decoded : null;
        }

        public function clear_keys(string $pattern): void
        {
            $regex = '/^' . str_replace('\*', '.*', preg_quote($pattern, '/')) . '$/';
            foreach (array_keys($this->store) as $key) {
                if (preg_match($regex, $key)) {
                    unset($this->store[$key]);
                }
            }
        }
    }
}

if (!class_exists('TestableSystemSearchService')) {
    class TestableSystemSearchService extends system_search_service
    {
        /**
         * @var array<int, array<string, mixed>>
         */
        public array $lexicalCalls = [];
        /**
         * @var array<int, array<int, array<string, mixed>>>
         */
        private array $queuedLexicalResults;

        public function __construct(array $queuedLexicalResults)
        {
            $this->queuedLexicalResults = $queuedLexicalResults;
            parent::__construct();
        }

        protected function executeLexicalSearch(
            array $activeTypes,
            array $terms,
            array $entityBoost,
            array $ownOnlyTypes,
            ?int $ownCustomerNumber,
            array $permissionsCatalogAll,
            array $permissionsCatalogOwn,
            array $moduleConfigVisibility,
            array $allowedDepartmentIds = [],
            array $forcedCustomerNumbers = []
        ): array {
            $this->lexicalCalls[] = [
                'activeTypes' => $activeTypes,
                'terms' => $terms,
                'entityBoost' => $entityBoost,
                'ownOnlyTypes' => $ownOnlyTypes,
                'ownCustomerNumber' => $ownCustomerNumber,
                'allowedDepartmentIds' => $allowedDepartmentIds,
                'forcedCustomerNumbers' => $forcedCustomerNumbers,
            ];
            if (empty($this->queuedLexicalResults)) {
                return [];
            }
            return array_shift($this->queuedLexicalResults);
        }
    }
}

if (!class_exists('CustomerContextAwareTestableSystemSearchService')) {
    class CustomerContextAwareTestableSystemSearchService extends TestableSystemSearchService
    {
        /**
         * @var array<int, array<string, mixed>>
         */
        public array $customerContexts = [];

        protected function loadCustomerContexts(array $customerNumbers): array
        {
            $contexts = [];
            foreach ($customerNumbers as $customerNumber) {
                $normalized = (int)$customerNumber;
                if ($normalized <= 0 || !isset($this->customerContexts[$normalized])) {
                    continue;
                }
                $contexts[$normalized] = $this->customerContexts[$normalized];
            }
            return $contexts;
        }
    }
}

if (!function_exists('system_search_service_invoke_private')) {
    function system_search_service_invoke_private(object $instance, string $method, array $args = []): mixed
    {
        $reflection = new ReflectionMethod($instance, $method);
        return $reflection->invokeArgs($instance, $args);
    }
}

beforeEach(function (): void {
    system_search_cache::setAdapterForTests(null);
});

it('returns a single capped result set without pagination or intent metadata', function (): void {
    $rows = [];
    for ($i = 1; $i <= 55; $i++) {
        $rows[] = ['entity_type' => 'orders', 'entity_id' => (string)$i, 'title' => 'Order #' . $i, 'score' => 100 - ($i % 10)];
    }

    $service = new TestableSystemSearchService([$rows]);
    $result = $service->search([
        'query' => 'order',
        'allowed_types' => ['orders'],
        'max_results' => 500,
        'include_associations' => false,
    ]);

    expect(count($result['results']))->toBe(50);
    expect($result['meta']['max_results'])->toBe(50);
    expect($result['meta']['returned'])->toBe(50);
    expect($result['meta']['truncated'])->toBeTrue();
    expect($result['meta'])->not->toHaveKey('limit');
    expect($result['meta'])->not->toHaveKey('offset');
    expect($result['meta'])->not->toHaveKey('intent_parser');
});

it('honors smaller max_results values for one-shot search responses', function (): void {
    $service = new TestableSystemSearchService([[
        ['entity_type' => 'orders', 'entity_id' => '1', 'title' => 'Order #1', 'score' => 100],
        ['entity_type' => 'orders', 'entity_id' => '2', 'title' => 'Order #2', 'score' => 99],
        ['entity_type' => 'orders', 'entity_id' => '3', 'title' => 'Order #3', 'score' => 98],
        ['entity_type' => 'orders', 'entity_id' => '4', 'title' => 'Order #4', 'score' => 97],
    ]]);

    $result = $service->search([
        'query' => 'order',
        'allowed_types' => ['orders'],
        'max_results' => 3,
        'include_associations' => false,
    ]);

    expect(array_column($result['results'], 'entity_id'))->toBe(['1', '2', '3']);
    expect($result['meta']['returned'])->toBe(3);
    expect($result['meta']['truncated'])->toBeTrue();
});

it('uses default search types unless the query names a lower priority entity type', function (): void {
    $service = new TestableSystemSearchService([[]]);

    $service->search([
        'query' => 'acme',
        'allowed_types' => ['customers', 'orders', 'module_config', 'permissions'],
        'include_associations' => false,
    ]);

    expect($service->lexicalCalls[0]['activeTypes'])->toContain('customers');
    expect($service->lexicalCalls[0]['activeTypes'])->toContain('orders');
    expect($service->lexicalCalls[0]['activeTypes'])->not->toContain('module_config');
    expect($service->lexicalCalls[0]['activeTypes'])->not->toContain('permissions');

    $service = new TestableSystemSearchService([[]]);
    $service->search([
        'query' => 'stripe module config',
        'allowed_types' => ['customers', 'orders', 'module_config', 'permissions'],
        'include_associations' => false,
    ]);

    expect($service->lexicalCalls[0]['activeTypes'])->toContain('module_config');
});

it('lets explicit include type filters search and return focused lower-score matches', function (): void {
    $service = new TestableSystemSearchService([[
        ['entity_type' => 'module_config', 'entity_id' => 'Stripe:enabled', 'title' => 'Stripe.enabled', 'score' => 30],
        ['entity_type' => 'module_config', 'entity_id' => 'Stripe:key', 'title' => 'Stripe.api_key', 'score' => 10],
    ]]);

    $result = $service->search([
        'query' => 'stripe',
        'include_types' => ['module_config'],
        'allowed_types' => ['module_config'],
        'include_associations' => false,
    ]);

    expect(array_column($result['results'], 'entity_id'))->toBe(['Stripe:enabled']);
});

it('drops weak non-explicit matches after relevance penalties', function (): void {
    $service = new TestableSystemSearchService([[
        ['entity_type' => 'orders', 'entity_id' => '1', 'title' => 'Order #1', 'score' => 30],
        ['entity_type' => 'module_config', 'entity_id' => 'openAI:enabled', 'title' => 'openAI.enabled', 'score' => 100],
        ['entity_type' => 'vehicles', 'entity_id' => '3', 'title' => 'Vehicle #3', 'score' => 5],
    ]]);

    $result = $service->search([
        'query' => 'acme',
        'allowed_types' => ['orders', 'module_config', 'vehicles'],
        'include_associations' => false,
    ]);

    expect(array_column($result['results'], 'entity_id'))->toBe(['1']);
});

it('scopes query cache by permission context and result cap', function (): void {
    system_search_cache::setAdapterForTests(new SystemSearchTestRedisAdapter());

    $service = new TestableSystemSearchService([
        [['entity_type' => 'orders', 'entity_id' => '1', 'title' => 'first', 'score' => 95]],
        [['entity_type' => 'orders', 'entity_id' => '2', 'title' => 'second', 'score' => 96]],
    ]);

    $first = $service->search([
        'query' => 'acme',
        'allowed_types' => ['orders'],
        'include_associations' => false,
        'max_results' => 1,
        'permissions_catalog_own' => ['list_orders'],
    ]);
    $cached = $service->search([
        'query' => 'acme',
        'allowed_types' => ['orders'],
        'include_associations' => false,
        'max_results' => 1,
        'permissions_catalog_own' => ['list_orders'],
    ]);
    $secondCap = $service->search([
        'query' => 'acme',
        'allowed_types' => ['orders'],
        'include_associations' => false,
        'max_results' => 2,
        'permissions_catalog_own' => ['list_orders'],
    ]);

    expect(count($service->lexicalCalls))->toBe(2);
    expect($first['results'][0]['entity_id'])->toBe('1');
    expect($cached['results'][0]['entity_id'])->toBe('1');
    expect($cached['meta']['cache']['hit'])->toBeTrue();
    expect($secondCap['results'][0]['entity_id'])->toBe('2');
});

it('only expands associations from strong customer matches', function (): void {
    $strong = new TestableSystemSearchService([
        [[
            'entity_type' => 'customers',
            'entity_id' => '10',
            'title' => 'Acme',
            'customer_number' => 777,
            'score' => 90,
        ]],
        [[
            'entity_type' => 'orders',
            'entity_id' => '501',
            'title' => 'Order #501',
            'customer_number' => 777,
            'score' => 95,
        ]],
    ]);

    $strongResult = $strong->search([
        'query' => 'acme',
        'allowed_types' => ['customers', 'orders'],
        'include_associations' => true,
    ]);

    expect(count($strong->lexicalCalls))->toBe(2);
    expect($strong->lexicalCalls[1]['forcedCustomerNumbers'])->toBe([777]);
    expect(array_column($strongResult['results'], 'entity_type'))->toContain('orders');

    $weak = new TestableSystemSearchService([
        [[
            'entity_type' => 'customers',
            'entity_id' => '10',
            'title' => 'Acme',
            'customer_number' => 777,
            'score' => 20,
        ]],
        [[
            'entity_type' => 'orders',
            'entity_id' => '501',
            'title' => 'Order #501',
            'customer_number' => 777,
            'score' => 95,
        ]],
    ]);

    $weak->search([
        'query' => 'acme',
        'allowed_types' => ['customers', 'orders'],
        'include_associations' => true,
    ]);

    expect(count($weak->lexicalCalls))->toBe(1);
});

it('passes allowed department ids into lexical execution context', function (): void {
    $service = new TestableSystemSearchService([[
        ['entity_type' => 'orders', 'entity_id' => '1', 'title' => 'Order #1', 'score' => 70],
    ]]);

    $service->search([
        'query' => 'order',
        'allowed_types' => ['orders'],
        'include_associations' => false,
        'allowed_department_ids' => [3, '7', 3],
    ]);

    expect(count($service->lexicalCalls))->toBe(1);
    expect($service->lexicalCalls[0]['allowedDepartmentIds'])->toBe([3, 7]);
});

it('only applies indexed department filters to department-scoped indexed entities', function (): void {
    $service = new TestableSystemSearchService([]);

    expect(system_search_service_invoke_private($service, 'indexedEntitySupportsDepartmentFilter', ['orders']))->toBeTrue();
    expect(system_search_service_invoke_private($service, 'indexedEntitySupportsDepartmentFilter', ['objects']))->toBeTrue();
    expect(system_search_service_invoke_private($service, 'indexedEntitySupportsDepartmentFilter', ['bookings']))->toBeTrue();
    expect(system_search_service_invoke_private($service, 'indexedEntitySupportsDepartmentFilter', ['customers']))->toBeFalse();
    expect(system_search_service_invoke_private($service, 'indexedEntitySupportsDepartmentFilter', ['invoices']))->toBeFalse();
});

it('expands danish discount wording into lexical discount synonyms', function (): void {
    $service = new TestableSystemSearchService([[
        ['entity_type' => 'customer_discounts', 'entity_id' => '1', 'title' => 'd1', 'score' => 95],
    ]]);

    $service->search([
        'query' => 'pleno rabat',
        'allowed_types' => ['customer_discounts'],
        'include_types' => ['customer_discounts'],
        'include_associations' => false,
    ]);

    $terms = $service->lexicalCalls[0]['terms'] ?? [];
    expect($terms)->toContain('rabat');
    expect($terms)->toContain('discount');
});

it('prefers newer records when relevance scores are comparable', function (): void {
    $service = new TestableSystemSearchService([[
        [
            'entity_type' => 'bookings',
            'entity_id' => '1',
            'title' => 'Older booking',
            'score' => 90,
            'payload' => ['updated_at' => '2026-01-01 00:00:00'],
        ],
        [
            'entity_type' => 'bookings',
            'entity_id' => '2',
            'title' => 'Newer booking',
            'score' => 89,
            'payload' => ['updated_at' => '2026-03-10 12:00:00'],
        ],
    ]]);

    $result = $service->search([
        'query' => 'booking',
        'allowed_types' => ['bookings'],
        'include_associations' => false,
    ]);

    expect($result['results'][0]['entity_id'])->toBe('2');
});

it('keeps explicit identifier matches ahead of newer but weaker records', function (): void {
    $service = new TestableSystemSearchService([[
        [
            'entity_type' => 'orders',
            'entity_id' => '100',
            'title' => 'Exact order',
            'score' => 90,
            'payload' => ['updated_at' => '2026-01-01 00:00:00'],
        ],
        [
            'entity_type' => 'orders',
            'entity_id' => '101',
            'title' => 'Newer but weaker',
            'score' => 89,
            'payload' => ['updated_at' => '2026-03-12 00:00:00'],
        ],
    ]]);

    $result = $service->search([
        'query' => '12345',
        'allowed_types' => ['orders'],
        'include_associations' => false,
    ]);

    expect($result['results'][0]['entity_id'])->toBe('100');
});

it('promotes invoices orders order bookings and customers in ranking', function (): void {
    $service = new TestableSystemSearchService([[
        ['entity_type' => 'vehicles', 'entity_id' => '800', 'title' => 'Vehicle #800', 'score' => 97],
        ['entity_type' => 'departments', 'entity_id' => '801', 'title' => 'Department #801', 'score' => 96],
        ['entity_type' => 'invoices', 'entity_id' => '802', 'title' => 'Invoice #802', 'score' => 70],
        ['entity_type' => 'orders', 'entity_id' => '803', 'title' => 'Order #803', 'score' => 69],
        ['entity_type' => 'order_bookings', 'entity_id' => '804', 'title' => 'Order booking #804', 'score' => 68],
        ['entity_type' => 'customers', 'entity_id' => '805', 'title' => 'Customer #805', 'score' => 67],
    ]]);

    $result = $service->search([
        'query' => '12345',
        'allowed_types' => ['vehicles', 'departments', 'invoices', 'orders', 'order_bookings', 'customers'],
        'include_associations' => false,
    ]);

    $types = array_map(static fn(array $row): string => (string)$row['entity_type'], $result['results']);
    expect(array_slice($types, 0, 4))->toBe([
        'invoices',
        'orders',
        'order_bookings',
        'customers',
    ]);
});

it('never prioritizes cancelled bookings over active bookings', function (): void {
    $service = new TestableSystemSearchService([[
        [
            'entity_type' => 'bookings',
            'entity_id' => '500',
            'title' => 'Cancelled booking',
            'score' => 99,
            'payload' => [
                'updated_at' => '2026-03-12 12:00:00',
                'status' => 'cancelled',
            ],
        ],
        [
            'entity_type' => 'bookings',
            'entity_id' => '501',
            'title' => 'Active booking',
            'score' => 80,
            'payload' => [
                'updated_at' => '2026-03-11 12:00:00',
                'status' => 'active',
            ],
        ],
    ]]);

    $result = $service->search([
        'query' => 'booking',
        'allowed_types' => ['bookings'],
        'include_associations' => false,
    ]);

    expect($result['results'][0]['entity_id'])->toBe('501');
});

it('tokenizes unicode names without stripping non ascii letters', function (): void {
    $service = new TestableSystemSearchService([]);

    $tokens = system_search_service_invoke_private($service, 'tokenize', ['Møller Århus']);

    expect($tokens)->toContain('møller');
    expect($tokens)->toContain('århus');
    expect($tokens)->not->toContain('ller');
    expect($tokens)->not->toContain('rhus');
});

it('requires broader term coverage for multi word scoring', function (): void {
    $service = new TestableSystemSearchService([]);

    $narrowScore = system_search_service_invoke_private(
        $service,
        'scoreRow',
        [['title' => 'Acme Corp', 'description' => ''], ['title' => 4, 'description' => 2], ['acme', 'overdue', 'invoice']]
    );
    $broadScore = system_search_service_invoke_private(
        $service,
        'scoreRow',
        [['title' => 'Acme Corp', 'description' => 'Overdue invoice'], ['title' => 4, 'description' => 2], ['acme', 'overdue', 'invoice']]
    );

    expect($narrowScore)->toBe(0);
    expect($broadScore)->toBeGreaterThan(0);
});

it('falls back to invoice date ranges when invoice names are missing', function (): void {
    $service = new TestableSystemSearchService([]);

    $fullRangeTitle = system_search_service_invoke_private(
        $service,
        'invoiceResultTitle',
        [null, '2026-02-01 00:00:01', '2026-02-28 23:59:59', 42]
    );
    $fromOnlyTitle = system_search_service_invoke_private(
        $service,
        'invoiceResultTitle',
        ['', '2026-02-01 00:00:01', null, 43]
    );
    $fallbackTitle = system_search_service_invoke_private(
        $service,
        'invoiceResultTitle',
        [null, null, null, 44]
    );

    expect($fullRangeTitle)->toBe('2026-02-01 - 2026-02-28');
    expect($fromOnlyTitle)->toBe('2026-02-01');
    expect($fallbackTitle)->toBe('Invoice collection #44');
});

it('uses the goal criteria label for department goal titles', function (): void {
    $service = new TestableSystemSearchService([]);

    $labeledTitle = system_search_service_invoke_private(
        $service,
        'departmentGoalResultTitle',
        [json_encode(['label' => 'Weekly Wash Goal'], JSON_UNESCAPED_UNICODE), 91, 'Department goals #91']
    );
    $fallbackTitle = system_search_service_invoke_private(
        $service,
        'departmentGoalResultTitle',
        [json_encode(['target' => 12], JSON_UNESCAPED_UNICODE), 92, 'Department goals #92']
    );

    expect($labeledTitle)->toBe('Weekly Wash Goal');
    expect($fallbackTitle)->toBe('Department goals #92');
});

it('derives xlvask customer numbers only from digits-only extern ids', function (): void {
    $service = new TestableSystemSearchService([]);

    $digitsOnly = system_search_service_invoke_private($service, 'resolveConfiguredCustomerNumber', ['12345679', 'digits_only']);
    $uuidLike = system_search_service_invoke_private($service, 'resolveConfiguredCustomerNumber', ['09ed15d4-5a12-4d23-beac-4065174a74eb', 'digits_only']);
    $mixed = system_search_service_invoke_private($service, 'resolveConfiguredCustomerNumber', ['12345-A', 'digits_only']);
    $blank = system_search_service_invoke_private($service, 'resolveConfiguredCustomerNumber', ['   ', 'digits_only']);

    expect($digitsOnly)->toBe(12345679);
    expect($uuidLike)->toBeNull();
    expect($mixed)->toBeNull();
    expect($blank)->toBeNull();
});

it('replaces unnamed user titles with the customer context name', function (): void {
    $service = new CustomerContextAwareTestableSystemSearchService([]);
    $service->customerContexts = [
        777 => [
            'customer_number' => 777,
            'name' => 'Acme Transport',
            'barred' => false,
            'status' => 'active',
        ],
    ];

    $result = system_search_service_invoke_private($service, 'decorateSearchResultWithCustomerContext', [[
        'entity_type' => 'users',
        'entity_id' => '55',
        'title' => 'unnamed',
        'description' => '',
        'customer_number' => 777,
        'payload' => [
            'id' => 55,
            'display_name' => 'unnamed',
        ],
    ]]);

    expect($result['title'])->toBe('Acme Transport');
    expect($result['customer_name'])->toBe('Acme Transport');
});

it('enriches object attachment results with associated customer context', function (): void {
    $service = new CustomerContextAwareTestableSystemSearchService([]);
    $service->customerContexts = [
        777 => [
            'customer_number' => 777,
            'user_id' => 55,
            'name' => 'Acme Transport',
            'barred' => true,
            'status' => 'barred',
            'email' => 'dispatch@acme.test',
            'phone' => '40112233',
            'cvr' => '12345678',
            'address' => 'Road 1',
            'city' => 'Aarhus',
            'zip' => '8000',
        ],
    ];

    $result = system_search_service_invoke_private($service, 'buildObjectSearchResult', [[
        'id' => 88,
        'object_type' => 'orders',
        'object_id' => 501,
        'content' => json_encode(['other' => 'wash_certificate.pdf'], JSON_UNESCAPED_UNICODE),
        'customer_number' => 777,
        'department_id' => 12,
        'order_reference' => 'REF-501',
        'customer_name' => 'Acme Transport',
        'updated_at' => '2026-03-11 12:00:00',
        'created_at' => '2026-03-10 12:00:00',
    ], ['acme', '777'], 9]);

    expect($result['entity_type'])->toBe('objects');
    expect($result['customer_number'])->toBe(777);
    expect($result['customer_name'])->toBe('Acme Transport');
    expect($result['customer_barred'])->toBeTrue();
    expect($result['customer_status'])->toBe('barred');
    expect($result['description'])->toBe('REF-501 / Acme Transport');
    expect($result['payload']['linked_entity_type'])->toBe('orders');
    expect($result['payload']['order_reference'])->toBe('REF-501');
    expect($result['payload']['customer_context']['cvr'])->toBe('12345678');
});

it('includes the economic customer index in cache dependencies for customer scoped results', function (): void {
    $service = new TestableSystemSearchService([]);

    $tables = system_search_service_invoke_private($service, 'relevantSourceTables', [['objects', 'orders', 'vehicles']]);

    expect($tables)->toContain(system_search_economic_customer_index::TABLE);
});
