<?php

namespace MintyPHP\Module\Helpdesk\Service;

/**
 * Service for loading debtor detail data (master data, contacts, tickets).
 *
 * Provides both a combined loadDetail() method (legacy) and individual
 * methods for async loading via data endpoints.
 */
class DebitorDetailService
{
    /** @var array<int, string> */
    private const CLOSED_TICKET_STATES = ['Erledigt', 'Resolved', 'Closed', 'Geschlossen'];

    public function __construct(
        private readonly BcODataGateway $bcODataGateway,
        private readonly EffectiveHelpdeskSettingsService $settingsGateway
    ) {
    }

    /**
     * Load only the customer master data (1 OData call).
     *
     * Used by the main page load — contacts and tickets are loaded async.
     *
     * @return array{status: string, customer?: array<string, mixed>, error?: string}
     */
    public function loadCustomer(string $customerNo): array
    {
        $customerNo = trim($customerNo);
        if ($customerNo === '') {
            return ['status' => 'not_found'];
        }

        if (!$this->settingsGateway->isConfigured()) {
            return ['status' => 'not_configured', 'error' => 'BC connection not configured'];
        }

        try {
            $customer = $this->bcODataGateway->getCustomer($customerNo);
        } catch (\Throwable) {
            return ['status' => 'error', 'error' => 'BC connection failed'];
        }

        if ($customer === null) {
            return ['status' => 'not_found'];
        }

        return [
            'status' => 'success',
            'customer' => $customer,
        ];
    }

    /**
     * Load contacts for a customer (for async data endpoint).
     *
     * @return array{
     *   ok: bool,
     *   contacts?: array<int, array<string, mixed>>,
     *   meta?: array{
     *     source: string,
     *     fallback_used: bool,
     *     fallback_reason: string
     *   },
     *   error?: string
     * }
     */
    public function loadContacts(string $customerNo, string $customerName): array
    {
        $customerNo = trim($customerNo);
        $customerName = trim($customerName);

        if ($customerNo === '' || $customerName === '') {
            return ['ok' => false, 'error' => 'Missing customer number or name'];
        }

        if (!$this->settingsGateway->isConfigured()) {
            return ['ok' => false, 'error' => 'BC connection not configured'];
        }

        try {
            $result = $this->bcODataGateway->getContactsForCustomerWithMeta($customerNo, $customerName);
        } catch (\Throwable $e) {
            return ['ok' => false, 'error' => $e->getMessage()];
        }

        $contacts = $result['contacts'];
        $meta = $result['meta'];

        return [
            'ok' => true,
            'contacts' => $contacts,
            'meta' => [
                'source' => $meta['source'],
                'fallback_used' => $meta['fallback_used'],
                'fallback_reason' => $meta['fallback_reason'],
            ],
        ];
    }

    /**
     * Load tickets for a customer (for async data endpoint).
     *
     * @return array{
     *   ok: bool,
     *   tickets?: array<int, array<string, mixed>>,
     *   meta?: array{
     *     source: string,
     *     fallback_used: bool,
     *     fallback_reason: string
     *   },
     *   error?: string
     * }
     */
    public function loadTickets(string $customerNo, string $customerName): array
    {
        $customerNo = trim($customerNo);
        $customerName = trim($customerName);

        if ($customerNo === '' || $customerName === '') {
            return ['ok' => false, 'error' => 'Missing customer number or name'];
        }

        if (!$this->settingsGateway->isConfigured()) {
            return ['ok' => false, 'error' => 'BC connection not configured'];
        }

        try {
            $result = $this->bcODataGateway->getTicketsForCustomerWithMeta($customerNo, $customerName);
        } catch (\Throwable $e) {
            return ['ok' => false, 'error' => $e->getMessage()];
        }

        $tickets = $result['tickets'];
        $meta = $result['meta'];

        return [
            'ok' => true,
            'tickets' => $tickets,
            'meta' => [
                'source' => $meta['source'],
                'fallback_used' => $meta['fallback_used'],
                'fallback_reason' => $meta['fallback_reason'],
            ],
        ];
    }

    /**
     * Load a summary of ticket KPIs for a customer (exact counts, not sampled).
     *
     * @return array{ok: bool, total?: int, open?: int, last_activity?: string, error?: string}
     */
    public function loadTicketSummary(string $customerNo, string $customerName): array
    {
        $result = $this->loadTickets($customerNo, $customerName);

        if (!$result['ok']) {
            return $result;
        }

        return self::summarizeTickets($result['tickets'] ?? []);
    }

    /**
     * Load support dashboard data for a debtor.
     *
     * @return array{
     *   ok: bool,
     *   health?: array{
     *     total_tickets: int,
     *     open_tickets: int,
     *     critical_tickets: int,
     *     oldest_open_age_hours: int|null,
     *     escalation_count: int|null,
     *     escalation_available: bool
     *   },
     *   actions?: array<int, array{
     *     action_id: string,
     *     type: string,
     *     priority: int,
     *     ticket_no: string,
     *     age_hours: int|null
     *   }>,
     *   meta?: array{
     *     critical_threshold_hours: int,
     *     tickets_considered: int
     *   },
     *   error?: string
     * }
     */
    public function loadSupportDashboard(string $customerNo, string $customerName, int $criticalThresholdHours = 48): array
    {
        $result = $this->loadTickets($customerNo, $customerName);
        if (!$result['ok']) {
            return [
                'ok' => false,
                'error' => (string) $result['error'],
            ];
        }

        $tickets = is_array($result['tickets'] ?? null) ? $result['tickets'] : [];
        $escalation = $this->loadEscalationHealth($customerNo, $tickets);
        if (!$escalation['ok']) {
            $escalation = null;
        }

        return self::buildSupportDashboard($tickets, $criticalThresholdHours, is_array($escalation) ? $escalation : null);
    }

