<?php

declare(strict_types=1);

namespace App\Libraries;

use App\Models\CloudTalkConfigModel;
use RuntimeException;
use Throwable;

class CloudTalkService
{
    private string $apiKeyId = '';
    private string $apiKeySecret = '';
    private string $baseUrl = 'https://my.cloudtalk.io/api';
    private string $ciBaseUrl = 'https://api.cloudtalk.io/v1';

    public function __construct(?string $overrideApiKeyId = null, ?string $overrideApiKeySecret = null)
    {
        if ($overrideApiKeyId !== null && $overrideApiKeySecret !== null) {
            $this->apiKeyId     = (string) $overrideApiKeyId;
            $this->apiKeySecret = (string) $overrideApiKeySecret;
            return;
        }
        try {
            $cfg = new CloudTalkConfigModel();
            $this->apiKeyId     = (string) $cfg->getValue('api_key_id', '');
            $this->apiKeySecret = (string) $cfg->getValue('api_key_secret', '');
        } catch (Throwable $e) {
            $this->apiKeyId = '';
            $this->apiKeySecret = '';
        }
    }

    public function isConfigured(): bool
    {
        return $this->apiKeyId !== '' && $this->apiKeySecret !== '';
    }

    public function getCredentials(): array
    {
        return [
            'api_key_id'     => $this->apiKeyId,
            'api_key_secret' => $this->apiKeySecret,
        ];
    }

    public function testConnection(): array
    {
        if (!$this->isConfigured()) {
            return ['ok' => false, 'error' => 'CloudTalk API Key ID and Secret not configured yet.'];
        }

        $probePaths = ['/me', '/agents', '/numbers/index', '/numbers', '/contacts', '/users'];
        $lastErr = null;
        $probesTried = [];
        $anyPassed = false;

        foreach ($probePaths as $path) {
            try {
                $resp = $this->request('GET', $path);
                $probesTried[] = ['path' => $path, 'ok' => true, 'sample' => substr(json_encode($resp, JSON_UNESCAPED_UNICODE | JSON_UNESCAPED_SLASHES), 0, 140)];
                $anyPassed = true;
            } catch (Throwable $e) {
                $msg = $e->getMessage();
                $probesTried[] = ['path' => $path, 'ok' => false, 'error' => $msg];
                if (stripos($msg, '401') !== false || stripos($msg, '403') !== false) {
                    return ['ok' => false, 'error' => 'CloudTalk rejected credentials. ' . $msg . ' Probe endpoint: GET ' . $path . '.json. Ensure the API Key ID and Secret come from your CloudTalk v1 REST API keys page, not a personal token.'];
                }
                $lastErr = $msg;
            }
        }

        try {
            $numbers = $this->listNumbers();
            try {
                $agents = $this->listAgents();
            } catch (\Throwable $eAgents) {
                $agents = [];
                $agentsListError = $eAgents->getMessage();
            }
            return [
                'ok' => true,
                'message' => 'Connection successful. Found ' . count($numbers) . ' CloudTalk number(s) and ' . count($agents) . ' agent(s).'
                    . ($anyPassed ? ' Probes passed: ' . count(array_filter($probesTried, static fn($p) => !empty($p['ok']))) . '/' . count($probesTried) . ' v1 endpoints.' : ''),
                'numbers_count' => count($numbers),
                'agents_count'  => count($agents),
                'probes' => $probesTried,
                'numbers' => $numbers,
                'agents'  => $agents,
            ] + (isset($agentsListError) ? ['agents_list_error' => $agentsListError] : []);
        } catch (Throwable $e) {
            if ($anyPassed) {
                try {
                    $agents = $this->listAgents();
                } catch (\Throwable $eAgents) {
                    $agents = [];
                    $agentsListError = $eAgents->getMessage();
                }
                return [
                    'ok' => true,
                    'message' => 'Credentials valid (' . count(array_filter($probesTried, static fn($p) => !empty($p['ok']))) . '/' . count($probesTried) . ' v1 endpoints reachable). The /numbers.json list endpoint returned HTTP 404 for this account type — click-to-call will still work if you manually paste the Number ID. List-numbers error: ' . $e->getMessage(),
                    'numbers_count' => 0,
                    'agents_count'  => count($agents ?? []),
                    'probes' => $probesTried,
                    'numbers_list_error' => $e->getMessage(),
                    'agents'  => $agents ?? [],
                ] + (isset($agentsListError) ? ['agents_list_error' => $agentsListError] : []);
            }
            return [
                'ok' => false,
                'error' => ($lastErr !== null ? $lastErr : $e->getMessage())
                    . ' Attempted ' . count($probesTried) . ' CloudTalk v1 endpoint probes (' . implode(', ', array_map(static fn($p) => $p['path'] . '.json', $probePaths)) . '). If your CloudTalk plan uses a newer REST v2 / GraphQL API, you may need v1 REST credentials specifically.',
                'probes' => $probesTried,
            ];
        }
    }

    public function listNumbers(): array
    {
        $paths = [
            '/numbers/index',
            '/numbers',
            '/numbers/',
            '/dids',
            '/dids/',
            '/did_numbers',
            '/phone_numbers',
            '/phone_numbers/',
            '/available_numbers',
            '/available_numbers/',
            '/purchased_numbers',
            '/account_numbers',
            '/account/numbers',
            '/caller_ids',
            '/caller_ids/',
            '/outbound_numbers',
            '/outbound_numbers/',
            '/numbers/purchased',
            '/numbers/purchased/',
            '/numbers/active',
            '/numbers/index/',
            '/numbers/index/index/',
        ];
        $last404Url = null;
        $last404Body = null;
        $lastNon404Error = null;
        $lastNon404Body = null;
        $attemptedPaths = [];

        foreach ($paths as $path) {
            $attemptedPaths[] = $path;
            try {
                $respRaw = $this->request('GET', $path);
                $resp = $this->unwrapCloudTalkEnvelope($respRaw);
                $list = $this->pickBestArrayFromResponse($resp, ['number','phone','msisdn','e164','value','did','caller_id','number_value','display_number','international','national','caller_id_e164','internal_name']);
                if (!is_array($list) || $list === []) {
                    $list = $resp['data'] ?? $resp['content'] ?? [];
                    if (!is_array($list)) {
                        if (isset($resp['numbers']) && is_array($resp['numbers'])) {
                            $list = $resp['numbers'];
                        } elseif (isset($resp['items']) && is_array($resp['items'])) {
                            $list = $resp['items'];
                        } elseif (isset($resp['results']) && is_array($resp['results'])) {
                            $list = $resp['results'];
                        } elseif (isset($resp['CallNumbers']) && is_array($resp['CallNumbers'])) {
                            $list = $resp['CallNumbers'];
                        } else {
                            $list = [];
                        }
                    }
                }
                $normalized = [];
                foreach ($list as $row) {
                    $row = (array) $row;
                    $callNumber = null;
                    if (isset($row['CallNumber']) && is_array($row['CallNumber'])) {
                        $callNumber = (array) $row['CallNumber'];
                    } elseif (isset($row['Number']) && is_array($row['Number'])) {
                        $callNumber = (array) $row['Number'];
                    } elseif (isset($row['Did']) && is_array($row['Did'])) {
                        $callNumber = (array) $row['Did'];
                    }
                    $n = $callNumber !== null ? array_replace($row, $callNumber) + $callNumber : $row;
                    $numberCandidates = [
                        $n['caller_id_e164'] ?? null,
                        $n['number'] ?? null,
                        $n['value'] ?? null,
                        $n['phone_number'] ?? null,
                        $n['msisdn'] ?? null,
                        $n['e164'] ?? null,
                        $n['did_number'] ?? null,
                        $n['did'] ?? null,
                        $n['caller_id'] ?? null,
                        $n['number_value'] ?? null,
                        $n['display_number'] ?? null,
                        $n['international'] ?? null,
                        $n['national'] ?? null,
                        $n['formatted'] ?? null,
                        $n['id'] ?? '',
                    ];
                    $number = '';
                    foreach ($numberCandidates as $cand) {
                        if ($cand === null || $cand === '') continue;
                        $str = (string) $cand;
                        if ((($str !== '' && $str[0] === '+') || preg_match('/^[0-9\-\(\)\s]{7,}$/', $str))) {
                            if ($str !== '' && ctype_digit(str_replace(['+','-','(',')',' '], '', $str))) {
                                $number = $str;
                                break;
                            }
                        }
                    }
                    $idRaw = $n['id'] ?? $n['number_id'] ?? $n['nid'] ?? $n['did_id'] ?? $n['source_id'] ?? 0;
                    if ($idRaw === null || $idRaw === '') $idRaw = 0;
                    $nameRow = (string) ($n['internal_name'] ?? $n['name'] ?? $n['friendly_name'] ?? $n['display_name'] ?? $n['label'] ?? $n['description'] ?? $number);
                    if ($number === '' && (int)$idRaw <= 0) continue;
                    $normalized[] = [
                        'id'     => (int) $idRaw,
                        'number' => $number,
                        'name'   => $nameRow,
                        'type'   => (string) ($n['type'] ?? $n['kind'] ?? $n['category'] ?? $n['connected_to'] ?? 'unknown'),
                        'raw'    => $row,
                    ];
                }
                $normalized = array_values(array_filter($normalized, static fn($x) => $x['id'] > 0 || $x['number'] !== ''));
                if ($normalized !== []) {
                    return $normalized;
                }
            } catch (Throwable $e) {
                $msg = $e->getMessage();
                if (stripos($msg, '404') === false) {
                    $lastNon404Error = $msg;
                    if (preg_match('@(https?://[^\s]+)@i', $msg, $m)) {
                        $last404Url = $m[1];
                    }
                    $lastNon404Body = json_encode(['path' => $path, 'msg' => substr($msg, 0, 260)], JSON_UNESCAPED_UNICODE | JSON_UNESCAPED_SLASHES);
                    continue;
                }
                if (preg_match('@(https?://[^\s]+)@i', $msg, $m)) {
                    $last404Url = $m[1];
                }
                $last404Body = $msg;
                continue;
            }
        }

        if ($lastNon404Error !== null) {
            throw new RuntimeException(
                'CloudTalk listNumbers failed (non-404 error).'
                . ' Attempted ' . count($attemptedPaths) . ' endpoint variants: ' . implode(', ', $attemptedPaths)
                . '. Last error: ' . $lastNon404Error
                . ($lastNon404Body ? ' Raw context: ' . $lastNon404Body : '')
            );
        }

        throw new RuntimeException(
            'CloudTalk listNumbers failed (all endpoint variants returned HTTP 404 or zero matched rows).'
            . ' Tried: ' . implode(', ', $attemptedPaths) . '  → .json appended automatically.'
            . ($last404Url ? ' Last URL: ' . $last404Url : '')
            . ' Official docs endpoint GET /numbers/index.json. Common fixes: (1) ensure these are CloudTalk v1 REST credentials, not personal/v2 tokens.'
            . ' (2) check the account has at least one purchased number assigned (you have +1 Caller ID set, so it should).'
            . ' (3) click the Test Connection button — if that alert shows numbers then you can SELECT THEM DIRECTLY from the dropdown after Test Connection runs (dropdown auto-refreshes).'
            . ' (4) you can still paste the Number ID manually: open CloudTalk dashboard → Numbers → click the purchased number → URL contains /numbers/12345 → paste integer 12345 into this field.'
            . ($last404Body ? ' Last response snippet: ' . substr($last404Body, 0, 240) : '')
        );
    }

