<?php

namespace MintyPHP\Module\Helpdesk\Service;

/**
 * Aggregates ticket communication from SOAP and OData into a normalized feed.
 */
class TicketCommunicationService
{
    public function __construct(
        private readonly BcSoapGateway $bcSoapGateway,
        private readonly BcODataGateway $bcODataGateway
    ) {
    }

    /**
     * @return array{
     *   ok: bool,
     *   entries?: array<int, array<string, string>>,
     *   meta?: array<string, mixed>,
     *   error?: string
     * }
     */
    public function loadTicketCommunicationFeed(
        string $ticketNo,
        int $maxEntries = 40,
        array $actorNameMap = [],
        bool $allowPerTicketActorLookup = true
    ): array {
        $ticketNo = trim($ticketNo);
        if ($ticketNo === '') {
            return ['ok' => false, 'error' => 'Missing ticket number'];
        }

        $maxEntries = max(1, min(250, $maxEntries));

        $ticketLog = [];
        $odataError = '';
        $odataEntries = [];
        try {
            $ticketLog = $this->bcODataGateway->getTicketLog($ticketNo);
        } catch (\Throwable $e) {
            $odataError = $e->getMessage();
        }

        $messageEntries = $this->buildSoapMessageEntriesFromTicketLog($ticketLog);
        $soapResult = $this->bcSoapGateway->getTicketCommunicationText($ticketNo, $messageEntries);
        $soapError = (string) ($soapResult['error'] ?? '');
        $soapUsed = (bool) $soapResult['soap_used'];
        $soapEntries = [];
        $soapMessageMap = [];
        if ($soapResult['ok'] === true) {
            $soapEntries = $this->parseSoapEntries(
                $ticketNo,
                (string) $soapResult['communication_text'],
                (string) $soapResult['message_entries']
            );
            $soapMessageMap = $this->extractSoapMessageMap(
                (string) $soapResult['communication_text'],
                (string) $soapResult['message_entries']
            );
        }

        if ($ticketLog !== []) {
            $odataEntries = $this->mapODataEntries($ticketNo, $ticketLog, $soapMessageMap);
        }

        // When OData entries are enriched with SOAP message text via soapMessageMap,
        // the separate SOAP entries for those messages are redundant and cause
        // duplicates. Drop SOAP message entries when the map was applied.
        if ($soapMessageMap !== [] && $soapEntries !== []) {
            $soapEntries = array_values(array_filter(
                $soapEntries,
                static fn (array $e): bool => ($e['type_variant'] ?? '') !== 'message'
            ));
        }

        if ($actorNameMap !== []) {
            $odataEntries = $this->applyActorNameMap($odataEntries, $actorNameMap);
            $soapEntries = $this->applyActorNameMap($soapEntries, $actorNameMap);
        } elseif ($allowPerTicketActorLookup) {
            $ticket = null;
            try {
                $ticket = $this->bcODataGateway->getTicket($ticketNo);
            } catch (\Throwable) {
                // ignore
            }
            $resolvedActorNameMap = $this->resolveContactActorNames($ticket, $odataEntries, $soapEntries);
            $supportActorNameMap = $this->resolveSupportActorNames($ticket, $odataEntries, $soapEntries);
            $combinedMap = array_merge($resolvedActorNameMap, $supportActorNameMap);
            if ($combinedMap !== []) {
                $odataEntries = $this->applyActorNameMap($odataEntries, $combinedMap);
                $soapEntries = $this->applyActorNameMap($soapEntries, $combinedMap);
            }
        }

        $merged = $this->mergeEntries($odataEntries, $soapEntries, $maxEntries);
        $fallbackUsed = $this->hasFallbackOnlyEntries($odataEntries) && $soapEntries === [];

        if ($merged === [] && $soapError !== '' && $odataError !== '') {
            return [
                'ok' => false,
                'error' => 'Could not load communication feed',
                'meta' => [
                    'ticket_no' => $ticketNo,
                    'soap_used' => false,
                    'soap_error' => $soapError,
                    'fallback_used' => false,
                ],
            ];
        }

        return [
            'ok' => true,
            'entries' => $merged,
            'meta' => [
                'ticket_no' => $ticketNo,
                'soap_used' => $soapUsed,
                'soap_error' => $soapError,
                'fallback_used' => $fallbackUsed,
            ],
        ];
    }