    /**
     * Load escalation health for a debtor by matching ticket escalation codes
     * against escalation target definitions.
     *
     * @param array<int, array<string, mixed>> $tickets Current ticket list used in support dashboard
     * @return array{
     *   ok: bool,
     *   overdue_count?: int,
     *   entries?: array<int, array{
     *     ticket_no: string,
     *     escalation_code: string,
     *     target_seconds: int,
     *     last_activity_iso: string,
     *     overdue_seconds: int
     *   }>,
     *   matches?: array<int, array{
     *     ticket_no: string,
     *     escalation_code: string,
     *     target_seconds: int,
     *     age_hours: int,
     *     is_overdue: bool
     *   }>,
     *   tickets_considered?: int,
     *   tickets_matched?: int,
     *   definitions_count?: int,
     *   error?: string
     * }
     */
    public function loadEscalationHealth(string $customerNo, array $tickets): array
    {
        $customerNo = trim($customerNo);
        if ($customerNo === '') {
            return ['ok' => false, 'error' => 'Missing customer number'];
        }

        if (!$this->settingsGateway->isConfigured()) {
            return ['ok' => false, 'error' => 'BC connection not configured'];
        }

        try {
            $definitions = $this->bcODataGateway->getEscalationDefinitions();
            $ticketEscalations = $this->bcODataGateway->getEscalationTicketsForCustomer($customerNo);
        } catch (\Throwable $e) {
            return ['ok' => false, 'error' => $e->getMessage()];
        }

        if ($definitions === []) {
            return ['ok' => false, 'error' => 'No escalation definitions available'];
        }

        $targetSecondsByCode = [];
        foreach ($definitions as $definition) {
            $code = trim((string) ($definition['Code'] ?? ''));
            if ($code === '') {
                continue;
            }

            $targetSeconds = self::parseIsoDurationToSeconds((string) ($definition['Target_Time'] ?? ''));
            if ($targetSeconds === null || $targetSeconds <= 0) {
                continue;
            }

            $targetSecondsByCode[$code] = $targetSeconds;
        }

        if ($targetSecondsByCode === []) {
            return ['ok' => false, 'error' => 'No valid escalation targets available'];
        }

        $escalationByTicketNo = [];
        foreach ($ticketEscalations as $ticketEscalation) {
            $ticketNo = trim((string) ($ticketEscalation['No'] ?? ''));
            if ($ticketNo === '') {
                continue;
            }
            $escalationByTicketNo[$ticketNo] = $ticketEscalation;
        }

        $ticketsConsidered = 0;
        $ticketsMatched = 0;
        $overdueCount = 0;
        $entries = [];
        $matches = [];
        $nowTs = time();

        foreach ($tickets as $ticket) {
            $state = trim((string) ($ticket['Ticket_State'] ?? ''));
            if (self::isClosedTicketState($state)) {
                continue;
            }

            $ticketNo = trim((string) ($ticket['No'] ?? ''));
            if ($ticketNo === '') {
                continue;
            }

            $ticketsConsidered++;
            $escalationTicket = $escalationByTicketNo[$ticketNo] ?? null;
            if (!is_array($escalationTicket)) {
                continue;
            }

            $escalationCode = trim((string) ($escalationTicket['Escalation_Code'] ?? ''));
            if ($escalationCode === '') {
                continue;
            }

            $targetSeconds = $targetSecondsByCode[$escalationCode] ?? null;
            if (!is_int($targetSeconds)) {
                continue;
            }

            $activityTs = self::resolveTicketActivityTimestamp($ticket);
            if ($activityTs === null) {
                continue;
            }

            $ticketsMatched++;
            $ageSeconds = max(0, $nowTs - $activityTs);
            $ageHours = max(0, intdiv($ageSeconds, 3600));
            $isOverdue = $ageSeconds > $targetSeconds;
            $matches[] = [
                'ticket_no' => $ticketNo,
                'escalation_code' => $escalationCode,
                'target_seconds' => $targetSeconds,
                'age_hours' => $ageHours,
                'is_overdue' => $isOverdue,
            ];

            if ($isOverdue) {
                $overdueCount++;
                $lastActivityIso = trim((string) ($ticket['Last_Activity_Date'] ?? ''));
                if ($lastActivityIso === '' || $lastActivityIso === '0001-01-01T00:00:00Z') {
                    $lastActivityIso = trim((string) ($ticket['Created_On'] ?? ''));
                }

                $entries[] = [
                    'ticket_no' => $ticketNo,
                    'escalation_code' => $escalationCode,
                    'target_seconds' => $targetSeconds,
                    'last_activity_iso' => $lastActivityIso,
                    'overdue_seconds' => max(1, $ageSeconds - $targetSeconds),
                ];
            }
        }

        usort($entries, static function (array $a, array $b): int {
            $cmp = $b['overdue_seconds'] <=> $a['overdue_seconds'];
            if ($cmp !== 0) {
                return $cmp;
            }

            return strcmp($a['ticket_no'], $b['ticket_no']);
        });

        return [
            'ok' => true,
            'overdue_count' => $overdueCount,
            'entries' => array_slice($entries, 0, 50),
            'matches' => array_slice($matches, 0, 300),
            'tickets_considered' => $ticketsConsidered,
            'tickets_matched' => $ticketsMatched,
            'definitions_count' => count($targetSecondsByCode),
        ];
    }

    /**
     * Load customer contracts/products for support dashboard widgets.
     *
     * @return array{
     *   ok: bool,
     *   entries?: array<int, array{
     *     contract_no: string,
     *     product_type: string,
     *     description: string,
     *     state: string,
     *     next_invoicing_date: string,
     *     sell_to_customer_no: string,
     *     sell_to_customer_name: string,
     *     bill_to_customer_no: string,
     *     bill_to_customer_name: string,
     *     total_payoff_amount: float,
     *     active_positions: int,
     *     support_hours: float
     *   }>,
     *   summary?: array{
     *     total_contracts: int,
     *     active_contracts: int,
     *     product_types: array<int, array{type: string, count: int}>
     *   },
     *   error?: string
     * }
     */
    public function loadContracts(string $customerNo): array
    {
        $customerNo = trim($customerNo);
        if ($customerNo === '') {
            return ['ok' => false, 'error' => 'Missing customer number'];
        }

        if (!$this->settingsGateway->isConfigured()) {
            return ['ok' => false, 'error' => 'BC connection not configured'];
        }

        try {
            $contracts = $this->bcODataGateway->getContractsForCustomer($customerNo);
        } catch (\Throwable $e) {
            return ['ok' => false, 'error' => $e->getMessage()];
        }

        $normalized = self::buildContractsWidgetData($contracts, $customerNo);

        return [
            'ok' => true,
            'entries' => $normalized['entries'],
            'summary' => $normalized['summary'],
        ];
    }

    /**
     * Aggregate ticket KPIs from a raw ticket array.
     *
     * Used by both the service method and the data endpoint (which has its own cache layer).
     *
     * @param array<int, array<string, mixed>> $tickets
     * @return array{ok: true, total: int, open: int, last_activity: string}
     */
    public static function summarizeTickets(array $tickets): array
    {
        $openCount = 0;
        $lastActivity = '';

        foreach ($tickets as $ticket) {
            $state = (string) ($ticket['Ticket_State'] ?? '');
            if (!self::isClosedTicketState($state)) {
                $openCount++;
            }

            $activityDate = (string) ($ticket['Last_Activity_Date'] ?? '');
            if ($activityDate !== '' && $activityDate !== '0001-01-01T00:00:00Z' && $activityDate > $lastActivity) {
                $lastActivity = $activityDate;
            }
        }

        return [
            'ok' => true,
            'total' => count($tickets),
            'open' => $openCount,
            'last_activity' => $lastActivity,
        ];
    }

    /**
     * @param array<int, array<string, mixed>> $tickets
     * @return array{
     *   ok: true,
     *   health: array{
     *     total_tickets: int,
     *     open_tickets: int,
     *     critical_tickets: int,
     *     oldest_open_age_hours: int|null,
     *     escalation_count: int|null,
     *     escalation_available: bool
     *   },
     *   actions: array<int, array{
     *     action_id: string,
     *     type: string,
     *     priority: int,
     *     ticket_no: string,
     *     age_hours: int|null
     *   }>,
     *   meta: array{
     *     critical_threshold_hours: int,
     *     tickets_considered: int,
     *     escalation_tickets_considered?: int,
     *     escalation_tickets_matched?: int,
     *     escalation_definitions_count?: int
     *   }
     * }
     */
    public static function buildSupportDashboard(array $tickets, int $criticalThresholdHours = 48, ?array $escalationHealth = null): array
    {
        $criticalThresholdHours = max(1, min(168, $criticalThresholdHours));
        $nowTs = time();
        $totalTickets = count($tickets);
        $openTickets = [];
        $criticalTickets = 0;
        $oldestOpenAgeHours = 0;

        foreach ($tickets as $ticket) {
            $state = trim((string) ($ticket['Ticket_State'] ?? ''));
            if (self::isClosedTicketState($state)) {
                continue;
            }

            $ticketNo = trim((string) ($ticket['No'] ?? ''));
            if ($ticketNo === '') {
                continue;
            }

            $activityTs = self::resolveTicketActivityTimestamp($ticket);
            $ageHours = null;
            if ($activityTs !== null) {
                $ageHours = max(0, intdiv(max(0, $nowTs - $activityTs), 3600));
                $oldestOpenAgeHours = max($oldestOpenAgeHours, $ageHours);
                if ($ageHours >= $criticalThresholdHours) {
                    $criticalTickets++;
                }
            }

            $openTickets[] = [
                'ticket_no' => $ticketNo,
                'support_user_name' => trim((string) ($ticket['Support_User_Name'] ?? '')),
                'age_hours' => $ageHours,
            ];
        }

        // Trend KPIs: 30-day windows
        $trendDays = 30;
        $boundary30 = $nowTs - ($trendDays * 86400);
        $boundary60 = $nowTs - (2 * $trendDays * 86400);
        $createdCurrent = 0;
        $createdPrevious = 0;
        $closedCurrent = 0;
        $closedPrevious = 0;
        $openAgeSum = 0;
        $openAgeCount = 0;

        foreach ($tickets as $ticket) {
            $createdRaw = trim((string) ($ticket['Created_On'] ?? ''));
            $createdTs = ($createdRaw !== '' && !str_starts_with($createdRaw, '0001')) ? strtotime($createdRaw) : false;

            if ($createdTs !== false) {
                if ($createdTs >= $boundary30) {
                    $createdCurrent++;
                } elseif ($createdTs >= $boundary60) {
                    $createdPrevious++;
                }
            }

            $state = trim((string) ($ticket['Ticket_State'] ?? ''));
            if (self::isClosedTicketState($state)) {
                $activityTs = self::resolveTicketActivityTimestamp($ticket);
                if ($activityTs !== null) {
                    if ($activityTs >= $boundary30) {
                        $closedCurrent++;
                    } elseif ($activityTs >= $boundary60) {
                        $closedPrevious++;
                    }
                }
            }
        }

        foreach ($openTickets as $openTicket) {
            if ($openTicket['age_hours'] !== null) {
                $openAgeSum += $openTicket['age_hours'];
                $openAgeCount++;
            }
        }

        $avgOpenAgeHours = $openAgeCount > 0 ? (int) round($openAgeSum / $openAgeCount) : null;

        $escalationCount = null;
        $escalationAvailable = false;
        $meta = [
            'critical_threshold_hours' => $criticalThresholdHours,
            'tickets_considered' => $totalTickets,
        ];

        if (is_array($escalationHealth) && ($escalationHealth['ok'] ?? false)) {
            $escalationCount = (int) ($escalationHealth['overdue_count'] ?? 0);
            $escalationAvailable = true;
            $meta['escalation_tickets_considered'] = (int) ($escalationHealth['tickets_considered'] ?? 0);
            $meta['escalation_tickets_matched'] = (int) ($escalationHealth['tickets_matched'] ?? 0);
            $meta['escalation_definitions_count'] = (int) ($escalationHealth['definitions_count'] ?? 0);
        }

        return [
            'ok' => true,
            'health' => [
                'total_tickets' => $totalTickets,
                'open_tickets' => count($openTickets),
                'critical_tickets' => $criticalTickets,
                'oldest_open_age_hours' => $openTickets === [] ? null : $oldestOpenAgeHours,
                'avg_open_age_hours' => $avgOpenAgeHours,
                'created_30d' => $createdCurrent,
                'created_prev_30d' => $createdPrevious,
                'closed_30d' => $closedCurrent,
                'closed_prev_30d' => $closedPrevious,
                'escalation_count' => $escalationCount,
                'escalation_available' => $escalationAvailable,
            ],
            'actions' => self::buildSupportActions($openTickets, $criticalThresholdHours),
            'meta' => $meta,
        ];
    }