    public function listAgents(): array
    {
        $paths = ['/agents/index', '/agents/', '/agents', '/agents/index/', '/users/', '/users', '/users/index', '/users/index/'];
        $last404Url = null;
        $last404Body = null;

        foreach ($paths as $path) {
            try {
                $respRaw = $this->request('GET', $path);
                $resp = $this->unwrapCloudTalkEnvelope($respRaw);
                $list = $this->pickBestArrayFromResponse($resp, ['email','firstname','lastname','fullname','extension','agent_id','user_id','availability_status','status']);
                if (!is_array($list) || $list === []) {
                    $list = $resp['data'] ?? $resp['content'] ?? [];
                    if (!is_array($list)) {
                        if (isset($resp['agents']) && is_array($resp['agents'])) {
                            $list = $resp['agents'];
                        } elseif (isset($resp['users']) && is_array($resp['users'])) {
                            $list = $resp['users'];
                        } elseif (isset($resp['items']) && is_array($resp['items'])) {
                            $list = $resp['items'];
                        } elseif (isset($resp['results']) && is_array($resp['results'])) {
                            $list = $resp['results'];
                        } else {
                            $list = [];
                        }
                    }
                }
                $out = [];
                foreach ($list as $row) {
                    if (!is_array($row)) continue;
                    $agentObj = null;
                    if (isset($row['Agent']) && is_array($row['Agent'])) $agentObj = (array) $row['Agent'];
                    elseif (isset($row['User']) && is_array($row['User'])) $agentObj = (array) $row['User'];
                    $src = $agentObj !== null ? array_replace($row, $agentObj) + $agentObj : $row;
                    $id = (int) ($src['id'] ?? $src['agent_id'] ?? $src['user_id'] ?? 0);
                    $email = mb_strtolower(trim((string) ($src['email'] ?? $src['agent_email'] ?? $src['user_email'] ?? '')), 'UTF-8');
                    $first = trim((string) ($src['firstname'] ?? $src['first_name'] ?? ''));
                    $last  = trim((string) ($src['lastname'] ?? $src['last_name'] ?? ''));
                    $full  = trim((string) ($src['name'] ?? $src['full_name'] ?? $src['display_name'] ?? ''));
                    if ($full === '' && ($first !== '' || $last !== '')) $full = trim($first . ' ' . $last);
                    $status = mb_strtolower(trim((string) ($src['status'] ?? $src['agent_status'] ?? $src['availability_status'] ?? $src['availability'] ?? $src['state'] ?? '')), 'UTF-8');
                    $invite = mb_strtolower(trim((string) ($src['invite_status'] ?? $src['invitation'] ?? $src['status_invite'] ?? '')), 'UTF-8');
                    $callNumberId = (int) ($src['call_number_id'] ?? 0);
                    $defaultNumber = (string) ($src['default_number'] ?? $src['default_outbound_number'] ?? '');
                    $extension = (string) ($src['extension'] ?? '');
                    if ($id <= 0 && $email === '') continue;
                    $out[] = [
                        'id'              => $id,
                        'email'           => $email,
                        'name'            => $full,
                        'status'          => $status,
                        'invite_status'   => $invite,
                        'call_number_id'  => $callNumberId,
                        'default_number'  => $defaultNumber,
                        'extension'       => $extension,
                        'raw'             => $row,
                    ];
                }
                if ($out !== []) return $out;
            } catch (Throwable $e) {
                $msg = $e->getMessage();
                if (stripos($msg, '404') === false) {
                    throw $e;
                }
                if (preg_match('@(https?://[^\s]+)@i', $msg, $m)) {
                    $last404Url = $m[1];
                }
                $last404Body = $msg;
                continue;
            }
        }
        return [];
    }

    public function createOrGetContact(array $data): int
    {
        $phone = trim((string) ($data['phone'] ?? ''));
        $name  = trim((string) ($data['name'] ?? ''));
        $email = trim((string) ($data['email'] ?? ''));
        if ($phone === '') {
            throw new RuntimeException('Contact phone number is required.');
        }

        $payload = [
            'name'     => $name ?: ('Lead ' . substr(preg_replace('/\D+/', '', $phone), -6)),
            'phone_1'  => $phone,
            'email'    => $email,
            'company'  => (string) ($data['company'] ?? ''),
            'external_id' => (string) ($data['external_id'] ?? ''),
        ];

        try {
            $resp = $this->request('POST', '/contacts/', $payload);
            $id = (int) ($resp['id'] ?? $resp['data']['id'] ?? 0);
            if ($id > 0) return $id;
        } catch (Throwable $e) {
            if (stripos($e->getMessage(), '422') === false && stripos($e->getMessage(), '409') === false && stripos($e->getMessage(), '400') === false) {
                throw $e;
            }
        }

        try {
            $search = $this->request('GET', '/contacts/', ['q' => $phone, 'phone' => $phone]);
            $items = (array) ($search['data'] ?? $search['content'] ?? []);
            foreach ($items as $c) {
                $c = (array) $c;
                $phones = array_filter([
                    (string) ($c['phone_1'] ?? ''),
                    (string) ($c['phone_2'] ?? ''),
                    (string) ($c['phone_3'] ?? ''),
                ]);
                if (in_array($phone, $phones, true)) {
                    return (int) ($c['id'] ?? 0);
                }
            }
        } catch (Throwable $e2) {
        }
        return 0;
    }