    /**
     * @return array{
     *   ok: bool,
     *   entries?: array<int, array<string, string>>,
     *   meta?: array<string, mixed>,
     *   error?: string
     * }
     */
    public function loadDebitorCommunicationFeed(
        string $customerNo,
        string $customerName,
        int $ticketsLimit = 10,
        int $maxEntriesPerTicket = 40,
        int $maxEntriesTotal = 250
    ): array {
        $customerNo = trim($customerNo);
        $customerName = trim($customerName);
        if ($customerNo === '' || $customerName === '') {
            return ['ok' => false, 'error' => 'Missing customer number or name'];
        }

        $ticketsLimit = max(1, min(25, $ticketsLimit));
        $maxEntriesPerTicket = max(1, min(80, $maxEntriesPerTicket));
        $maxEntriesTotal = max(1, min(500, $maxEntriesTotal));

        try {
            $tickets = $this->bcODataGateway->getTicketsForCustomer($customerNo, $customerName);
        } catch (\Throwable) {
            return ['ok' => false, 'error' => 'Could not load tickets for customer'];
        }

        if ($tickets === []) {
            return [
                'ok' => true,
                'entries' => [],
                'meta' => [
                    'customer_no' => $customerNo,
                    'tickets_considered' => 0,
                    'tickets_with_data' => 0,
                    'soap_partial_failures' => 0,
                ],
            ];
        }

        usort($tickets, function (array $a, array $b): int {
            $sa = $this->ticketSortStamp($a);
            $sb = $this->ticketSortStamp($b);

            return $sb <=> $sa;
        });

        $ticketsToProcess = [];
        foreach ($tickets as $ticket) {
            $ticketNo = trim((string) ($ticket['No'] ?? ''));
            if ($ticketNo === '') {
                continue;
            }
            $ticketsToProcess[$ticketNo] = true;
            if (count($ticketsToProcess) >= $ticketsLimit) {
                break;
            }
        }

        $allEntries = [];
        $ticketsWithData = 0;
        $soapPartialFailures = 0;
        $actorNameMap = $this->buildContactActorNameMapForDebitor($customerName);

        // Collect support user name → ticket code mappings
        $supportUserNamesByTicket = [];
        foreach ($tickets as $ticket) {
            $no = trim((string) ($ticket['No'] ?? ''));
            $name = trim((string) ($ticket['Support_User_Name'] ?? ''));
            if ($no !== '' && $name !== '') {
                $supportUserNamesByTicket[$no] = $name;
            }
        }

        foreach (array_keys($ticketsToProcess) as $ticketNo) {
            $feed = $this->loadTicketCommunicationFeed(
                $ticketNo,
                $maxEntriesPerTicket,
                $actorNameMap,
                false
            );
            $ticketEntries = [];
            if ($feed['ok']) {
                $ticketEntries = is_array($feed['entries'] ?? null) ? $feed['entries'] : [];
            }

            if ($ticketEntries !== []) {
                $ticketsWithData++;
                foreach ($ticketEntries as $entry) {
                    $allEntries[] = $entry;
                }
            }

            $meta = is_array($feed['meta'] ?? null) ? $feed['meta'] : [];
            $soapUsed = (bool) ($meta['soap_used'] ?? false);
            $soapError = trim((string) ($meta['soap_error'] ?? ''));
            if (!$soapUsed && $soapError !== '') {
                $soapPartialFailures++;
            }
        }

        // Build support code→name map from entries + ticket data, then apply
        $supportActorNameMap = $this->buildSupportActorNameMapFromEntries($allEntries, $supportUserNamesByTicket);
        if ($supportActorNameMap !== []) {
            $allEntries = $this->applyActorNameMap($allEntries, $supportActorNameMap);
        }

        $entries = $this->mergeEntries($allEntries, [], $maxEntriesTotal);

        return [
            'ok' => true,
            'entries' => $entries,
            'meta' => [
                'customer_no' => $customerNo,
                'tickets_considered' => count($ticketsToProcess),
                'tickets_with_data' => $ticketsWithData,
                'soap_partial_failures' => $soapPartialFailures,
            ],
        ];
    }