    /**
     * @param array<int, array<string, mixed>> $contracts
     * @return array{
     *   entries: array<int, array{
     *     contract_no: string,
     *     product_type: string,
     *     description: string,
     *     state: string,
     *     next_invoicing_date: string,
     *     sell_to_customer_no: string,
     *     sell_to_customer_name: string,
     *     bill_to_customer_no: string,
     *     bill_to_customer_name: string,
     *     total_payoff_amount: float,
     *     active_positions: int,
     *     support_hours: float
     *   }>,
     *   summary: array{
     *     total_contracts: int,
     *     active_contracts: int,
     *     product_types: array<int, array{type: string, count: int}>
     *   }
     * }
     */
    public static function buildContractsWidgetData(array $contracts, string $customerNo): array
    {
        $customerNo = trim($customerNo);
        $entries = [];
        $productTypeCounts = [];
        $activeContracts = 0;

        foreach ($contracts as $contract) {
            $contractNo = trim((string) ($contract['No'] ?? ''));
            if ($contractNo === '') {
                continue;
            }

            $billToNo = trim((string) ($contract['Bill_to_Cust_No'] ?? ''));
            $sellToNo = trim((string) ($contract['Sell_to_Cust_No'] ?? ''));
            if ($billToNo !== $customerNo && $sellToNo !== $customerNo) {
                continue;
            }

            $state = trim((string) ($contract['State'] ?? ''));
            if (mb_strtolower($state) === 'aktiv') {
                $activeContracts++;
            }

            $productType = trim((string) ($contract['Periodic_Invoicing_Type'] ?? ''));
            if ($productType !== '') {
                $productTypeCounts[$productType] = (int) ($productTypeCounts[$productType] ?? 0) + 1;
            }

            $nextInvoicingDate = trim((string) ($contract['Next_Invoicing_Date'] ?? ''));
            if ($nextInvoicingDate === '0001-01-01' || $nextInvoicingDate === '0001-01-01T00:00:00Z') {
                $nextInvoicingDate = '';
            }

            $entries[] = [
                'contract_no' => $contractNo,
                'product_type' => $productType,
                'description' => trim((string) ($contract['Description'] ?? '')),
                'state' => $state,
                'next_invoicing_date' => $nextInvoicingDate,
                'sell_to_customer_no' => $sellToNo,
                'sell_to_customer_name' => trim((string) ($contract['Sell_to_Cust_Name'] ?? '')),
                'bill_to_customer_no' => $billToNo,
                'bill_to_customer_name' => trim((string) ($contract['Bill_to_Cust_Name'] ?? '')),
                'total_payoff_amount' => (float) ($contract['TotalPayoffAmount'] ?? 0),
                'active_positions' => (int) ($contract['No_of_Active_Position'] ?? 0),
                'support_hours' => (float) ($contract['Support_Hours'] ?? 0),
            ];
        }

        usort($entries, static function (array $a, array $b): int {
            $aActive = mb_strtolower((string) $a['state']) === 'aktiv' ? 1 : 0;
            $bActive = mb_strtolower((string) $b['state']) === 'aktiv' ? 1 : 0;
            if ($aActive !== $bActive) {
                return $bActive <=> $aActive;
            }

            $aDate = (string) $a['next_invoicing_date'];
            $bDate = (string) $b['next_invoicing_date'];
            if ($aDate === '' && $bDate !== '') {
                return 1;
            }
            if ($aDate !== '' && $bDate === '') {
                return -1;
            }
            if ($aDate !== $bDate) {
                return strcmp($aDate, $bDate);
            }

            return strcmp((string) $a['contract_no'], (string) $b['contract_no']);
        });

        arsort($productTypeCounts);
        $productTypes = [];
        foreach ($productTypeCounts as $type => $count) {
            $productTypes[] = [
                'type' => (string) $type,
                'count' => (int) $count,
            ];
        }

        return [
            'entries' => $entries,
            'summary' => [
                'total_contracts' => count($entries),
                'active_contracts' => $activeContracts,
                'product_types' => $productTypes,
            ],
        ];
    }

    /**
     * Load sales dashboard data for a debtor: KPIs, contracts with line items, contacts.
     *
     * @return array{
     *   ok: bool,
     *   kpis?: array{active_contracts: int, monthly_volume: float, support_hours: float, next_invoicing_date: string},
     *   contracts?: array<int, array<string, mixed>>,
     *   contacts?: array<int, array<string, mixed>>,
     *   error?: string
     * }
     */
    /**
     * Load meetings for a customer from BC.
     *
     * @return array{ok: bool, timeline?: array, kpis?: array, error?: string}
     */
    public function loadMeetings(string $customerNo): array
    {
        $customerNo = trim($customerNo);
        if ($customerNo === '') {
            return ['ok' => false, 'error' => 'Missing customer number'];
        }

        if (!$this->settingsGateway->isConfigured()) {
            return ['ok' => false, 'error' => 'BC connection not configured'];
        }

        try {
            $meetings = $this->bcODataGateway->getMeetingsForCustomer($customerNo);
        } catch (\Throwable $e) {
            return ['ok' => false, 'error' => $e->getMessage()];
        }

        $result = self::buildMeetingsTimeline($meetings);

        return [
            'ok' => true,
            'timeline' => $result['timeline'],
            'kpis' => $result['kpis'],
        ];
    }

