<?php

namespace classes;

require_once WD . '/interfaces/bird_i.php';
require_once WD . '/modules/bird/bird_c.php';
require_once WD . '/modules/bird/classes/bird_api_client.php';
require_once WD . '/modules/bird/classes/bird_voice_calls_client.php';
require_once WD . '/modules/bird/classes/bird_voice_recordings_client.php';
require_once WD . '/modules/bird/classes/bird_voice_insights_client.php';
require_once WD . '/modules/bird/classes/bird_flash_calls_client.php';


use bird\bird_c;
use bird\classes\bird_flash_calls_client;
use bird\classes\bird_voice_calls_client;
use bird\classes\bird_voice_insights_client;
use bird\classes\bird_voice_recordings_client;
use Exception;
use interfaces\bird_i;
use objects\logs_o;

class bird implements bird_i
{
    public const TEST_OUTBOUND_NUMBER_RAW = '+45 42 33 11 28';
    public const TEST_OUTBOUND_NUMBER_E164 = '+4542331128';
    public const OUTGOING_NUMBER_E164 = '+4532330288';
    public const OUTGOING_NUMBER_RAW = '+45 32 33 02 88';

    public const OUTGOING_NUMBER = '+4532330288';

    private const ALLOWED_HANGUP_CAUSES = ['rejected', 'busy'];
    private const ACCEPTED_CALL_STATUSES = ['accepted', 'ongoing'];
    private const TERMINAL_GATE_FAILURE_STATUSES = ['rejected', 'busy', 'failed', 'cancelled', 'no-answer', 'completed'];
    private const FLASH_GATE_SUCCESS_STATUSES = ['accepted', 'ongoing', 'completed'];
    private const FLASH_GATE_FAILURE_STATUSES = ['rejected', 'busy', 'failed', 'cancelled', 'no-answer'];

    /**
     * Configuration of the Bird module
     * @var bird_c
     */
    public bird_c $config;

    private ?bird_voice_calls_client $voice_calls_client = null;
    private ?bird_voice_recordings_client $voice_recordings_client = null;
    private ?bird_voice_insights_client $voice_insights_client = null;
    private ?bird_flash_calls_client $flash_calls_client = null;

    public function __construct()
    {
        $this->config = new bird_c();
    }

    private function voiceCallsClient(): bird_voice_calls_client
    {
        if ($this->voice_calls_client === null) {
            $this->voice_calls_client = new bird_voice_calls_client($this);
        }
        return $this->voice_calls_client;
    }

    private function voiceRecordingsClient(): bird_voice_recordings_client
    {
        if ($this->voice_recordings_client === null) {
            $this->voice_recordings_client = new bird_voice_recordings_client($this);
        }
        return $this->voice_recordings_client;
    }

    private function voiceInsightsClient(): bird_voice_insights_client
    {
        if ($this->voice_insights_client === null) {
            $this->voice_insights_client = new bird_voice_insights_client($this);
        }
        return $this->voice_insights_client;
    }

    private function flashCallsClient(): bird_flash_calls_client
    {
        if ($this->flash_calls_client === null) {
            $this->flash_calls_client = new bird_flash_calls_client($this);
        }
        return $this->flash_calls_client;
    }

    /**
     * Ensure module is enabled
     * @throws Exception
     */
    function requireModuleEnabled(): void
    {
        if (!$this->config->enabled->isTrue()) {
            throw new Exception('The bird module is not enabled');
        }
    }

    /**
     * Ensure API key is present
     * @throws Exception
     */
    function requireValidApiKey(): void
    {
        $k = $this->config->api_key->getVariableValue();
        if ($k === null || $k === '') {
            throw new Exception('Invalid API key defined in the config (bird_api_key_c)');
        }
    }

    /**
     * Ensure base URL is defined
     * @throws Exception
     */
    function requireValidServerURL(): void
    {
        $u = $this->config->server_url->getVariableValue();
        if ($u === null || $u === '') {
            throw new Exception('Invalid server URL defined in the config (bird_server_url_c)');
        }
    }

    /**
     * Send a request to Bird API
     * @param string $endpoint e.g. "/v1/devices"
     * @param array $data request body
     * @param string $method HTTP method (currently only POST supported)
     * @return object|array|null
     * @throws Exception
     */
    function sendRequest(string $endpoint, array $data = [], string $method = 'POST'): object|array|null
    {
        $this->requireModuleEnabled();
        $this->requireValidApiKey();
        $this->requireValidServerURL();
        return match (strtoupper($method)) {
            'POST' => $this->sendPostRequest($endpoint, $data),
            'GET'  => $this->sendGetRequest($endpoint, $data),
            'PATCH' => $this->sendPatchRequest($endpoint, $data),
            'DELETE' => $this->sendDeleteRequest($endpoint, $data),
            default => throw new Exception('Invalid request method'),
        };
    }