    public function initiateCall(string $agentEmail, string $destinationNumber, array $opts = []): array
    {
        if (!$this->isConfigured()) {
            throw new RuntimeException('CloudTalk is not configured. Ask an admin to set the API Key ID and Secret.');
        }

        $agentEmail = trim($agentEmail);
        $destinationNumber = trim($destinationNumber);

        if ($agentEmail === '') {
            throw new RuntimeException('Agent email is empty. CloudTalk click-to-call requires a valid CloudTalk agent_email. Go to Admin > Agents, verify the assigned agent has a real CloudTalk email matching their CloudTalk dashboard invite (accept status = active), not a placeholder email like test@example.com.');
        }
        if (!filter_var($agentEmail, FILTER_VALIDATE_EMAIL)) {
            throw new RuntimeException('Agent email has invalid format: ' . $agentEmail . '. CloudTalk requires a valid email that matches an active invited agent in your CloudTalk workspace.');
        }
        if ($destinationNumber === '') {
            throw new RuntimeException('Destination phone number is empty.');
        }

        $normalizeE164 = static function (string $number): string {
            $digits = preg_replace('/\D+/', '', $number);
            if ($digits === '') return $number;
            if (strpos($number, '+') === 0) return '+' . $digits;
            if (strpos($digits, '00') === 0) return '+' . substr($digits, 2);
            if (strlen($digits) >= 10 && strlen($digits) <= 15) {
                if (strpos($digits, '63') === 0 && strlen($digits) === 12) return '+' . $digits;
                if (strpos($digits, '1') === 0  && strlen($digits) === 11) return '+' . $digits;
                if (strpos($digits, '44') === 0 && strlen($digits) === 12) return '+' . $digits;
                if (strpos($digits, '0') === 0) {
                    if (strlen($digits) === 11) return '+63' . substr($digits, 1);
                    return '+' . substr($digits, 1);
                }
                return '+' . $digits;
            }
            return $number;
        };
        $normalizedPhone = $normalizeE164($destinationNumber);

        $numberId   = (int) ($opts['number_id']    ?? 0);
        $contactId  = (int) ($opts['contact_id']   ?? 0);
        $externalIdRaw = trim((string) ($opts['external_id'] ?? ''));
        $fromNumber = trim((string) ($opts['from_number'] ?? ''));
        $externalIdInt = (is_numeric($externalIdRaw) && preg_match('/^\d+$/', $externalIdRaw)) ? (int) $externalIdRaw : null;

        $strContains7 = static function (string $haystack, string $needle): bool {
            if ($needle === '') return true;
            return strpos($haystack, $needle) !== false;
        };

        $probeAgent = null;
        $probeNumber = null;
        try {
            $agents = $this->listAgents();
            $needle = mb_strtolower($agentEmail, 'UTF-8');
            foreach ($agents as $a) {
                if ($a['email'] !== '' && $a['email'] === $needle) {
                    $probeAgent = $a;
                    break;
                }
            }
            if ($probeAgent === null) {
                foreach ($agents as $a) {
                    if ($a['email'] !== '' && $strContains7($a['email'], $needle)) {
                        $probeAgent = $a;
                        break;
                    }
                }
            }
        } catch (\Throwable $e) {
            $probeAgent = null;
        }

        if ($numberId <= 0) {
            try {
                $numbers = $this->listNumbers();
                if (is_array($numbers) && $numbers !== []) {
                    $first = reset($numbers);
                    if (is_array($first) && isset($first['id']) && (int)$first['id'] > 0) {
                        $numberId = (int)$first['id'];
                        $probeNumber = $first;
                    }
                }
            } catch (\Throwable $e) {
                $probeNumber = null;
            }
        }

        $agentIdInt = (int) (($probeAgent['id'] ?? 0) > 0 ? $probeAgent['id'] : 0);
        $statusHint = (string) ($probeAgent['status'] ?? 'unknown');
        $inviteHint = (string) ($probeAgent['invite_status'] ?? 'unknown');
        $agentNameHint = (string) ($probeAgent['name'] ?? $agentEmail);

        $agentUnavailable = false;
        $agentUnavailableReason = '';
        if ($probeAgent !== null) {
            $statusMatchOffline = false;
            if ($statusHint !== '') {
                $offlineKeywords = ['offline', 'away', 'busy', 'dnd', 'do_not_disturb', 'unavailable', 'logged_out', 'inactive'];
                foreach ($offlineKeywords as $k) {
                    if ($strContains7($statusHint, $k)) { $statusMatchOffline = true; break; }
                }
            }
            $inviteMatchBad = false;
            if ($inviteHint !== '') {
                $inviteFailed = ['pending', 'sent', 'invited', 'not_accepted', 'not_activated', 'expired', 'cancelled', 'revoked'];
                foreach ($inviteFailed as $k) {
                    if ($strContains7($inviteHint, $k)) { $inviteMatchBad = true; break; }
                }
            }
            if ($agentIdInt <= 0 && $statusHint === '' && $inviteHint === '') {
                $agentUnavailable = false;
            }
            if ($inviteMatchBad) {
                $agentUnavailable = false;
                $agentUnavailableReason = '';
            }
            if ($statusMatchOffline) {
                $agentUnavailable = false;
                $agentUnavailableReason = '';
            }
        }

        $candidatePaths = [
            '/calls/create',
            '/calls/click_to_call/',
            '/calls/click-to-call/',
            '/calls/clicktocall/',
            '/call/click_to_call/',
            '/calls/create/',
        ];
        $primaryPath = $candidatePaths[0];

        $buildCleanBase = function (array $overrides) use ($agentEmail, $agentIdInt, $normalizedPhone, $numberId, $contactId, $externalIdRaw, $externalIdInt, $fromNumber, $normalizeE164): array {
            $agentKey  = $overrides['agent_key']   ?? null;
            $agentIntKey = $overrides['agent_int_key'] ?? null;
            $toKey     = $overrides['to_key']      ?? 'to';
            $fromKey   = $overrides['from_key']    ?? 'from';
            $keepExtras = (bool) ($overrides['keep_extras'] ?? true);
            $externalAsInt = (bool) ($overrides['external_as_int'] ?? false);

            $payload = [
                $toKey    => $normalizedPhone,
            ];
            if ($agentKey !== null && $agentEmail !== '') {
                $payload[$agentKey] = $agentEmail;
            }
            if ($agentIntKey !== null && $agentIdInt > 0) {
                $payload[$agentIntKey] = $agentIdInt;
            }
            if ($keepExtras) {
                if ($numberId > 0)                 $payload['number_id']  = $numberId;
                if ($contactId > 0)                $payload['contact_id'] = $contactId;
                if ($externalIdRaw !== '') {
                    if ($externalAsInt && $externalIdInt !== null) {
                        $payload['external_id'] = $externalIdInt;
                    } else {
                        $payload['external_id'] = (string) $externalIdRaw;
                    }
                }
                if ($fromNumber !== '') {
                    $payload[$fromKey] = $normalizeE164($fromNumber);
                }
            }
            return $payload;
        };

        $fieldCombos = [];

        if ($agentIdInt > 0) {
            $fieldCombos[] = ['label' => 'OFFICIAL agent_id+callee_number (no extras, /calls/create.json)',  'fields' => $buildCleanBase(['agent_int_key' => 'agent_id', 'to_key' => 'callee_number', 'keep_extras' => false])];
            $fieldCombos[] = ['label' => 'OFFICIAL agent_id+callee_number (with number_id extras)',           'fields' => $buildCleanBase(['agent_int_key' => 'agent_id', 'to_key' => 'callee_number', 'keep_extras' => true])];
            $fieldCombos[] = ['label' => 'agent_id+to (ext STRING)',                                          'fields' => $buildCleanBase(['agent_int_key' => 'agent_id'])];
            $fieldCombos[] = ['label' => 'agent_id+to (no extras)',                                           'fields' => $buildCleanBase(['agent_int_key' => 'agent_id', 'keep_extras' => false])];
            $fieldCombos[] = ['label' => 'agent_id+agent_email+to (ext STRING)',                              'fields' => $buildCleanBase(['agent_key' => 'agent_email', 'agent_int_key' => 'agent_id'])];
            $fieldCombos[] = ['label' => 'agent_id+phone_number (ext STRING)',                                'fields' => $buildCleanBase(['agent_int_key' => 'agent_id', 'to_key' => 'phone_number'])];
        }
        $fieldCombos[] = ['label' => 'agent_email+number_id+to (all extras, ext STRING)',  'fields' => $buildCleanBase(['agent_key' => 'agent_email'])];
        $fieldCombos[] = ['label' => 'agent_email+to (all extras, ext STRING)',                'fields' => $buildCleanBase(['agent_key' => 'agent_email', 'keep_extras' => true])];
        $fieldCombos[] = ['label' => 'agent_email+to (no extras)',                             'fields' => $buildCleanBase(['agent_key' => 'agent_email', 'keep_extras' => false])];
        $fieldCombos[] = ['label' => 'agent_email+to (ext INT)',                               'fields' => $buildCleanBase(['agent_key' => 'agent_email', 'external_as_int' => true])];
        $fieldCombos[] = ['label' => 'agent_email+phone_number (ext STRING)',                  'fields' => $buildCleanBase(['agent_key' => 'agent_email', 'to_key' => 'phone_number'])];
        $fieldCombos[] = ['label' => 'agent_email+phone_number (no extras)',                   'fields' => $buildCleanBase(['agent_key' => 'agent_email', 'to_key' => 'phone_number', 'keep_extras' => false])];

        $fieldCombos[] = ['label' => 'email+to (LAST FALLBACK, ext STRING)',                   'fields' => $buildCleanBase(['agent_key' => 'email'])];
        $fieldCombos[] = ['label' => 'email+to (LAST FALLBACK, no extras)',                    'fields' => $buildCleanBase(['agent_key' => 'email', 'keep_extras' => false])];
        $fieldCombos[] = ['label' => 'email+phone_number (LAST FALLBACK, ext STRING)',         'fields' => $buildCleanBase(['agent_key' => 'email', 'to_key' => 'phone_number'])];
        $fieldCombos[] = ['label' => 'email+phone_number (LAST FALLBACK, no extras)',          'fields' => $buildCleanBase(['agent_key' => 'email', 'to_key' => 'phone_number', 'keep_extras' => false])];

        if ($fromNumber !== '') {
            $fieldCombos[] = ['label' => 'agent_email+to+from_number (ext STRING)',  'fields' => $buildCleanBase(['agent_key' => 'agent_email', 'from_key' => 'from_number'])];
            if ($agentIdInt > 0) {
                $fieldCombos[] = ['label' => 'agent_id+to+from_number (ext INT)', 'fields' => $buildCleanBase(['agent_int_key' => 'agent_id', 'from_key' => 'from_number', 'external_as_int' => true])];
            }
            $fieldCombos[] = ['label' => 'email+to+from_number (LAST FALLBACK, no extras)', 'fields' => $buildCleanBase(['agent_key' => 'email', 'from_key' => 'from_number', 'keep_extras' => false])];
        }

        $forceFlags = [
            ['flag' => null,                'suffix' => ''],
        ];
        if (count($fieldCombos) <= 10) {
            $forceFlags[] = ['flag' => false, 'suffix' => ' +b2bqueue_forcecall=false'];
            $forceFlags[] = ['flag' => true,  'suffix' => ' +b2bqueue_forcecall=true'];
        }

        $variants = [];
        foreach ($fieldCombos as $fc) {
            foreach ($candidatePaths as $pIdx => $path) {
                foreach ($forceFlags as $ff) {
                    if ($pIdx > 0 && $ff['suffix'] !== '') continue;
                    if ($pIdx > 1 && $path !== $primaryPath) continue;
                    $fields = $fc['fields'];
                    if ($ff['flag'] !== null) {
                        $fields['b2bqueue_forcecall'] = (bool) $ff['flag'];
                    }
                    $variants[] = [
                        'label' => $fc['label'] . $ff['suffix'] . ' path=' . $path,
                        'fields' => $fields,
                        'path'   => $path,
                    ];
                    if (count($variants) >= 20) break 3;
                }
            }
        }

        $uniqueVariants = [];
        $seen = [];
        foreach ($variants as $v) {
            $key = $v['path'] . '|' . json_encode($v['fields'], JSON_UNESCAPED_UNICODE);
            if (isset($seen[$key])) continue;
            $seen[$key] = true;
            $uniqueVariants[] = $v;
        }

        if ($agentUnavailable && false) {
            $probeInfo = ' Pre-call agent probe found this CloudTalk account: name=' . $agentNameHint
                . ', email=' . $agentEmail
                . ', status=' . ($statusHint ?: 'unknown')
                . ', invite=' . ($inviteHint ?: 'unknown')
                . ', agent_id=' . ($agentIdInt > 0 ? $agentIdInt : '(not returned by /agents.json)')
                . ', auto_number_id=' . ($numberId > 0 ? $numberId : '(none)')
                . '.';
            throw new RuntimeException($agentUnavailableReason . $probeInfo);
        }

        $lastErr = null;
        $lastResp = null;
        $tried = [];
        $bodyFormats = ['json', 'form', 'multipart'];
        $maxTotalAttempts = 24;
        $attemptCount = 0;
        $hitLimit = false;

        $breakAll = false;
        foreach ($uniqueVariants as $idx => $variant) {
            if ($breakAll) break;
            $payloadClean = array_filter(
                $variant['fields'],
                static function ($val, $key) {
                    if ($val === null || $val === '') return false;
                    if (is_int($val) && $val === 0 && in_array($key, ['number_id', 'contact_id'], true)) return false;
                    return true;
                },
                ARRAY_FILTER_USE_BOTH
            );
            $hasNumberKey = false;
            foreach (array_keys($payloadClean) as $k) {
                if (strtolower((string)$k) === 'number_id') { $hasNumberKey = true; break; }
            }
            $roundFormats = $bodyFormats;
            if (!$hasNumberKey && $numberId > 0) {
                $payloadWithNumber = $payloadClean;
                $payloadWithNumber['number_id'] = $numberId;
                $variantPayloads = [
                    ['label' => '', 'fields' => $payloadClean],
                    ['label' => ' +force_number_id=' . $numberId, 'fields' => $payloadWithNumber],
                ];
            } else {
                $variantPayloads = [
                    ['label' => '', 'fields' => $payloadClean],
                ];
            }
            foreach ($variantPayloads as $vPayloadIdx => $vPayload) {
                if ($breakAll) break;
                foreach ($roundFormats as $fmtIdx => $fmt) {
                    if ($breakAll) break;
                    $attemptCount++;
                    if ($attemptCount > $maxTotalAttempts) {
                        $hitLimit = true;
                        $breakAll = true;
                        break;
                    }
                    $vFields = $vPayload['fields'];
                    try {
                        $resp = $this->request('POST', $variant['path'], $vFields, [], $fmt);
                        $lastResp = $resp;
                        $tried[] = [
                            'variant'     => $idx + 1,
                            'subvariant'  => ($vPayloadIdx + 1) . '.' . ($fmtIdx + 1),
                            'path'        => $variant['path'],
                            'label'       => $variant['label'] . $vPayload['label'] . ' fmt=' . $fmt,
                            'fields'      => array_keys($vFields),
                            'body_format' => $fmt,
                            'ok'          => true,
                        ];
                        $breakAll = true;
                        break;
                    } catch (Throwable $e) {
                        $msg = $e->getMessage();
                        $tried[] = [
                            'variant'     => $idx + 1,
                            'subvariant'  => ($vPayloadIdx + 1) . '.' . ($fmtIdx + 1),
                            'path'        => $variant['path'],
                            'label'       => $variant['label'] . $vPayload['label'] . ' fmt=' . $fmt,
                            'fields'      => array_keys($vFields),
                            'body_format' => $fmt,
                            'error'       => $msg,
                        ];
                        if (stripos($msg, '401') !== false || stripos($msg, '403') !== false
                            || (stripos($msg, 'HTTP 404') === false && stripos($msg, 'HTTP 400') === false && stripos($msg, 'HTTP 422') === false)) {
                            $lastErr = $msg;
                            $breakAll = true;
                            break;
                        }
                        $lastErr = $msg;
                        continue;
                    }
                }
            }
        }

        if ($lastResp === null) {
            $lastLine = is_string($lastErr) ? $lastErr : 'No response from CloudTalk.';
            $summaries = [];
            foreach (array_slice($tried, 0, 8) as $t) {
                $firstErr = '';
                if (!empty($t['error']) && is_string($t['error'])) {
                    $short = $t['error'];
                    if (strpos($short, 'CloudTalk API HTTP') !== false) {
                        if (preg_match('/CloudTalk API HTTP \d+:\s*(.+?)(\s+Request:|$)/s', $short, $mm)) {
                            $firstErr = ' :: ' . trim($mm[1]);
                            if (mb_strlen($firstErr) > 110) $firstErr = mb_substr($firstErr, 0, 107) . '…';
                        }
                    }
                }
                $summaries[] = '#' . $t['variant'] . '.' . ($t['subvariant'] ?? '?') . ' ' . ($t['label'] ?? '') . $firstErr;
            }
            $probeInfo = ' 🔍 PRE-CALL PROBE DETAILS: agent_found_in_cloudtalk=' . ($probeAgent !== null ? 'YES' : 'NO')
                . ($probeAgent !== null
                    ? ', name=' . $agentNameHint . ', email=' . $agentEmail . ', status=' . ($statusHint ?: 'unknown') . ', invite=' . ($inviteHint ?: 'unknown') . ', agent_id=' . ($agentIdInt > 0 ? $agentIdInt : '(null)')
                    : ' (email ' . $agentEmail . ' not present in /agents.json response; confirm the CloudTalk user was created with this exact email, or accept their pending invite)')
                . ', number_id_auto_picked=' . ($numberId > 0 ? 'YES (' . $numberId . ')' : 'NO')
                . ($probeNumber !== null ? ' (name=' . ($probeNumber['name'] ?? $probeNumber['number'] ?? '?') . ' number=' . ($probeNumber['number'] ?? '?') . ')' : '')
                . '. Attempts=' . $attemptCount . ' (cap=' . $maxTotalAttempts . ($hitLimit ? ' → HIT LIMIT' : '') . ').';
            $attempts = ' Attempted variants+formats (' . count($tried) . ', first 8 with CloudTalk response snippets, formats attempted: ' . implode('/', $bodyFormats) . '): — ' . implode(' — ', $summaries) . ' — '
                . 'Each base field variant is retried in JSON body, then application/x-www-form-urlencoded, then multipart/form-data, plus a +force_number_id round if number_id was missing from that field-combo. '
                . 'If every variant still returns HTTP 400, the issue is 99% either (A) agent_email="' . $agentEmail . '" matches a CloudTalk user but their invitation is not yet accepted / invite_status=pending (PRE-CALL PROBE above checks this), OR (B) the calling Number is not assigned to this agent seat — in CloudTalk dashboard confirm the purchased DID is assigned to agent "Martin Joseph Andal" under Users → Permissions / Groups / Number Routing.'
                . $probeInfo;
            throw new RuntimeException('Failed to initiate CloudTalk call after ' . count($tried) . ' retries. Last error: ' . $lastLine . $attempts);
        }

        $callId = (string) ($lastResp['responseData']['id'] ?? $lastResp['id'] ?? $lastResp['responseData']['call_id'] ?? $lastResp['call_id'] ?? $lastResp['data']['id'] ?? $lastResp['data']['call_id'] ?? '');
        $status = (string) ($lastResp['responseData']['status'] ?? $lastResp['status'] ?? $lastResp['data']['status'] ?? 'initiated');
        $recordingHint = (string) ($lastResp['responseData']['recording_link'] ?? $lastResp['recording_link'] ?? '');
        return [
            'ok'                => true,
            'cloudtalk_call_id' => $callId,
            'status'            => $status,
            'tried_variants'    => $tried,
            'normalized_phone'  => $normalizedPhone,
            'raw'               => $lastResp,
            'recording_link'    => $recordingHint,
            'agent_probe'       => $probeAgent !== null ? [
                'agent_id'      => $agentIdInt,
                'name'          => $agentNameHint,
                'email'         => $agentEmail,
                'status'        => $statusHint,
                'invite_status' => $inviteHint,
                'number_id'     => $numberId > 0 ? $numberId : null,
                'number_name'   => $probeNumber !== null ? ($probeNumber['name'] ?? null) : null,
            ] : null,
        ];
    }