    /**
     * Build meetings timeline from raw OData meeting records.
     *
     * @param array<int, array<string, mixed>> $meetings
     * @return array{timeline: array{upcoming: array, past: array}, kpis: array}
     */
    public static function buildMeetingsTimeline(array $meetings): array
    {
        $today = date('Y-m-d');
        $upcoming = [];
        $past = [];
        $total = 0;
        $lastMeetingDate = '';

        foreach ($meetings as $meeting) {
            $appointmentDate = trim((string) ($meeting['Appointment_Date'] ?? ''));
            if ($appointmentDate === '' || str_starts_with($appointmentDate, '0001-01-01')) {
                continue;
            }

            $releaseDate = trim((string) ($meeting['Release_Date'] ?? ''));
            $isReleased = $releaseDate !== '' && !str_starts_with($releaseDate, '0001-01-01');

            $entry = [
                'entry_no' => (int) ($meeting['Entry_No'] ?? 0),
                'appointment_date' => $appointmentDate,
                'customer_no' => trim((string) ($meeting['Customer_No'] ?? '')),
                'customer_name' => trim((string) ($meeting['Customer_Name'] ?? '')),
                'salesperson_code' => trim((string) ($meeting['Salesperson_Code'] ?? '')),
                'comment' => trim((string) ($meeting['Comment'] ?? '')),
                'released_by' => trim((string) ($meeting['Released_by'] ?? '')),
                'release_date' => $isReleased ? $releaseDate : null,
                'is_released' => $isReleased,
            ];

            $total++;

            if ($appointmentDate >= $today) {
                $monthKey = substr($appointmentDate, 0, 7);
                $upcoming[$monthKey][] = $entry;
            } else {
                $monthKey = substr($appointmentDate, 0, 7);
                $past[$monthKey][] = $entry;
                if ($lastMeetingDate === '' || $appointmentDate > $lastMeetingDate) {
                    $lastMeetingDate = $appointmentDate;
                }
            }
        }

        // Sort upcoming ascending (nearest first), past descending (most recent first)
        ksort($upcoming);
        krsort($past);

        // Within each month group: upcoming asc, past desc
        foreach ($upcoming as &$group) {
            usort($group, static fn (array $a, array $b) => strcmp($a['appointment_date'], $b['appointment_date']));
        }
        unset($group);
        foreach ($past as &$group) {
            usort($group, static fn (array $a, array $b) => strcmp($b['appointment_date'], $a['appointment_date']));
        }
        unset($group);

        $daysSinceLast = 0;
        if ($lastMeetingDate !== '') {
            $diff = (new \DateTimeImmutable($today))->diff(new \DateTimeImmutable($lastMeetingDate));
            $daysSinceLast = (int) $diff->days;
        }

        $upcomingCount = 0;
        foreach ($upcoming as $group) {
            $upcomingCount += count($group);
        }

        return [
            'timeline' => [
                'upcoming' => $upcoming,
                'past' => $past,
            ],
            'kpis' => [
                'total' => $total,
                'upcoming_count' => $upcomingCount,
                'days_since_last' => $daysSinceLast,
                'last_meeting_date' => $lastMeetingDate !== '' ? $lastMeetingDate : null,
            ],
        ];
    }

    public function loadSalesDashboard(string $customerNo, string $customerName): array
    {
        $customerNo = trim($customerNo);
        $customerName = trim($customerName);

        if ($customerNo === '' || $customerName === '') {
            return ['ok' => false, 'error' => 'Missing customer number or name'];
        }

        if (!$this->settingsGateway->isConfigured()) {
            return ['ok' => false, 'error' => 'BC connection not configured'];
        }

        $contracts = [];
        $lines = [];
        $contacts = [];
        $contractsError = '';
        $linesError = '';

        try {
            $contracts = $this->bcODataGateway->getContractsForCustomer($customerNo);
        } catch (\Throwable $e) {
            $contractsError = $e->getMessage();
        }

        try {
            $lines = $this->bcODataGateway->getContractLinesForCustomer($customerNo);
        } catch (\Throwable $e) {
            $linesError = $e->getMessage();
        }

        try {
            $contacts = $this->bcODataGateway->getContactsForCustomer($customerNo, $customerName);
        } catch (\Throwable) {
            // Contacts are secondary — don't fail the dashboard
        }

        if ($contracts === [] && $contractsError !== '') {
            return ['ok' => false, 'error' => $contractsError];
        }

        $dashboard = self::buildSalesDashboard($contracts, $lines, $customerNo);

        return [
            'ok' => true,
            'kpis' => $dashboard['kpis'],
            'contracts' => $dashboard['contracts'],
            'contacts' => $contacts,
        ];
    }