    /**
     * Send POST request
     * @param string $endpoint
     * @param array $data
     * @return array|object|null
     * @throws Exception
     */
    function sendPostRequest(string $endpoint, array $data): array|object|null
    {
        $this->requireModuleEnabled();
        $this->requireValidApiKey();
        $this->requireValidServerURL();

        $url = rtrim($this->config->server_url->getVariableValue(), '/') . $endpoint;
        $headers = [
            'Content-Type: application/json',
            $this->buildAuthorizationHeader(),
        ];
        $body = $this->encodeJsonBody($data);

        $this->logBirdAction(
            'BIRD_HTTP_REQUEST',
            'method=POST endpoint=' . $endpoint . ' payload_keys=' . implode(',', array_keys($data))
        );
        $result = $this->doHttpRequest('POST', $url, $headers, $body);
        $status = (int)($result['status_code'] ?? 0);
        $response = $result['body'] ?? '';
        $this->logBirdAction('BIRD_HTTP_RESPONSE', 'method=POST endpoint=' . $endpoint . ' status=' . $status);
        if ($status >= 400) {
            $this->logBirdAction('BIRD_HTTP_ERROR', 'method=POST endpoint=' . $endpoint . ' status=' . $status, 0);
            throw new Exception($this->buildHttpErrorMessage($status, $response));
        }
        if ($response === '' || $response === false || $response === null) {
            return null;
        }
        $decoded = json_decode($response, true);
        return $decoded ?? $response;
    }

    /**
     * Low-level HTTP transport (curl). Tests can override this to stub network.
     * @param string $method
     * @param string $url
     * @param array $headers
     * @param string $body
     * @return array{status_code:int, body:string|false}
     * @throws Exception
     */
    protected function doHttpRequest(string $method, string $url, array $headers, string $body = ''): array
    {
        $ch = curl_init();
        curl_setopt($ch, CURLOPT_URL, $url);
        curl_setopt($ch, CURLOPT_RETURNTRANSFER, true);
        curl_setopt($ch, CURLOPT_CONNECTTIMEOUT, 10);
        curl_setopt($ch, CURLOPT_TIMEOUT, 40);
        $upper = strtoupper($method);
        if ($upper === 'POST') {
            curl_setopt($ch, CURLOPT_POST, true);
            curl_setopt($ch, CURLOPT_POSTFIELDS, $body);
        } else {
            curl_setopt($ch, CURLOPT_CUSTOMREQUEST, $upper);
        }
        curl_setopt($ch, CURLOPT_HTTPHEADER, $headers);
        $resp = curl_exec($ch);
        $code = curl_getinfo($ch, CURLINFO_HTTP_CODE);
        if (curl_errno($ch)) {
            $err = curl_error($ch);
            curl_close($ch);
            $this->logBirdAction('BIRD_HTTP_CURL_ERROR', 'method=' . $upper . ' url=' . $url . ' error=' . $err, 0);
            throw new Exception('cURL error: ' . $err);
        }
        curl_close($ch);
        // Debug slack
        $data = json_decode($body, true) ?? [];
        $resp = $resp === false ? 'cURL error with no response' : $resp;
        $slack_debug_message = "*Bird API Request Debug:*"
            . "\nEndpoint: $url"
            . "\nMethod: $method"
            . "\nStatus: $code"
            . "\nPayload Keys: " . implode(',', array_keys($data))
            . "\nResponse: $resp";

        // Send slack notification for every request for easier debugging of issues in production (can be removed later if too noisy)
        $slack = new \classes\slack();
        $slack->send_message($slack_debug_message);
        return [
            'status_code' => (int)$code,
            'body' => $resp,
        ];
    }

    /**
     * Send GET request
     * @param string $endpoint
     * @param array $query
     * @return array|object|null
     * @throws Exception
     */
    function sendGetRequest(string $endpoint, array $query = []): array|object|null
    {
        $this->requireModuleEnabled();
        $this->requireValidApiKey();
        $this->requireValidServerURL();

        $base = rtrim($this->config->server_url->getVariableValue(), '/');
        $url = $base . $endpoint;
        if (!empty($query)) {
            $qs = http_build_query($query);
            $url .= (str_contains($url, '?') ? '&' : '?') . $qs;
        }
        $headers = [
            'Content-Type: application/json',
            $this->buildAuthorizationHeader(),
        ];
        $this->logBirdAction(
            'BIRD_HTTP_REQUEST',
            'method=GET endpoint=' . $endpoint . ' query_keys=' . implode(',', array_keys($query))
        );
        $result = $this->doHttpRequest('GET', $url, $headers);
        $status = (int)($result['status_code'] ?? 0);
        $response = $result['body'] ?? '';
        $this->logBirdAction('BIRD_HTTP_RESPONSE', 'method=GET endpoint=' . $endpoint . ' status=' . $status);
        if ($status >= 400) {
            $this->logBirdAction('BIRD_HTTP_ERROR', 'method=GET endpoint=' . $endpoint . ' status=' . $status, 0);
            throw new Exception($this->buildHttpErrorMessage($status, $response));
        }
        if ($response === '' || $response === false || $response === null) {
            return null;
        }
        $decoded = json_decode($response, true);
        return $decoded ?? $response;
    }

    function sendPatchRequest(string $endpoint, array $data = []): array|object|null
    {
        $this->requireModuleEnabled();
        $this->requireValidApiKey();
        $this->requireValidServerURL();

        $url = rtrim($this->config->server_url->getVariableValue(), '/') . $endpoint;
        $headers = [
            'Content-Type: application/json',
            $this->buildAuthorizationHeader(),
        ];
        $body = $this->encodeJsonBody($data);

        $this->logBirdAction(
            'BIRD_HTTP_REQUEST',
            'method=PATCH endpoint=' . $endpoint . ' payload_keys=' . implode(',', array_keys($data))
        );
        $result = $this->doHttpRequest('PATCH', $url, $headers, $body);
        $status = (int)($result['status_code'] ?? 0);
        $response = $result['body'] ?? '';
        $this->logBirdAction('BIRD_HTTP_RESPONSE', 'method=PATCH endpoint=' . $endpoint . ' status=' . $status);
        if ($status >= 400) {
            $this->logBirdAction('BIRD_HTTP_ERROR', 'method=PATCH endpoint=' . $endpoint . ' status=' . $status, 0);
            throw new Exception($this->buildHttpErrorMessage($status, $response));
        }
        if ($response === '' || $response === false || $response === null) {
            return null;
        }
        $decoded = json_decode($response, true);
        return $decoded ?? $response;
    }