    public function listCalls(array $opts = []): array
    {
        if (!$this->isConfigured()) {
            throw new RuntimeException('CloudTalk is not configured.');
        }

        $query = [];
        if (!empty($opts['public_external'])) $query['public_external'] = (string) $opts['public_external'];
        if (!empty($opts['public_internal'])) $query['public_internal'] = (string) $opts['public_internal'];
        if (!empty($opts['contact_id']))      $query['contact_id']      = (int) $opts['contact_id'];
        if (!empty($opts['user_id']))         $query['user_id']         = (int) $opts['user_id'];
        if (!empty($opts['agent_extension'])) $query['agent_extension'] = (int) $opts['agent_extension'];
        if (!empty($opts['call_id']))         $query['call_id']         = (int) $opts['call_id'];
        if (!empty($opts['type']) && in_array((string)$opts['type'], ['incoming', 'outgoing', 'internal'], true)) {
            $query['type'] = (string) $opts['type'];
        }
        if (!empty($opts['status']) && in_array((string)$opts['status'], ['missed', 'answered'], true)) {
            $query['status'] = (string) $opts['status'];
        }
        if (!empty($opts['date_from']))       $query['date_from'] = (string) $opts['date_from'];
        if (!empty($opts['date_to']))         $query['date_to']   = (string) $opts['date_to'];
        $query['limit'] = isset($opts['limit']) ? max(1, min(1000, (int)$opts['limit'])) : 500;
        $query['page']  = isset($opts['page'])  ? max(1, (int)$opts['page']) : 1;

        $paths = ['/calls/index'];
        $lastErr = null;
        $lastList = [];
        foreach ($paths as $path) {
            try {
                $raw = $this->request('GET', $path, [], $query, 'json');
                $resp = $this->unwrapCloudTalkEnvelope($raw);
                $candidates = $resp['data'] ?? $resp;
                $isList = static fn(array $arr): bool => $arr === [] || array_keys($arr) === range(0, count($arr) - 1);
                if (is_array($candidates) && ($isList($candidates) || (!empty($candidates) && is_array($candidates[0] ?? null)))) {
                    $arr = $candidates;
                } else {
                    $arr = $this->pickBestArrayFromResponse($resp, ['id','public_external','public_internal','billsec','talking_time','started_at','ended_at','recording_link','user_id','type','status','contact_id','call_id','cdr_id']);
                }
                $out = [];
                foreach ($arr as $row) {
                    if (!is_array($row)) continue;
                    $cdr = $row;
                    if (isset($row['Cdr']) && is_array($row['Cdr'])) $cdr = $row['Cdr'];
                    elseif (isset($row['Call']) && is_array($row['Call'])) $cdr = $row['Call'];
                    elseif (isset($row['call']) && is_array($row['call'])) $cdr = $row['call'];
                    $contact = null;
                    if (isset($row['Contact']) && is_array($row['Contact'])) $contact = $row['Contact'];
                    elseif (isset($row['contact']) && is_array($row['contact'])) $contact = $row['contact'];

                    $idRaw = $cdr['id'] ?? $cdr['call_id'] ?? $cdr['cdr_id'] ?? null;
                    $callId = $idRaw === null ? '' : (string) $idRaw;
                    $recLink = (string) ($cdr['recording_link'] ?? $cdr['recording_url'] ?? '');
                    $external = (string) ($cdr['public_external'] ?? $cdr['source_number'] ?? $cdr['from'] ?? $cdr['caller_number'] ?? '');
                    $internal = (string) ($cdr['public_internal'] ?? $cdr['destination'] ?? $cdr['to'] ?? $cdr['destination_number'] ?? '');
                    $userId = (int) ($cdr['user_id'] ?? $cdr['agent_id'] ?? 0);
                    $billsec = (int) ($cdr['billsec'] ?? $cdr['total_duration'] ?? $cdr['duration'] ?? 0);
                    $talk = (int) ($cdr['talking_time'] ?? $cdr['talk_time'] ?? $cdr['billsec'] ?? 0);
                    $wrapup = (int) ($cdr['wrapup_time'] ?? $cdr['wrap_up_time'] ?? 0);
                    $ctype = strtr(strtolower((string)($cdr['type'] ?? 'outgoing')), ['outgoing' => 'outbound', 'incoming' => 'inbound']);
                    if (!in_array($ctype, ['outbound', 'inbound', 'internal'], true)) $ctype = 'outbound';
                    $started = (string) ($cdr['started_at'] ?? $cdr['start_time'] ?? $cdr['t_started'] ?? $cdr['date'] ?? '');
                    $answered = (string) ($cdr['answered_at'] ?? $cdr['answer_time'] ?? $cdr['t_answered'] ?? '');
                    $ended = (string) ($cdr['ended_at'] ?? $cdr['end_time'] ?? $cdr['t_ended'] ?? '');
                    $status = (string) ($cdr['status'] ?? '');
                    if ($status === '') {
                        $status = ((int)($cdr['waiting_time'] ?? 0) > 0 && $talk <= 0) ? 'missed'
                            : ($talk > 0 ? 'answered' : ($ended !== '' ? 'ended' : 'initiated'));
                    }

                    $out[] = [
                        'cloudtalk_call_id'    => $callId,
                        'cloudtalk_contact_id' => $contact !== null ? (string) ($contact['id'] ?? '') : '',
                        'contact_name'         => $contact !== null ? (string) ($contact['name'] ?? '') : '',
                        'user_id'              => $userId,
                        'direction'            => $ctype,
                        'caller_number'        => in_array($ctype, ['outbound', 'internal'], true) ? $internal : $external,
                        'destination_number'   => in_array($ctype, ['outbound', 'internal'], true) ? $external : $internal,
                        'status'               => $status,
                        'started_at'           => $started,
                        'answered_at'          => $answered,
                        'ended_at'             => $ended,
                        'duration_seconds'     => $billsec,
                        'talk_time_seconds'    => $talk,
                        'wrap_up_time_seconds' => $wrapup,
                        'recorded'             => !empty($cdr['recorded']) || $recLink !== '',
                        'recording_link'       => $recLink,
                        'raw'                  => $row,
                    ];
                }
                return [
                    'items_count' => (int) ($resp['itemsCount'] ?? $resp['items_count'] ?? count($out)),
                    'page_count'  => (int) ($resp['pageCount']  ?? $resp['page_count']  ?? 1),
                    'page_number' => (int) ($resp['pageNumber'] ?? $resp['page_number'] ?? 1),
                    'limit'       => (int) ($resp['limit'] ?? $query['limit']),
                    'calls'       => $out,
                ];
            } catch (Throwable $e) {
                $lastErr = $e->getMessage();
            }
        }

        throw new RuntimeException('Failed to list CloudTalk calls. Last error: ' . (string)$lastErr . '. Official endpoint GET /calls/index.json. Either API Key does not have "Calls read" permissions, or no calls match the filters.');
    }