    /**
     * Build sales dashboard from raw contract headers and line items.
     *
     * @param array<int, array<string, mixed>> $contracts
     * @param array<int, array<string, mixed>> $lines
     * @return array{
     *   kpis: array{active_contracts: int, monthly_volume: float, support_hours: float, next_invoicing_date: string},
     *   contracts: array<int, array<string, mixed>>
     * }
     */
    public static function buildSalesDashboard(array $contracts, array $lines, string $customerNo): array
    {
        $customerNo = trim($customerNo);
        $twoYearsAgo = date('Y-m-d', strtotime('-2 years'));

        // Group lines by Header_No
        $linesByContract = [];
        foreach ($lines as $line) {
            $headerNo = trim((string) ($line['Header_No'] ?? ''));
            if ($headerNo === '') {
                continue;
            }
            $linesByContract[$headerNo][] = $line;
        }

        $activeContracts = 0;
        $totalContracts = 0;
        $monthlyVolume = 0.0;
        $supportHours = 0.0;
        $nextInvoicingDate = '';
        $largestContractVolume = 0.0;
        $totalPositions = 0;

        // 30-day trend windows for contracts
        $trendDays = 30;
        $nowTs = time();
        $boundary30 = date('Y-m-d', $nowTs - ($trendDays * 86400));
        $boundary60 = date('Y-m-d', $nowTs - (2 * $trendDays * 86400));
        $startedCurrent = 0;
        $startedPrevious = 0;
        $endedCurrent = 0;
        $endedPrevious = 0;
        $volumeNewContracts = 0.0;

        $entries = [];

        foreach ($contracts as $contract) {
            $contractNo = trim((string) ($contract['No'] ?? ''));
            if ($contractNo === '') {
                continue;
            }

            $billToNo = trim((string) ($contract['Bill_to_Cust_No'] ?? ''));
            $sellToNo = trim((string) ($contract['Sell_to_Cust_No'] ?? ''));
            if ($customerNo !== '' && $billToNo !== $customerNo && $sellToNo !== $customerNo) {
                continue;
            }

            $state = trim((string) ($contract['State'] ?? ''));
            $stateLower = mb_strtolower($state);
            $isActive = $stateLower === 'aktiv';

            // Filter ended contracts older than 2 years
            if ($stateLower === 'beendet') {
                $nextInv = self::normalizeDateField((string) ($contract['Next_Invoicing_Date'] ?? ''));
                $startDate = self::normalizeDateField((string) ($contract['Starting_Date'] ?? ''));
                $relevantDate = $nextInv !== '' ? $nextInv : $startDate;
                if ($relevantDate !== '' && $relevantDate < $twoYearsAgo) {
                    continue;
                }
            }

            $totalContracts++;

            // Trend: count started/ended contracts in 30d windows
            $contractStartDate = self::normalizeDateField((string) ($contract['Starting_Date'] ?? ''));
            if ($contractStartDate !== '') {
                if ($contractStartDate >= $boundary30) {
                    $startedCurrent++;
                    if ($isActive) {
                        $volumeNewContracts += (float) ($contract['MontlyAmount_InvoicingDate'] ?? 0);
                    }
                } elseif ($contractStartDate >= $boundary60) {
                    $startedPrevious++;
                }
            }
            if ($stateLower === 'beendet') {
                $endRelevant = self::normalizeDateField((string) ($contract['Next_Invoicing_Date'] ?? ''));
                if ($endRelevant === '') {
                    $endRelevant = $contractStartDate;
                }
                if ($endRelevant !== '') {
                    if ($endRelevant >= $boundary30) {
                        $endedCurrent++;
                    } elseif ($endRelevant >= $boundary60) {
                        $endedPrevious++;
                    }
                }
            }

            // KPI aggregation (only active)
            if ($isActive) {
                $activeContracts++;
                $contractMonthly = (float) ($contract['MontlyAmount_InvoicingDate'] ?? 0);
                $monthlyVolume += $contractMonthly;
                $supportHours += (float) ($contract['Support_Hours'] ?? 0);

                if ($contractMonthly > $largestContractVolume) {
                    $largestContractVolume = $contractMonthly;
                }

                $invDate = self::normalizeDateField((string) ($contract['Next_Invoicing_Date'] ?? ''));
                if ($invDate !== '' && ($nextInvoicingDate === '' || $invDate < $nextInvoicingDate)) {
                    $nextInvoicingDate = $invDate;
                }
            }

            // Normalize line items for this contract
            $contractLines = [];
            foreach (($linesByContract[$contractNo] ?? []) as $line) {
                $lineDesc = trim((string) ($line['Description'] ?? ''));
                if ($lineDesc === '') {
                    continue;
                }
                $contractLines[] = [
                    'line_no' => (int) ($line['Line_No'] ?? 0),
                    'description' => $lineDesc,
                    'type' => trim((string) ($line['Type'] ?? '')),
                    'quantity' => (float) ($line['Quantity'] ?? 0),
                    'unit_price' => (float) ($line['Unit_Price'] ?? 0),
                    'line_amount' => (float) ($line['Line_Amount'] ?? 0),
                    'line_discount_percent' => (float) ($line['Line_Discount_Percent'] ?? 0),
                    'line_state' => trim((string) ($line['Line_State'] ?? '')),
                    'starting_date' => self::normalizeDateField((string) ($line['Starting_Date'] ?? '')),
                    'ending_date' => self::normalizeDateField((string) ($line['Ending_Date'] ?? '')),
                ];
            }

            $entries[] = [
                'contract_no' => $contractNo,
                'description' => trim((string) ($contract['Description'] ?? '')),
                'state' => $state,
                'product_type' => trim((string) ($contract['Periodic_Invoicing_Type'] ?? '')),
                'starting_date' => self::normalizeDateField((string) ($contract['Starting_Date'] ?? '')),
                'next_invoicing_date' => self::normalizeDateField((string) ($contract['Next_Invoicing_Date'] ?? '')),
                'invoicing_period' => trim((string) ($contract['Invoicing_Period'] ?? '')),
                'monthly_amount' => (float) ($contract['MontlyAmount_InvoicingDate'] ?? 0),
                'total_payoff_amount' => (float) ($contract['TotalPayoffAmount'] ?? 0),
                'support_hours' => (float) ($contract['Support_Hours'] ?? 0),
                'active_positions' => (int) ($contract['No_of_Active_Position'] ?? 0),
                'sell_to_customer_name' => trim((string) ($contract['Sell_to_Cust_Name'] ?? '')),
                'bill_to_customer_name' => trim((string) ($contract['Bill_to_Cust_Name'] ?? '')),
                'salesperson_code' => trim((string) ($contract['Salesperson_Purchaser_Code'] ?? '')),
                'main_contract' => (bool) ($contract['Main_Contract'] ?? false),
                'lines' => $contractLines,
            ];
        }

        // Sort: active first, then by next invoicing date
        usort($entries, static function (array $a, array $b): int {
            $aActive = mb_strtolower((string) $a['state']) === 'aktiv' ? 1 : 0;
            $bActive = mb_strtolower((string) $b['state']) === 'aktiv' ? 1 : 0;
            if ($aActive !== $bActive) {
                return $bActive <=> $aActive;
            }

            $aDate = (string) $a['next_invoicing_date'];
            $bDate = (string) $b['next_invoicing_date'];
            if ($aDate === '' && $bDate !== '') {
                return 1;
            }
            if ($aDate !== '' && $bDate === '') {
                return -1;
            }

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

        // Count actual line items from active contracts
        foreach ($entries as $entry) {
            if (mb_strtolower((string) $entry['state']) === 'aktiv') {
                $totalPositions += count($entry['lines']);
            }
        }

        $avgContractValue = $activeContracts > 0 ? round($monthlyVolume / $activeContracts, 2) : 0.0;

        // Days until next invoicing
        $daysUntilNextInvoicing = null;
        if ($nextInvoicingDate !== '') {
            $nowDate = new \DateTimeImmutable('today', new \DateTimeZone('UTC'));
            $invDateObj = \DateTimeImmutable::createFromFormat('Y-m-d', $nextInvoicingDate, new \DateTimeZone('UTC'));
            if ($invDateObj !== false) {
                $daysUntilNextInvoicing = (int) $nowDate->diff($invDateObj)->format('%r%a');
            }
        }

        return [
            'kpis' => [
                'active_contracts' => $activeContracts,
                'total_contracts' => $totalContracts,
                'monthly_volume' => round($monthlyVolume, 2),
                'support_hours' => round($supportHours, 2),
                'next_invoicing_date' => $nextInvoicingDate,
                'avg_contract_value' => $avgContractValue,
                'largest_contract_volume' => round($largestContractVolume, 2),
                'total_positions' => $totalPositions,
                'days_until_next_invoicing' => $daysUntilNextInvoicing,
                'started_30d' => $startedCurrent,
                'started_prev_30d' => $startedPrevious,
                'ended_30d' => $endedCurrent,
                'ended_prev_30d' => $endedPrevious,
                'volume_new_contracts' => round($volumeNewContracts, 2),
            ],
            'contracts' => $entries,
        ];
    }

    private static function normalizeDateField(string $value): string
    {
        $value = trim($value);
        if ($value === '' || $value === '0001-01-01' || str_starts_with($value, '0001-01-01')) {
            return '';
        }

        // Handle ISO datetime (strip time part for display)
        if (str_contains($value, 'T')) {
            $value = substr($value, 0, 10);
        }

        return $value;
    }

    private static function isClosedTicketState(string $state): bool
    {
        return in_array(trim($state), self::CLOSED_TICKET_STATES, true);
    }

    /**
     * @param array<string, mixed> $ticket
     */
    private static function resolveTicketActivityTimestamp(array $ticket): ?int
    {
        $lastActivity = trim((string) ($ticket['Last_Activity_Date'] ?? ''));
        if ($lastActivity !== '' && $lastActivity !== '0001-01-01T00:00:00Z') {
            $ts = strtotime($lastActivity);
            if (is_int($ts)) {
                return $ts;
            }
        }

        $createdOn = trim((string) ($ticket['Created_On'] ?? ''));
        if ($createdOn !== '' && $createdOn !== '0001-01-01T00:00:00Z') {
            $ts = strtotime($createdOn);
            if (is_int($ts)) {
                return $ts;
            }
        }

        return null;
    }

    private static function parseIsoDurationToSeconds(string $value): ?int
    {
        $value = strtoupper(trim($value));
        if ($value === '' || !str_starts_with($value, 'P')) {
            return null;
        }

        if (!preg_match('/^P(?:(\d+)D)?(?:T(?:(\d+)H)?(?:(\d+)M)?(?:(\d+(?:\.\d+)?)S)?)?$/', $value, $matches)) {
            return null;
        }

        $days = isset($matches[1]) && $matches[1] !== '' ? (int) $matches[1] : 0;
        $hours = isset($matches[2]) && $matches[2] !== '' ? (int) $matches[2] : 0;
        $minutes = isset($matches[3]) && $matches[3] !== '' ? (int) $matches[3] : 0;
        $secondsFloat = isset($matches[4]) ? (float) $matches[4] : 0.0;
        $seconds = (int) floor($secondsFloat);

        return ($days * 86400) + ($hours * 3600) + ($minutes * 60) + $seconds;
    }

    /**
     * @param array<int, array{
     *   ticket_no: string,
     *   support_user_name: string,
     *   age_hours: int|null
     * }> $openTickets
     * @return array<int, array{
     *   action_id: string,
     *   type: string,
     *   priority: int,
     *   ticket_no: string,
     *   age_hours: int|null
     * }>
     */
    private static function buildSupportActions(array $openTickets, int $criticalThresholdHours): array
    {
        $candidates = [];

        foreach ($openTickets as $ticket) {
            $ticketNo = $ticket['ticket_no'];
            $ageHours = $ticket['age_hours'];
            $supportUserName = trim($ticket['support_user_name']);

            if (is_int($ageHours) && $ageHours >= $criticalThresholdHours) {
                $candidates[] = [
                    'action_id' => 'critical_stale-' . $ticketNo,
                    'type' => 'critical_stale',
                    'priority' => 3,
                    'ticket_no' => $ticketNo,
                    'age_hours' => $ageHours,
                ];
            }

            if ($supportUserName === '') {
                $candidates[] = [
                    'action_id' => 'unassigned_support-' . $ticketNo,
                    'type' => 'unassigned_support',
                    'priority' => 2,
                    'ticket_no' => $ticketNo,
                    'age_hours' => $ageHours,
                ];
            }
        }

        $oldestOpen = null;
        foreach ($openTickets as $ticket) {
            $ticketNo = $ticket['ticket_no'];
            $ageHours = $ticket['age_hours'];
            if ($oldestOpen === null || ($ageHours ?? -1) > ((int) ($oldestOpen['age_hours'] ?? -1))) {
                $oldestOpen = [
                    'ticket_no' => $ticketNo,
                    'age_hours' => $ageHours,
                ];
            }
        }

        if (is_array($oldestOpen)) {
            $candidates[] = [
                'action_id' => 'oldest_open_followup-' . $oldestOpen['ticket_no'],
                'type' => 'oldest_open_followup',
                'priority' => 1,
                'ticket_no' => $oldestOpen['ticket_no'],
                'age_hours' => $oldestOpen['age_hours'],
            ];
        }

        usort($candidates, static function (array $a, array $b): int {
            $priorityCmp = $b['priority'] <=> $a['priority'];
            if ($priorityCmp !== 0) {
                return $priorityCmp;
            }
            $ageA = is_int($a['age_hours'] ?? null) ? (int) $a['age_hours'] : -1;
            $ageB = is_int($b['age_hours'] ?? null) ? (int) $b['age_hours'] : -1;
            $ageCmp = $ageB <=> $ageA;
            if ($ageCmp !== 0) {
                return $ageCmp;
            }

            return strcmp($a['ticket_no'], $b['ticket_no']);
        });

        return array_slice($candidates, 0, 3);
    }

    /**
     * Load activity log for a ticket (for async data endpoint).
     *
     * @return array{ok: bool, log?: array<int, array<string, mixed>>, error?: string}
     */
    public function loadTicketLog(string $ticketNo): array
    {
        $ticketNo = trim($ticketNo);

        if ($ticketNo === '') {
            return ['ok' => false, 'error' => 'Missing ticket number'];
        }

        if (!$this->settingsGateway->isConfigured()) {
            return ['ok' => false, 'error' => 'BC connection not configured'];
        }

        try {
            $log = $this->bcODataGateway->getTicketLog($ticketNo);
        } catch (\Throwable $e) {
            return ['ok' => false, 'error' => $e->getMessage()];
        }

        return ['ok' => true, 'log' => $log];
    }

    /**
     * Load full detail for a debtor: master data, contacts, and tickets.
     *
     * @deprecated Use loadCustomer() + async endpoints for contacts/tickets instead.
     *
     * @return array{status: string, customer?: array<string, mixed>, contacts?: array<int, array<string, mixed>>, tickets?: array<int, array<string, mixed>>, error?: string, contactsError?: string, ticketsError?: string}
     */
    public function loadDetail(string $customerNo): array
    {
        $customerNo = trim($customerNo);
        if ($customerNo === '') {
            return ['status' => 'not_found'];
        }

        if (!$this->settingsGateway->isConfigured()) {
            return ['status' => 'not_configured', 'error' => 'BC connection not configured'];
        }

        try {
            $customer = $this->bcODataGateway->getCustomer($customerNo);
        } catch (\Throwable) {
            return ['status' => 'error', 'error' => 'BC connection failed'];
        }

        if ($customer === null) {
            return ['status' => 'not_found'];
        }

        // Load contacts — BC contacts are linked via Company_Name, not customer number.
        // IntegrationCustomerNo is not filterable in BC OData, so we use the customer name.
        $contacts = [];
        $contactsError = '';
        $customerName = (string) ($customer['Name'] ?? '');
        try {
            $contacts = $this->bcODataGateway->getContactsForCustomer($customerNo, $customerName);
        } catch (\Throwable $e) {
            $contactsError = $e->getMessage();
        }

        // Load tickets via PBI_FP_Tickets entity which supports server-side filtering.
        $tickets = [];
        $ticketsError = '';
        try {
            $tickets = $this->bcODataGateway->getTicketsForCustomer($customerNo, $customerName);
        } catch (\Throwable $e) {
            $ticketsError = $e->getMessage();
        }

        return [
            'status' => 'success',
            'customer' => $customer,
            'contacts' => $contacts,
            'tickets' => $tickets,
            'contactsError' => $contactsError,
            'ticketsError' => $ticketsError,
        ];
    }

    // --- Controlling dashboard ---

    /**
     * Build controlling dashboard from raw tickets and contract KPIs.
     *
     * @param array<int, array<string, mixed>> $tickets     Raw OData tickets with Created_On, Process_Stage
     * @param int                               $periodDays  One of 30, 90, 180, 365
     * @param array<string, mixed>             $riskConfig   Risk rules from settings
     *
     * @return array<string, mixed>
     */
    public static function buildControllingDashboard(
        array $tickets,
        int $periodDays,
        array $riskConfig
    ): array {
        $nowTs = time();
        $periodStart = $nowTs - ($periodDays * 86400);
        $prevPeriodStart = $periodStart - ($periodDays * 86400);

        // --- Single-pass counters ---
        $createdInPeriod = 0;
        $closedInPeriod = 0;
        $createdInPrev = 0;
        $closedInPrev = 0;

        // Resolution: all tickets CLOSED in the period (regardless of creation date)
        $resolutionHoursCurrent = [];
        // Resolution: all tickets CLOSED in the previous period
        $resolutionHoursPrev = [];



        // Backlog: snapshot of currently open tickets (period-independent)
        $openTickets = 0;
        $unassignedOpen = 0;
        $criticalOpen = 0;
        $oldestOpenAgeHours = 0;

        $criticalThresholdHours = 48;

        // Trend buckets
        $buckets = self::initTrendBuckets($periodDays, $nowTs);

        foreach ($tickets as $ticket) {
            $state = trim((string) ($ticket['Ticket_State'] ?? ''));
            $isClosed = self::isClosedTicketState($state)
                || (int) ($ticket['Process_Stage'] ?? 0) === 40;

            $createdTs = self::parseIsoToTimestamp((string) ($ticket['Created_On'] ?? ''));
            $activityTs = self::resolveTicketActivityTimestamp($ticket);

            // --- Created in current period ---
            if ($createdTs !== null && $createdTs >= $periodStart) {
                $createdInPeriod++;

                $bucketKey = self::resolveBucketKey($createdTs, $periodDays);
                if (isset($buckets[$bucketKey])) {
                    $buckets[$bucketKey]['created']++;
                }

            }

            // --- Created in previous period ---
            if ($createdTs !== null && $createdTs >= $prevPeriodStart && $createdTs < $periodStart) {
                $createdInPrev++;
            }

            // --- Closed in current period (any creation date) ---
            if ($isClosed && $activityTs !== null && $activityTs >= $periodStart) {
                $closedInPeriod++;

                $bucketKey = self::resolveBucketKey($activityTs, $periodDays);
                if (isset($buckets[$bucketKey])) {
                    $buckets[$bucketKey]['closed']++;
                }

                // Resolution time: Last_Activity_Date − Created_On
                if ($createdTs !== null && $activityTs > $createdTs) {
                    $resolutionHoursCurrent[] = intdiv($activityTs - $createdTs, 3600);
                }
            }

            // --- Closed in previous period (any creation date) ---
            if ($isClosed && $activityTs !== null && $activityTs >= $prevPeriodStart && $activityTs < $periodStart) {
                $closedInPrev++;

                if ($createdTs !== null && $activityTs > $createdTs) {
                    $resolutionHoursPrev[] = intdiv($activityTs - $createdTs, 3600);
                }
            }

            // --- Open ticket snapshot (period-independent) ---
            if (!$isClosed) {
                $openTickets++;

                $supportUser = trim((string) ($ticket['Support_User_Name'] ?? ''));
                if ($supportUser === '') {
                    $unassignedOpen++;
                }

                if ($activityTs !== null) {
                    $ageHours = intdiv(max(0, $nowTs - $activityTs), 3600);
                    if ($ageHours > $criticalThresholdHours) {
                        $criticalOpen++;
                    }
                    if ($ageHours > $oldestOpenAgeHours) {
                        $oldestOpenAgeHours = $ageHours;
                    }
                }
            }
        }

        // --- KPI 1: Volume (created in period, delta vs prev) ---
        $volumeDelta = $createdInPeriod - $createdInPrev;

        // --- KPI 2: Close-Rate (closed ÷ created, as factor) ---
        $closeRate = $createdInPeriod > 0
            ? round($closedInPeriod / $createdInPeriod, 2)
            : null;
        $closeRatePrev = $createdInPrev > 0
            ? round($closedInPrev / $createdInPrev, 2)
            : null;

        // --- KPI 3: Resolution (median hours, all tickets closed in period) ---
        $resolutionMedian = self::computeMedian($resolutionHoursCurrent);
        $resolutionMedianPrev = self::computeMedian($resolutionHoursPrev);

        // --- KPI 4: Backlog (open snapshot) ---
        // No computation needed — $openTickets, $unassignedOpen, $criticalOpen are ready

        // --- Efficiency (detail section) ---
        $unassignedRate = $openTickets > 0
            ? round(($unassignedOpen / $openTickets) * 100, 1)
            : 0.0;

        // --- Risk evaluation ---
        $rules = is_array($riskConfig['rules'] ?? null) ? $riskConfig['rules'] : [];
        $riskIndicators = self::evaluateControllingRisks(
            $rules,
            $createdInPeriod,
            $createdInPrev,
            $resolutionMedian,
            $oldestOpenAgeHours,
            $unassignedRate
        );
        $triggeredCount = 0;
        $totalRules = count($riskIndicators);
        foreach ($riskIndicators as $indicator) {
            if ($indicator['triggered']) {
                $triggeredCount++;
            }
        }
        $riskLevel = $triggeredCount === 0 ? 'low' : ($triggeredCount <= 2 ? 'medium' : 'high');

        return [
            'kpis' => [
                'volume' => $createdInPeriod,
                'volume_prev' => $createdInPrev,
                'volume_delta' => $volumeDelta,
                'close_rate' => $closeRate,
                'close_rate_prev' => $closeRatePrev,
                'resolution_hours' => $resolutionMedian,
                'resolution_hours_prev' => $resolutionMedianPrev,
                'backlog_open' => $openTickets,
                'backlog_unassigned' => $unassignedOpen,
                'backlog_critical' => $criticalOpen,
            ],
            'trend' => array_values($buckets),
            'risk_indicators' => $riskIndicators,
            'risk_summary' => [
                'triggered' => $triggeredCount,
                'total' => $totalRules,
                'level' => $riskLevel,
            ],
            'meta' => [
                'period_days' => $periodDays,
                'created_in_period' => $createdInPeriod,
                'closed_in_period' => $closedInPeriod,
                'created_in_prev' => $createdInPrev,
                'closed_in_prev' => $closedInPrev,
            ],
        ];
    }

    /**
     * @return array<string, array{label: string, created: int, closed: int}>
     */
    private static function initTrendBuckets(int $periodDays, int $nowTs): array
    {
        $buckets = [];

        if ($periodDays <= 30) {
            // Weekly buckets for 30-day period
            for ($i = 3; $i >= 0; $i--) {
                $weekStart = $nowTs - (($i + 1) * 7 * 86400);
                $dt = new \DateTimeImmutable('@' . $weekStart);
                $key = $dt->format('o-W');
                $buckets[$key] = ['label' => 'KW' . $dt->format('W'), 'created' => 0, 'closed' => 0];
            }
        } else {
            // Monthly buckets
            $months = (int) ceil($periodDays / 30);
            for ($i = $months - 1; $i >= 0; $i--) {
                $dt = (new \DateTimeImmutable('@' . $nowTs))->modify("-{$i} months");
                $key = $dt->format('Y-m');
                $monthNames = ['Jan', 'Feb', 'Mär', 'Apr', 'Mai', 'Jun', 'Jul', 'Aug', 'Sep', 'Okt', 'Nov', 'Dez'];
                $buckets[$key] = ['label' => $monthNames[(int) $dt->format('n') - 1], 'created' => 0, 'closed' => 0];
            }
        }

        return $buckets;
    }

    private static function resolveBucketKey(int $timestamp, int $periodDays): string
    {
        $dt = new \DateTimeImmutable('@' . $timestamp);

        if ($periodDays <= 30) {
            return $dt->format('o-W');
        }

        return $dt->format('Y-m');
    }

    /**
     * @param array<int, int> $values
     */
    private static function computeMedian(array $values): ?float
    {
        if ($values === []) {
            return null;
        }

        sort($values);
        $count = count($values);
        $mid = intdiv($count, 2);

        if ($count % 2 === 0) {
            return round(($values[$mid - 1] + $values[$mid]) / 2, 1);
        }

        return (float) $values[$mid];
    }

    private static function parseIsoToTimestamp(string $value): ?int
    {
        $value = trim($value);
        if ($value === '' || str_starts_with($value, '0001-01-01')) {
            return null;
        }

        $ts = strtotime($value);

        return is_int($ts) ? $ts : null;
    }

    /**
     * @param array<string, array<string, mixed>> $rules
     * @return array<int, array{rule_id: string, label: string, description: string, triggered: bool, value: string, raw_value: float|int, threshold_value: int, unit: string}>
     */
    private static function evaluateControllingRisks(
        array $rules,
        int $createdInPeriod,
        int $createdInPrev,
        ?float $avgResolution,
        int $oldestOpenAgeHours,
        float $unassignedRate
    ): array {
        $indicators = [];

        // Ticket volume increase
        $rule = is_array($rules['ticket_volume_increase'] ?? null) ? $rules['ticket_volume_increase'] : [];
        if (($rule['enabled'] ?? true) === true) {
            $threshold = (int) ($rule['threshold_percent'] ?? 30);
            $increase = $createdInPrev > 0
                ? round((($createdInPeriod - $createdInPrev) / $createdInPrev) * 100, 1)
                : ($createdInPeriod > 0 ? 100.0 : 0.0);
            $indicators[] = [
                'rule_id' => 'ticket_volume_increase',
                'label' => 'Ticket volume',
                'description' => 'risk_desc_ticket_volume',
                'triggered' => $increase >= $threshold,
                'value' => ($increase > 0 ? '+' : '') . $increase . '%',
                'raw_value' => max(0, $increase),
                'threshold_value' => $threshold,
                'unit' => '%',
            ];
        }

        // Avg resolution high
        $rule = is_array($rules['avg_resolution_high'] ?? null) ? $rules['avg_resolution_high'] : [];
        if (($rule['enabled'] ?? true) === true) {
            $threshold = (int) ($rule['threshold_hours'] ?? 72);
            $triggered = $avgResolution !== null && $avgResolution >= $threshold;
            $rawValue = $avgResolution !== null ? round($avgResolution, 1) : 0;
            $indicators[] = [
                'rule_id' => 'avg_resolution_high',
                'label' => 'Resolution time',
                'description' => 'risk_desc_resolution_time',
                'triggered' => $triggered,
                'value' => $avgResolution !== null ? round($avgResolution, 1) . 'h' : '—',
                'raw_value' => $rawValue,
                'threshold_value' => $threshold,
                'unit' => 'h',
            ];
        }

        // Open aging
        $rule = is_array($rules['open_aging'] ?? null) ? $rules['open_aging'] : [];
        if (($rule['enabled'] ?? true) === true) {
            $threshold = (int) ($rule['threshold_hours'] ?? 168);
            $indicators[] = [
                'rule_id' => 'open_aging',
                'label' => 'Oldest open ticket',
                'description' => 'risk_desc_open_aging',
                'triggered' => $oldestOpenAgeHours >= $threshold,
                'value' => $oldestOpenAgeHours . 'h',
                'raw_value' => $oldestOpenAgeHours,
                'threshold_value' => $threshold,
                'unit' => 'h',
            ];
        }

        // Unassigned ratio
        $rule = is_array($rules['unassigned_ratio'] ?? null) ? $rules['unassigned_ratio'] : [];
        if (($rule['enabled'] ?? true) === true) {
            $threshold = (int) ($rule['threshold_percent'] ?? 20);
            $indicators[] = [
                'rule_id' => 'unassigned_ratio',
                'label' => 'Unassigned',
                'description' => 'risk_desc_unassigned',
                'triggered' => $unassignedRate >= $threshold,
                'value' => $unassignedRate . '%',
                'raw_value' => $unassignedRate,
                'threshold_value' => $threshold,
                'unit' => '%',
            ];
        }

        return $indicators;
    }

    /**
     * Build a team workload dashboard from a global (non-customer-scoped) ticket set.
     *
     * Groups tickets by Support_User_Name and computes per-agent metrics.
     * Unassigned tickets (empty Support_User_Name) are collected into a dedicated entry.
     *
     * @param array<int, array<string, mixed>> $tickets    Raw OData tickets
     * @param int                              $periodDays One of 30, 90, 180, 365
     *
     * @return array{kpis: array<string, mixed>, members: array<int, array<string, mixed>>, meta: array<string, mixed>}
     */
    public static function buildTeamWorkloadDashboard(array $tickets, int $periodDays): array
    {
        $nowTs = time();
        $periodStart = $nowTs - ($periodDays * 86400);
        $criticalThresholdHours = 48;

        /** @var array<string, array{display_name: string, support_user: string, open_tickets: int, critical_tickets: int, age_hours_sum: int, resolved_in_period: int, open_ticket_details: list<array{no: string, description: string, age_hours: int, critical: bool, customer: string}>}> $agents */
        $agents = [];
        $globalOpen = 0;
        $globalUnassigned = 0;
        $globalAgeSum = 0;
        $globalOpenCount = 0;
        $globalResolved = 0;

        foreach ($tickets as $ticket) {
            $state = trim((string) ($ticket['Ticket_State'] ?? ''));
            // Team query uses PBI_FP_Tickets which has no Process_Stage;
            // fall back to Process_Stage only when present (LV entity).
            $isClosed = self::isClosedTicketState($state)
                || (isset($ticket['Process_Stage']) && (int) $ticket['Process_Stage'] === 40);

            $rawUser = trim((string) ($ticket['Support_User_Name'] ?? ''));
            $normalizedUser = $rawUser === '' ? '' : strtolower($rawUser);

            $activityTs = self::resolveTicketActivityTimestamp($ticket);

            if (!isset($agents[$normalizedUser])) {
                $agents[$normalizedUser] = [
                    'display_name' => $rawUser,
                    'support_user' => $normalizedUser,
                    'open_tickets' => 0,
                    'critical_tickets' => 0,
                    'age_hours_sum' => 0,
                    'resolved_in_period' => 0,
                    'received_in_period' => 0,
                    'open_ticket_details' => [],
                    'resolved_customers' => [],
                    'resolved_categories' => [],
                    'resolution_hours' => [],
                    'resolved_ticket_details' => [],
                ];
            }

            if ($isClosed) {
                $createdTs = self::parseIsoToTimestamp((string) ($ticket['Created_On'] ?? ''));
                if ($createdTs !== null && $createdTs >= $periodStart) {
                    $agents[$normalizedUser]['received_in_period']++;
                }
                if ($activityTs !== null && $activityTs >= $periodStart) {
                    $agents[$normalizedUser]['resolved_in_period']++;
                    $globalResolved++;

                    $customerName = trim((string) ($ticket['Company_Contact_Name'] ?? (string) ($ticket['Cust_Name'] ?? '')));
                    $category = trim((string) ($ticket['Category_1_Description'] ?? (string) ($ticket['Category_1_Code'] ?? '')));
                    if ($customerName !== '') {
                        $agents[$normalizedUser]['resolved_customers'][$customerName] = ($agents[$normalizedUser]['resolved_customers'][$customerName] ?? 0) + 1;
                    }
                    if ($category !== '') {
                        $agents[$normalizedUser]['resolved_categories'][$category] = ($agents[$normalizedUser]['resolved_categories'][$category] ?? 0) + 1;
                    }
                    $resolutionH = null;
                    if ($createdTs !== null && $activityTs > $createdTs) {
                        $resolutionH = intdiv($activityTs - $createdTs, 3600);
                        $agents[$normalizedUser]['resolution_hours'][] = $resolutionH;
                    }

                    $agents[$normalizedUser]['resolved_ticket_details'][] = [
                        'customer_name' => $customerName,
                        'category' => $category,
                        'resolution_hours' => $resolutionH,
                    ];
                }
            } else {
                $agents[$normalizedUser]['open_tickets']++;
                $globalOpen++;

                $createdTs = self::parseIsoToTimestamp((string) ($ticket['Created_On'] ?? ''));
                if ($createdTs !== null && $createdTs >= $periodStart) {
                    $agents[$normalizedUser]['received_in_period']++;
                }

                if ($rawUser === '') {
                    $globalUnassigned++;
                }

                $ageHours = 0;
                $isCritical = false;
                if ($activityTs !== null) {
                    $ageHours = intdiv(max(0, $nowTs - $activityTs), 3600);
                    $agents[$normalizedUser]['age_hours_sum'] += $ageHours;

                    if ($ageHours > $criticalThresholdHours) {
                        $agents[$normalizedUser]['critical_tickets']++;
                        $isCritical = true;
                    }

                    $globalAgeSum += $ageHours;
                    $globalOpenCount++;
                }

                $lastActivityRaw = trim((string) ($ticket['Last_Activity_Date'] ?? ''));
                $lastActivityDisplay = ($lastActivityRaw !== '' && !str_starts_with($lastActivityRaw, '0001'))
                    ? $lastActivityRaw
                    : trim((string) ($ticket['Created_On'] ?? ''));

                $agents[$normalizedUser]['open_ticket_details'][] = [
                    'no' => trim((string) ($ticket['No'] ?? '')),
                    'description' => trim((string) ($ticket['Description'] ?? '')),
                    'category' => trim((string) ($ticket['Category_1_Description'] ?? (string) ($ticket['Category_1_Code'] ?? ''))),
                    'age_hours' => $ageHours,
                    'critical' => $isCritical,
                    'customer_name' => trim((string) ($ticket['Company_Contact_Name'] ?? (string) ($ticket['Cust_Name'] ?? ''))),
                    'customer_no' => trim((string) ($ticket['Customer_No'] ?? '')),
                    'last_activity' => $lastActivityDisplay,
                ];
            }
        }

        $members = [];
        foreach ($agents as $key => $agent) {
            $avgAge = $agent['open_tickets'] > 0
                ? (int) round($agent['age_hours_sum'] / $agent['open_tickets'])
                : 0;

            // Sort ticket details: critical first, then by age descending
            $details = $agent['open_ticket_details'];
            usort($details, static function (array $a, array $b): int {
                if ($a['critical'] !== $b['critical']) {
                    return $b['critical'] <=> $a['critical'];
                }

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

            // Performance insights from resolved tickets
            $topCustomers = $agent['resolved_customers'];
            arsort($topCustomers);
            $topCustomers = array_slice($topCustomers, 0, 3, true);

            $topCategories = $agent['resolved_categories'];
            arsort($topCategories);
            $topCategories = array_slice($topCategories, 0, 3, true);

            $resHours = $agent['resolution_hours'];
            $avgResolution = $resHours !== [] ? (int) round(array_sum($resHours) / count($resHours)) : null;
            $minResolution = $resHours !== [] ? min($resHours) : null;
            $maxResolution = $resHours !== [] ? max($resHours) : null;

            $members[] = [
                'support_user' => $agent['support_user'],
                'display_name' => $agent['display_name'],
                'open_tickets' => $agent['open_tickets'],
                'critical_tickets' => $agent['critical_tickets'],
                'avg_age_hours' => $avgAge,
                'resolved_in_period' => $agent['resolved_in_period'],
                'received_in_period' => $agent['received_in_period'],
                'trend_delta' => $agent['received_in_period'] - $agent['resolved_in_period'],
                'open_ticket_details' => $details,
                'performance' => [
                    'top_customers' => array_map(
                        static fn (string $name, int $count): array => ['name' => $name, 'count' => $count],
                        array_keys($topCustomers),
                        array_values($topCustomers)
                    ),
                    'top_categories' => array_map(
                        static fn (string $name, int $count): array => ['name' => $name, 'count' => $count],
                        array_keys($topCategories),
                        array_values($topCategories)
                    ),
                    'avg_resolution_hours' => $avgResolution,
                    'min_resolution_hours' => $minResolution,
                    'max_resolution_hours' => $maxResolution,
                    'resolved_details' => $agent['resolved_ticket_details'],
                ],
            ];
        }

        // Sort: unassigned last, then critical_tickets desc, then open_tickets desc
        usort($members, static function (array $a, array $b): int {
            if ($a['support_user'] === '') {
                return 1;
            }
            if ($b['support_user'] === '') {
                return -1;
            }

            if ($a['critical_tickets'] !== $b['critical_tickets']) {
                return $b['critical_tickets'] <=> $a['critical_tickets'];
            }

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

        $globalAvgAge = $globalOpenCount > 0
            ? (int) round($globalAgeSum / $globalOpenCount)
            : 0;

        return [
            'kpis' => [
                'open_tickets' => $globalOpen,
                'unassigned' => $globalUnassigned,
                'avg_age_hours' => $globalAvgAge,
                'resolved_in_period' => $globalResolved,
            ],
            'members' => $members,
            'meta' => [
                'period_days' => $periodDays,
                'total_tickets' => count($tickets),
                'unique_users' => count(array_filter(array_keys($agents), static fn (string $k): bool => $k !== '')),
            ],
        ];
    }
}