    /**
     * @param array<int, array<string, mixed>> $ticketLog
     * @param array<string, string> $soapMessageMap
     * @return array<int, array<string, string>>
     */
    private function mapODataEntries(string $ticketNo, array $ticketLog, array $soapMessageMap = []): array
    {
        $entries = [];
        foreach ($ticketLog as $row) {
            $type = trim((string) ($row['Type'] ?? ''));
            $stateSubtype = trim((string) ($row['State_Subtype'] ?? ''));
            $recordId = trim((string) ($row['Record_ID'] ?? ''));
            $actor = trim((string) ($row['Created_By'] ?? ''));
            $createdByType = trim((string) ($row['Created_By_Type'] ?? ''));
            $date = trim((string) ($row['Creation_Date'] ?? ''));
            $time = trim((string) ($row['Creation_Time'] ?? ''));
            $entryNo = $this->normalizeEntryNo($row['Entry_No'] ?? null);

            $timestampIso = $this->combineDateTimeToIso($date, $time);
            $soapMessage = $entryNo !== '' ? (string) ($soapMessageMap[$entryNo] ?? '') : '';
            $message = $soapMessage !== ''
                ? $soapMessage
                : $this->buildODataMessage($type, $stateSubtype, $recordId);
            [$normalizedType, $typeVariant] = $this->normalizeType($type, $message);

            $entry = [
                'entry_id' => 'odata-' . substr(hash('sha256', $ticketNo . '|' . ($row['Entry_No'] ?? '') . '|' . $timestampIso . '|' . $actor . '|' . $normalizedType . '|' . $message), 0, 20),
                'ticket_no' => $ticketNo,
                'timestamp_iso' => $timestampIso,
                'date' => $date,
                'time' => $this->normalizeTime($time),
                'actor' => $actor,
                'actor_role' => $this->normalizeActorRole($createdByType, $actor),
                'type' => $normalizedType,
                'type_variant' => $typeVariant,
                'state_subtype' => $stateSubtype,
                'message' => $message,
                'file_entry_no' => $typeVariant === 'file' ? $entryNo : '',
                'file_name' => $typeVariant === 'file' ? $this->extractFilenameFromMessage($message, $recordId) : '',
                'source' => $soapMessage !== '' ? 'soap' : 'odata',
            ];

            $entries[] = $entry;
        }

        return $entries;
    }

    /**
     * @return array<int, array<string, string>>
     */
    private function parseSoapEntries(string $ticketNo, string $communicationText, string $messageEntries): array
    {
        $entries = [];
        $candidates = [];
        if (!$this->isSoapPayloadEmpty($messageEntries)) {
            $candidates[] = $messageEntries;
        }
        if (!$this->isSoapPayloadEmpty($communicationText)) {
            $candidates[] = $communicationText;
        }

        foreach ($candidates as $candidate) {
            foreach ($this->extractEntriesFromPayload($ticketNo, $candidate) as $entry) {
                $entries[] = $entry;
            }
        }

        return $this->mergeEntries($entries, [], 200);
    }

    /**
     * @param array<int, array<string, mixed>> $ticketLog
     */
    private function buildSoapMessageEntriesFromTicketLog(array $ticketLog): string
    {
        $entryNos = [];
        foreach ($ticketLog as $row) {
            $type = trim((string) ($row['Type'] ?? ''));
            [$normalizedType, $typeVariant] = $this->normalizeType($type, '');
            if ($typeVariant !== 'message' && !in_array($normalizedType, ['Message', 'Nachricht'], true)) {
                continue;
            }

            $entryNo = $this->normalizeEntryNo($row['Entry_No'] ?? null);
            if ($entryNo !== '') {
                $entryNos[$entryNo] = true;
            }
        }

        if ($entryNos === []) {
            return '';
        }

        return implode(';', array_keys($entryNos)) . ';';
    }

    /**
     * @return array<string, string>
     */
    private function extractSoapMessageMap(string $communicationText, string $messageEntries): array
    {
        $sources = [$communicationText, $messageEntries];
        foreach ($sources as $payload) {
            $payload = trim($payload);
            if ($payload === '' || $this->isSoapPayloadEmpty($payload)) {
                continue;
            }

            $decoded = json_decode($payload, true);
            if (!is_array($decoded) || !array_is_list($decoded)) {
                continue;
            }

            $map = [];
            foreach ($decoded as $row) {
                if (!is_array($row)) {
                    continue;
                }

                $entryNo = $this->normalizeEntryNo($row['EntryNo'] ?? $row['entryNo'] ?? $row['entry_no'] ?? null);
                if ($entryNo === '') {
                    continue;
                }

                $message = $this->normalizeSoapMessageText((string) ($row['DataBlob'] ?? $row['dataBlob'] ?? $row['message'] ?? $row['text'] ?? ''));
                if ($message === '') {
                    continue;
                }

                $map[$entryNo] = $message;
            }

            if ($map !== []) {
                return $map;
            }
        }

        return [];
    }

