<?php

namespace MintyPHP\Module\Helpdesk\Service;

use MintyPHP\Http\SessionStoreInterface;

/**
 * Gateway for Business Central OData V4 API calls.
 *
 * Reads debtors, contacts and tickets from BC published web services.
 * Uses Basic Auth by default; supports Bearer token for OAuth2 mode.
 */
class BcODataGateway
{
    public const ENTITY_CUSTOMER = 'Integration_Customer_Card';
    public const ENTITY_CONTACT = 'Integration_Contact_Card';
    public const ENTITY_TICKETS = 'PBI_FP_Tickets';
    public const ENTITY_TICKETS_LV = 'PBI_LV_Tickets';
    public const ENTITY_ESCALATION = 'PBI_LV_Escalation';
    public const ENTITY_CONTRACTS = 'FS_Contracts_Test';
    public const ENTITY_TICKET_LOG = 'PBI_FP_TicketLog';
    public const ENTITY_TICKET_LOG_LV = 'PBI_LV_SupportTicketLog';
    public const ENTITY_CONTRACT_LINES = 'FS_Contract_Lines_Test';
    public const ENTITY_MEETINGS = 'FS_Debitor_Meetings';
    /** @api Used by listContractTypes() */
    public const ENTITY_CONTRACT_TYPES = 'FS_Contract_Types';
    /** @api Used by handover-domains-data endpoint and domains-data endpoint */
    public const ENTITY_CONTRACT_DOMAINS = 'FS_Contract_Domains';

    private const CONNECT_TIMEOUT = 10;
    private const REQUEST_TIMEOUT = 30;

    public function __construct(
        private readonly EffectiveHelpdeskSettingsService $settingsGateway,
        private readonly HelpdeskOAuthTokenService $oauthTokenService,
        private readonly SessionStoreInterface $sessionStore
    ) {
    }

    /**
     * Search customers by name or number.
     *
     * Tries `contains()` first; falls back to `startswith()` if BC rejects
     * the filter (many BC OData endpoints do not support `contains`).
     *
     * @return array<int, array<string, mixed>>
     */
    public function searchCustomers(string $query): array
    {
        $query = trim($query);
        if ($query === '') {
            return [];
        }

        $escaped = $this->escapeODataStrictUserInput($query);
        $upperQuery = mb_strtoupper($escaped);
        $select = '&$select=No,Name,Search_Name,Address,City,Post_Code,Phone_No,E_Mail,Salesperson_Code';
        $baseUrl = $this->settingsGateway->buildEntityUrl(self::ENTITY_CUSTOMER);

        $strategies = [
            // Strategy 1: contains() — most flexible but not supported by all BC versions
            "contains(Search_Name,'" . $upperQuery . "') or contains(Name,'" . $escaped . "') or contains(No,'" . $escaped . "')",
            // Strategy 2: startswith() — universally supported fallback
            "startswith(Search_Name,'" . $upperQuery . "') or startswith(Name,'" . $escaped . "') or startswith(No,'" . $escaped . "')",
            // Strategy 3: range filter on Search_Name (last resort)
            "Search_Name ge '" . $upperQuery . "' and Search_Name lt '" . $upperQuery . "z'",
        ];

        $lastException = null;
        foreach ($strategies as $filter) {
            $url = $baseUrl . '?$filter=' . rawurlencode($filter) . '&$top=50' . $select;
            try {
                $response = $this->request('GET', $url);
                if ($response !== null) {
                    return $this->extractODataValues($response);
                }

                // request() returned null → server/network error, propagate
                throw new \RuntimeException('BC OData request failed for URL: ' . $url);
            } catch (\RuntimeException $e) {
                $lastException = $e;
                // Filter not supported by this BC version (4xx) — try next strategy
                continue;
            }
        }

        // All strategies failed — re-throw so DebitorSearchService shows error, not "no results"
        throw $lastException ?? new \RuntimeException('All OData search strategies failed');
    }

    /**
     * Read customer list for grid pagination with optional search/city filters.
     *
     * @return array{rows: array<int, array<string, mixed>>, total: int}
     */
    public function listCustomers(
        string $search = '',
        string $city = '',
        int $limit = 10,
        int $offset = 0,
        string $order = 'Name',
        string $dir = 'asc'
    ): array {
        $search = trim($search);
        $city = trim($city);
        $limit = max(1, min(100, $limit));
        $offset = max(0, $offset);
        $sortField = $this->normalizeCustomerSortField($order);
        $sortDir = strtolower(trim($dir)) === 'desc' ? 'desc' : 'asc';

        $baseUrl = $this->settingsGateway->buildEntityUrl(self::ENTITY_CUSTOMER);
        $select = '&$select=No,Name,Search_Name,Address,City,Post_Code,Phone_No,E_Mail,Salesperson_Code';

        $cityClause = '';
        if ($city !== '') {
            $escapedCity = $this->escapeODataStrictUserInput($city);
            $cityClause = "startswith(City,'" . $escapedCity . "')";
        }

        $searchStrategies = [''];
        if ($search !== '') {
            $escapedSearch = $this->escapeODataStrictUserInput($search);
            $upperSearch = mb_strtoupper($escapedSearch);
            $searchStrategies = [
                "contains(Search_Name,'" . $upperSearch . "') or contains(Name,'" . $escapedSearch . "') or contains(No,'" . $escapedSearch . "')",
                "startswith(Search_Name,'" . $upperSearch . "') or startswith(Name,'" . $escapedSearch . "') or startswith(No,'" . $escapedSearch . "')",
                "Search_Name ge '" . $upperSearch . "' and Search_Name lt '" . $upperSearch . "z'",
            ];
        }
        $baseNonEmptyNameClause = "Name ne ''";

        $lastException = null;
        foreach ($searchStrategies as $searchClause) {
            $clauses = [$baseNonEmptyNameClause];
            if ($searchClause !== '') {
                $clauses[] = '(' . $searchClause . ')';
            }
            if ($cityClause !== '') {
                $clauses[] = $cityClause;
            }

            $url = $baseUrl
                . '?$top=' . $limit
                . '&$skip=' . $offset
                . '&$count=true'
                . '&$orderby=' . rawurlencode($sortField . ' ' . $sortDir)
                . $select;
            $url .= '&$filter=' . rawurlencode(implode(' and ', $clauses));

            try {
                $response = $this->request('GET', $url);
                if ($response === null) {
                    throw new \RuntimeException('BC OData request failed for URL: ' . $url);
                }

                $rows = $this->extractODataValues($response);

                return [
                    'rows' => $rows,
                    'total' => $this->resolveCustomerListTotal($response, $offset, $limit, count($rows)),
                ];
            } catch (\RuntimeException $e) {
                $lastException = $e;

                if ($search === '') {
                    break;
                }
            }
        }

        throw $lastException;
    }