    function sendDeleteRequest(string $endpoint, array $query = []): array|object|null
    {
        $this->requireModuleEnabled();
        $this->requireValidApiKey();
        $this->requireValidServerURL();

        $base = rtrim($this->config->server_url->getVariableValue(), '/');
        $url = $base . $endpoint;
        if (!empty($query)) {
            $qs = http_build_query($query);
            $url .= (str_contains($url, '?') ? '&' : '?') . $qs;
        }
        $headers = [
            'Content-Type: application/json',
            $this->buildAuthorizationHeader(),
        ];
        $this->logBirdAction(
            'BIRD_HTTP_REQUEST',
            'method=DELETE endpoint=' . $endpoint . ' query_keys=' . implode(',', array_keys($query))
        );
        $result = $this->doHttpRequest('DELETE', $url, $headers);
        $status = (int)($result['status_code'] ?? 0);
        $response = $result['body'] ?? '';
        $this->logBirdAction('BIRD_HTTP_RESPONSE', 'method=DELETE endpoint=' . $endpoint . ' status=' . $status);
        if ($status >= 400) {
            $this->logBirdAction('BIRD_HTTP_ERROR', 'method=DELETE endpoint=' . $endpoint . ' status=' . $status, 0);
            throw new Exception($this->buildHttpErrorMessage($status, $response));
        }
        if ($response === '' || $response === false || $response === null) {
            return null;
        }
        $decoded = json_decode($response, true);
        return $decoded ?? $response;
    }

    public function createVoiceCall(string $workspaceId, string $channelId, array $payload): array|object|null
    {
        $this->logBirdAction('BIRD_VOICE_CALL_CREATE', 'workspace=' . $workspaceId . ' channel=' . $channelId);
        return $this->voiceCallsClient()->createVoiceCall($workspaceId, $channelId, $payload);
    }

    public function listVoiceCalls(string $workspaceId, string $channelId, array $query = []): array|object|null
    {
        $this->logBirdAction('BIRD_VOICE_CALL_LIST', 'workspace=' . $workspaceId . ' channel=' . $channelId);
        return $this->voiceCallsClient()->listVoiceCalls($workspaceId, $channelId, $query);
    }

    public function getVoiceCall(string $workspaceId, string $channelId, string $callId): array|object|null
    {
        $this->logBirdAction('BIRD_VOICE_CALL_GET', 'workspace=' . $workspaceId . ' channel=' . $channelId . ' call=' . $callId);
        return $this->voiceCallsClient()->getVoiceCall($workspaceId, $channelId, $callId);
    }

    public function updateVoiceCall(string $workspaceId, string $channelId, string $callId, array $payload): array|object|null
    {
        $this->logBirdAction('BIRD_VOICE_CALL_UPDATE', 'workspace=' . $workspaceId . ' channel=' . $channelId . ' call=' . $callId);
        return $this->voiceCallsClient()->updateVoiceCall($workspaceId, $channelId, $callId, $payload);
    }

    public function answerVoiceCall(string $workspaceId, string $channelId, string $callId, array $payload = []): array|object|null
    {
        $this->logBirdAction('BIRD_VOICE_CALL_ANSWER', 'workspace=' . $workspaceId . ' channel=' . $channelId . ' call=' . $callId);
        return $this->voiceCallsClient()->answerVoiceCall($workspaceId, $channelId, $callId, $payload);
    }

    public function ringVoiceCall(string $workspaceId, string $channelId, string $callId, array $payload = []): array|object|null
    {
        $this->logBirdAction('BIRD_VOICE_CALL_RINGING', 'workspace=' . $workspaceId . ' channel=' . $channelId . ' call=' . $callId);
        return $this->voiceCallsClient()->ringVoiceCall($workspaceId, $channelId, $callId, $payload);
    }

    public function hangupVoiceCall(string $workspaceId, string $channelId, string $callId, array $payload = []): array|object|null
    {
        $this->logBirdAction('BIRD_VOICE_CALL_HANGUP', 'workspace=' . $workspaceId . ' channel=' . $channelId . ' call=' . $callId);
        return $this->voiceCallsClient()->hangupVoiceCall($workspaceId, $channelId, $callId, $payload);
    }

    public function playbackVoiceCall(string $workspaceId, string $channelId, string $callId, array $payload): array|object|null
    {
        $this->logBirdAction('BIRD_VOICE_CALL_PLAYBACK', 'workspace=' . $workspaceId . ' channel=' . $channelId . ' call=' . $callId);
        return $this->voiceCallsClient()->playbackVoiceCall($workspaceId, $channelId, $callId, $payload);
    }