    public function getCallRecordingDownloadUrl(string $callId): string
    {
        $cidInt = (int) $callId;
        if ($cidInt > 0) {
            return rtrim($this->baseUrl, '/') . '/calls/recording/' . $cidInt . '.json';
        }
        return '';
    }

    public function getRecordingStreamInfo(string $callId): array
    {
        $cidInt = (int) $callId;
        if ($cidInt <= 0) {
            return ['url' => '', 'basic_auth' => '', 'api_key_id' => ''];
        }
        $url = rtrim($this->baseUrl, '/') . '/calls/recording/' . $cidInt . '.json';
        $authHeader = $this->apiKeyId !== '' && $this->apiKeySecret !== ''
            ? 'Authorization: Basic ' . base64_encode($this->apiKeyId . ':' . $this->apiKeySecret)
            : '';
        return [
            'url'          => $url,
            'basic_auth'   => $authHeader,
            'api_key_id'   => $this->apiKeyId,
        ];
    }

    public function getCall(string $callId): array
    {
        $paths = ['/calls/' . urlencode($callId), '/calls/index'];
        $lastErr = null;
        foreach ($paths as $pIdx => $path) {
            try {
                $query = [];
                if ($pIdx === 1) {
                    $query['call_id'] = (int) $callId;
                    $query['limit'] = 1;
                }
                $raw = $this->request('GET', $path, [], $query, 'json');
                return $raw;
            } catch (Throwable $e) {
                $lastErr = $e->getMessage();
            }
        }
        throw new RuntimeException('Failed to fetch CloudTalk call ' . $callId . '. Last error: ' . (string)$lastErr);
    }

    public function getCallRecording(string $callId): array
    {
        try {
            $paths = [
                '/calls/recording/' . urlencode($callId),
                '/calls/' . urlencode($callId) . '/recording',
            ];
            $lastErr = null;
            foreach ($paths as $path) {
                try {
                    $resp = $this->request('GET', $path);
                    if (is_array($resp)) {
                        $url = (string) ($resp['responseData']['url'] ?? $resp['url'] ?? $resp['responseData']['recording_url'] ?? $resp['recording_url'] ?? $resp['data']['url'] ?? '');
                        if ($url === '') {
                            $maybeBest = $this->pickBestArrayFromResponse($resp, ['url','recording_url','download_url','file_size','mime_type','duration']);
                            if (!empty($maybeBest[0])) {
                                $f = $maybeBest[0];
                                $url = (string) ($f['url'] ?? $f['recording_url'] ?? $f['download_url'] ?? '');
                            }
                        }
                        return ['exists' => $url !== '', 'url' => $url, 'raw' => $resp];
                    }
                } catch (Throwable $e) {
                    if (stripos($e->getMessage(), '404') === false) {
                        $lastErr = $e->getMessage();
                    }
                }
            }
            if ($lastErr !== null) {
                throw new RuntimeException($lastErr);
            }
            return ['exists' => false, 'url' => null];
        } catch (Throwable $e) {
            if (stripos($e->getMessage(), '404') !== false) {
                return ['exists' => false, 'url' => null];
            }
            throw $e;
        }
    }