    /**
     * @return array<int, array<string, string>>
     */
    private function extractEntriesFromPayload(string $ticketNo, string $payload): array
    {
        $payload = trim($payload);
        if ($payload === '' || $this->isSoapPayloadEmpty($payload)) {
            return [];
        }

        $jsonDecoded = json_decode($payload, true);
        if (is_array($jsonDecoded) && array_is_list($jsonDecoded)) {
            $entries = [];
            foreach ($jsonDecoded as $row) {
                if (!is_array($row)) {
                    continue;
                }
                $message = $this->normalizeSoapMessageText((string) ($row['message'] ?? $row['text'] ?? $row['DataBlob'] ?? $row['dataBlob'] ?? ''));
                if ($message === '') {
                    continue;
                }
                $timestampIso = trim((string) ($row['timestamp'] ?? $row['created_at'] ?? ''));
                $actor = trim((string) ($row['actor'] ?? $row['user'] ?? ''));
                $createdByType = trim((string) ($row['created_by_type'] ?? $row['Created_By_Type'] ?? ''));
                $stateSubtype = trim((string) ($row['state_subtype'] ?? ''));
                [$type, $variant] = $this->normalizeType((string) ($row['type'] ?? 'Nachricht'), $message);
                [$date, $time] = $this->splitTimestamp($timestampIso);
                $entry = $this->buildSoapEntry(
                    $ticketNo,
                    $timestampIso,
                    $date,
                    $time,
                    $actor,
                    $this->normalizeActorRole($createdByType, $actor),
                    $type,
                    $variant,
                    $stateSubtype,
                    $message
                );
                $entryNo = $this->normalizeEntryNo($row['EntryNo'] ?? $row['entryNo'] ?? $row['entry_no'] ?? null);
                if ($entryNo !== '') {
                    $entry['entry_id'] = 'soap-' . substr(hash('sha256', $ticketNo . '|' . $entryNo . '|' . $timestampIso . '|' . $actor . '|' . $type . '|' . $message), 0, 20);
                }
                $entries[] = $entry;
            }
            if ($entries !== []) {
                return $entries;
            }
        }

        $normalized = str_replace(["\r\n", "\r"], "\n", $payload);
        $normalized = preg_replace('#<br\s*/?>#i', "\n", $normalized) ?? $normalized;
        $normalized = strip_tags($normalized);
        $normalized = html_entity_decode($normalized, ENT_QUOTES | ENT_HTML5, 'UTF-8');

        $lines = preg_split('/\n+/', $normalized) ?: [];
        $lines = array_values(array_filter(array_map(static fn (string $line): string => trim($line), $lines), static fn (string $line): bool => $line !== ''));
        if ($lines === []) {
            return [];
        }

        $entries = [];
        foreach ($lines as $line) {
            $entries[] = $this->parseSoapLine($ticketNo, $line);
        }

        return $entries;
    }

    private function isSoapPayloadEmpty(string $payload): bool
    {
        $normalized = trim(html_entity_decode($payload, ENT_QUOTES | ENT_HTML5, 'UTF-8'));
        if ($normalized === '') {
            return true;
        }

        return in_array($normalized, ['0', '[]', '0[]', 'null', '{}'], true);
    }

    /**
     * @return array<string, string>
     */
    private function parseSoapLine(string $ticketNo, string $line): array
    {
        $working = trim($line);
        $timestampIso = '';
        $date = '';
        $time = '';

        if (preg_match('/^(\d{4}-\d{2}-\d{2})(?:[T\s](\d{2}:\d{2}(?::\d{2})?))?\s*[-|]\s*(.+)$/u', $working, $m) === 1) {
            $date = $m[1];
            $time = $this->normalizeTime($m[2]);
            $timestampIso = $this->combineDateTimeToIso($date, $time);
            $working = trim($m[3]);
        } elseif (preg_match('/^(\d{2}\.\d{2}\.\d{4})\s+(\d{2}:\d{2}(?::\d{2})?)\s*[-|]\s*(.+)$/u', $working, $m) === 1) {
            $date = $m[1];
            $time = $this->normalizeTime($m[2]);
            $timestampIso = $this->convertEuropeanDateTimeToIso($date, $time);
            $working = trim($m[3]);
        }

        $actor = '';
        $message = $working;
        if (preg_match('/^([^:]{2,80}):\s*(.+)$/u', $working, $m) === 1) {
            $actor = trim($m[1]);
            $message = trim($m[2]);
        }

        [$type, $variant] = $this->normalizeType('Nachricht', $message);
        $stateSubtype = '';

        return $this->buildSoapEntry(
            $ticketNo,
            $timestampIso,
            $date,
            $time,
            $actor,
            $this->normalizeActorRole('', $actor),
            $type,
            $variant,
            $stateSubtype,
            $message
        );
    }