    public function sayMessage(string $workspaceId, string $channelId, string $callId, array $payload): array|object|null
    {
        $this->logBirdAction('BIRD_VOICE_CALL_SAY', 'workspace=' . $workspaceId . ' channel=' . $channelId . ' call=' . $callId);
        /**
         * Example payload:
         * https://api.bird.com/workspaces/{workspaceId}/channels/{channelId}/calls/{callId}/say
         * {
         * "text": "text",
         * "locale": "en-US",
         * "voice": "text",
         * "loop": 1,
         * "timeout": 1
         * }
         */
        if (!isset($payload['text']) || !is_string($payload['text']) || trim($payload['text']) === '') {
            throw new Exception('The "text" field is required in the payload and must be a non-empty string');
        }
        $tmp = [
            // Default values
            'locale' => 'da-DK',
            'voice' => 'text',
            'loop' => 1,
            'timeout' => 1,
            ...$payload,
        ];
        return $this->voiceCallsClient()->sayVoiceCall($workspaceId, $channelId, $callId, $tmp);
    }

    public function gatherMessage(string $workspaceId, string $channelId, string $callId, array $payload): array|object|null
    {
        $this->logBirdAction('BIRD_VOICE_CALL_GATHER', 'workspace=' . $workspaceId . ' channel=' . $channelId . ' call=' . $callId);
        /**
         * Example payload:
         * https://api.bird.com/workspaces/{workspaceId}/channels/{channelId}/calls/{callId}/gather
         * {
         * "maxNumKeys": 1,
         * "endKey": "0",
         * "timeout": 1,
         * "retries": 1,
         * "input": "dtmf",
         * "speechLocale": "en-US",
         * "playback": {
         * "media": [ "text" ],
         * "loop": 1,
         * "timeout": 1,
         * "pauseMilliseconds": 1
         * },
         * "say": {
         * "text": "text",
         * "locale": "en-US",
         * "voice": "text",
         * "loop": 1,
         * "timeout": 1
         * }
         * }
         */
        /**
         * Example response:
         * {
         * "id": "123e4567-e89b-12d3-a456-426614174000",
         * "callId": "123e4567-e89b-12d3-a456-426614174000",
         * "callFlowId": "123e4567-e89b-12d3-a456-426614174000",
         * "status": "text",
         * "command": "text",
         * "conditions": [
         * {
         * "variable": "keys",
         * "operator": "eq",
         * "value": "text"
         * }
         * ]
         * }
         */
        $tmp = [
            // Default values
            'input' => 'dtmf',
            ...$payload,
        ];
        return $this->voiceCallsClient()->gatherVoiceCall($workspaceId, $channelId, $callId, $tmp);
    }

    public function bridgeVoiceCall(string $workspaceId, string $channelId, string $callId, array $payload): array|object|null
    {
        $this->logBirdAction('BIRD_VOICE_CALL_BRIDGE', 'workspace=' . $workspaceId . ' channel=' . $channelId . ' call=' . $callId);
        return $this->voiceCallsClient()->bridgeVoiceCall($workspaceId, $channelId, $callId, $payload);
    }

    public function recordVoiceCall(string $workspaceId, string $channelId, string $callId, array $payload): array|object|null
    {
        $this->logBirdAction('BIRD_VOICE_CALL_RECORD', 'workspace=' . $workspaceId . ' channel=' . $channelId . ' call=' . $callId);
        return $this->voiceCallsClient()->recordVoiceCall($workspaceId, $channelId, $callId, $payload);
    }

    public function createVoiceCallRecordingSession(string $workspaceId, string $channelId, string $callId, array $payload): array|object|null
    {
        $this->logBirdAction('BIRD_VOICE_CALL_RECORDING_CREATE', 'workspace=' . $workspaceId . ' channel=' . $channelId . ' call=' . $callId);
        return $this->voiceRecordingsClient()->createVoiceCallRecordingSession($workspaceId, $channelId, $callId, $payload);
    }

    public function listVoiceCallRecordings(string $workspaceId, string $channelId, string $callId, array $query = []): array|object|null
    {
        $this->logBirdAction('BIRD_VOICE_CALL_RECORDING_LIST', 'workspace=' . $workspaceId . ' channel=' . $channelId . ' call=' . $callId);
        return $this->voiceRecordingsClient()->listVoiceCallRecordings($workspaceId, $channelId, $callId, $query);
    }

    public function getVoiceCallRecording(string $workspaceId, string $channelId, string $callId, string $recordingId): array|object|null
    {
        $this->logBirdAction('BIRD_VOICE_CALL_RECORDING_GET', 'workspace=' . $workspaceId . ' channel=' . $channelId . ' call=' . $callId . ' recording=' . $recordingId);
        return $this->voiceRecordingsClient()->getVoiceCallRecording($workspaceId, $channelId, $callId, $recordingId);
    }

    public function updateVoiceCallRecording(string $workspaceId, string $channelId, string $callId, string $recordingId, array $payload): array|object|null
    {
        $this->logBirdAction('BIRD_VOICE_CALL_RECORDING_UPDATE', 'workspace=' . $workspaceId . ' channel=' . $channelId . ' call=' . $callId . ' recording=' . $recordingId);
        return $this->voiceRecordingsClient()->updateVoiceCallRecording($workspaceId, $channelId, $callId, $recordingId, $payload);
    }

    public function getVoiceCallInsights(string $workspaceId, string $channelId, string $callId): array|object|null
    {
        $this->logBirdAction('BIRD_VOICE_CALL_INSIGHTS_GET', 'workspace=' . $workspaceId . ' channel=' . $channelId . ' call=' . $callId);
        return $this->voiceInsightsClient()->getVoiceCallInsights($workspaceId, $channelId, $callId);
    }

    public function getVoiceCallsLog(string $workspaceId, array $query = []): array|object|null
    {
        $this->logBirdAction('BIRD_VOICE_CALL_LOG_LIST', 'workspace=' . $workspaceId);
        return $this->voiceInsightsClient()->getVoiceCallsLog($workspaceId, $query);
    }