    /**
     * Get a single customer by customer number.
     *
     * @return array<string, mixed>|null
     */
    public function getCustomer(string $customerNo): ?array
    {
        $customerNo = trim($customerNo);
        if ($customerNo === '') {
            return null;
        }

        $escaped = $this->escapeODataStrictUserInput($customerNo);
        $filter = "No eq '" . $escaped . "'";
        $url = $this->settingsGateway->buildEntityUrl(self::ENTITY_CUSTOMER)
            . '?$filter=' . rawurlencode($filter)
            . '&$select=No,Name,Search_Name,Address,City,Post_Code,Phone_No,E_Mail,Salesperson_Code,Support_Type';

        $response = $this->request('GET', $url);
        if ($response === null) {
            return null;
        }

        $values = $this->extractODataValues($response);

        return $values[0] ?? null;
    }

    /**
     * Get contacts for a customer by customer name.
     *
     * @return array<int, array<string, mixed>>
     */
    public function getContactsForCustomer(string $customerNo, string $customerName = ''): array
    {
        $result = $this->getContactsForCustomerWithMeta($customerNo, $customerName);

        return $result['contacts'];
    }

    /**
     * Get contacts for a customer with fallback metadata.
     *
     * @return array{
     *   contacts: array<int, array<string, mixed>>,
     *   meta: array{
     *     source: string,
     *     fallback_used: bool,
     *     fallback_reason: string
     *   }
     * }
     */
    public function getContactsForCustomerWithMeta(string $customerNo, string $customerName = ''): array
    {
        $customerNo = trim($customerNo);
        $customerName = trim($customerName);
        if ($customerNo === '' && $customerName === '') {
            return [
                'contacts' => [],
                'meta' => [
                    'source' => 'primary.company_name',
                    'fallback_used' => false,
                    'fallback_reason' => '',
                ],
            ];
        }

        $primaryError = false;
        $primaryContacts = [];

        try {
            $primaryContacts = $this->fetchContactsByField('Company_Name', $customerName);
        } catch (\Throwable) {
            $primaryError = true;
        }

        if ($primaryContacts !== []) {
            return [
                'contacts' => $primaryContacts,
                'meta' => [
                    'source' => 'primary.company_name',
                    'fallback_used' => false,
                    'fallback_reason' => '',
                ],
            ];
        }

        $fallbackReason = $primaryError ? 'primary_query_failed' : 'primary_empty';

        try {
            $integrationContacts = $this->fetchContactsByField('IntegrationCustomerNo', $customerNo);
            if ($integrationContacts !== []) {
                return [
                    'contacts' => $integrationContacts,
                    'meta' => [
                        'source' => 'fallback.integration_customer_no',
                        'fallback_used' => true,
                        'fallback_reason' => $fallbackReason,
                    ],
                ];
            }
        } catch (\Throwable) {
            // Continue with second fallback.
        }

        try {
            $companyNoContacts = $this->fetchContactsByField('Company_No', $customerNo);
            if ($companyNoContacts !== []) {
                return [
                    'contacts' => $companyNoContacts,
                    'meta' => [
                        'source' => 'fallback.company_no',
                        'fallback_used' => true,
                        'fallback_reason' => $fallbackReason,
                    ],
                ];
            }
        } catch (\Throwable) {
            // Fall back to primary result (empty set).
        }

        return [
            'contacts' => $this->dedupeRowsByNo($primaryContacts),
            'meta' => [
                'source' => 'primary.company_name',
                'fallback_used' => false,
                'fallback_reason' => '',
            ],
        ];
    }

    /**
     * Get tickets for a customer by customer name.
     *
     * @return array<int, array<string, mixed>>
     */
    public function getTicketsForCustomer(string $customerNo, string $customerName = ''): array
    {
        $result = $this->getTicketsForCustomerWithMeta($customerNo, $customerName);

        return $result['tickets'];
    }

    /**
     * Get tickets for a customer with fallback metadata.
     *
     * @return array{
     *   tickets: array<int, array<string, mixed>>,
     *   meta: array{
     *     source: string,
     *     fallback_used: bool,
     *     fallback_reason: string
     *   }
     * }
     */
    public function getTicketsForCustomerWithMeta(string $customerNo, string $customerName = ''): array
    {
        $customerNo = trim($customerNo);
        $customerName = trim($customerName);
        if ($customerNo === '' || $customerName === '') {
            return [
                'tickets' => [],
                'meta' => [
                    'source' => 'primary.company_contact_name',
                    'fallback_used' => false,
                    'fallback_reason' => '',
                ],
            ];
        }

        $primaryError = false;
        $primaryTickets = [];

        try {
            $primaryTickets = $this->fetchTicketsByCompanyContactName($customerName);
        } catch (\Throwable) {
            $primaryError = true;
        }

        if ($primaryTickets !== []) {
            return [
                'tickets' => $primaryTickets,
                'meta' => [
                    'source' => 'primary.company_contact_name',
                    'fallback_used' => false,
                    'fallback_reason' => '',
                ],
            ];
        }

        $fallbackReason = $primaryError ? 'primary_query_failed' : 'primary_empty';
        try {
            $fallbackTickets = $this->fetchTicketsByCustomerNoFromLv($customerNo, $customerName);
        } catch (\Throwable) {
            $fallbackTickets = [];
        }

        if ($fallbackTickets !== []) {
            return [
                'tickets' => $fallbackTickets,
                'meta' => [
                    'source' => 'fallback.customer_no_lv',
                    'fallback_used' => true,
                    'fallback_reason' => $fallbackReason,
                ],
            ];
        }

        return [
            'tickets' => $primaryTickets,
            'meta' => [
                'source' => 'primary.company_contact_name',
                'fallback_used' => false,
                'fallback_reason' => '',
            ],
        ];
    }

    /**
     * @return array<int, array<string, mixed>>
     */
    private function fetchContactsByField(string $field, string $value): array
    {
        $value = trim($value);
        if ($value === '') {
            return [];
        }

        $escaped = $this->escapeODataTrustedLiteral($value);
        $filter = $field . " eq '" . $escaped . "'";
        $url = $this->settingsGateway->buildEntityUrl(self::ENTITY_CONTACT)
            . '?$filter=' . rawurlencode($filter)
            . '&$top=100'
            . '&$select=No,Name,Type,Company_No,Company_Name,IntegrationCustomerNo,E_Mail,Phone_No,Mobile_Phone_No,Job_Title';

        $response = $this->request('GET', $url);
        if ($response === null) {
            return [];
        }

        return $this->dedupeRowsByNo($this->extractODataValues($response));
    }