    /**
     * @return array<string, string>
     */
    private function buildSoapEntry(
        string $ticketNo,
        string $timestampIso,
        string $date,
        string $time,
        string $actor,
        string $actorRole,
        string $type,
        string $variant,
        string $stateSubtype,
        string $message
    ): array {
        return [
            'entry_id' => 'soap-' . substr(hash('sha256', $ticketNo . '|' . $timestampIso . '|' . $actor . '|' . $type . '|' . $message), 0, 20),
            'ticket_no' => $ticketNo,
            'timestamp_iso' => $timestampIso,
            'date' => $date,
            'time' => $time,
            'actor' => $actor,
            'actor_role' => $actorRole,
            'type' => $type,
            'type_variant' => $variant,
            'state_subtype' => $stateSubtype,
            'message' => $message,
            'source' => 'soap',
        ];
    }

    /**
     * @param array<int, array<string, string>> $primary
     * @param array<int, array<string, string>> $secondary
     * @return array<int, array<string, string>>
     */
    private function mergeEntries(array $primary, array $secondary, int $maxEntries): array
    {
        $combined = [...$primary, ...$secondary];
        if ($combined === []) {
            return [];
        }

        usort($combined, function (array $a, array $b): int {
            $ta = trim((string) ($a['timestamp_iso'] ?? ''));
            $tb = trim((string) ($b['timestamp_iso'] ?? ''));
            if ($ta === $tb) {
                return strcmp((string) ($b['entry_id'] ?? ''), (string) ($a['entry_id'] ?? ''));
            }
            if ($ta === '') {
                return 1;
            }
            if ($tb === '') {
                return -1;
            }

            return strcmp($tb, $ta);
        });

        $deduped = [];
        $seen = [];
        foreach ($combined as $entry) {
            $signature = $this->entrySignature($entry);
            if (isset($seen[$signature])) {
                continue;
            }
            $seen[$signature] = true;
            $deduped[] = $entry;
            if (count($deduped) >= $maxEntries) {
                break;
            }
        }

        return $deduped;
    }

    /**
     * @param array<string, string> $entry
     */
    private function entrySignature(array $entry): string
    {
        return hash(
            'sha256',
            implode('|', [
                (string) ($entry['ticket_no'] ?? ''),
                (string) ($entry['timestamp_iso'] ?? ''),
                (string) ($entry['type'] ?? ''),
                hash('sha256', (string) ($entry['message'] ?? '')),
            ])
        );
    }

    /**
     * @param array<int, array<string, string>> $entries
     */
    private function hasFallbackOnlyEntries(array $entries): bool
    {
        if ($entries === []) {
            return false;
        }

        foreach ($entries as $entry) {
            if (($entry['source'] ?? '') === 'soap') {
                return false;
            }
        }

        return true;
    }

    private function buildODataMessage(string $type, string $stateSubtype, string $recordId): string
    {
        $type = trim($type);
        $recordId = trim($recordId);
        $stateSubtype = trim($stateSubtype);

        if ($type === 'Status' && $stateSubtype !== '') {
            return $stateSubtype;
        }

        if ($recordId !== '') {
            return $recordId;
        }

        if ($type !== '') {
            return $type;
        }

        return '';
    }

    private function normalizeEntryNo(mixed $value): string
    {
        if (is_int($value)) {
            return (string) $value;
        }

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

        return (string) ((int) $raw);
    }

    private function normalizeSoapMessageText(string $message): string
    {
        $message = trim($message);
        if ($message === '') {
            return '';
        }

        $message = preg_replace('#<br\s*/?>#i', "\n", $message) ?? $message;
        $message = preg_replace('#</p>\s*<p>#i', "\n\n", $message) ?? $message;
        $message = strip_tags($message);
        $message = html_entity_decode($message, ENT_QUOTES | ENT_HTML5, 'UTF-8');
        $message = str_replace(["\r\n", "\r"], "\n", $message);
        $message = preg_replace("/\n{3,}/", "\n\n", $message) ?? $message;

        return trim($message);
    }