    public function getCallTranscript(string $callId): array
    {
        $t = $this->getAiTranscription($callId);
        return [
            'exists'   => (bool) ($t['exists'] ?? false),
            'text'     => (string) ($t['text'] ?? ''),
            'segments' => $t['segments'] ?? [],
            'language' => (string) ($t['language'] ?? ''),
            'raw'      => $t['raw'] ?? null,
        ];
    }

    public function verifyWebhookSignature(string $rawBody, string $receivedSignature, string $secret): bool
    {
        if ($secret === '' || $receivedSignature === '') return true;
        $expected = hash_hmac('sha256', $rawBody, $secret);
        return hash_equals($expected, $receivedSignature);
    }

    private function pickBestArrayFromResponse($resp, array $candidateKeys): array
    {
        if (!is_array($resp)) return [];
        if ($resp === []) return [];

        $arrayIsList = static function (array $arr): bool {
            if ($arr === []) return true;
            return array_keys($arr) === range(0, count($arr) - 1);
        };
        $strContains = static function (string $haystack, string $needle): bool {
            if ($needle === '') return true;
            return strpos($haystack, $needle) !== false;
        };

        $isListItem = static function ($entry) use ($candidateKeys, $strContains) {
            if (!is_array($entry)) return false;
            $entryFlat = [];
            array_walk_recursive($entry, static function ($v, $k) use (&$entryFlat) {
                if (is_string($k) && is_string($v)) $entryFlat[mb_strtolower($k)] = $v;
                elseif (is_string($k) && is_numeric($v)) $entryFlat[mb_strtolower($k)] = (string)$v;
            });
            foreach ($candidateKeys as $ck) {
                $ckL = mb_strtolower($ck);
                foreach ($entryFlat as $k => $v) {
                    if ($strContains($k, $ckL) && $v !== '') return true;
                }
            }
            return false;
        };

        $candidates = [];

        $queue = [$resp];
        while (($cur = array_shift($queue)) !== null) {
            if (!is_array($cur)) continue;
            if ($arrayIsList($cur)) {
                $score = 0;
                foreach ($cur as $elem) {
                    if ($isListItem($elem)) $score++;
                }
                if ($score > 0) $candidates[] = [$score, $cur];
            }
            foreach ($cur as $v) {
                if (is_array($v)) $queue[] = $v;
            }
        }

        if ($candidates === []) return [];
        usort($candidates, static fn($a, $b) => $b[0] <=> $a[0]);
        return $candidates[0][1];
    }

    private function unwrapCloudTalkEnvelope($resp): array
    {
        if (!is_array($resp)) return [];
        if (isset($resp['responseData']) && is_array($resp['responseData'])) {
            return $resp['responseData'];
        }
        if (isset($resp['response_data']) && is_array($resp['response_data'])) {
            return $resp['response_data'];
        }
        if (isset($resp['data']) && is_array($resp['data'])) {
            return $resp['data'];
        }
        return $resp;
    }