    /**
     * Uses the PBI_FP_Tickets entity (FactPage / Power BI view) which supports
     * server-side $filter and $orderby.
     *
     * @return array<int, array<string, mixed>>
     */
    private function fetchTicketsByCompanyContactName(string $customerName): array
    {
        $customerName = trim($customerName);
        if ($customerName === '') {
            return [];
        }

        $escaped = $this->escapeODataTrustedLiteral($customerName);
        $filter = "Company_Contact_Name eq '" . $escaped . "'";
        $url = $this->settingsGateway->buildEntityUrl(self::ENTITY_TICKETS)
            . '?$filter=' . rawurlencode($filter)
            . '&$top=200'
            . '&$orderby=' . rawurlencode('Created_On desc')
            . '&$select=No,Description,Company_Contact_Name,Current_Contact_Name,Category_1_Description,Ticket_State,Support_User_Name,Created_On,Last_Activity_Date';

        $response = $this->request('GET', $url);
        if ($response === null) {
            return [];
        }

        return $this->dedupeRowsByNo($this->extractODataValues($response));
    }

    /**
     * Fallback ticket source by customer number using LV entity.
     *
     * @return array<int, array<string, mixed>>
     */
    private function fetchTicketsByCustomerNoFromLv(string $customerNo, string $customerName): array
    {
        $customerNo = trim($customerNo);
        if ($customerNo === '') {
            return [];
        }

        $escaped = $this->escapeODataStrictUserInput($customerNo);
        $filter = "Customer_No eq '" . $escaped . "'";
        $url = $this->settingsGateway->buildEntityUrl(self::ENTITY_TICKETS_LV)
            . '?$filter=' . rawurlencode($filter)
            . '&$top=300'
            . '&$select=' . rawurlencode('No,Customer_No,Category_1_Code,Escalation_Code,Ticket_State,Last_Activity_Date,Created_On,Process_Stage,Process_Code,Support_User_Name')
            . '&$orderby=' . rawurlencode('Created_On desc');

        $response = $this->request('GET', $url);
        if ($response === null) {
            return [];
        }

        $rows = $this->extractODataValues($response);
        $mapped = [];
        foreach ($rows as $row) {
            $ticketNo = trim((string) ($row['No'] ?? ''));
            if ($ticketNo === '') {
                continue;
            }

            $mapped[] = [
                'No' => $ticketNo,
                'Description' => trim((string) ($row['Process_Code'] ?? '')),
                'Company_Contact_Name' => $customerName,
                'Current_Contact_Name' => '',
                'Category_1_Description' => trim((string) ($row['Category_1_Code'] ?? '')),
                'Ticket_State' => trim((string) ($row['Ticket_State'] ?? '')),
                'Support_User_Name' => trim((string) ($row['Support_User_Name'] ?? '')),
                'Created_On' => trim((string) ($row['Created_On'] ?? '')),
                'Last_Activity_Date' => trim((string) ($row['Last_Activity_Date'] ?? '')),
            ];
        }

        return $this->dedupeRowsByNo($mapped);
    }

    /**
     * Get escalation definitions (code -> target time).
     *
     * @return array<int, array<string, mixed>>
     */
    public function getEscalationDefinitions(): array
    {
        $tenantScope = DebitorCacheControl::resolveTenantScope($this->sessionStore->all());
        $cacheKey = DebitorCacheControl::escalationDefinitionsKey($tenantScope);
        $cached = $this->sessionStore->get($cacheKey);
        if (is_array($cached) && isset($cached['fetched_at']) && (time() - (int) $cached['fetched_at']) < DebitorCacheControl::TTL_ESCALATION_DEFINITIONS_SECONDS) {
            $values = $cached['values'] ?? [];
            if (is_array($values)) {
                return $values;
            }
        }

        $url = $this->settingsGateway->buildEntityUrl(self::ENTITY_ESCALATION)
            . '?$top=50'
            . '&$select=' . rawurlencode('Code,Description,Target_Time,No_of_Escalation_Level')
            . '&$orderby=' . rawurlencode('Code asc');

        $response = $this->request('GET', $url);
        if ($response === null) {
            return [];
        }

        $values = $this->extractODataValues($response);
        $this->sessionStore->set($cacheKey, [
            'values' => $values,
            'fetched_at' => time(),
        ]);

        return $values;
    }

    /**
     * Get ticket escalation rows for a customer (ticket no + escalation code).
     *
     * @return array<int, array<string, mixed>>
     */
    public function getEscalationTicketsForCustomer(string $customerNo): array
    {
        $customerNo = trim($customerNo);
        if ($customerNo === '') {
            return [];
        }

        $escaped = $this->escapeODataStrictUserInput($customerNo);
        $filter = "Customer_No eq '" . $escaped . "'";
        $url = $this->settingsGateway->buildEntityUrl(self::ENTITY_TICKETS_LV)
            . '?$filter=' . rawurlencode($filter)
            . '&$top=200'
            . '&$select=' . rawurlencode('No,Customer_No,Category_1_Code,Escalation_Code,Ticket_State,Last_Activity_Date')
            . '&$orderby=' . rawurlencode('Last_Activity_Date desc');

        $response = $this->request('GET', $url);
        if ($response === null) {
            return [];
        }

        return $this->extractODataValues($response);
    }

    /**
     * Get ticket rows for controlling dashboard (wider field set, higher limit).
     *
     * Uses the same PBI_LV_Tickets entity but includes Created_On, Process_Stage,
     * Process_Code and Support_User_Name for resolution time and efficiency metrics.
     *
     * @return array<int, array<string, mixed>>
     */
    public function getTicketsForControlling(string $customerNo): array
    {
        $customerNo = trim($customerNo);
        if ($customerNo === '') {
            return [];
        }

        $escaped = $this->escapeODataStrictUserInput($customerNo);
        $filter = "Customer_No eq '" . $escaped . "'";
        $url = $this->settingsGateway->buildEntityUrl(self::ENTITY_TICKETS_LV)
            . '?$filter=' . rawurlencode($filter)
            . '&$top=300'
            . '&$select=' . rawurlencode('No,Customer_No,Category_1_Code,Escalation_Code,Ticket_State,Last_Activity_Date,Created_On,Process_Stage,Process_Code,Support_User_Name')
            . '&$orderby=' . rawurlencode('Created_On desc');

        $response = $this->request('GET', $url);
        if ($response === null) {
            return [];
        }

        return $this->extractODataValues($response);
    }