    /**
     * @return array{0: string, 1: string}
     */
    private function normalizeType(string $type, string $message): array
    {
        $type = trim($type);
        $needle = mb_strtolower($type . ' ' . $message);
        if ($type === 'Status' || str_contains($needle, 'status')) {
            return ['Status', 'status'];
        }
        if ($type === 'Weiterleitung' || str_contains($needle, 'weiterleit') || str_contains($needle, 'forward')) {
            return ['Weiterleitung', 'forward'];
        }
        if ($type === 'Datei' || str_contains($needle, 'datei') || str_contains($needle, 'file') || str_contains($needle, 'anhang')) {
            return ['Datei', 'file'];
        }
        if ($type === 'Nachricht' || $type === 'Message') {
            return ['Nachricht', 'message'];
        }
        if ($type !== '') {
            return [$type, 'other'];
        }

        return ['Nachricht', 'message'];
    }

    private function ticketSortStamp(array $ticket): int
    {
        $activity = trim((string) ($ticket['Last_Activity_Date'] ?? ''));
        if ($activity !== '' && $activity !== '0001-01-01T00:00:00Z') {
            $ts = strtotime($activity);
            if (is_int($ts)) {
                return $ts;
            }
        }

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

        return 0;
    }

    private function combineDateTimeToIso(string $date, string $time): string
    {
        $date = trim($date);
        $time = $this->normalizeTime($time);
        if ($date === '') {
            return '';
        }

        if ($time === '') {
            if (preg_match('/^\d{4}-\d{2}-\d{2}$/', $date) === 1) {
                return $date . 'T00:00:00';
            }

            return '';
        }

        $ts = strtotime($date . ' ' . $time);
        if (!is_int($ts)) {
            return '';
        }

        return date('Y-m-d\TH:i:s', $ts);
    }

    private function convertEuropeanDateTimeToIso(string $date, string $time): string
    {
        if (preg_match('/^(\d{2})\.(\d{2})\.(\d{4})$/', trim($date), $m) !== 1) {
            return '';
        }

        return $this->combineDateTimeToIso($m[3] . '-' . $m[2] . '-' . $m[1], $time);
    }

    /**
     * @return array{0: string, 1: string}
     */
    private function splitTimestamp(string $timestamp): array
    {
        $timestamp = trim($timestamp);
        if ($timestamp === '') {
            return ['', ''];
        }

        $ts = strtotime($timestamp);
        if (!is_int($ts)) {
            return ['', ''];
        }

        return [date('Y-m-d', $ts), date('H:i:s', $ts)];
    }

    private function normalizeTime(string $time): string
    {
        $time = trim($time);
        if ($time === '') {
            return '';
        }

        if (preg_match('/^(\d{2}:\d{2})(:\d{2})?/', $time, $m) === 1) {
            return isset($m[2]) ? ($m[1] . $m[2]) : ($m[1] . ':00');
        }

        return $time;
    }

    /**
     * @param array<string, mixed>|null $ticket Pre-fetched ticket data
     * @param array<int, array<string, string>> $odataEntries
     * @param array<int, array<string, string>> $soapEntries
     * @return array<string, string>
     */
    private function resolveContactActorNames(?array $ticket, array $odataEntries, array $soapEntries): array
    {
        $contactActorIds = $this->extractContactActorIds([...$odataEntries, ...$soapEntries]);
        if ($contactActorIds === []) {
            return [];
        }

        $currentContactName = trim((string) (($ticket['Current_Contact_Name'] ?? '')));
        $customerName = trim((string) (($ticket['Company_Contact_Name'] ?? '')));

        $nameMap = [];
        if ($customerName !== '') {
            try {
                $contacts = $this->bcODataGateway->getContactsForCustomer('', $customerName);
                foreach ($contacts as $contact) {
                    $contactNo = strtoupper(trim((string) ($contact['No'] ?? '')));
                    $contactName = trim((string) ($contact['Name'] ?? ''));
                    if ($contactNo !== '' && $contactName !== '') {
                        $nameMap[$contactNo] = $contactName;
                    }
                }
            } catch (\Throwable) {
                $nameMap = [];
            }
        }

        $resolved = [];
        foreach ($contactActorIds as $contactActorId) {
            if (isset($nameMap[$contactActorId])) {
                $resolved[$contactActorId] = $nameMap[$contactActorId];
            }
        }

        if ($currentContactName !== '') {
            $remaining = array_values(array_filter(
                $contactActorIds,
                static fn (string $contactActorId): bool => !isset($resolved[$contactActorId])
            ));
            if (count($remaining) === 1) {
                $resolved[$remaining[0]] = $currentContactName;
            }
        }

        return $resolved;
    }