    public function listNumbers(string $workspaceId, array $query = []): array|object|null
    {
        $this->logBirdAction('BIRD_NUMBERS_LIST', 'workspace=' . $workspaceId . ' query_keys=' . implode(',', array_keys($query)));
        return $this->sendGetRequest('/workspaces/' . rawurlencode($workspaceId) . '/numbers', $query);
    }

    public function getNumber(string $workspaceId, string $numberId): array|object|null
    {
        $this->logBirdAction('BIRD_NUMBER_GET', 'workspace=' . $workspaceId . ' number=' . $numberId);
        return $this->sendGetRequest('/workspaces/' . rawurlencode($workspaceId) . '/numbers/' . rawurlencode($numberId));
    }

    public function deleteNumber(string $workspaceId, string $numberId): array|object|null
    {
        $this->logBirdAction('BIRD_NUMBER_DELETE', 'workspace=' . $workspaceId . ' number=' . $numberId);
        return $this->sendDeleteRequest('/workspaces/' . rawurlencode($workspaceId) . '/numbers/' . rawurlencode($numberId));
    }

    public function createFlashCall(string $workspaceId, string $channelId, array $payload): array|object|null
    {
        $this->logBirdAction('BIRD_FLASH_CALL_CREATE', 'workspace=' . $workspaceId . ' channel=' . $channelId);
        return $this->flashCallsClient()->createFlashCall($workspaceId, $channelId, $payload);
    }

    public function listFlashCalls(string $workspaceId, string $channelId, array $query = []): array|object|null
    {
        $this->logBirdAction('BIRD_FLASH_CALL_LIST', 'workspace=' . $workspaceId . ' channel=' . $channelId);
        return $this->flashCallsClient()->listFlashCalls($workspaceId, $channelId, $query);
    }

    public function getFlashCall(string $workspaceId, string $channelId, string $callId): array|object|null
    {
        $this->logBirdAction('BIRD_FLASH_CALL_GET', 'workspace=' . $workspaceId . ' channel=' . $channelId . ' call=' . $callId);
        return $this->flashCallsClient()->getFlashCall($workspaceId, $channelId, $callId);
    }

    public function endFlashCall(string $workspaceId, string $channelId, string $callId, array $payload = []): array|object|null
    {
        $this->logBirdAction('BIRD_FLASH_CALL_END', 'workspace=' . $workspaceId . ' channel=' . $channelId . ' call=' . $callId);
        return $this->flashCallsClient()->endFlashCall($workspaceId, $channelId, $callId, $payload);
    }

    public function hangupFlashCall(string $workspaceId, string $channelId, array $payload = []): array|object|null
    {
        $this->logBirdAction('BIRD_FLASH_CALL_HANGUP', 'workspace=' . $workspaceId . ' channel=' . $channelId);
        return $this->flashCallsClient()->hangupFlashCall($workspaceId, $channelId, $payload);
    }

    public function createOutboundTestCallAndHangupWhenAccepted(string $workspaceId, string $channelId, array $options = []): array
    {
        return $this->executeCallAndHangupWhenAccepted($workspaceId, $channelId, $options, 'BIRD_TEST_OUTBOUND_CALL');
    }