    /**
     * Get ticket rows across all customers for team workload aggregation.
     *
     * Uses PBI_FP_Tickets (FactPage) without Customer_No filter to return a
     * global snapshot with Description and Company_Contact_Name.
     * Support_User_Name is used for per-agent grouping.
     *
     * @return array<int, array<string, mixed>>
     */
    public function getTicketsForTeam(): array
    {
        $url = $this->settingsGateway->buildEntityUrl(self::ENTITY_TICKETS)
            . '?$top=500'
            . '&$select=' . rawurlencode('No,Description,Company_Contact_Name,Category_1_Description,Ticket_State,Support_User_Name,Created_On,Last_Activity_Date,Last_Activity_By_Code')
            . '&$orderby=' . rawurlencode('Created_On desc');

        $response = $this->request('GET', $url);
        if ($response === null) {
            return [];
        }

        return $this->extractODataValues($response);
    }

    /**
     * Get tickets across all customers for risk radar aggregation.
     *
     * Uses PBI_LV_Tickets with client-driven paging ($skip/$top).
     * Tries a server-side date filter first (2× periodDays for trend window).
     * Falls back to unfiltered pagination if BC rejects the filter.
     * Follows @odata.nextLink for robust pagination.
     *
     * @return array{tickets: array<int, array<string, mixed>>, truncated: bool}
     */
    public function getTicketsForRiskRadar(int $periodDays = 90): array
    {
        $select = 'No,Customer_No,Cust_Name,Escalation_Code,Created_On,Last_Activity_Date,Process_Stage,Ticket_State,Support_User_Name,Category_1_Code,Process_Code';
        $baseUrl = $this->settingsGateway->buildEntityUrl(self::ENTITY_TICKETS_LV);

        // Strategy 1: server-side date filter (drastically reduces data volume)
        $cutoff = (new \DateTimeImmutable('-' . ($periodDays * 2) . ' days'))->format('Y-m-d');
        $filteredUrl = $baseUrl
            . '?$top=500'
            . '&$select=' . rawurlencode($select)
            . '&$filter=' . rawurlencode("Created_On ge " . $cutoff)
            . '&$orderby=' . rawurlencode('Created_On desc');

        $result = $this->paginateOData($filteredUrl, 20);
        if ($result['tickets'] !== []) {
            return $result;
        }

        // Strategy 2: fallback without filter (BC may not support date filters on this entity)
        $unfilteredUrl = $baseUrl
            . '?$top=500'
            . '&$select=' . rawurlencode($select)
            . '&$orderby=' . rawurlencode('Created_On desc');

        return $this->paginateOData($unfilteredUrl, 10);
    }

    /**
     * Paginate an OData request following @odata.nextLink.
     *
     * @return array{tickets: array<int, array<string, mixed>>, truncated: bool}
     */
    private function paginateOData(string $startUrl, int $maxPages): array
    {
        $allTickets = [];
        $truncated = false;
        $nextUrl = $startUrl;

        for ($page = 0; $page < $maxPages; $page++) {
            $response = $this->request('GET', $nextUrl);
            if ($response === null) {
                break;
            }

            $rows = $this->extractODataValues($response);
            if ($rows === []) {
                break;
            }

            foreach ($rows as $row) {
                $allTickets[] = $row;
            }

            $link = trim((string) ($response['@odata.nextLink'] ?? $response['odata.nextLink'] ?? ''));
            if ($link === '' || count($rows) < 500) {
                break;
            }

            $nextUrl = $link;

            if ($page === $maxPages - 1) {
                $truncated = true;
            }
        }

        return ['tickets' => $allTickets, 'truncated' => $truncated];
    }

    /**
     * Fetch all update/hotfix tickets across all customers.
     *
     * Queries PBI_LV_Tickets with Category_1_Code filter for UPDATE and UPDATE-HF.
     * Results are cached in session with standard TTL.
     * Uses pagination via @odata.nextLink (same as risk radar).
     *
     * @return array{tickets: array<int, array<string, mixed>>, truncated: bool}
     */
    public function fetchUpdateTickets(): array
    {
        $tenantScope = DebitorCacheControl::resolveTenantScope($this->sessionStore->all());
        $cacheKey = DebitorCacheControl::updateTicketsKey($tenantScope);
        $cached = $this->sessionStore->get($cacheKey);
        if (is_array($cached) && isset($cached['fetched_at']) && (time() - (int) $cached['fetched_at']) < DebitorCacheControl::TTL_STANDARD_SECONDS) {
            $tickets = $cached['tickets'] ?? [];
            $truncated = (bool) ($cached['truncated'] ?? false);
            if (is_array($tickets)) {
                return ['tickets' => $tickets, 'truncated' => $truncated];
            }
        }

        $select = 'No,Description,Customer_No,Cust_Name,Category_1_Code,Ticket_State,Created_On,Last_Activity_Date,Support_User_Name';
        $baseUrl = $this->settingsGateway->buildEntityUrl(self::ENTITY_TICKETS_LV);

        // BC OData often rejects `or` in $filter — fetch each category separately and merge.
        $allTickets = [];
        $truncated = false;
        foreach (['UPDATE', 'UPDATE-HF'] as $category) {
            $filter = "Category_1_Code eq '" . $category . "'";
            $url = $baseUrl
                . '?$top=500'
                . '&$select=' . rawurlencode($select)
                . '&$filter=' . rawurlencode($filter)
                . '&$orderby=' . rawurlencode('Created_On desc');

            $partial = $this->paginateOData($url, 10);
            foreach ($partial['tickets'] as $ticket) {
                $allTickets[] = $ticket;
            }
            if ($partial['truncated']) {
                $truncated = true;
            }
        }

        $result = ['tickets' => $this->dedupeRowsByNo($allTickets), 'truncated' => $truncated];

        $this->sessionStore->set($cacheKey, [
            'tickets' => $result['tickets'],
            'truncated' => $result['truncated'],
            'fetched_at' => time(),
        ]);

        return $result;
    }

    /**
     * Get contracts for a customer by customer number.
     *
     * Reads from the custom FS_Contracts_Test OData entity and matches both
     * bill-to and sell-to customer number to stay robust for mixed setups.
     *
     * Some BC installations do not support logical OR in $filter for this entity.
     * Therefore, we execute two separate requests and merge the rows.
     *
     * @return array<int, array<string, mixed>>
     */
    public function getContractsForCustomer(string $customerNo): array
    {
        $customerNo = trim($customerNo);
        if ($customerNo === '') {
            return [];
        }

        $select = 'No,Periodic_Invoicing_Type,Description,State,Sell_to_Cust_No,Sell_to_Cust_Name,Bill_to_Cust_No,Bill_to_Cust_Name,Next_Invoicing_Date,TotalPayoffAmount,No_of_Active_Position,Support_Hours,Starting_Date,Invoicing_Period,MontlyAmount_InvoicingDate,Salesperson_Purchaser_Code,Main_Contract';
        $allRows = [];
        $errors = [];

        foreach (['Bill_to_Cust_No', 'Sell_to_Cust_No'] as $customerField) {
            try {
                $allRows = array_merge($allRows, $this->fetchContractsByCustomerField($customerNo, $customerField, $select));
            } catch (\RuntimeException $e) {
                $errors[] = $e;
            }
        }

        if ($allRows === [] && $errors !== []) {
            throw $errors[0];
        }

        $uniqueRows = [];
        foreach ($allRows as $row) {
            $contractNo = trim((string) ($row['No'] ?? ''));
            $rowKey = $contractNo !== '' ? $contractNo : md5((string) json_encode($row));
            if (isset($uniqueRows[$rowKey])) {
                continue;
            }
            $uniqueRows[$rowKey] = $row;
        }

        $merged = array_values($uniqueRows);
        usort($merged, static function (array $a, array $b): int {
            $aDate = trim((string) ($a['Next_Invoicing_Date'] ?? ''));
            $bDate = trim((string) ($b['Next_Invoicing_Date'] ?? ''));
            if ($aDate === '' && $bDate !== '') {
                return 1;
            }
            if ($aDate !== '' && $bDate === '') {
                return -1;
            }

            return strcmp($aDate, $bDate);
        });

        return $merged;
    }