    /**
     * @param array<int, array<string, string>> $entries
     * @return array<int, array<string, string>>
     */
    private function applyActorNameMap(array $entries, array $actorNameMap): array
    {
        if ($entries === [] || $actorNameMap === []) {
            return $entries;
        }

        foreach ($entries as $index => $entry) {
            $actor = strtoupper(trim((string) ($entry['actor'] ?? '')));
            if ($actor !== '' && isset($actorNameMap[$actor])) {
                $entries[$index]['actor'] = $actorNameMap[$actor];
            }
        }

        return $entries;
    }

    /**
     * @param array<int, array<string, string>> $entries
     * @return array<int, string>
     */
    private function extractContactActorIds(array $entries): array
    {
        $ids = [];
        foreach ($entries as $entry) {
            $actor = strtoupper(trim((string) ($entry['actor'] ?? '')));
            if ($this->isContactActorId($actor)) {
                $ids[$actor] = true;
            }
        }

        return array_keys($ids);
    }

    private function isContactActorId(string $value): bool
    {
        return $value !== '' && preg_match('/^KT\d+$/', $value) === 1;
    }

    /**
     * @return array<string, string>
     */
    private function buildContactActorNameMapForDebitor(string $customerName): array
    {
        $customerName = trim($customerName);
        if ($customerName === '') {
            return [];
        }

        try {
            $contacts = $this->bcODataGateway->getContactsForCustomer('', $customerName);
        } catch (\Throwable) {
            return [];
        }

        $nameMap = [];
        foreach ($contacts as $contact) {
            $contactNo = strtoupper(trim((string) ($contact['No'] ?? '')));
            $contactName = trim((string) ($contact['Name'] ?? ''));
            if ($contactNo === '' || $contactName === '') {
                continue;
            }
            $nameMap[$contactNo] = $contactName;
        }

        return $nameMap;
    }

    /**
     * Resolve support user actor codes to full names for a single ticket.
     *
     * Strategy: use the pre-fetched ticket's Support_User_Name, then look at
     * which support-type Created_By codes appear in the log entries. If there
     * is exactly one unique support code, map it to the Support_User_Name.
     *
     * @param array<string, mixed>|null $ticket Pre-fetched ticket data
     * @param array<int, array<string, string>> $odataEntries
     * @param array<int, array<string, string>> $soapEntries
     * @return array<string, string>
     */
    private function resolveSupportActorNames(?array $ticket, array $odataEntries, array $soapEntries): array
    {
        $supportUserName = trim((string) ($ticket['Support_User_Name'] ?? ''));
        if ($supportUserName === '') {
            return [];
        }

        // Collect all unique support actor codes
        $codes = [];
        foreach ([...$odataEntries, ...$soapEntries] as $entry) {
            $actorRole = trim((string) ($entry['actor_role'] ?? ''));
            if ($actorRole !== 'support') {
                continue;
            }
            $actor = strtoupper(trim((string) ($entry['actor'] ?? '')));
            if ($actor !== '' && !$this->isContactActorId($actor) && mb_strlen($actor) <= 10) {
                $codes[$actor] = true;
            }
        }

        if ($codes === []) {
            return [];
        }

        // If only one support code exists, it must be the current user
        if (count($codes) === 1) {
            return [array_keys($codes)[0] => $supportUserName];
        }

        // Multiple codes (ticket was reassigned): find the code that appears
        // in the most recent entry — that's most likely the current assignee.
        // Only map that code; leave older codes as abbreviations.
        $latestCode = null;
        $latestTs = '';
        foreach ([...$odataEntries, ...$soapEntries] as $entry) {
            $actorRole = trim((string) ($entry['actor_role'] ?? ''));
            if ($actorRole !== 'support') {
                continue;
            }
            $actor = strtoupper(trim((string) ($entry['actor'] ?? '')));
            if (!isset($codes[$actor])) {
                continue;
            }
            $ts = trim((string) ($entry['timestamp_iso'] ?? ''));
            if ($ts > $latestTs) {
                $latestTs = $ts;
                $latestCode = $actor;
            }
        }

        if ($latestCode !== null) {
            return [$latestCode => $supportUserName];
        }

        return [];
    }