    private function request(string $method, string $path, array $body = [], array $query = [], string $bodyFormat = 'json'): array
    {
        if (!$this->isConfigured()) {
            throw new RuntimeException('CloudTalk API credentials not configured.');
        }

        $cleanPath = trim($path, '/');
        if ($cleanPath !== '' && stripos($cleanPath, '.json') === false) {
            $qsPos = strpos($cleanPath, '?');
            if ($qsPos !== false) {
                $cleanPath = substr($cleanPath, 0, $qsPos) . '.json' . substr($cleanPath, $qsPos);
            } else {
                $cleanPath .= '.json';
            }
        }

        $url = rtrim($this->baseUrl, '/') . '/' . $cleanPath;
        if (!empty($query)) {
            $url .= (strpos($url, '?') === false ? '?' : '&') . http_build_query($query);
        }

        $methodUpper = strtoupper($method);
        $isWriteMethod = in_array($methodUpper, ['POST', 'PUT', 'PATCH'], true);

        $ch = curl_init($url);
        if ($ch === false) {
            throw new RuntimeException('curl_init() failed.');
        }

        $auth = $this->apiKeyId . ':' . $this->apiKeySecret;
        $headers = [
            'Accept: application/json, */*',
            'Authorization: Basic ' . base64_encode($auth),
        ];

        $bodyFormat = strtolower(trim($bodyFormat));
        if ($bodyFormat === '' || !in_array($bodyFormat, ['json', 'form', 'multipart', 'xml'], true)) $bodyFormat = 'json';

        if (in_array(strtoupper($method), ['POST', 'PUT', 'PATCH'], true)) {
            if ($bodyFormat === 'json') {
                $payload = json_encode($body, JSON_UNESCAPED_UNICODE | JSON_UNESCAPED_SLASHES);
                if ($payload === false) $payload = '';
                curl_setopt($ch, CURLOPT_POSTFIELDS, $payload);
                $headers[] = 'Content-Type: application/json';
            } elseif ($bodyFormat === 'form') {
                $payload = http_build_query($body, '', '&');
                curl_setopt($ch, CURLOPT_POSTFIELDS, $payload);
                $headers[] = 'Content-Type: application/x-www-form-urlencoded';
            } elseif ($bodyFormat === 'multipart') {
                $flat = [];
                foreach ($body as $k => $v) {
                    if (is_array($v)) {
                        foreach ($v as $subKey => $subVal) {
                            if (is_scalar($subVal)) $flat[(string)$k . '[' . (string)$subKey . ']'] = (string)$subVal;
                        }
                    } elseif (is_scalar($v) || $v === null) {
                        $flat[$k] = $v === null ? '' : (string)$v;
                    }
                }
                curl_setopt($ch, CURLOPT_POSTFIELDS, $flat);
            } elseif ($bodyFormat === 'xml') {
                $xml = '<?xml version="1.0" encoding="UTF-8"?>' . "\n" . '<request>';
                $xmlWalk = static function ($val, $depth = 0) use (&$xmlWalk) {
                    if (is_array($val)) {
                        $out = '';
                        foreach ($val as $k => $v) {
                            $tag = is_string($k) ? preg_replace('/[^A-Za-z0-9_-]/', '', $k) : 'item';
                            if ($tag === '') $tag = 'item';
                            $out .= '<' . $tag . '>' . $xmlWalk($v, $depth + 1) . '</' . $tag . '>';
                        }
                        return $out;
                    }
                    return htmlspecialchars((string) $val, ENT_XML1 | ENT_QUOTES, 'UTF-8');
                };
                $payload = $xml . $xmlWalk($body) . '</request>';
                curl_setopt($ch, CURLOPT_POSTFIELDS, $payload);
                $headers[] = 'Content-Type: application/xml';
            }
        } elseif (!empty($body)) {
            $url .= (strpos($url, '?') === false ? '?' : '&') . http_build_query($body);
            curl_setopt($ch, CURLOPT_URL, $url);
        }

        curl_setopt($ch, CURLOPT_CUSTOMREQUEST, strtoupper($method));
        curl_setopt($ch, CURLOPT_RETURNTRANSFER, true);
        curl_setopt($ch, CURLOPT_TIMEOUT, 30);
        curl_setopt($ch, CURLOPT_HTTPHEADER, $headers);
        curl_setopt($ch, CURLOPT_FOLLOWLOCATION, true);
        curl_setopt($ch, CURLOPT_SSL_VERIFYPEER, true);
        curl_setopt($ch, CURLOPT_SSL_VERIFYHOST, 2);

        $respBody  = curl_exec($ch);
        $httpCode  = curl_getinfo($ch, CURLINFO_HTTP_CODE);
        $curlError = curl_error($ch);
        curl_close($ch);

        if ($respBody === false) {
            throw new RuntimeException('CloudTalk curl error: ' . $curlError);
        }

        $data = json_decode((string) $respBody, true);
        if (!is_array($data)) {
            $data = ['raw_response' => (string) $respBody];
        }
        $httpErrorFromBody = null;
        if (isset($data['responseData']) && is_array($data['responseData'])) {
            $appStatus = null;
            $appMsg = null;
            if (isset($data['responseData']['status']) && is_numeric($data['responseData']['status'])) {
                $appStatus = (int)$data['responseData']['status'];
            }
            if (isset($data['responseData']['message']) && is_scalar($data['responseData']['message'])) {
                $appMsg = (string)$data['responseData']['message'];
            } elseif (isset($data['responseData']['error']) && is_scalar($data['responseData']['error'])) {
                $appMsg = (string)$data['responseData']['error'];
            }
            if ($appStatus !== null && $appStatus >= 400) {
                if ($httpCode < 400 || $httpCode === 0) {
                    $httpErrorFromBody = [
                        'status' => $appStatus,
                        'message' => $appMsg !== null && trim($appMsg) !== '' ? $appMsg : 'Application-level status=' . $appStatus,
                    ];
                }
            }
        }

        $maskKey = static function (string $v): string {
            $v = trim($v);
            if ($v === '') return '';
            if (strlen($v) <= 8) return '***';
            return substr($v, 0, 4) . '…' . substr($v, -3);
        };
        $dumpPayload = static function (array $b) use ($maskKey): string {
            $out = [];
            foreach ($b as $k => $v) {
                if (is_array($v)) $out[$k] = '[array ' . count($v) . ']';
                elseif (is_string($v)) {
                    $lk = strtolower((string) $k);
                    if (strpos($lk, 'secret') !== false || strpos($lk, 'password') !== false || strpos($lk, 'token') !== false || strpos($lk, 'api_') !== false || strpos($lk, 'auth') !== false) {
                        $out[$k] = $maskKey($v);
                    } elseif (strlen($v) > 90) $out[$k] = substr($v, 0, 87) . '…';
                    else $out[$k] = $v;
                }
                else $out[$k] = var_export($v, true);
            }
            return json_encode($out, JSON_UNESCAPED_UNICODE | JSON_UNESCAPED_SLASHES);
        };

        if ($httpErrorFromBody !== null) {
            $msgs = [];
            $msgs[] = (string) ($httpErrorFromBody['message'] ?? ('Application-level HTTP ' . $httpErrorFromBody['status']));
            if (!empty($data['message']) && is_scalar($data['message'])) $msgs[] = (string) $data['message'];
            if (!empty($data['error']) && is_scalar($data['error']))   $msgs[] = (string) $data['error'];
            $rawMsg = $msgs !== [] ? implode(' | ', array_unique($msgs)) : 'HTTP ' . ($httpErrorFromBody['status'] ?? 999);
            $appCode = (int) ($httpErrorFromBody['status'] ?? $httpCode);
            $urlSafe = (string) $url;
            $reqTail  = '  Request: ' . $methodUpper . ' ' . $urlSafe . '.';
            if ($isWriteMethod && $body !== []) {
                $reqTail .= '  Payload: ' . $dumpPayload($body) . '.';
            }
            if ($appCode === 400) {
                $hint = '  CloudTalk application-level HTTP 400 = malformed fields. Refer to CloudTalk v1 docs swagger: POST /calls/create.json requires agent_id (INTEGER, not email) + callee_number (E.164 string). Use integer agent_id from /agents/index.json id column, not email.';
                throw new RuntimeException('CloudTalk API HTTP 400: ' . $rawMsg . $reqTail . $hint);
            }
            if ($appCode === 403) {
                $hint = '  403 message from CloudTalk. Official doc says 403 = Agent is not online. Confirm the logged-in CloudTalk desktop user (Martin Andal) has their extension status green/online in CloudTalk app. The CloudTalk "agent online" presence is needed for click-to-call to route the call leg.';
                throw new RuntimeException('CloudTalk API HTTP 403: ' . $rawMsg . $reqTail . $hint);
            }
            if ($appCode === 404) {
                $hint = '  Application-level 404. If initiating a call, verify agent_id belongs to a real CloudTalk agent (not email).';
                throw new RuntimeException('CloudTalk API HTTP 404: ' . $rawMsg . $reqTail . $hint);
            }
            if ($appCode === 406) {
                $hint = '  406 Invalid input. Official docs: required agent_id (integer) + callee_number (string E.164). Number IDs are integers from /numbers/index.json.';
                throw new RuntimeException('CloudTalk API HTTP 406: ' . $rawMsg . $reqTail . $hint);
            }
            if ($appCode === 409) {
                $hint = '  409 message: Agent is already calling. Wait for any active call to finish.';
                throw new RuntimeException('CloudTalk API HTTP 409: ' . $rawMsg . $reqTail . $hint);
            }
            throw new RuntimeException('CloudTalk API HTTP ' . $appCode . ': ' . $rawMsg . $reqTail);
        }

        if ($httpCode >= 400) {
            $msgs = [];
            if (!empty($data['message']) && is_scalar($data['message'])) $msgs[] = (string) $data['message'];
            if (!empty($data['error']) && is_scalar($data['error']))   $msgs[] = (string) $data['error'];
            if (!empty($data['error_description']) && is_scalar($data['error_description'])) $msgs[] = (string) $data['error_description'];
            if (!empty($data['errors'])) {
                if (is_array($data['errors'])) {
                    $flattened = [];
                    array_walk_recursive($data['errors'], static function ($v, $k) use (&$flattened) {
                        if (is_scalar($v) && trim((string) $v) !== '') {
                            $flattened[] = (string) $k . ': ' . (string) $v;
                        }
                    });
                    if ($flattened !== []) $msgs[] = implode('; ', array_slice($flattened, 0, 10));
                } elseif (is_scalar($data['errors'])) {
                    $msgs[] = (string) $data['errors'];
                }
            }
            if (!empty($data['details']) && is_scalar($data['details'])) $msgs[] = (string) $data['details'];
            if (isset($data['errors']['title']) && is_scalar($data['errors']['title'])) $msgs[] = (string) $data['errors']['title'];
            $rawMsg = $msgs !== [] ? implode(' | ', $msgs) : 'HTTP ' . $httpCode;

            $urlSafe = (string) $url;
            $reqTail  = '  Request: ' . $methodUpper . ' ' . $urlSafe . '.';
            if ($isWriteMethod && $body !== []) {
                $reqTail .= '  Payload: ' . $dumpPayload($body) . '.';
            }

            if ($httpCode === 400) {
                $hint = '  CloudTalk HTTP 400 = malformed request fields. Common fixes:'
                    . ' (1) "agent_email" must belong to an ACTIVE CloudTalk agent / invite accepted, not the admin dashboard email (test first by calling /agents.json to find a real agent email).'
                    . ' (2) "number_id" — if set, use integer ID from /numbers.json (the column `id`, not the phone string). If you did not paste a Number ID in settings, leave it blank (it is optional).'
                    . ' (3) "to" number must be E.164 format with + and country code (e.g. +639061235120), no hyphens/spaces inside the digits.'
                    . ' (4) Some CloudTalk accounts use alternate keys: "email" instead of "agent_email", "phone_number" instead of "to", "from_number" instead of "from". The initiateCall helper now auto-retries these variants when 400 hits.';
                throw new RuntimeException('CloudTalk API HTTP ' . $httpCode . ': ' . $rawMsg . $reqTail . $hint);
            }
            if ($httpCode === 404) {
                $hint = ' Common CloudTalk 404 causes: (1) API expects an /index.json suffix variant like /numbers/index.json instead of /numbers.json.'
                    . ' (2) Wrong credential type — use CloudTalk v1 REST API keys (look for "v1 REST" in your dashboard), not Personal Tokens or v2 API keys.'
                    . ' (3) Wrong account region — some CloudTalk accounts use eu-west-1 or different base URLs.'
                    . ' If the message below contains "Accepting only .json requests", that is a v1 suffix bug and the /RESOURCE.json URL pattern is correct for the account.';
                throw new RuntimeException('CloudTalk API HTTP ' . $httpCode . ': ' . $rawMsg . $reqTail . $hint);
            }
            if ($httpCode === 401) {
                $hint = ' Verify: (1) API Key ID is used as the Basic Auth username.'
                    . ' (2) API Key Secret is used as the Basic Auth password.'
                    . ' (3) The key pair comes from the CloudTalk dashboard > Settings > API keys page and is labelled REST v1.'
                    . ' (4) Neither field has leading/trailing whitespace.';
                throw new RuntimeException('CloudTalk API HTTP ' . $httpCode . ': ' . $rawMsg . $reqTail . $hint);
            }
            if ($httpCode === 422) {
                $hint = '  422 Unprocessable = specific field failed validation. Inspect the Payload above vs CloudTalk v1 docs. If 422 mentions a missing required field, paste that field-name into initiateCall() payload.';
                throw new RuntimeException('CloudTalk API HTTP ' . $httpCode . ': ' . $rawMsg . $reqTail . $hint);
            }
            $msg = 'CloudTalk API HTTP ' . $httpCode . ': ' . $rawMsg . $reqTail;
            if (isset($data['raw_response']) && is_string($data['raw_response']) && $data['raw_response'] !== '') {
                $msg .= '  Raw response start: ' . substr($data['raw_response'], 0, 180);
            }
            throw new RuntimeException($msg);
        }

        return $data;
    }

    private function requestCi(string $method, string $path, array $query = [], int $timeoutSeconds = 0): array
    {
        if (!$this->isConfigured()) {
            throw new RuntimeException('CloudTalk API credentials not configured.');
        }
        if ($timeoutSeconds <= 0) $timeoutSeconds = 60;

        $cleanPath = '/' . ltrim($path, '/');
        $url = rtrim($this->ciBaseUrl, '/') . $cleanPath;
        if (!empty($query)) {
            $url .= (strpos($url, '?') === false ? '?' : '&') . http_build_query($query);
        }

        $ch = curl_init($url);
        if ($ch === false) {
            throw new RuntimeException('curl_init() failed for Conversation Intelligence endpoint.');
        }

        $headers = [
            'Accept: application/json, */*',
            'Authorization: Basic ' . base64_encode($this->apiKeyId . ':' . $this->apiKeySecret),
        ];

        curl_setopt($ch, CURLOPT_CUSTOMREQUEST, strtoupper($method));
        curl_setopt($ch, CURLOPT_RETURNTRANSFER, true);
        curl_setopt($ch, CURLOPT_TIMEOUT, $timeoutSeconds);
        curl_setopt($ch, CURLOPT_CONNECTTIMEOUT, 15);
        curl_setopt($ch, CURLOPT_HTTPHEADER, $headers);
        curl_setopt($ch, CURLOPT_FOLLOWLOCATION, true);
        curl_setopt($ch, CURLOPT_SSL_VERIFYPEER, true);
        curl_setopt($ch, CURLOPT_SSL_VERIFYHOST, 2);

        $respBody  = curl_exec($ch);
        $httpCode  = curl_getinfo($ch, CURLINFO_HTTP_CODE);
        $curlError = curl_error($ch);
        curl_close($ch);

        if ($respBody === false) {
            throw new RuntimeException('CloudTalk CI curl error: ' . $curlError);
        }

        $data = json_decode((string)$respBody, true);
        if (!is_array($data)) {
            $data = ['raw_response' => (string)$respBody];
        }

        if ($httpCode === 404) {
            throw new RuntimeException('CloudTalk CI HTTP 404: endpoint ' . $url . ' — data not generated yet or transcription disabled.');
        }
        if ($httpCode >= 400) {
            $msgs = [];
            if (!empty($data['message']) && is_scalar($data['message'])) $msgs[] = (string)$data['message'];
            if (!empty($data['error']) && is_scalar($data['error'])) $msgs[] = (string)$data['error'];
            if (!empty($data['errors'])) {
                $flat = [];
                array_walk_recursive($data['errors'], static function ($v, $k) use (&$flat) {
                    if (is_scalar($v) && trim((string)$v) !== '') $flat[] = (string)$k . ': ' . $v;
                });
                if ($flat !== []) $msgs[] = implode('; ', $flat);
            }
            $rawMsg = $msgs !== [] ? implode(' | ', $msgs) : 'HTTP ' . $httpCode;
            throw new RuntimeException('CloudTalk CI HTTP ' . $httpCode . ': ' . $rawMsg . ' URL: ' . $url);
        }
        return $data;
    }