    /**
     * @return array<int, array<string, mixed>>
     */
    private function fetchContractsByCustomerField(string $customerNo, string $customerField, string $select): array
    {
        $escaped = $this->escapeODataStrictUserInput($customerNo);
        $filter = $customerField . " eq '" . $escaped . "'";
        $url = $this->settingsGateway->buildEntityUrl(self::ENTITY_CONTRACTS)
            . '?$filter=' . rawurlencode($filter)
            . '&$top=150'
            . '&$select=' . rawurlencode($select)
            . '&$orderby=' . rawurlencode('Next_Invoicing_Date asc');

        $response = $this->request('GET', $url);
        if ($response === null) {
            return [];
        }

        return $this->extractODataValues($response);
    }

    /**
     * Get contract line items for a customer.
     *
     * @return array<int, array<string, mixed>>
     */
    public function getContractLinesForCustomer(string $customerNo): array
    {
        $customerNo = trim($customerNo);
        if ($customerNo === '') {
            return [];
        }

        $escaped = $this->escapeODataStrictUserInput($customerNo);
        $filter = "Customer_No eq '" . $escaped . "'";
        $select = 'Header_No,Line_No,PI_Header_State,PI_Header_Description,Type,Description,Quantity,Unit_of_Measure_Code,Unit_Price,Line_Discount_Percent,Line_Amount,Line_State,Starting_Date,Ending_Date,Position,Attached_to_Line_No';
        $url = $this->settingsGateway->buildEntityUrl(self::ENTITY_CONTRACT_LINES)
            . '?$filter=' . rawurlencode($filter)
            . '&$top=300'
            . '&$select=' . rawurlencode($select)
            . '&$orderby=' . rawurlencode('Header_No asc,Line_No asc');

        $response = $this->request('GET', $url);
        if ($response === null) {
            return [];
        }

        return $this->extractODataValues($response);
    }

    /**
     * Get all domains (unfiltered, for the domains list page).
     *
     * @api Called from domains-data endpoint
     * @return array<int, array<string, mixed>>
     */
    public function listDomains(): array
    {
        $select = 'No,Customer_No,Customer_Name,URL,State,Administration';
        $url = $this->settingsGateway->buildEntityUrl(self::ENTITY_CONTRACT_DOMAINS)
            . '?$top=5000'
            . '&$select=' . rawurlencode($select)
            . '&$orderby=' . rawurlencode('Customer_Name asc');

        $response = $this->request('GET', $url);
        if ($response === null) {
            return [];
        }

        return $this->extractODataValues($response);
    }

    /**
     * Get all contract lines of type "Domain".
     *
     * Returns contract line metadata for domain-type lines,
     * keyed by DNS number (No field). Used to enrich the domain
     * list with contract type information (PI_Header_Type).
     *
     * @return array<int, array<string, mixed>>
     */
    public function listDomainContractLines(): array
    {
        $filter = "Type eq 'Domain'";
        $select = 'No,Header_No,PI_Header_Type,PI_Header_Description,PI_Header_State,Line_State';
        $url = $this->settingsGateway->buildEntityUrl(self::ENTITY_CONTRACT_LINES)
            . '?$filter=' . rawurlencode($filter)
            . '&$top=5000'
            . '&$select=' . rawurlencode($select)
            . '&$orderby=' . rawurlencode('No asc');

        $response = $this->request('GET', $url);
        if ($response === null) {
            return [];
        }

        return $this->extractODataValues($response);
    }

    /**
     * Get a single domain by its domain number.
     *
     * @api Called from domain($id) action
     * @return array<string, mixed>|null
     */
    public function getDomain(string $domainNo): ?array
    {
        $domainNo = trim($domainNo);
        if ($domainNo === '') {
            return null;
        }

        $escaped = $this->escapeODataStrictUserInput($domainNo);
        $filter = "No eq '" . $escaped . "'";
        $select = 'No,Customer_No,Customer_Name,URL,State,Administration';
        $url = $this->settingsGateway->buildEntityUrl(self::ENTITY_CONTRACT_DOMAINS)
            . '?$filter=' . rawurlencode($filter)
            . '&$top=1'
            . '&$select=' . rawurlencode($select);

        $response = $this->request('GET', $url);
        if ($response === null) {
            return null;
        }

        $values = $this->extractODataValues($response);

        return $values[0] ?? null;
    }

    /**
     * Get active domains for a customer.
     *
     * @api Called from handover-domains-data endpoint
     * @return array<int, array<string, mixed>>
     */
    public function getDomainsForCustomer(string $customerNo): array
    {
        $customerNo = trim($customerNo);
        if ($customerNo === '') {
            return [];
        }

        $escaped = $this->escapeODataStrictUserInput($customerNo);
        $filter = "Customer_No eq '" . $escaped . "' and State eq 'Aktiv'";
        $select = 'No,Customer_No,URL,State';
        $url = $this->settingsGateway->buildEntityUrl(self::ENTITY_CONTRACT_DOMAINS)
            . '?$filter=' . rawurlencode($filter)
            . '&$top=200'
            . '&$select=' . rawurlencode($select)
            . '&$orderby=' . rawurlencode('URL asc');

        $response = $this->request('GET', $url);
        if ($response === null) {
            return [];
        }

        return $this->extractODataValues($response);
    }