    /**
     * Internal method to place a call, wait for it to be accepted/ongoing, and then hang up.
     * Used by both test outbound calls and actual gate calls.
     */
    private function executeCallAndHangupWhenAccepted(string $workspaceId, string $channelId, array $options, string $logPrefix): array
    {
        $pollIntervalSeconds = max(1, (int)($options['pollIntervalSeconds'] ?? 2));
        $maxPollSeconds = max(5, (int)($options['maxPollSeconds'] ?? 30));
        $maxAttempts = (int)max(1, floor($maxPollSeconds / $pollIntervalSeconds));

        $payload = $options;
        // Keep polling/hangup controls out of create-call payload to avoid Bird validation errors.
        unset($payload['pollIntervalSeconds'], $payload['maxPollSeconds'], $payload['hangupCause']);
        $targetNumber = (string)($payload['to'] ?? self::TEST_OUTBOUND_NUMBER_E164);
        if ($targetNumber === '') {
            $targetNumber = self::TEST_OUTBOUND_NUMBER_E164;
        }
        $payload['to'] = $targetNumber;
        $payload = $this->normalizeCreateVoiceCallPayload($payload, $maxPollSeconds);

        $this->logBirdAction(
            $logPrefix . '_START',
            'workspace=' . $workspaceId . ' channel=' . $channelId . ' to=' . $targetNumber
        );

        $createResponse = $this->createVoiceCall($workspaceId, $channelId, $payload);
        $callId = $this->extractId($createResponse);
        if ($callId === null) {
            $this->logBirdAction($logPrefix . '_NO_ID', 'Call accepted but no call ID returned', 0);
            return [
                'to' => $targetNumber,
                'to_e164' => $targetNumber,
                'created_call' => $createResponse,
                'hangup_sent' => false,
                'message' => 'Call was created, but no call ID was returned, so hangup could not be sent.',
            ];
        }

        $lastCall = null;
        for ($attempt = 1; $attempt <= $maxAttempts; $attempt++) {
            $current = $this->getVoiceCall($workspaceId, $channelId, $callId);
            $lastCall = $current;
            $status = $this->extractStatus($current);
            $normalizedStatus = $status === null ? null : strtolower($status);

            $this->logBirdAction(
                $logPrefix . '_POLL',
                'call=' . $callId . ' attempt=' . $attempt . '/' . $maxAttempts . ' status=' . ($status ?? 'unknown')
            );

            if ($normalizedStatus !== null && in_array($normalizedStatus, self::ACCEPTED_CALL_STATUSES, true)) {
                $hangupPayload = [];
                if (isset($options['hangupCause']) && is_string($options['hangupCause']) && $options['hangupCause'] !== '') {
                    $normalizedCause = strtolower(trim($options['hangupCause']));
                    if (in_array($normalizedCause, self::ALLOWED_HANGUP_CAUSES, true)) {
                        $hangupPayload['cause'] = $normalizedCause;
                    }
                }
                $hangupResponse = $this->hangupVoiceCall($workspaceId, $channelId, $callId, $hangupPayload);
                $this->logBirdAction($logPrefix . '_HANGUP_SENT', 'call=' . $callId . ' status=' . $status);
                return [
                    'to' => $targetNumber,
                    'to_e164' => $targetNumber,
                    'call_id' => $callId,
                    'final_status' => $status,
                    'hangup_sent' => true,
                    'created_call' => $createResponse,
                    'last_call_snapshot' => $lastCall,
                    'hangup_response' => $hangupResponse,
                ];
            }

            if ($normalizedStatus !== null && in_array($normalizedStatus, self::TERMINAL_GATE_FAILURE_STATUSES, true)) {
                $this->logBirdAction(
                    $logPrefix . '_TERMINAL',
                    'call=' . $callId . ' status=' . $status
                );
                return [
                    'to' => $targetNumber,
                    'to_e164' => $targetNumber,
                    'call_id' => $callId,
                    'final_status' => $status,
                    'hangup_sent' => false,
                    'terminal_failure' => true,
                    'created_call' => $createResponse,
                    'last_call_snapshot' => $lastCall,
                    'message' => 'Call reached terminal status before acceptance',
                ];
            }

            if ($attempt < $maxAttempts) {
                $this->waitForCallPollInterval($pollIntervalSeconds);
            }
        }

        $lastStatus = $this->extractStatus($lastCall);
        $this->logBirdAction(
            $logPrefix . '_TIMEOUT',
            'call=' . $callId . ' last_status=' . ($lastStatus ?? 'unknown'),
            0
        );
        return [
            'to' => $targetNumber,
            'to_e164' => $targetNumber,
            'call_id' => $callId,
            'final_status' => $lastStatus,
            'hangup_sent' => false,
            'timed_out_waiting_for_accepted' => true,
            'created_call' => $createResponse,
            'last_call_snapshot' => $lastCall,
        ];
    }

    /**
     * Hook point for tests to avoid real waiting during call polling.
     */
    protected function waitForCallPollInterval(int $pollIntervalSeconds): void
    {
        sleep($pollIntervalSeconds);
    }

    private function voiceBase(string $workspaceId, string $channelId): string
    {
        return '/workspaces/' . rawurlencode($workspaceId) . '/channels/' . rawurlencode($channelId) . '/calls';
    }

    private function flashBase(string $workspaceId, string $channelId): string
    {
        return '/workspaces/' . rawurlencode($workspaceId) . '/channels/' . rawurlencode($channelId) . '/flashcalls';
    }

    private function sayBase(string $workspaceId, string $channelId, string $callId): string
    {
        // https://api.bird.com/workspaces/{workspaceId}/channels/{channelId}/calls/{callId}/say
        return '/workspaces/' . rawurlencode($workspaceId) . '/channels/' . rawurlencode($channelId) . '/calls/' . rawurlencode($callId) . '/say';
    }

    private function gatherBase(string $workspaceId, string $channelId, string $callId): string
    {
        // https://api.bird.com/workspaces/{workspaceId}/channels/{channelId}/calls/{callId}/gather
        return '/workspaces/' . rawurlencode($workspaceId) . '/channels/' . rawurlencode($channelId) . '/calls/' . rawurlencode($callId) . '/gather';
    }

    protected function extractId(array|object|string|null $response): ?string
    {
        if (is_object($response) && isset($response->id) && is_string($response->id) && $response->id !== '') {
            return $response->id;
        }
        if (is_array($response) && isset($response['id']) && is_string($response['id']) && $response['id'] !== '') {
            return $response['id'];
        }
        return null;
    }

    protected function extractStatus(array|object|string|null $response): ?string
    {
        if (is_object($response) && isset($response->status) && is_string($response->status) && $response->status !== '') {
            return $response->status;
        }
        if (is_array($response) && isset($response['status']) && is_string($response['status']) && $response['status'] !== '') {
            return $response['status'];
        }
        return null;
    }

    protected function extractFrom(array|object|string|null $response): ?string
    {
        if (is_object($response)) {
            if (isset($response->from) && is_string($response->from)) {
                return $this->normalizePhoneIdentifier($response->from);
            }
            return null;
        }
        if (is_array($response)) {
            if (array_key_exists('from', $response) && is_string($response['from'])) {
                return $this->normalizePhoneIdentifier($response['from']);
            }
            return null;
        }
        return null;
    }