    public function getAiTranscription(string $callId, int $limit = 1000, int $offset = 0): array
    {
        try {
            $resp = $this->requestCi('GET', '/ai/calls/' . urlencode((string)$callId) . '/transcription', [
                'limit'  => $limit,
                'offset' => $offset,
            ], 120);
        } catch (Throwable $e) {
            if (stripos($e->getMessage(), '404') !== false) {
                return ['exists' => false, 'call_id' => $callId, 'segments' => [], 'text' => '', 'language' => '', 'callers' => [], 'raw' => null];
            }
            throw $e;
        }
        $data = $resp['data'] ?? $resp;
        $segmentsRaw = $data['segments'] ?? $resp['segments'] ?? [];
        if (!is_array($segmentsRaw)) $segmentsRaw = [];
        $callers = $data['callers'] ?? $resp['callers'] ?? [];
        $language = (string) ($data['language'] ?? $resp['language'] ?? '');
        $callerMap = [];
        if (is_array($callers)) {
            foreach ($callers as $c) {
                if (is_array($c) && isset($c['localIdentifier']) && isset($c['type'])) {
                    $callerMap[(string)$c['localIdentifier']] = ['id' => $c['id'] ?? null, 'type' => (string)$c['type']];
                }
            }
        }
        $text = '';
        $segments = [];
        foreach ($segmentsRaw as $s) {
            if (!is_array($s)) continue;
            $start = (float) ($s['start'] ?? 0);
            $end   = (float) ($s['end'] ?? 0);
            $callerId = (string) ($s['caller'] ?? '');
            $line    = trim((string) ($s['text'] ?? ''));
            if ($line === '') continue;
            $speaker = $callerMap[$callerId]['type'] ?? $callerId;
            if ($speaker === 'agent')        $speakerLabel = 'Agent';
            elseif ($speaker === 'contact')  $speakerLabel = 'Contact';
            else                             $speakerLabel = ucfirst((string)$speaker);
            $segments[] = [
                'start'     => $start,
                'end'       => $end,
                'speaker'   => $speakerLabel,
                'speaker_type' => $speaker,
                'caller_id' => $callerId,
                'text'      => $line,
            ];
            $text .= $speakerLabel . ': ' . $line . "\n";
        }
        return [
            'exists'   => $segments !== [],
            'call_id'  => $callId,
            'segments' => $segments,
            'callers'  => $callers,
            'language' => $language,
            'text'     => trim($text),
            'raw'      => $resp,
        ];
    }

    public function getAiSummary(string $callId): array
    {
        try {
            $resp = $this->requestCi('GET', '/ai/calls/' . urlencode((string)$callId) . '/summary', [], 60);
        } catch (Throwable $e) {
            if (stripos($e->getMessage(), '404') !== false) {
                return ['exists' => false, 'call_id' => $callId, 'summary' => null, 'raw' => null];
            }
            throw $e;
        }
        $summary = (string) ($resp['summary'] ?? $resp['responseData']['summary'] ?? $resp['data']['summary'] ?? '');
        return [
            'exists'  => $summary !== '',
            'call_id' => $callId,
            'summary' => $summary,
            'raw'     => $resp,
        ];
    }

    public function getAiOverallSentiment(string $callId): array
    {
        try {
            $resp = $this->requestCi('GET', '/ai/calls/' . urlencode((string)$callId) . '/overall-sentiment', [], 30);
        } catch (Throwable $e) {
            if (stripos($e->getMessage(), '404') !== false) {
                return ['exists' => false, 'call_id' => $callId, 'overall_sentiment' => null, 'raw' => null];
            }
            throw $e;
        }
        $sentiment = (string) ($resp['overallSentiment'] ?? $resp['overall_sentiment'] ?? $resp['responseData']['overallSentiment'] ?? $resp['data']['overallSentiment'] ?? '');
        $score = 0;
        switch (mb_strtolower(trim($sentiment))) {
            case 'very positive': $score = 100; break;
            case 'positive':      $score = 75;  break;
            case 'neutral':       $score = 50;  break;
            case 'negative':      $score = 25;  break;
            case 'very negative': $score = 0;   break;
        }
        return [
            'exists'            => $sentiment !== '',
            'call_id'           => $callId,
            'overall_sentiment' => $sentiment,
            'sentiment_score'   => $score,
            'raw'               => $resp,
        ];
    }

    public function getAiTopics(string $callId): array
    {
        try {
            $resp = $this->requestCi('GET', '/ai/calls/' . urlencode((string)$callId) . '/topics', [], 30);
        } catch (Throwable $e) {
            if (stripos($e->getMessage(), '404') !== false) {
                return ['exists' => false, 'call_id' => $callId, 'topics' => [], 'raw' => null];
            }
            throw $e;
        }
        $topics = $resp['topics'] ?? $resp['responseData']['topics'] ?? $resp['data']['topics'] ?? null;
        if (!is_array($topics)) $topics = [];
        return [
            'exists'  => $topics !== [],
            'call_id' => $callId,
            'topics'  => $topics,
            'raw'     => $resp,
        ];
    }

    public function getAiTalkListenRatio(string $callId): array
    {
        try {
            $resp = $this->requestCi('GET', '/ai/calls/' . urlencode((string)$callId) . '/talk-listen-ratio', [], 30);
        } catch (Throwable $e) {
            if (stripos($e->getMessage(), '404') !== false) {
                return ['exists' => false, 'call_id' => $callId, 'talk_listen_ratio' => [], 'callers' => [], 'raw' => null];
            }
            throw $e;
        }
        $ratio = $resp['talkListenRatio'] ?? $resp['talk_listen_ratio'] ?? $resp['responseData']['talkListenRatio'] ?? $resp['data']['talkListenRatio'] ?? null;
        $callers = $resp['callers'] ?? $resp['responseData']['callers'] ?? $resp['data']['callers'] ?? [];
        if (!is_array($ratio)) $ratio = [];
        return [
            'exists'            => $ratio !== [],
            'call_id'           => $callId,
            'talk_listen_ratio' => $ratio,
            'callers'           => is_array($callers) ? $callers : [],
            'raw'               => $resp,
        ];
    }

    public function getAiSmartNotes(string $callId): array
    {
        try {
            $resp = $this->requestCi('GET', '/ai/calls/' . urlencode((string)$callId) . '/smart-notes', [], 60);
        } catch (Throwable $e) {
            if (stripos($e->getMessage(), '404') !== false) {
                return ['exists' => false, 'call_id' => $callId, 'notes' => [], 'raw' => null];
            }
            throw $e;
        }
        $notes = $resp['notes'] ?? $resp['smartNotes'] ?? $resp['smart_notes'] ?? $resp['responseData']['notes'] ?? $resp['data']['notes'] ?? null;
        if (!is_array($notes)) $notes = [];
        return [
            'exists'  => $notes !== [],
            'call_id' => $callId,
            'notes'   => $notes,
            'raw'     => $resp,
        ];
    }

    public function getAiBundle(string $callId): array
    {
        $out = [
            'call_id'  => $callId,
            'transcription' => null,
            'summary'       => null,
            'sentiment'     => null,
            'topics'        => null,
            'talk_listen'   => null,
            'smart_notes'   => null,
            'errors'        => [],
        ];
        try { $out['transcription'] = $this->getAiTranscription($callId); } catch (Throwable $e) { $out['errors']['transcription'] = $e->getMessage(); }
        try { $out['summary']       = $this->getAiSummary($callId); }       catch (Throwable $e) { $out['errors']['summary']       = $e->getMessage(); }
        try { $out['sentiment']     = $this->getAiOverallSentiment($callId); } catch (Throwable $e) { $out['errors']['sentiment']  = $e->getMessage(); }
        try { $out['topics']        = $this->getAiTopics($callId); }         catch (Throwable $e) { $out['errors']['topics']        = $e->getMessage(); }
        try { $out['talk_listen']   = $this->getAiTalkListenRatio($callId); } catch (Throwable $e) { $out['errors']['talk_listen'] = $e->getMessage(); }
        try { $out['smart_notes']   = $this->getAiSmartNotes($callId); }     catch (Throwable $e) { $out['errors']['smart_notes']   = $e->getMessage(); }
        return $out;
    }
}