    /**
     * Get meetings/appointments for a customer.
     *
     * @return array<int, array<string, mixed>>
     */
    public function getMeetingsForCustomer(string $customerNo): array
    {
        $customerNo = trim($customerNo);
        if ($customerNo === '') {
            return [];
        }

        $escaped = $this->escapeODataStrictUserInput($customerNo);
        $filter = "Customer_No eq '" . $escaped . "'";
        $select = 'Entry_No,Appointment_Date,Customer_No,Customer_Name,Salesperson_Code,Comment,Released_by,Release_Date';
        $url = $this->settingsGateway->buildEntityUrl(self::ENTITY_MEETINGS)
            . '?$filter=' . rawurlencode($filter)
            . '&$top=200'
            . '&$select=' . rawurlencode($select)
            . '&$orderby=' . rawurlencode('Appointment_Date desc');

        $response = $this->request('GET', $url);
        if ($response === null) {
            return [];
        }

        return $this->extractODataValues($response);
    }

    /**
     * Get all contract types where Create_SaaS_License is true.
     *
     * Tries $filter=Create_SaaS_License eq true first; falls back to
     * fetching all types and filtering PHP-side if BC rejects the filter.
     *
     * Returns trimmed Code + Description. Entries with empty Code are skipped.
     *
     * @return array<int, array{Code: string, Description: string}>
     */
    public function listContractTypes(): array
    {
        $select = 'Code,Description,Create_SaaS_License';
        $filter = 'Create_SaaS_License eq true';
        $url = $this->settingsGateway->buildEntityUrl(self::ENTITY_CONTRACT_TYPES)
            . '?$filter=' . rawurlencode($filter)
            . '&$top=5000'
            . '&$select=' . rawurlencode($select);

        $response = $this->request('GET', $url);

        // Fallback: fetch all, filter PHP-side if BC rejects the boolean filter
        if ($response === null) {
            $urlAll = $this->settingsGateway->buildEntityUrl(self::ENTITY_CONTRACT_TYPES)
                . '?$top=5000'
                . '&$select=' . rawurlencode($select);

            $response = $this->request('GET', $urlAll);
            if ($response === null) {
                return [];
            }

            $all = $this->extractODataValues($response);

            return $this->normalizeContractTypes(array_filter(
                $all,
                static fn (array $row): bool => !empty($row['Create_SaaS_License'])
            ));
        }

        return $this->normalizeContractTypes($this->extractODataValues($response));
    }

    /**
     * Trim and filter contract type rows: skip empty codes, trim whitespace.
     *
     * @param  array<int, array<string, mixed>> $rows
     * @return array<int, array{Code: string, Description: string}>
     */
    private function normalizeContractTypes(array $rows): array
    {
        $result = [];
        foreach ($rows as $row) {
            $code = trim((string) ($row['Code'] ?? ''));
            if ($code === '') {
                continue;
            }
            $result[] = [
                'Code' => $code,
                'Description' => trim((string) ($row['Description'] ?? '')),
            ];
        }

        return $result;
    }

    /**
     * Get a single ticket by ticket number.
     *
     * @return array<string, mixed>|null
     */
    public function getTicket(string $ticketNo): ?array
    {
        $ticketNo = trim($ticketNo);
        if ($ticketNo === '') {
            return null;
        }

        $escaped = $this->escapeODataStrictUserInput($ticketNo);
        $filter = "No eq '" . $escaped . "'";
        $url = $this->settingsGateway->buildEntityUrl(self::ENTITY_TICKETS)
            . '?$filter=' . rawurlencode($filter)
            . '&$select=No,Description,Company_Contact_Name,Current_Contact_Name,Category_1_Description,Ticket_State,Support_User_Name,Created_On,Last_Activity_Date';

        $response = $this->request('GET', $url);
        if ($response === null) {
            return null;
        }

        $values = $this->extractODataValues($response);

        return $values[0] ?? null;
    }

    /**
     * Get the activity log for a ticket.
     *
     * Merges data from two BC entities:
     * - PBI_FP_TicketLog: detailed log (user, time, Record_ID)
     * - PBI_LV_SupportTicketLog: has human-readable State_Subtype (e.g. "In Bearbeitung")
     *
     * Entries are joined by Entry_No and returned newest first.
     *
     * @return array<int, array<string, mixed>>
     */
    public function getTicketLog(string $ticketNo): array
    {
        $ticketNo = trim($ticketNo);
        if ($ticketNo === '') {
            return [];
        }

        $escaped = $this->escapeODataStrictUserInput($ticketNo);
        $filter = "Support_Ticket_No eq '" . $escaped . "'";

        // Fetch detailed log (user, time, Record_ID)
        $detailUrl = $this->settingsGateway->buildEntityUrl(self::ENTITY_TICKET_LOG)
            . '?$filter=' . rawurlencode($filter)
            . '&$orderby=' . rawurlencode('Entry_No desc')
            . '&$top=100'
            . '&$select=Entry_No,Created_By_Type,Created_By,Creation_Date,Creation_Time,Support_Ticket_No,Type,Record_ID';

        $detailResponse = $this->request('GET', $detailUrl);
        if ($detailResponse === null) {
            return [];
        }

        $entries = $this->extractODataValues($detailResponse);
        if ($entries === []) {
            return [];
        }

        // Only fetch status subtypes if detail entries contain status-type rows
        $hasStatusEntries = false;
        foreach ($entries as $entry) {
            if (($entry['Type'] ?? '') === 'Status') {
                $hasStatusEntries = true;
                break;
            }
        }

        $subtypeMap = [];
        if ($hasStatusEntries) {
            $lvUrl = $this->settingsGateway->buildEntityUrl(self::ENTITY_TICKET_LOG_LV)
                . '?$filter=' . rawurlencode($filter)
                . '&$orderby=' . rawurlencode('Entry_No desc')
                . '&$top=100'
                . '&$select=Entry_No,State_Subtype';

            $lvResponse = $this->request('GET', $lvUrl);
            if ($lvResponse !== null) {
                foreach ($this->extractODataValues($lvResponse) as $lv) {
                    $entryNo = $lv['Entry_No'] ?? null;
                    $subtype = (string) ($lv['State_Subtype'] ?? '');
                    if ($entryNo !== null && $subtype !== '' && $subtype !== '0') {
                        $subtypeMap[$entryNo] = $subtype;
                    }
                }
            }
        }

        // Merge State_Subtype into detail entries
        foreach ($entries as &$entry) {
            $entryNo = $entry['Entry_No'] ?? null;
            $entry['State_Subtype'] = $subtypeMap[$entryNo] ?? '';
        }
        unset($entry);

        return $entries;
    }