    /**
     * Build a support user code→name map from already-fetched communication entries.
     *
     * Groups entries by ticket_no, finds unique support-type actor codes per
     * ticket, and maps each code to the ticket's Support_User_Name (from the
     * pre-built $supportUserNamesByTicket lookup). No additional API calls.
     *
     * @param array<int, array<string, string>> $entries
     * @param array<string, string> $supportUserNamesByTicket ticket_no → Support_User_Name
     * @return array<string, string> code → full name
     */
    private function buildSupportActorNameMapFromEntries(array $entries, array $supportUserNamesByTicket): array
    {
        if ($entries === [] || $supportUserNamesByTicket === []) {
            return [];
        }

        // Group support actor codes per ticket with their latest timestamp.
        // For tickets with a single code, map it directly. For tickets with
        // multiple codes (reassigned), only map the most recent one.
        /** @var array<string, array<string, string>> $codesByTicket  ticket_no → [CODE → latest_ts] */
        $codesByTicket = [];
        foreach ($entries as $entry) {
            $actorRole = trim((string) ($entry['actor_role'] ?? ''));
            if ($actorRole !== 'support') {
                continue;
            }
            $actor = strtoupper(trim((string) ($entry['actor'] ?? '')));
            if ($actor === '' || $this->isContactActorId($actor) || mb_strlen($actor) > 10) {
                continue;
            }
            $ticketNo = trim((string) ($entry['ticket_no'] ?? ''));
            if ($ticketNo === '') {
                continue;
            }
            $ts = trim((string) ($entry['timestamp_iso'] ?? ''));
            if (!isset($codesByTicket[$ticketNo][$actor]) || $ts > $codesByTicket[$ticketNo][$actor]) {
                $codesByTicket[$ticketNo][$actor] = $ts;
            }
        }

        $result = [];
        foreach ($codesByTicket as $ticketNo => $codes) {
            $supportUserName = $supportUserNamesByTicket[$ticketNo] ?? '';
            if ($supportUserName === '') {
                continue;
            }

            if (count($codes) === 1) {
                $result[array_keys($codes)[0]] = $supportUserName;
                continue;
            }

            // Multiple codes: map only the most recent one
            $latestCode = null;
            $latestTs = '';
            foreach ($codes as $code => $ts) {
                if ($ts > $latestTs) {
                    $latestTs = $ts;
                    $latestCode = $code;
                }
            }
            if ($latestCode !== null && !isset($result[$latestCode])) {
                $result[$latestCode] = $supportUserName;
            }
        }

        return $result;
    }

    /**
     * Extract a filename from the message or record ID of a file-type log entry.
     */
    private function extractFilenameFromMessage(string $message, string $recordId): string
    {
        // Message itself may contain the filename
        $trimmed = trim($message);
        if ($trimmed !== '' && str_contains($trimmed, '.') && !str_contains($trimmed, ' ')) {
            return $trimmed;
        }

        // Record_ID sometimes contains the filename
        $recordTrimmed = trim($recordId);
        if ($recordTrimmed !== '' && str_contains($recordTrimmed, '.')) {
            // Extract last segment after colon or space that looks like a filename
            if (preg_match('/([^\s:]+\.\w{2,5})$/i', $recordTrimmed, $m)) {
                return $m[1];
            }
        }

        return $trimmed;
    }

    private function normalizeActorRole(string $createdByType, string $actor): string
    {
        $type = mb_strtolower(trim($createdByType));
        if ($type !== '') {
            if (
                str_contains($type, 'kontakt')
                || str_contains($type, 'contact')
                || str_contains($type, 'customer')
                || str_contains($type, 'debitor')
                || str_contains($type, 'client')
                || str_contains($type, 'external')
            ) {
                return 'customer';
            }
            if (
                str_contains($type, 'user')
                || str_contains($type, 'benutzer')
                || str_contains($type, 'support')
                || str_contains($type, 'agent')
                || str_contains($type, 'internal')
            ) {
                return 'support';
            }
        }

        $actorCode = strtoupper(trim($actor));
        if ($this->isContactActorId($actorCode)) {
            return 'customer';
        }

        return 'support';
    }
}