    private function buildHttpErrorMessage(int $status, string|false|null $response): string
    {
        $base = 'Bird API request failed with status ' . $status;
        if (!is_string($response) || trim($response) === '') {
            return $base;
        }

        $decoded = json_decode($response, true);
        if (is_array($decoded)) {
            $details = [];
            foreach (['message', 'error', 'description'] as $key) {
                if (isset($decoded[$key]) && is_string($decoded[$key]) && trim($decoded[$key]) !== '') {
                    $details[] = trim($decoded[$key]);
                }
            }
            if (isset($decoded['errors'])) {
                if (is_string($decoded['errors']) && trim($decoded['errors']) !== '') {
                    $details[] = trim($decoded['errors']);
                } elseif (is_array($decoded['errors'])) {
                    foreach ($decoded['errors'] as $error) {
                        if (is_string($error) && trim($error) !== '') {
                            $details[] = trim($error);
                        } elseif (is_array($error) && isset($error['message']) && is_string($error['message']) && trim($error['message']) !== '') {
                            $details[] = trim($error['message']);
                        }
                    }
                }
            }
            $details = array_values(array_unique(array_filter($details)));
            if (!empty($details)) {
                return $base . ': ' . implode(' | ', $details);
            }
        }

        $snippet = preg_replace('/\s+/', ' ', trim($response));
        if (!is_string($snippet) || $snippet === '') {
            return $base;
        }
        if (strlen($snippet) > 220) {
            $snippet = substr($snippet, 0, 220) . '...';
        }
        return $base . ': ' . $snippet;
    }

    private function buildAuthorizationHeader(): string
    {
        $apiKey = trim((string)$this->config->api_key->getVariableValue());
        if ($apiKey === '') {
            return 'Authorization: AccessKey';
        }
        if (preg_match('/^(Bearer|AccessKey)\s+/i', $apiKey) === 1) {
            return 'Authorization: ' . $apiKey;
        }
        return 'Authorization: AccessKey ' . $apiKey;
    }

    private function encodeJsonBody(array $data): string
    {
        // Bird control endpoints expect an object payload; use {} for empty body.
        if ($data === []) {
            return '{}';
        }
        return json_encode($data);
    }

    private function logBirdAction(string $action, string $message, int $type = 1): void
    {
        try {
            $userId = 0;
            if (isset($_SERVER['HTTP_AUTHORIZATION']) || isset($_SERVER['Authorization'])) {
                try {
                    $auth = new authentication();
                    $user = $auth->get_user();
                    if ($user && isset($user->id)) {
                        $userId = (int)$user->id;
                    }
                } catch (\Throwable) {
                    $userId = 0;
                }
            }
            (new logs_o())->add('bird', 'global', $type, $userId, $action, $message);
        } catch (\Throwable) {
            // Logging must never break Bird request flow.
        }
    }

    public function callGateAndHangupWhenAccepted(int $countryCode, int $phone, int $timeout)
    {
        $ws = $this->getConfiguredWorkspaceId();
        $ch = $this->getConfiguredChannelId();
        if ($ws === '') {
            throw new Exception('Bird workspaceId is not configured for gate calls');
        }
        if ($ch === '') {
            throw new Exception('Bird channelId is not configured for gate calls');
        }

        $normalizedRingTimeout = $this->normalizeRingTimeoutValue($timeout);
        if ($normalizedRingTimeout === null) {
            $normalizedRingTimeout = 30;
        }
        $options = [
            'from' => self::OUTGOING_NUMBER_E164,
            'to' => '+' . $countryCode . $phone,
            'maxPollSeconds' => max(5, (int)$timeout),
            'ringTimeout' => $normalizedRingTimeout,
        ];

        $result = $this->executeCallAndHangupWhenAccepted($ws, $ch, $options, 'BIRD_GATE_CALL');

        if (!($result['hangup_sent'] ?? false)) {
            $msg = $result['message'] ?? 'Failed to call gate and hangup when accepted';
            if ($result['timed_out_waiting_for_accepted'] ?? false) {
                $msg = 'Timed out waiting for gate to accept call';
            } elseif ($result['terminal_failure'] ?? false) {
                $status = isset($result['final_status']) ? (string)$result['final_status'] : 'unknown';
                $msg = 'Gate call reached terminal status: ' . $status;
            }
            throw new Exception("Failed to call gate (+{$countryCode}{$phone}): " . $msg);
        }
    }