    /**
     * Test the connection by fetching one customer record.
     *
     * @return array{ok: bool, error?: string, url?: string, http_code?: int}
     */
    public function testConnection(): array
    {
        if (!$this->settingsGateway->isConfigured()) {
            return ['ok' => false, 'error' => 'Configuration incomplete'];
        }

        $url = $this->settingsGateway->buildEntityUrl(self::ENTITY_CUSTOMER)
            . '?$top=1&$select=No';

        $ch = curl_init();
        if ($ch === false) {
            return ['ok' => false, 'error' => 'curl_init failed'];
        }

        curl_setopt_array($ch, [
            CURLOPT_URL => $url,
            CURLOPT_RETURNTRANSFER => true,
            CURLOPT_CONNECTTIMEOUT => self::CONNECT_TIMEOUT,
            CURLOPT_TIMEOUT => self::REQUEST_TIMEOUT,
            CURLOPT_HTTPHEADER => [
                'Accept: application/json',
                'OData-Version: 4.0',
            ],
            CURLOPT_CUSTOMREQUEST => 'GET',
            CURLOPT_SSL_VERIFYPEER => true,
            CURLOPT_SSL_VERIFYHOST => 2,
        ]);

        $this->applyAuth($ch);

        $responseBody = curl_exec($ch);
        $curlError = curl_error($ch);
        $curlErrno = curl_errno($ch);
        $httpCode = (int) curl_getinfo($ch, CURLINFO_HTTP_CODE);
        if ($curlErrno !== 0) {
            return ['ok' => false, 'error' => 'cURL error ' . $curlErrno . ': ' . $curlError, 'url' => $url];
        }

        if ($httpCode < 200 || $httpCode >= 300) {
            $hint = match ($httpCode) {
                401 => 'Unauthorized — check username/password',
                403 => 'Forbidden — user has no access to this endpoint',
                404 => 'Not found — check OData URL and company name',
                default => 'HTTP ' . $httpCode,
            };

            return ['ok' => false, 'error' => $hint, 'url' => $url, 'http_code' => $httpCode];
        }

        if (!is_string($responseBody)) {
            return ['ok' => false, 'error' => 'Empty response body', 'url' => $url, 'http_code' => $httpCode];
        }

        $decoded = json_decode($responseBody, true);
        if (!is_array($decoded)) {
            return ['ok' => false, 'error' => 'Invalid JSON response', 'url' => $url, 'http_code' => $httpCode];
        }

        return ['ok' => true, 'url' => $url, 'http_code' => $httpCode];
    }

    /**
     * Diagnose OData calls for a specific customer number.
     *
     * Makes raw requests to all three entities and returns URLs, HTTP codes,
     * result counts, field names, and first records — without any filtering fallback.
     *
     * @return array<string, mixed>
     */
    public function diagnoseCustomer(string $customerNo): array
    {
        $customerNo = trim($customerNo);
        if ($customerNo === '' || !$this->settingsGateway->isConfigured()) {
            return ['error' => 'Not configured or empty customer number'];
        }

        $escapedCustomerNo = $this->escapeODataStrictUserInput($customerNo);
        $results = [];

        // 1. Customer entity
        $customerUrl = $this->settingsGateway->buildEntityUrl(self::ENTITY_CUSTOMER)
            . '?$filter=' . rawurlencode("No eq '" . $escapedCustomerNo . "'");
        $results['customer'] = $this->rawRequest($customerUrl);

        // Get customer name for contact/ticket filter
        $customerName = '';
        if (($results['customer']['first_record']['Name'] ?? '') !== '') {
            $customerName = $results['customer']['first_record']['Name'];
        }

        // 2. Contact entity — filter by Company_Name
        $contactBaseUrl = $this->settingsGateway->buildEntityUrl(self::ENTITY_CONTACT);
        if ($customerName !== '') {
            $escapedCustomerName = $this->escapeODataTrustedLiteral((string) $customerName);
            $contactFilteredUrl = $contactBaseUrl
                . '?$filter=' . rawurlencode("Company_Name eq '" . $escapedCustomerName . "'")
                . '&$top=10&$select=' . rawurlencode('No,Name,Type,Company_No,Company_Name,E_Mail,Phone_No,Job_Title');
            $results['contact_filtered'] = $this->rawRequest($contactFilteredUrl);
        } else {
            $results['contact_filtered'] = ['error' => 'No customer name available for contact lookup'];
        }

        // 3. Tickets entity (PBI_FP_Tickets) — filter by Company_Contact_Name
        $ticketsBaseUrl = $this->settingsGateway->buildEntityUrl(self::ENTITY_TICKETS);
        if ($customerName !== '') {
            $escapedCustomerName = $this->escapeODataTrustedLiteral((string) $customerName);
            $ticketsFilteredUrl = $ticketsBaseUrl
                . '?$filter=' . rawurlencode("Company_Contact_Name eq '" . $escapedCustomerName . "'")
                . '&$top=10&$orderby=' . rawurlencode('Created_On desc')
                . '&$select=' . rawurlencode('No,Description,Company_Contact_Name,Ticket_State,Created_On,Last_Activity_Date');
            $results['tickets_filtered'] = $this->rawRequest($ticketsFilteredUrl);
        } else {
            $results['tickets_filtered'] = ['error' => 'No customer name available for ticket lookup'];
        }

        return $results;
    }

    /**
     * Raw HTTP request that returns diagnostic information (URL, status, body excerpt, field names).
     *
     * @return array{url: string, http_code: int, error?: string, count: int, fields: array<string>, first_record?: array<string, mixed>}
     */
    private function rawRequest(string $url): array
    {
        $ch = curl_init();
        if ($ch === false) {
            return ['url' => $url, 'http_code' => 0, 'error' => 'curl_init failed', 'count' => 0, 'fields' => []];
        }

        curl_setopt_array($ch, [
            CURLOPT_URL => $url,
            CURLOPT_RETURNTRANSFER => true,
            CURLOPT_CONNECTTIMEOUT => self::CONNECT_TIMEOUT,
            CURLOPT_TIMEOUT => self::REQUEST_TIMEOUT,
            CURLOPT_HTTPHEADER => ['Accept: application/json', 'OData-Version: 4.0'],
            CURLOPT_CUSTOMREQUEST => 'GET',
            CURLOPT_SSL_VERIFYPEER => true,
            CURLOPT_SSL_VERIFYHOST => 2,
        ]);
        $this->applyAuth($ch);

        $body = curl_exec($ch);
        $httpCode = (int) curl_getinfo($ch, CURLINFO_HTTP_CODE);
        $curlError = curl_error($ch);
        unset($ch);

        if (!is_string($body)) {
            return ['url' => $url, 'http_code' => $httpCode, 'error' => $curlError ?: 'Empty response', 'count' => 0, 'fields' => []];
        }

        if ($httpCode < 200 || $httpCode >= 300) {
            // Return a snippet of the error body for diagnosis
            $snippet = mb_substr($body, 0, 500);

            return ['url' => $url, 'http_code' => $httpCode, 'error' => $snippet, 'count' => 0, 'fields' => []];
        }

        $decoded = json_decode($body, true);
        if (!is_array($decoded)) {
            return ['url' => $url, 'http_code' => $httpCode, 'error' => 'Invalid JSON', 'count' => 0, 'fields' => []];
        }

        $values = $decoded['value'] ?? [];
        if (!is_array($values)) {
            $values = [];
        }

        $fields = [];
        $firstRecord = null;
        if ($values !== []) {
            $firstRecord = $values[0];
            $fields = array_keys($values[0]);
        }

        return [
            'url' => $url,
            'http_code' => $httpCode,
            'count' => count($values),
            'fields' => $fields,
            'first_record' => $firstRecord,
        ];
    }

    private function normalizeCustomerSortField(string $order): string
    {
        return match (trim($order)) {
            'No' => 'No',
            'City' => 'City',
            default => 'Name',
        };
    }

    /**
     * @param array<string,mixed> $response
     */
    private function resolveCustomerListTotal(array $response, int $offset, int $limit, int $rowCount): int
    {
        $rawCount = $response['@odata.count'] ?? $response['odata.count'] ?? null;
        if (is_numeric($rawCount)) {
            return max(0, (int) $rawCount);
        }

        $nextLink = trim((string) ($response['@odata.nextLink'] ?? $response['odata.nextLink'] ?? ''));
        if ($nextLink !== '') {
            return $offset + $rowCount + 1;
        }

        if ($rowCount === 0) {
            return max(0, $offset);
        }

        if ($rowCount < $limit) {
            return $offset + $rowCount;
        }

        return $offset + $rowCount + 1;
    }

    /**
     * @return array<string, mixed>|null Decoded JSON body, or null on failure.
     */
    private function request(string $method, string $url): ?array
    {
        if (!$this->settingsGateway->isConfigured()) {
            return null;
        }

        $ch = curl_init();
        if ($ch === false) {
            return null;
        }

        curl_setopt_array($ch, [
            CURLOPT_URL => $url,
            CURLOPT_RETURNTRANSFER => true,
            CURLOPT_CONNECTTIMEOUT => self::CONNECT_TIMEOUT,
            CURLOPT_TIMEOUT => self::REQUEST_TIMEOUT,
            CURLOPT_HTTPHEADER => [
                'Accept: application/json',
                'OData-Version: 4.0',
            ],
            CURLOPT_CUSTOMREQUEST => $method,
            CURLOPT_SSL_VERIFYPEER => true,
            CURLOPT_SSL_VERIFYHOST => 2,
        ]);

        $this->applyAuth($ch);

        $responseBody = curl_exec($ch);
        $httpCode = (int) curl_getinfo($ch, CURLINFO_HTTP_CODE);
        unset($ch);

        if (!is_string($responseBody) || $httpCode < 200 || $httpCode >= 300) {
            if ($httpCode >= 400 && $httpCode < 500) {
                throw new \RuntimeException('BC OData client error: HTTP ' . $httpCode);
            }

            return null;
        }

        $decoded = json_decode($responseBody, true);
        if (!is_array($decoded)) {
            return null;
        }

        return $decoded;
    }

    /**
     * Apply authentication to the cURL handle based on the configured auth mode.
     *
     * @param \CurlHandle $ch
     */
    private function applyAuth($ch): void
    {
        $authMode = $this->settingsGateway->getAuthMode();

        if ($authMode === HelpdeskSettingsGateway::AUTH_MODE_BASIC) {
            $user = $this->settingsGateway->getBasicUser() ?? '';
            $password = $this->settingsGateway->getBasicPassword() ?? '';
            curl_setopt($ch, CURLOPT_USERPWD, $user . ':' . $password);
            curl_setopt($ch, CURLOPT_HTTPAUTH, CURLAUTH_BASIC);
            return;
        }

        if ($authMode !== HelpdeskSettingsGateway::AUTH_MODE_OAUTH2) {
            return;
        }

        $tenantId = $this->resolveCurrentTenantId();
        if ($tenantId <= 0) {
            return;
        }

        $token = $this->oauthTokenService->getAccessToken($tenantId, $this->buildOAuthAudience());
        if ($token === null || trim($token) === '') {
            return;
        }

        curl_setopt($ch, CURLOPT_HTTPHEADER, [
            'Accept: application/json',
            'OData-Version: 4.0',
            'Authorization: Bearer ' . $token,
        ]);
    }

    /**
     * Extract the 'value' array from an OData JSON response.
     *
     * @param array<string, mixed> $response
     * @return array<int, array<string, mixed>>
     */
    private function extractODataValues(array $response): array
    {
        $values = $response['value'] ?? [];
        if (!is_array($values)) {
            return [];
        }

        return array_values($values);
    }

    /**
     * @param array<int, array<string, mixed>> $rows
     * @return array<int, array<string, mixed>>
     */
    private function dedupeRowsByNo(array $rows): array
    {
        $uniqueRows = [];
        foreach ($rows as $row) {

            $no = trim((string) ($row['No'] ?? ''));
            $rowKey = $no !== '' ? $no : md5((string) json_encode($row));
            if (isset($uniqueRows[$rowKey])) {
                continue;
            }

            $uniqueRows[$rowKey] = $row;
        }

        return array_values($uniqueRows);
    }

    /**
     * Sanitize and escape a user-provided string for use in OData filters.
     *
     * @throws \InvalidArgumentException If the value contains disallowed characters.
     */
    private function escapeODataStrictUserInput(string $value): string
    {
        if (preg_match('/^[\p{L}\p{N}\s.\-_@\/&,;:#+*]+$/u', $value) !== 1) {
            throw new \InvalidArgumentException('Search query contains invalid characters.');
        }

        return str_replace("'", "''", $value);
    }

    /**
     * Escape trusted literals (e.g. BC-sourced names/codes) for OData filters.
     */
    private function escapeODataTrustedLiteral(string $value): string
    {
        return str_replace("'", "''", trim($value));
    }

    private function resolveCurrentTenantId(): int
    {
        $session = $this->sessionStore->all();
        $tenantId = (int) (($session['current_tenant']['id'] ?? 0));

        return $tenantId > 0 ? $tenantId : 0;
    }

    private function buildOAuthAudience(): ?string
    {
        $baseUrl = trim($this->settingsGateway->getODataBaseUrl());
        if ($baseUrl === '') {
            return null;
        }

        $parsed = parse_url($baseUrl);
        if (!is_array($parsed)) {
            return null;
        }

        $scheme = strtolower((string) ($parsed['scheme'] ?? 'https'));
        $host = trim((string) ($parsed['host'] ?? ''));
        if ($host === '') {
            return null;
        }

        $port = (int) ($parsed['port'] ?? 0);
        $authority = $host;
        if ($port > 0 && !(($scheme === 'https' && $port === 443) || ($scheme === 'http' && $port === 80))) {
            $authority .= ':' . $port;
        }

        return $scheme . '://' . $authority;
    }
}