    public function callGateViaFlashCall(int $countryCode, int $phone, int $ringTimeout): void
    {
        $ws = $this->getConfiguredWorkspaceId();
        $ch = $this->getConfiguredChannelId();
        if ($ws === '') {
            throw new Exception('Bird workspaceId is not configured for gate flash calls');
        }
        if ($ch === '') {
            throw new Exception('Bird channelId is not configured for gate flash calls');
        }

        $normalizedRingTimeout = $this->normalizeRingTimeoutValue($ringTimeout);
        if ($normalizedRingTimeout === null) {
            $normalizedRingTimeout = 30;
        }

        $createResponse = $this->createFlashCall($ws, $ch, [
            'from' => self::OUTGOING_NUMBER_E164,
            'to' => '+' . $countryCode . $phone,
            'ringTimeout' => $normalizedRingTimeout,
        ]);

        $expectedFrom = $this->normalizePhoneIdentifier(self::OUTGOING_NUMBER_E164);
        $flashFrom = $this->extractFrom($createResponse);
        if ($flashFrom === null) {
            throw new Exception('Gate flash call did not confirm caller id');
        }
        if ($expectedFrom !== null && $flashFrom !== $expectedFrom) {
            throw new Exception('Gate flash call used unexpected caller id: ' . $flashFrom);
        }

        $initialStatus = $this->extractStatus($createResponse);
        $normalizedInitialStatus = $initialStatus === null ? null : strtolower($initialStatus);
        if ($normalizedInitialStatus !== null && in_array($normalizedInitialStatus, self::FLASH_GATE_FAILURE_STATUSES, true)) {
            throw new Exception('Gate flash call failed with status: ' . $initialStatus);
        }
        if ($normalizedInitialStatus !== null && in_array($normalizedInitialStatus, self::FLASH_GATE_SUCCESS_STATUSES, true)) {
            return;
        }

        $callId = $this->extractId($createResponse);
        if ($callId === null) {
            throw new Exception('Failed to create gate flash call: no call id returned');
        }

        $pollIntervalSeconds = 5;
        $maxPollSeconds = max(5, $normalizedRingTimeout + 5);
        $maxAttempts = (int)max(1, floor($maxPollSeconds / $pollIntervalSeconds));

        for ($attempt = 1; $attempt <= $maxAttempts; $attempt++) {
            $current = $this->getFlashCall($ws, $ch, $callId);
            $currentFrom = $this->extractFrom($current);
            if ($expectedFrom !== null && $currentFrom !== null && $currentFrom !== $expectedFrom) {
                throw new Exception('Gate flash call switched to unexpected caller id: ' . $currentFrom);
            }
            $status = $this->extractStatus($current);
            $normalizedStatus = $status === null ? null : strtolower($status);
            if ($normalizedStatus !== null && in_array($normalizedStatus, self::FLASH_GATE_SUCCESS_STATUSES, true)) {
                return;
            }
            if ($normalizedStatus !== null && in_array($normalizedStatus, self::FLASH_GATE_FAILURE_STATUSES, true)) {
                throw new Exception('Gate flash call failed with status: ' . $status);
            }
            if ($attempt < $maxAttempts) {
                $this->waitForCallPollInterval($pollIntervalSeconds);
            }
        }

        throw new Exception('Timed out waiting for gate flash call completion');
    }

    public function callGatePreferringFlashCall(int $countryCode, int $phone, int $timeout): void
    {
        try {
            $this->callGateViaFlashCall($countryCode, $phone, $timeout);
            return;
        } catch (\Throwable $flashError) {
            $this->logBirdAction(
                'BIRD_GATE_FLASH_FALLBACK',
                'Flash gate call failed, falling back to regular call. reason=' . $flashError->getMessage(),
                0
            );
        }

        $this->callGateAndHangupWhenAccepted($countryCode, $phone, $timeout);
    }

    protected function getConfiguredWorkspaceId(): string
    {
        if (!is_object($this->config)) {
            return '';
        }
        $workspaceConfig = null;
        if (property_exists($this->config, 'workspaceId')) {
            $workspaceConfig = $this->config->workspaceId;
        } elseif (property_exists($this->config, 'workplaceId')) {
            // Backward compatibility with existing Bird module variable naming.
            $workspaceConfig = $this->config->workplaceId;
        }
        if (!is_object($workspaceConfig) || !method_exists($workspaceConfig, 'getVariableValue')) {
            return '';
        }
        return $this->normalizeOptionalString($workspaceConfig->getVariableValue());
    }

    protected function getConfiguredChannelId(): string
    {
        if (!is_object($this->config)) {
            return '';
        }
        $channelConfig = null;
        if (property_exists($this->config, 'channelId')) {
            $channelConfig = $this->config->channelId;
        }
        if (!is_object($channelConfig) || !method_exists($channelConfig, 'getVariableValue')) {
            return '';
        }
        return $this->normalizeOptionalString($channelConfig->getVariableValue());
    }

    protected function normalizeOptionalString(mixed $value): string
    {
        if (!is_scalar($value)) {
            return '';
        }
        $normalized = trim((string)$value);
        if ($normalized === '') {
            return '';
        }
        $lower = strtolower($normalized);
        if ($lower === 'undefined' || $lower === 'null') {
            return '';
        }
        return $normalized;
    }

    /**
     * Align create-call payload with Bird voice call schema.
     * - Map legacy `timeout` to documented `ringTimeout`.
     * - Clamp `ringTimeout` to documented [3,120] range.
     */
    protected function normalizeCreateVoiceCallPayload(array $payload, int $fallbackRingTimeout): array
    {
        if (array_key_exists('timeout', $payload) && !array_key_exists('ringTimeout', $payload)) {
            $payload['ringTimeout'] = $payload['timeout'];
        }
        unset($payload['timeout']);

        $normalizedRingTimeout = null;
        if (array_key_exists('ringTimeout', $payload)) {
            $normalizedRingTimeout = $this->normalizeRingTimeoutValue($payload['ringTimeout']);
        }
        if ($normalizedRingTimeout === null) {
            $normalizedRingTimeout = $this->normalizeRingTimeoutValue($fallbackRingTimeout);
        }
        if ($normalizedRingTimeout !== null) {
            $payload['ringTimeout'] = $normalizedRingTimeout;
        } else {
            unset($payload['ringTimeout']);
        }

        return $payload;
    }

    protected function normalizeRingTimeoutValue(mixed $value): ?int
    {
        if (!is_numeric($value)) {
            return null;
        }
        $timeout = (int)$value;
        if ($timeout < 3) {
            $timeout = 3;
        }
        if ($timeout > 120) {
            $timeout = 120;
        }
        return $timeout;
    }

    protected function normalizePhoneIdentifier(mixed $value): ?string
    {
        if (!is_string($value)) {
            return null;
        }

        $trimmed = trim($value);
        if ($trimmed === '') {
            return null;
        }

        $digits = preg_replace('/\D+/', '', $trimmed);
        if (!is_string($digits) || $digits === '') {
            return null;
        }

        return '+' . $digits;
    }
}


