<?php
if (!defined('ABSPATH'))
    exit;

/**
 * VMO Twilio Manager
 * 
 * Handles autonomous provisioning of Twilio phone numbers and AI agents
 * for VMO users. Uses VMO's master Twilio account to provision phone numbers
 * for all users globally.
 * 
 * @package VMO_AI_Website_Builder
 * @subpackage Twilio_Integration
 * @since 1.0.0
 */

if (!defined('ABSPATH')) {
    exit;
}

if (!function_exists('vmo_get_platform_faq_grounding_text')) {
    $vmo_platform_faq_path = __DIR__ . '/vmo-platform-faq.php';
    if (file_exists($vmo_platform_faq_path)) {
        require_once $vmo_platform_faq_path;
    }
}

// Polyfill esc_xml() for WordPress versions before 5.5
if (!function_exists('esc_xml')) {
    function esc_xml($text)
    {
        $text = (string) $text;
        if ('' === $text) {
            return '';
        }
        // Use the same escaping WordPress esc_attr() uses, which covers &, <, >, ", '
        if (function_exists('esc_attr')) {
            return esc_attr($text);
        }
        // Ultimate fallback if somehow called before WP loads
        return htmlspecialchars($text, ENT_QUOTES | ENT_XML1, 'UTF-8');
    }
}

// CRITICAL: Conditionally load Twilio SDK to prevent fatal errors
$twilio_sdk_path = __DIR__ . '/../vendor/autoload.php';
$sdk_exists = file_exists($twilio_sdk_path);

if ($sdk_exists) {
    require_once $twilio_sdk_path;
}

// use Twilio\Rest\Client; // Moved inside checking or handled with full path if needed, 
// but since we can't conditionally use `use`, we'll just leave it and handle class instantiation safely.
// Note: namespace imports don't execute code, so keeping `use` is fine as long as we don't `new Client` if it doesn't exist.

class VMO_Twilio_Manager
{

    /**
     * Twilio client instance
     */
    private $client = null;

    /**
     * Simulation Mode (when SDK is missing)
     */
    private $simulation_mode = false;

    /**
     * VMO Master Twilio Account SID
     */
    private $account_sid;

    /**
     * VMO Master Twilio Auth Token
     */
    private $auth_token;
    private $api_base = 'https://api.twilio.com/2010-04-01';

    /**
     * Singleton instance
     */
    private static $instance = null;

    /**
     * Cached detected phone column name for vmo_phone_numbers
     */
    private ?string $phone_numbers_phone_column = null;

    /**
     * Cached language config for the current request.
     * @var array|null
     */
    private ?array $lang_config_cache = null;

    /**
     * Check if call recording is enabled for a user.
     * Defaults to ON if the user has never explicitly toggled the setting.
     *
     * @param int $user_id
     * @return bool
     */
    public function is_recording_enabled(int $user_id): bool
    {
        if ($user_id <= 0)
            return false;
        $meta = get_user_meta($user_id, 'call_recording_enabled', true);
        // Never explicitly set → default ON
        if ($meta === '' || $meta === false)
            return true;
        return (bool) intval($meta);
    }

    /**
     * Get the TTS voice name for the configured language.
     */
    private function tts_voice(): string
    {
        if ($this->lang_config_cache === null) {
            $this->lang_config_cache = self::get_language_config();
        }
        return $this->lang_config_cache['voice'];
    }

    /**
     * Get the Gather speech-recognition language code.
     */
    private function gather_lang(): string
    {
        if ($this->lang_config_cache === null) {
            $this->lang_config_cache = self::get_language_config();
        }
        return $this->lang_config_cache['gather_lang'];
    }

    /**
     * Get the human-readable language instruction for the GPT prompt.
     */
    private function prompt_lang(): string
    {
        if ($this->lang_config_cache === null) {
            $this->lang_config_cache = self::get_language_config();
        }
        return $this->lang_config_cache['prompt_lang'];
    }

    private function receptionist_language_policy($call_language = null): string
    {
        $language = $this->normalize_call_language_context($call_language);
        $language_name = (string) ($language['name'] ?? 'English');
        $country = (string) ($language['country'] ?? '');
        $country_note = $country !== '' ? ' The initial language was inferred from caller location country ' . $country . '.' : ' If caller location is unknown, default to English.';

        return 'Speak ' . $language_name . ' for your first greeting and any opening department menu before the caller has spoken.' . $country_note . ' Never start in Mandarin, Chinese, Spanish, French, or any other language unless that is the inferred caller-location language or the caller has already spoken that language. After the caller speaks, detect the language they used and respond in that same language, even if it differs from the inferred location. Switch languages whenever the caller switches language or explicitly asks you to.';
    }

    private function normalize_call_language_context($call_language = null): array
    {
        if (is_array($call_language) && !empty($call_language['code'])) {
            $code = strtolower(trim((string) $call_language['code']));
            $cfg = self::get_language_config($code);
            return array(
                'code' => $code,
                'name' => (string) ($call_language['name'] ?? $cfg['prompt_lang'] ?? 'English'),
                'voice' => (string) ($call_language['voice'] ?? $cfg['voice'] ?? 'Polly.Joanna-Neural'),
                'gather_lang' => (string) ($call_language['gather_lang'] ?? $cfg['gather_lang'] ?? 'en-US'),
                'country' => strtoupper((string) ($call_language['country'] ?? '')),
                'source' => (string) ($call_language['source'] ?? 'context'),
            );
        }

        if (is_string($call_language) && trim($call_language) !== '') {
            $code = strtolower(trim($call_language));
            $cfg = self::get_language_config($code);
            return array(
                'code' => $code,
                'name' => (string) ($cfg['prompt_lang'] ?? 'English'),
                'voice' => (string) ($cfg['voice'] ?? 'Polly.Joanna-Neural'),
                'gather_lang' => (string) ($cfg['gather_lang'] ?? 'en-US'),
                'country' => '',
                'source' => 'explicit_code',
            );
        }

        $cfg = self::get_language_config('en');
        return array(
            'code' => 'en',
            'name' => (string) ($cfg['prompt_lang'] ?? 'English'),
            'voice' => (string) ($cfg['voice'] ?? 'Polly.Joanna-Neural'),
            'gather_lang' => (string) ($cfg['gather_lang'] ?? 'en-US'),
            'country' => '',
            'source' => 'default',
        );
    }

    private function infer_initial_call_language(array $params = array(), string $from = ''): array
    {
        $country = '';
        foreach (array('FromCountry', 'CallerCountry', 'CallerCountryCode', 'Country') as $key) {
            if (!empty($params[$key])) {
                $candidate = strtoupper(preg_replace('/[^A-Z]/i', '', (string) $params[$key]));
                if (strlen($candidate) === 2) {
                    $country = $candidate;
                    break;
                }
            }
        }

        if ($country === '') {
            $country = $this->infer_country_from_phone_number($from);
        }

        $region = '';
        foreach (array('FromState', 'CallerState', 'FromRegion', 'Region') as $key) {
            if (!empty($params[$key])) {
                $region = strtoupper(preg_replace('/[^A-Z]/i', '', (string) $params[$key]));
                break;
            }
        }

        $code = $this->language_code_for_country($country, $region, $from);
        $cfg = self::get_language_config($code);

        return array(
            'code' => $code,
            'name' => (string) ($cfg['prompt_lang'] ?? 'English'),
            'voice' => (string) ($cfg['voice'] ?? 'Polly.Joanna-Neural'),
            'gather_lang' => (string) ($cfg['gather_lang'] ?? 'en-US'),
            'country' => $country,
            'region' => $region,
            'source' => $country !== '' ? 'caller_location' : 'default',
        );
    }

    private function infer_country_from_phone_number(string $phone_number): string
    {
        $digits = preg_replace('/\D+/', '', $phone_number);
        if (!is_string($digits) || $digits === '') {
            return '';
        }

        $nanp_area = '';
        if (strlen($digits) >= 11 && $digits[0] === '1') {
            $nanp_area = substr($digits, 1, 3);
            $nanp_country_by_area = array(
                '787' => 'PR',
                '939' => 'PR',
                '809' => 'DO',
                '829' => 'DO',
                '849' => 'DO',
            );
            if (isset($nanp_country_by_area[$nanp_area])) {
                return $nanp_country_by_area[$nanp_area];
            }
        }

        $prefix_map = array(
            '1242' => 'BS',
            '1246' => 'BB',
            '1264' => 'AI',
            '1268' => 'AG',
            '1284' => 'VG',
            '1340' => 'VI',
            '1345' => 'KY',
            '1441' => 'BM',
            '1473' => 'GD',
            '1649' => 'TC',
            '1664' => 'MS',
            '1670' => 'MP',
            '1671' => 'GU',
            '1684' => 'AS',
            '1721' => 'SX',
            '1758' => 'LC',
            '1767' => 'DM',
            '1784' => 'VC',
            '1868' => 'TT',
            '1869' => 'KN',
            '1876' => 'JM',
            '44' => 'GB',
            '33' => 'FR',
            '34' => 'ES',
            '52' => 'MX',
            '55' => 'BR',
            '351' => 'PT',
            '49' => 'DE',
            '43' => 'AT',
            '39' => 'IT',
            '31' => 'NL',
            '32' => 'BE',
            '41' => 'CH',
            '46' => 'SE',
            '47' => 'NO',
            '45' => 'DK',
            '358' => 'FI',
            '48' => 'PL',
            '40' => 'RO',
            '7' => 'RU',
            '81' => 'JP',
            '82' => 'KR',
            '86' => 'CN',
            '852' => 'HK',
            '886' => 'TW',
            '966' => 'SA',
            '971' => 'AE',
            '90' => 'TR',
            '91' => 'IN',
            '234' => 'NG',
            '27' => 'ZA',
            '1' => 'US',
        );

        uksort($prefix_map, function ($left, $right) {
            return strlen((string) $right) <=> strlen((string) $left);
        });

        foreach ($prefix_map as $prefix => $mapped_country) {
            if (strpos($digits, (string) $prefix) === 0) {
                return $mapped_country;
            }
        }

        return '';
    }

    private function language_code_for_country(string $country, string $region = '', string $phone_number = ''): string
    {
        $country = strtoupper(trim($country));
        $region = strtoupper(trim($region));

        if ($country === 'CA') {
            if ($region === 'QC' || $region === 'QUEBEC' || $this->phone_number_matches_any_area($phone_number, array('418', '438', '450', '514', '581', '819', '873'))) {
                return 'fr-ca';
            }
            return 'en';
        }

        $country_map = array(
            'US' => 'en',
            'GB' => 'en-gb',
            'IE' => 'en-gb',
            'AU' => 'en-au',
            'NZ' => 'en-nz',
            'ZA' => 'en',
            'NG' => 'en',
            'GH' => 'en',
            'KE' => 'en',
            'UG' => 'en',
            'TZ' => 'en',
            'BS' => 'en',
            'BB' => 'en',
            'AI' => 'en',
            'AG' => 'en',
            'VG' => 'en',
            'VI' => 'en',
            'KY' => 'en',
            'BM' => 'en',
            'GD' => 'en',
            'TC' => 'en',
            'MS' => 'en',
            'MP' => 'en',
            'GU' => 'en',
            'AS' => 'en',
            'LC' => 'en',
            'DM' => 'en',
            'VC' => 'en',
            'TT' => 'en',
            'KN' => 'en',
            'JM' => 'en',
            'IN' => 'en',
            'MX' => 'es-mx',
            'ES' => 'es-es',
            'PR' => 'es',
            'DO' => 'es',
            'AR' => 'es',
            'BO' => 'es',
            'CL' => 'es',
            'CO' => 'es',
            'CR' => 'es',
            'CU' => 'es',
            'EC' => 'es',
            'SV' => 'es',
            'GQ' => 'es',
            'GT' => 'es',
            'HN' => 'es',
            'NI' => 'es',
            'PA' => 'es',
            'PY' => 'es',
            'PE' => 'es',
            'UY' => 'es',
            'VE' => 'es',
            'FR' => 'fr',
            'GF' => 'fr',
            'GP' => 'fr',
            'MQ' => 'fr',
            'RE' => 'fr',
            'YT' => 'fr',
            'NC' => 'fr',
            'PF' => 'fr',
            'HT' => 'fr',
            'BE' => 'fr',
            'BR' => 'pt',
            'PT' => 'pt-pt',
            'DE' => 'de',
            'AT' => 'de-at',
            'CH' => 'de',
            'IT' => 'it',
            'NL' => 'nl',
            'SE' => 'sv',
            'NO' => 'nb',
            'DK' => 'da',
            'FI' => 'fi',
            'PL' => 'pl',
            'RO' => 'ro',
            'RU' => 'ru',
            'IS' => 'is',
            'TR' => 'tr',
            'JP' => 'ja',
            'KR' => 'ko',
            'CN' => 'zh',
            'TW' => 'zh',
            'HK' => 'yue',
            'SA' => 'ar',
            'AE' => 'ar',
        );

        return $country_map[$country] ?? 'en';
    }

    private function phone_number_matches_any_area(string $phone_number, array $area_codes): bool
    {
        $digits = preg_replace('/\D+/', '', $phone_number);
        if (!is_string($digits) || strlen($digits) < 10) {
            return false;
        }
        $area = (strlen($digits) >= 11 && $digits[0] === '1') ? substr($digits, 1, 3) : substr($digits, 0, 3);
        return in_array($area, $area_codes, true);
    }

    private function call_language_context_key(string $call_sid): string
    {
        $safe_sid = sanitize_key($call_sid);
        return $safe_sid !== '' ? 'vmo_call_language_' . $safe_sid : '';
    }

    private function store_call_language_context(string $call_sid, array $call_language, int $ttl = 7200): void
    {
        $key = $this->call_language_context_key($call_sid);
        if ($key !== '') {
            set_transient($key, $this->normalize_call_language_context($call_language), max(60, $ttl));
        }
    }

    private function get_call_language_context(string $call_sid): array
    {
        $key = $this->call_language_context_key($call_sid);
        if ($key === '') {
            return $this->normalize_call_language_context(null);
        }
        $context = get_transient($key);
        return $this->normalize_call_language_context(is_array($context) ? $context : null);
    }

    private function localized_initial_greeting(array $call_language, string $fallback_english = ''): string
    {
        $language = $this->normalize_call_language_context($call_language);
        $code = (string) ($language['code'] ?? 'en');
        $fallback_english = trim(preg_replace('/\s+/', ' ', wp_strip_all_tags($fallback_english)));
        $english = $fallback_english !== '' ? $fallback_english : 'Thank you for calling. How can I help you today?';

        if (strpos($code, 'en') === 0) {
            return $english;
        }

        $templates = array(
            'es' => 'Gracias por llamar. Como puedo ayudarle hoy?',
            'fr' => 'Merci de votre appel. Comment puis-je vous aider aujourd\'hui?',
            'pt' => 'Obrigado por ligar. Como posso ajudar hoje?',
            'de' => 'Danke fur Ihren Anruf. Wie kann ich Ihnen heute helfen?',
            'it' => 'Grazie per aver chiamato. Come posso aiutarla oggi?',
            'nl' => 'Bedankt voor uw telefoontje. Waarmee kan ik u vandaag helpen?',
            'ja' => 'お電話ありがとうございます。本日はどのようなご用件でしょうか。',
            'ko' => '전화 주셔서 감사합니다. 무엇을 도와드릴까요?',
            'zh' => '感谢您来电。今天有什么可以帮您？',
            'yue' => '多謝您來電。今日有咩可以幫到您？',
            'ar' => 'شكرا لاتصالك. كيف يمكنني مساعدتك اليوم؟',
        );

        $root = preg_replace('/-.+$/', '', $code);
        return $templates[$code] ?? $templates[$root] ?? $english;
    }

    /**
     * Get the configured receptionist voice for a user.
     */
    private function get_selected_reception_voice(int $user_id = 0): string
    {
        $candidates = array();

        if ($user_id > 0) {
            $candidates[] = get_user_meta($user_id, 'vmo_selected_reception_voice', true);
            $candidates[] = get_user_meta($user_id, 'vmo_preferred_voice', true);
        }

        foreach ($candidates as $candidate) {
            $normalized = $this->normalize_openai_reception_voice($candidate);
            if ($normalized !== '') {
                return $normalized;
            }
        }

        return 'ash';
    }

    /**
     * Normalize user-facing reception voice names to OpenAI voice IDs.
     */
    private function normalize_openai_reception_voice($voice): string
    {
        $voice = strtolower(trim((string) $voice));
        if ($voice === '') {
            return '';
        }

        $aliases = array(
            'ash voice' => 'ash',
            'marin voice' => 'marin',
            'cedar voice' => 'cedar',
        );

        if (isset($aliases[$voice])) {
            return $aliases[$voice];
        }

        $supported = array('ash', 'marin', 'cedar', 'alloy', 'echo', 'fable', 'onyx', 'nova', 'shimmer', 'sage', 'ballad', 'coral', 'verse');
        return in_array($voice, $supported, true) ? $voice : '';
    }

    private function get_voice_quality_tier(int $user_id): string
    {
        $tier = strtolower(trim((string) get_user_meta($user_id, 'vmo_voice_quality_tier', true)));
        return ($tier === 'premium') ? 'premium' : 'standard';
    }

    private function is_premium_voice_tier(int $user_id): bool
    {
        return $this->get_voice_quality_tier($user_id) === 'premium';
    }

    private function get_bridge_base_url(): string
    {
        $candidates = array(
            defined('VMO_PHONE_BRIDGE_URL') ? (string) VMO_PHONE_BRIDGE_URL : '',
            (string) get_option('vmo_phone_bridge_url', ''),
            getenv('VMO_PHONE_BRIDGE_URL') ?: '',
            'wss://bridge.getvmo.com',
        );

        foreach ($candidates as $candidate) {
            $candidate = trim((string) $candidate);
            if ($candidate !== '') {
                return rtrim($candidate, '/');
            }
        }

        return 'wss://bridge.getvmo.com';
    }

    private function get_bridge_ws_url(string $endpoint): string
    {
        $base = $this->get_bridge_base_url();
        $base = preg_replace('/^https:/i', 'wss:', $base);
        $base = preg_replace('/^http:/i', 'ws:', $base);
        return rtrim((string) $base, '/') . '/' . ltrim($endpoint, '/');
    }

    private function is_client_mode(): bool
    {
        return defined('VMO_CLIENT_MODE') && VMO_CLIENT_MODE === true;
    }

    private function get_master_proxy_base(): string
    {
        $proxy_base = defined('VMO_MASTER_PROXY_URL') && VMO_MASTER_PROXY_URL
            ? (string) VMO_MASTER_PROXY_URL
            : (string) get_option('vmo_master_proxy_url', 'https://getvmo.com/wp-json/vmo-builder/v1/proxy');
        return rtrim(trim($proxy_base), '/');
    }

    private function get_master_bridge_sign_url(): string
    {
        $proxy_base = $this->get_master_proxy_base();
        if ($proxy_base === '' || strpos($proxy_base, '__VMO_MASTER_PROXY_URL__') !== false) {
            return '';
        }

        $needle = '/wp-json/vmo-builder/v1/proxy';
        $pos = strpos($proxy_base, $needle);
        if ($pos !== false) {
            $url = substr($proxy_base, 0, $pos) . '/wp-json/vmo-builder/v1/bridge/sign';
        } else {
            $url = preg_replace('#/proxy/?$#', '/bridge/sign', $proxy_base);
        }

        return esc_url_raw($url);
    }

    private function client_bridge_signer_available(): bool
    {
        if (!$this->is_client_mode()) {
            return false;
        }

        $sign_url = $this->get_master_bridge_sign_url();
        return $sign_url !== ''
            && filter_var($sign_url, FILTER_VALIDATE_URL)
            && trim((string) get_option('vmo_license_key', '')) !== ''
            && trim((string) get_option('vmo_installation_id', '')) !== '';
    }

    private function request_master_bridge_signature(string $purpose, array $payload)
    {
        if (!$this->client_bridge_signer_available()) {
            return new WP_Error('missing_master_bridge_signer', 'Master bridge signer is not configured for this client installation');
        }

        $user_id = isset($payload['user_id']) ? (int) $payload['user_id'] : get_current_user_id();
        $plan = $user_id > 0 ? (string) get_user_meta($user_id, 'vmo_subscription_status', true) : '';
        $payload['purpose'] = $purpose;
        $call_sid = isset($payload['call_sid']) ? sanitize_text_field((string) $payload['call_sid']) : '';
        $sign_url = $this->get_master_bridge_sign_url();

        $response = wp_remote_post($sign_url, array(
            'timeout' => 12,
            'headers' => array(
                'Content-Type' => 'application/json',
                'Accept' => 'application/json',
                'X-VMO-Installation' => (string) get_option('vmo_installation_id', ''),
                'X-VMO-License' => (string) get_option('vmo_license_key', ''),
                'X-VMO-User' => (string) max(0, $user_id),
                'X-VMO-Plan' => $plan !== '' ? $plan : 'tester',
            ),
            'body' => wp_json_encode($payload),
        ));

        if (is_wp_error($response)) {
            error_log('[VMO Bridge] Master bridge signer HTTP error purpose=' . $purpose . ' user=' . $user_id . ' call=' . $call_sid . ' url=' . $sign_url . ' error=' . $response->get_error_message());
            return $response;
        }

        $status = (int) wp_remote_retrieve_response_code($response);
        $body = json_decode((string) wp_remote_retrieve_body($response), true);
        if ($status < 200 || $status >= 300) {
            $message = is_array($body) && !empty($body['message']) ? (string) $body['message'] : 'Master bridge signer rejected the request';
            $remote_code = is_array($body) && !empty($body['code']) ? (string) $body['code'] : '';
            error_log('[VMO Bridge] Master bridge signer rejected purpose=' . $purpose . ' user=' . $user_id . ' call=' . $call_sid . ' status=' . $status . ($remote_code !== '' ? ' code=' . $remote_code : '') . ' message=' . $message);
            return new WP_Error('master_bridge_signer_failed', $message, array('status' => $status, 'remote_code' => $remote_code));
        }
        if (!is_array($body) || empty($body['success'])) {
            error_log('[VMO Bridge] Master bridge signer invalid response purpose=' . $purpose . ' user=' . $user_id . ' call=' . $call_sid . ' status=' . $status);
            return new WP_Error('master_bridge_signer_invalid_response', 'Master bridge signer returned an invalid response');
        }

        return $body;
    }

    private function create_local_bridge_action_token(string $call_sid, int $user_id, string $action, int $ttl = 1800): string
    {
        try {
            $token = bin2hex(random_bytes(24));
        } catch (Throwable $e) {
            $token = wp_generate_password(48, false, false);
        }
        $key = 'vmo_bridge_action_' . md5($action . '|' . $call_sid . '|' . $user_id . '|' . $token);
        set_transient($key, array(
            'call_sid' => $call_sid,
            'user_id' => $user_id,
            'action' => $action,
        ), max(60, $ttl));
        return $token;
    }

    private function verify_local_bridge_action_token(string $call_sid, int $user_id, string $action, string $token): bool
    {
        $token = trim($token);
        if ($token === '') {
            return false;
        }
        $key = 'vmo_bridge_action_' . md5($action . '|' . $call_sid . '|' . $user_id . '|' . $token);
        $stored = get_transient($key);
        return is_array($stored)
            && (string) ($stored['call_sid'] ?? '') === $call_sid
            && (int) ($stored['user_id'] ?? 0) === $user_id
            && (string) ($stored['action'] ?? '') === $action;
    }

    private function bridge_dns_status(string $endpoint = 'media'): array
    {
        $url = $this->get_bridge_ws_url($endpoint);
        $host = (string) (parse_url($url, PHP_URL_HOST) ?: '');
        $host = strtolower(trim($host));

        if ($host === '') {
            return array(
                'ok' => false,
                'host' => '',
                'message' => 'Bridge URL is missing a host',
            );
        }

        if (filter_var($host, FILTER_VALIDATE_IP)) {
            return array(
                'ok' => true,
                'host' => $host,
                'addresses' => array($host),
                'message' => 'Bridge URL uses an IP literal',
            );
        }

        $cache_key = 'vmo_bridge_dns_' . md5($host);
        $cached = get_transient($cache_key);
        if (is_array($cached) && array_key_exists('ok', $cached)) {
            return $cached;
        }

        $addresses = array();
        if (function_exists('dns_get_record')) {
            $records = @dns_get_record($host, DNS_A + DNS_AAAA);
            if (is_array($records)) {
                foreach ($records as $record) {
                    if (!empty($record['ip'])) {
                        $addresses[] = (string) $record['ip'];
                    }
                    if (!empty($record['ipv6'])) {
                        $addresses[] = (string) $record['ipv6'];
                    }
                }
            }
        }

        if (empty($addresses) && function_exists('gethostbynamel')) {
            $resolved = @gethostbynamel($host);
            if (is_array($resolved)) {
                foreach ($resolved as $address) {
                    if (is_string($address) && $address !== '') {
                        $addresses[] = $address;
                    }
                }
            }
        }

        $addresses = array_values(array_unique(array_filter($addresses)));
        $ok = !empty($addresses);
        $status = array(
            'ok' => $ok,
            'host' => $host,
            'addresses' => $addresses,
            'message' => $ok ? ('Resolves to ' . implode(', ', array_slice($addresses, 0, 3))) : 'DNS lookup failed',
            'checked_at' => time(),
        );
        set_transient($cache_key, $status, $ok ? 300 : 60);

        return $status;
    }

    private function get_bridge_secret(string $purpose = 'listen'): string
    {
        $media_candidates = array(
            defined('VMO_MEDIA_SECRET') ? (string) VMO_MEDIA_SECRET : '',
            (string) get_option('vmo_media_secret', ''),
            getenv('VMO_MEDIA_SECRET') ?: '',
            defined('VMO_BRIDGE_MEDIA_SECRET') ? (string) VMO_BRIDGE_MEDIA_SECRET : '',
            (string) get_option('vmo_bridge_media_secret', ''),
            getenv('VMO_BRIDGE_MEDIA_SECRET') ?: '',
        );

        $listen_candidates = array(
            defined('VMO_LISTEN_SECRET') ? (string) VMO_LISTEN_SECRET : '',
            (string) get_option('vmo_listen_secret', ''),
            getenv('VMO_LISTEN_SECRET') ?: '',
            defined('VMO_BRIDGE_LISTEN_SECRET') ? (string) VMO_BRIDGE_LISTEN_SECRET : '',
            (string) get_option('vmo_bridge_listen_secret', ''),
            getenv('VMO_BRIDGE_LISTEN_SECRET') ?: '',
            (string) get_option('vmo_phone_bridge_listen_secret', ''),
        );

        $candidates = ($purpose === 'media')
            ? array_merge($media_candidates, $listen_candidates)
            : $listen_candidates;

        foreach ($candidates as $candidate) {
            $candidate = trim((string) $candidate);
            if ($candidate !== '') {
                return $candidate;
            }
        }

        return '';
    }

    private function is_premium_bridge_enabled(int $user_id): bool
    {
        // Each condition must hold for the WebSocket realtime
        // bridge to engage. When ANY one of them silently fails, the call
        // falls back to the standard <Gather> AI loop with no surfacing of
        // why — which is exactly the "premium not answering" symptom we
        // were hunting. Log each component individually so the next call
        // produces a definitive answer in error_log.
        $tier_ok = $user_id > 0 && $this->is_premium_voice_tier($user_id);
        $secret_ok = $this->is_client_mode() ? $this->client_bridge_signer_available() : ($this->get_bridge_secret('media') !== '');
        $ws_url_ok = $this->get_bridge_ws_url('media') !== '';
        $dns_status = $this->bridge_dns_status('media');
        $enabled = $tier_ok && $secret_ok && $ws_url_ok;

        if (!$enabled) {
            $tier_value = ($user_id > 0) ? $this->get_voice_quality_tier($user_id) : '(no user)';
            error_log('[VMO Premium Bridge] is_premium_bridge_enabled=false'
                . ' user_id=' . $user_id
                . ' tier_value=' . $tier_value
                . ' tier_ok=' . var_export($tier_ok, true)
                . ' media_secret_present=' . var_export($secret_ok, true)
                . ' client_master_signer_present=' . var_export($this->client_bridge_signer_available(), true)
                . ' ws_url_present=' . var_export($ws_url_ok, true)
                . ' bridge_host=' . (string) ($dns_status['host'] ?? '')
                . ' bridge_dns_ok=' . var_export(!empty($dns_status['ok']), true)
                . ' bridge_dns_message=' . (string) ($dns_status['message'] ?? ''));
        }

        return $enabled;
    }

    private function is_live_ai_stream_enabled_for_user(int $user_id): bool
    {
        return $this->is_premium_bridge_enabled($user_id);
    }

    /**
     * Fire the owner-path ring REST calls (external phone simultaneous ring +
     * browser client ring) AFTER the TwiML response has been flushed to Twilio.
     *
     * Called from the premium-bridge emit branch so the AI receptionist
     * answers the caller instantly (sub-second) while the owner's external
     * phone and dashboard browser still ring in parallel \u2014 they just don't
     * block the WebSocket handshake. Standard (non-premium) calls still run
     * the inline ring code before emitting their Gather TwiML; this helper
     * is only used by the premium path.
     */
    private function dispatch_owner_rings_now(string $call_sid, int $user_id, string $from, string $to, string $external_phone, bool $owner_alert_ready_now, bool $recording_enabled): void
    {
        // ── External phone simultaneous ring ──
        if ($owner_alert_ready_now && $external_phone !== '') {
            $vmo_phone = $this->get_user_phone($user_id);
            $ext_ring_url = $this->phone_gateway_url('ext_ring', array(
                'uid' => $user_id,
                'call_sid' => $call_sid,
                'caller' => $from,
            ));
            try {
                $ext_call_params = array(
                    'To' => $external_phone,
                    'From' => $vmo_phone ?: $to,
                    'Url' => $ext_ring_url,
                    'StatusCallback' => $this->twilio_public_url('status_callback', array('parent_sid' => $call_sid)),
                    'StatusCallbackEvent' => array('initiated', 'ringing', 'answered', 'completed'),
                    'Timeout' => 25,
                    'MachineDetection' => 'Enable',
                );
                if ($recording_enabled) {
                    $ext_call_params['Record'] = 'true';
                    $ext_call_params['RecordingStatusCallback'] = $this->twilio_public_url('recording');
                    $ext_call_params['RecordingStatusCallbackEvent'] = 'completed';
                }
                $ext_ring_result = $this->twilio_request('POST', '/Calls.json', $ext_call_params);
                if (is_wp_error($ext_ring_result)) {
                    error_log('[VMO Premium Bridge] Deferred external ring FAILED: ' . $ext_ring_result->get_error_message());
                } else {
                    $ext_ring_call_sid = (string) ($ext_ring_result['sid'] ?? '');
                    if ($ext_ring_call_sid !== '') {
                        $this->track_child_ring_call($call_sid, $ext_ring_call_sid);
                    }
                    error_log('[VMO Premium Bridge] Deferred external ring initiated to ' . $external_phone . ' for ' . $call_sid . ' child=' . $ext_ring_call_sid);
                }
            } catch (Throwable $e) {
                error_log('[VMO Premium Bridge] Deferred external ring EXCEPTION: ' . $e->getMessage());
            }
        }

        // ── Extension/department phones simultaneous ring ──
        // Per VMO Phone System design, every configured extension phone also
        // doubles as a parallel ring target so the owner is reachable from
        // anywhere they configured a number — not just from the single
        // vmo_external_phone field. The Press-1-Listen / Press-2-Takeover
        // menu is served by the ext_ring TwiML URL the same way.
        // DISABLED on start of call so that department configured phones only
        // ring when chosen by the caller from the IVR menu, rather than all
        // ringing at the same time as the main line.
        if (false && $owner_alert_ready_now && $user_id > 0) {
            $ext_numbers_raw = get_user_meta($user_id, 'vmo_phone_extensions', true);
            $ext_labels_raw = get_user_meta($user_id, 'vmo_phone_extension_labels', true);
            $ext_numbers = is_array($ext_numbers_raw) ? $ext_numbers_raw : array();
            $ext_labels = is_array($ext_labels_raw) ? $ext_labels_raw : array();
            $already_rung = array();
            $external_norm = preg_replace('/\D+/', '', (string) $external_phone);
            if ($external_norm !== '') {
                $already_rung[$external_norm] = true;
            }
            $vmo_phone_for_ext = $this->get_user_phone($user_id);
            if (empty($vmo_phone_for_ext)) {
                $vmo_phone_for_ext = $to;
            }
            foreach ($ext_numbers as $digit => $ext_phone_raw) {
                $ext_phone = trim((string) $ext_phone_raw);
                if ($ext_phone === '') {
                    continue;
                }
                $ext_norm = preg_replace('/\D+/', '', $ext_phone);
                if ($ext_norm === '' || isset($already_rung[$ext_norm])) {
                    continue;
                }
                $already_rung[$ext_norm] = true;
                $ext_label = isset($ext_labels[$digit]) && $ext_labels[$digit] !== ''
                    ? (string) $ext_labels[$digit]
                    : ('Extension ' . preg_replace('/\D+/', '', (string) $digit));
                $ext_ring_url2 = $this->phone_gateway_url('ext_ring', array(
                    'uid' => $user_id,
                    'call_sid' => $call_sid,
                    'caller' => $from,
                ));
                try {
                    $ext_call_params2 = array(
                        'To' => $ext_phone,
                        'From' => $vmo_phone_for_ext,
                        'Url' => $ext_ring_url2,
                        'StatusCallback' => $this->twilio_public_url('status_callback', array('parent_sid' => $call_sid)),
                        'StatusCallbackEvent' => array('initiated', 'ringing', 'answered', 'completed'),
                        'Timeout' => 25,
                        'MachineDetection' => 'Enable',
                    );
                    if ($recording_enabled) {
                        $ext_call_params2['Record'] = 'true';
                        $ext_call_params2['RecordingStatusCallback'] = $this->twilio_public_url('recording');
                        $ext_call_params2['RecordingStatusCallbackEvent'] = 'completed';
                    }
                    $ext_ring_result2 = $this->twilio_request('POST', '/Calls.json', $ext_call_params2);
                    if (is_wp_error($ext_ring_result2)) {
                        error_log('[VMO Premium Bridge] Deferred extension ring FAILED to ' . $ext_phone . ' (' . $ext_label . '): ' . $ext_ring_result2->get_error_message());
                    } else {
                        $ext_child_sid = (string) ($ext_ring_result2['sid'] ?? '');
                        if ($ext_child_sid !== '') {
                            $this->track_child_ring_call($call_sid, $ext_child_sid);
                        }
                        error_log('[VMO Premium Bridge] Deferred extension ring initiated to ' . $ext_phone . ' (' . $ext_label . ') for ' . $call_sid . ' child=' . $ext_child_sid);
                    }
                } catch (Throwable $e) {
                    error_log('[VMO Premium Bridge] Deferred extension ring EXCEPTION for ' . $ext_phone . ': ' . $e->getMessage());
                }
            }
        }

        // ── Browser client ring ──
        // Honor the Silent toggle here too so the premium-bridge path doesn't
        // bypass it (the inline path was already gated above).
        $browser_silent = false;
        if ($user_id > 0) {
            $ring_enabled_meta = get_user_meta($user_id, 'vmo_ring_enabled', true);
            if ($ring_enabled_meta === '0') {
                $browser_silent = true;
                error_log('[VMO Premium Bridge] Deferred browser ring SUPPRESSED by Silent toggle for user ' . $user_id);
            }
        }
        if ($owner_alert_ready_now && $user_id > 0 && !$browser_silent) {
            $client_identity = 'vmo_user_' . $user_id;
            $browser_ring_url = $this->phone_gateway_url('ext_ring', array(
                'uid' => $user_id,
                'call_sid' => $call_sid,
                'caller' => $from,
            ));
            try {
                $browser_call_params = array(
                    'To' => 'client:' . $client_identity,
                    'From' => $to,
                    'Url' => $browser_ring_url,
                    'StatusCallback' => $this->twilio_public_url('status_callback', array('parent_sid' => $call_sid)),
                    'StatusCallbackEvent' => array('initiated', 'ringing', 'answered', 'completed'),
                    'Timeout' => 25,
                    'Parameters' => wp_json_encode(array(
                        'ParentCallSid' => $call_sid,
                        'CallerNumber' => $from,
                    )),
                );
                $ring_result = $this->twilio_request('POST', '/Calls.json', $browser_call_params);
                if (is_wp_error($ring_result)) {
                    error_log('[VMO Premium Bridge] Deferred browser ring FAILED: ' . $ring_result->get_error_message());
                } else {
                    $ring_call_sid = (string) ($ring_result['sid'] ?? '');
                    if ($ring_call_sid !== '') {
                        $this->track_child_ring_call($call_sid, $ring_call_sid);
                    }
                    error_log('[VMO Premium Bridge] Deferred browser ring initiated to client:' . $client_identity . ' for ' . $call_sid . ' child=' . $ring_call_sid);
                }
            } catch (Throwable $e) {
                error_log('[VMO Premium Bridge] Deferred browser ring EXCEPTION: ' . $e->getMessage());
            }
        }
    }

    private function bridge_active_key(string $call_sid): string
    {
        $safe_sid = sanitize_key($call_sid);
        return ($safe_sid !== '') ? ('vmo_bridge_active_' . $safe_sid) : '';
    }

    private function set_bridge_active_context(string $call_sid, array $context, int $ttl = 1800): void
    {
        $key = $this->bridge_active_key($call_sid);
        if ($key !== '') {
            set_transient($key, $context, max(60, $ttl));
        }
    }

    private function get_bridge_active_context(string $call_sid): array
    {
        $key = $this->bridge_active_key($call_sid);
        if ($key === '') {
            return array();
        }

        $context = get_transient($key);
        return is_array($context) ? $context : array();
    }

    private function clear_bridge_active_context(string $call_sid): void
    {
        $key = $this->bridge_active_key($call_sid);
        if ($key !== '') {
            delete_transient($key);
        }
    }

    private function build_bridge_media_parameters(int $user_id, string $call_sid, string $voice, array $bridge_context = array())
    {
        $secret = $this->is_client_mode() ? '' : $this->get_bridge_secret('media');

        $call_sid = sanitize_text_field($call_sid);
        $voice = $this->normalize_openai_reception_voice($voice) ?: 'ash';
        $media_exp = time() + 300;
        $call_language = $this->normalize_call_language_context($bridge_context['call_language'] ?? null);
        $language_policy = trim((string) ($bridge_context['language_policy'] ?? ''));
        if ($language_policy === '') {
            $language_policy = $this->receptionist_language_policy($call_language);
        }

        $language_policy = preg_replace('/\s+/', ' ', wp_strip_all_tags($language_policy));
        if ($this->is_client_mode()) {
            $this->trace_voice_call($call_sid, 'premium_bridge_media_sign_request mode=client');
            $signed = $this->request_master_bridge_signature('media', array(
                'call_sid' => $call_sid,
                'user_id' => $user_id,
                'voice' => $voice,
                'exp' => $media_exp,
            ));
            if (is_wp_error($signed)) {
                $this->trace_voice_call($call_sid, 'premium_bridge_media_sign_failed code=' . $signed->get_error_code() . ' message=' . $signed->get_error_message());
                $this->record_premium_bridge_status($user_id, $call_sid, 'Premium media signer failed: ' . $signed->get_error_message(), true);
                return $signed;
            }
            $media_exp = (int) ($signed['media_exp'] ?? $signed['mediaExp'] ?? $media_exp);
            $media_sig = (string) ($signed['media_sig'] ?? $signed['mediaSig'] ?? '');
            if ($media_sig === '') {
                $this->trace_voice_call($call_sid, 'premium_bridge_media_sign_failed code=missing_master_bridge_signature');
                $this->record_premium_bridge_status($user_id, $call_sid, 'Premium media signer returned no signature.', true);
                return new WP_Error('missing_master_bridge_signature', 'Master bridge signer did not return a media signature');
            }
            $this->trace_voice_call($call_sid, 'premium_bridge_media_sign_ok mode=client exp=' . $media_exp);
        } elseif ($secret !== '') {
            $payload = $call_sid . '|' . $user_id . '|' . $voice . '|' . $media_exp;
            $media_sig = hash_hmac('sha256', $payload, $secret);
            $this->trace_voice_call($call_sid, 'premium_bridge_media_sign_ok mode=local exp=' . $media_exp);
        } else {
            $this->trace_voice_call($call_sid, 'premium_bridge_media_sign_failed code=missing_bridge_secret');
            $this->record_premium_bridge_status($user_id, $call_sid, 'Premium media signer failed: bridge media secret is missing on master runtime.', true);
            return new WP_Error('missing_bridge_secret', 'Bridge media secret is not configured');
        }

        $parameters = array(
            'call_sid' => $call_sid,
            'callSid' => $call_sid,
            'user_id' => (string) $user_id,
            'userId' => (string) $user_id,
            'voice' => $voice,
            'default_language' => (string) ($call_language['code'] ?? 'en'),
            'default_language_name' => (string) ($call_language['name'] ?? 'English'),
            'default_language_country' => (string) ($call_language['country'] ?? ''),
            'language_policy' => substr($language_policy, 0, 480),
            'media_exp' => (string) $media_exp,
            'mediaExp' => (string) $media_exp,
            'media_sig' => $media_sig,
            'mediaSig' => $media_sig,
        );

        $phone_opening = (isset($bridge_context['phone_opening']) && is_array($bridge_context['phone_opening']))
            ? $bridge_context['phone_opening']
            : array();
        $initial_greeting = trim((string) ($phone_opening['initial_greeting'] ?? ($phone_opening['ai_greeting'] ?? '')));
        if ($initial_greeting !== '') {
            $initial_greeting = preg_replace('/\s+/', ' ', wp_strip_all_tags($initial_greeting));
            $parameters['initial_greeting'] = substr($initial_greeting, 0, 480);
            $parameters['initialGreeting'] = substr($initial_greeting, 0, 480);
        }

        $initial_instruction = trim((string) ($bridge_context['initial_instruction'] ?? ($phone_opening['realtime_instruction'] ?? '')));
        if ($initial_instruction !== '') {
            $initial_instruction = preg_replace('/\s+/', ' ', wp_strip_all_tags($initial_instruction));
            $parameters['initial_instruction'] = substr($initial_instruction, 0, 480);
            $parameters['initialInstruction'] = substr($initial_instruction, 0, 480);
        }

        $extension_map = (isset($bridge_context['extension_map']) && is_array($bridge_context['extension_map']))
            ? $bridge_context['extension_map']
            : array();
        if (!empty($extension_map)) {
            $menu_parts = array();
            foreach ($extension_map as $digit => $info) {
                if (!is_array($info)) {
                    continue;
                }
                $digit_str = preg_replace('/\D+/', '', (string) $digit);
                $label = trim((string) ($info['label'] ?? ''));
                if ($digit_str === '' || $label === '') {
                    continue;
                }
                $menu_parts[] = $label . ' press ' . $digit_str;
            }
            if (!empty($menu_parts)) {
                $parameters['extension_count'] = (string) count($menu_parts);
                $parameters['extensionCount'] = (string) count($menu_parts);
                $parameters['extension_menu'] = substr('Available departments: ' . implode('; ', $menu_parts) . '.', 0, 470);
                $parameters['extensionMenu'] = substr('Available departments: ' . implode('; ', $menu_parts) . '.', 0, 470);

                $compact_map = array();
                foreach ($extension_map as $digit => $info) {
                    if (!is_array($info)) {
                        continue;
                    }
                    $digit_str = preg_replace('/\D+/', '', (string) $digit);
                    $label = trim((string) ($info['label'] ?? ''));
                    if ($digit_str !== '' && $label !== '') {
                        $compact_map[$digit_str] = $label;
                    }
                }
                $map_json = wp_json_encode($compact_map);
                if (is_string($map_json) && $map_json !== '') {
                    $chunks = str_split($map_json, 430);
                    foreach ($chunks as $idx => $chunk) {
                        $parameters['extension_map_' . ($idx + 1)] = $chunk;
                        $parameters['extensionMap_' . ($idx + 1)] = $chunk;
                        $parameters['extensionMap' . ($idx + 1)] = $chunk;
                    }
                    $parameters['extension_map_chunks'] = (string) count($chunks);
                    $parameters['extensionMapChunks'] = (string) count($chunks);
                }

                $ext_exp = time() + 1800;
                $ext_payload = $call_sid . '|' . $user_id . '|' . $ext_exp;
                $parameters['extension_action_url'] = $this->phone_gateway_url('premium_extension_ring', array(
                    'uid' => $user_id,
                    'call_sid' => $call_sid,
                    'exp' => $ext_exp,
                    'sig' => $secret !== '' ? hash_hmac('sha256', $ext_payload, $secret) : '',
                    'token' => $secret === '' ? $this->create_local_bridge_action_token($call_sid, $user_id, 'premium_extension_ring', 1800) : '',
                ));
                $parameters['extensionActionUrl'] = $parameters['extension_action_url'];
                $parameters['extension_action'] = 'POST digit to extension_action_url to ring the matching department while AI continues speaking.';
                $parameters['extensionAction'] = $parameters['extension_action'];
            }
        }

        return $parameters;
    }

    private function extension_menu_item_text(string $label, string $digit, array $call_language, bool $allow_speech = true): string
    {
        $language = $this->normalize_call_language_context($call_language);
        $code = (string) ($language['code'] ?? 'en');
        $root = preg_replace('/-.+$/', '', $code);

        switch ($root) {
            case 'es':
                return 'Para ' . $label . ', presione ' . $digit . ($allow_speech ? ' o diga ' . $label : '') . '.';
            case 'fr':
                return 'Pour ' . $label . ', appuyez sur ' . $digit . ($allow_speech ? ' ou dites ' . $label : '') . '.';
            case 'pt':
                return 'Para ' . $label . ', pressione ' . $digit . ($allow_speech ? ' ou diga ' . $label : '') . '.';
            case 'de':
                return 'Fuer ' . $label . ', druecken Sie ' . $digit . ($allow_speech ? ' oder sagen Sie ' . $label : '') . '.';
            case 'it':
                return 'Per ' . $label . ', prema ' . $digit . ($allow_speech ? ' o dica ' . $label : '') . '.';
            case 'nl':
                return 'Voor ' . $label . ', toets ' . $digit . ($allow_speech ? ' of zeg ' . $label : '') . '.';
            default:
                return 'For ' . $label . ', press ' . $digit . ($allow_speech ? ' or say ' . $label : '') . '.';
        }
    }

    private function extension_menu_followup_text(array $call_language): string
    {
        $language = $this->normalize_call_language_context($call_language);
        $code = (string) ($language['code'] ?? 'en');
        $root = preg_replace('/-.+$/', '', $code);

        switch ($root) {
            case 'es':
                return 'Tambien puede decirme lo que necesita y le ayudare.';
            case 'fr':
                return 'Vous pouvez aussi me dire ce dont vous avez besoin et je vous aiderai.';
            case 'pt':
                return 'Voce tambem pode me dizer do que precisa, e eu vou ajudar.';
            case 'de':
                return 'Sie koennen mir auch sagen, was Sie brauchen, und ich helfe Ihnen.';
            case 'it':
                return 'Puo anche dirmi di cosa ha bisogno e la aiutero.';
            case 'nl':
                return 'U kunt mij ook vertellen wat u nodig heeft, dan help ik u.';
            default:
                return 'You can also tell me what you need, and I will help you.';
        }
    }

    private function localized_recording_notice(array $call_language): string
    {
        $language = $this->normalize_call_language_context($call_language);
        $code = (string) ($language['code'] ?? 'en');
        $root = preg_replace('/-.+$/', '', $code);

        switch ($root) {
            case 'es':
                return 'Esta llamada puede ser grabada para calidad y capacitacion.';
            case 'fr':
                return 'Cet appel peut etre enregistre a des fins de qualite et de formation.';
            case 'pt':
                return 'Esta chamada pode ser gravada para qualidade e treinamento.';
            case 'de':
                return 'Dieser Anruf kann zur Qualitaetssicherung und Schulung aufgezeichnet werden.';
            case 'it':
                return 'Questa chiamata puo essere registrata per qualita e formazione.';
            case 'nl':
                return 'Dit gesprek kan worden opgenomen voor kwaliteit en training.';
            case 'ja':
                return 'この通話は品質向上とトレーニングのために録音される場合があります。';
            case 'ko':
                return '이 통화는 품질 및 교육 목적으로 녹음될 수 있습니다.';
            case 'zh':
            case 'yue':
                return '本次通话可能会被录音，用于质量和培训目的。';
            case 'ar':
                return 'قد يتم تسجيل هذه المكالمة لاغراض الجودة والتدريب.';
            default:
                return 'This call may be recorded for quality and training purposes.';
        }
    }

    private function build_realtime_extension_opening(array $phone_opening, array $extension_map, array $call_language = array()): array
    {
        if (empty($extension_map)) {
            return $phone_opening;
        }

        $call_language = $this->normalize_call_language_context($call_language);

        $menu_parts = array();
        $instruction_lines = array();
        foreach ($extension_map as $digit_str => $info) {
            if (!is_array($info)) {
                continue;
            }
            $label = trim((string) ($info['label'] ?? ''));
            $digit = preg_replace('/\D+/', '', (string) $digit_str);
            if ($label === '' || $digit === '') {
                continue;
            }
            $menu_parts[] = $this->extension_menu_item_text($label, $digit, $call_language);
            $instruction_lines[] = "digit {$digit} = {$label}";
        }

        if (empty($menu_parts)) {
            return $phone_opening;
        }

        $current_greeting = $this->localized_initial_greeting($call_language, (string) ($phone_opening['initial_greeting'] ?? ($phone_opening['ai_greeting'] ?? '')));
        $current_greeting = preg_replace('/\s+/', ' ', wp_strip_all_tags($current_greeting));
        if ($current_greeting === '') {
            $current_greeting = 'Thank you for calling.';
        }
        $current_greeting = preg_replace('/\s*(?:how can I (?:help|assist) you(?: today)?|what can I (?:help|assist) you with(?: today)?)\??\s*$/i', '', $current_greeting);
        $current_greeting = rtrim(trim($current_greeting), '.?!');
        if ($current_greeting === '') {
            $current_greeting = 'Thank you for calling';
        }

        $spoken_menu = implode(' ', $menu_parts) . ' ' . $this->extension_menu_followup_text($call_language);
        $phone_opening['initial_greeting'] = $current_greeting . '. ' . $spoken_menu;

        $ext_instruction = 'This business has configured department routing. In your opening greeting, present these department options before asking what the caller needs. '
            . 'When the caller asks for one of these departments or enters a matching DTMF digit, ring/notify the matching department immediately while you keep speaking with and assisting the caller. '
            . 'After notifying the department, tell the caller professionally that the department has been notified and may join at any time, then continue helping them while they wait. '
            . 'Available extensions: ' . implode('; ', $instruction_lines) . '. If the caller does not request a department, assist them directly.';
        $phone_opening['realtime_instruction'] = trim((string) ($phone_opening['realtime_instruction'] ?? '') . ' ' . $ext_instruction);

        return $phone_opening;
    }

    private function normalize_extension_match_text(string $value): string
    {
        $value = wp_strip_all_tags($value);
        if (function_exists('remove_accents')) {
            $value = remove_accents($value);
        }
        $value = strtolower($value);
        $value = preg_replace('/[^a-z0-9]+/', ' ', $value);
        $value = is_string($value) ? preg_replace('/\s+/', ' ', $value) : '';
        return trim(is_string($value) ? $value : '');
    }

    private function find_extension_digit_from_speech(string $speech_result, array $extensions, array $extension_labels): string
    {
        $spoken_text = $this->normalize_extension_match_text($speech_result);
        if ($spoken_text === '' || empty($extensions)) {
            return '';
        }

        $digit_words = array(
            'zero' => '0',
            'oh' => '0',
            'one' => '1',
            'two' => '2',
            'three' => '3',
            'four' => '4',
            'five' => '5',
            'six' => '6',
            'seven' => '7',
            'eight' => '8',
            'nine' => '9',
        );

        foreach ($extensions as $digit => $target_number) {
            $digit_str = preg_replace('/\D+/', '', (string) $digit);
            if ($digit_str === '' || trim((string) $target_number) === '') {
                continue;
            }

            if (preg_match('/(?:^|\s)(?:extension|press|option|number|department)?\s*' . preg_quote($digit_str, '/') . '(?:\s|$)/', $spoken_text)) {
                return $digit_str;
            }

            foreach ($digit_words as $word => $mapped_digit) {
                if ($mapped_digit === $digit_str && preg_match('/(?:^|\s)(?:extension|press|option|number|department)?\s*' . preg_quote($word, '/') . '(?:\s|$)/', $spoken_text)) {
                    return $digit_str;
                }
            }
        }

        $label_matches = array();
        foreach ($extensions as $digit => $target_number) {
            $digit_str = preg_replace('/\D+/', '', (string) $digit);
            if ($digit_str === '' || trim((string) $target_number) === '') {
                continue;
            }
            $label = isset($extension_labels[$digit]) && trim((string) $extension_labels[$digit]) !== ''
                ? (string) $extension_labels[$digit]
                : ('extension ' . $digit_str);
            $label_text = $this->normalize_extension_match_text($label);
            if ($label_text !== '') {
                $label_matches[] = array(
                    'digit' => $digit_str,
                    'label' => $label_text,
                    'length' => strlen($label_text),
                );
            }
        }

        usort($label_matches, function ($left, $right) {
            return intval($right['length']) <=> intval($left['length']);
        });

        foreach ($label_matches as $match) {
            $label_text = (string) $match['label'];
            $label_core = preg_replace('/\b(department|team|office|desk|line)\b/', ' ', $label_text);
            $label_core = is_string($label_core) ? trim(preg_replace('/\s+/', ' ', $label_core)) : '';

            if ($label_text !== '' && strpos($spoken_text, $label_text) !== false) {
                return (string) $match['digit'];
            }
            if ($label_core !== '' && strlen($label_core) >= 3 && strpos($spoken_text, $label_core) !== false) {
                return (string) $match['digit'];
            }
        }

        return '';
    }

    private function build_premium_extension_ivr_prompt(string $ai_greeting, array $extensions, array $extension_labels, array $call_language = array()): string
    {
        $call_language = $this->normalize_call_language_context($call_language);
        $base_greeting = $this->localized_initial_greeting($call_language, $ai_greeting);
        $base_greeting = preg_replace('/\s+/', ' ', wp_strip_all_tags($base_greeting));
        $base_greeting = is_string($base_greeting) ? trim($base_greeting) : '';
        if ($base_greeting === '') {
            $base_greeting = 'Thank you for calling.';
        }
        $base_greeting = preg_replace('/\s*(?:how can I (?:help|assist) you(?: today)?|what can I (?:help|assist) you with(?: today)?)\??\s*$/i', '', $base_greeting);
        $base_greeting = rtrim(trim(is_string($base_greeting) ? $base_greeting : 'Thank you for calling'), '.?!');
        if ($base_greeting === '') {
            $base_greeting = 'Thank you for calling';
        }

        $menu_parts = array();
        foreach ($extensions as $digit => $target_number) {
            $digit_str = preg_replace('/\D+/', '', (string) $digit);
            if ($digit_str === '' || trim((string) $target_number) === '') {
                continue;
            }
            $label = isset($extension_labels[$digit]) && trim((string) $extension_labels[$digit]) !== ''
                ? trim((string) $extension_labels[$digit])
                : ('extension ' . $digit_str);
            $label = preg_replace('/\s+/', ' ', wp_strip_all_tags($label));
            $label = trim(is_string($label) ? $label : ('extension ' . $digit_str));
            if ($label === '') {
                $label = 'extension ' . $digit_str;
            }
            $menu_parts[] = $this->extension_menu_item_text($label, $digit_str, $call_language);
        }

        if (empty($menu_parts)) {
            return $base_greeting . '. How can I help you today?';
        }

        return $base_greeting . '. ' . implode(' ', $menu_parts) . ' ' . $this->extension_menu_followup_text($call_language);
    }

    private function ring_configured_extension_for_live_call(string $call_sid, int $user_id, string $digit, string $source = 'realtime')
    {
        $call_sid = sanitize_text_field($call_sid);
        $user_id = (int) $user_id;
        $digit = preg_replace('/\D+/', '', (string) $digit);
        if ($call_sid === '' || $user_id <= 0 || $digit === '') {
            return new WP_Error('missing_extension_context', 'Missing call, user, or extension digit');
        }

        $ctx = $this->get_bridge_active_context($call_sid);
        if (empty($ctx)) {
            $ctx = $this->get_realtime_call_context($call_sid);
        }
        $ext_map = (isset($ctx['extension_map']) && is_array($ctx['extension_map'])) ? $ctx['extension_map'] : array();
        if (empty($ext_map)) {
            $extensions_raw = get_user_meta($user_id, 'vmo_phone_extensions', true);
            $labels_raw = get_user_meta($user_id, 'vmo_phone_extension_labels', true);
            $extensions = is_array($extensions_raw) ? $extensions_raw : array();
            $labels = is_array($labels_raw) ? $labels_raw : array();
            foreach ($extensions as $ext_digit => $target_number) {
                $ext_digit_str = preg_replace('/\D+/', '', (string) $ext_digit);
                $phone = trim((string) $target_number);
                if ($ext_digit_str === '' || $phone === '') {
                    continue;
                }
                $ext_map[$ext_digit_str] = array(
                    'label' => isset($labels[$ext_digit]) && $labels[$ext_digit] !== '' ? (string) $labels[$ext_digit] : ('Extension ' . $ext_digit_str),
                    'phone' => $phone,
                );
            }
        }

        if (!isset($ext_map[$digit]) || !is_array($ext_map[$digit])) {
            return new WP_Error('unknown_extension', 'That extension is not configured for this call');
        }

        $ext_phone = trim((string) ($ext_map[$digit]['phone'] ?? ''));
        $ext_label = trim((string) ($ext_map[$digit]['label'] ?? ('extension ' . $digit)));
        if ($ext_phone === '') {
            return new WP_Error('missing_extension_phone', 'That extension does not have a phone number configured');
        }

        $dedupe_key = 'vmo_live_ext_ring_' . sanitize_key($call_sid . '_' . $digit);
        if (get_transient($dedupe_key)) {
            return array(
                'success' => true,
                'already_notified' => true,
                'digit' => $digit,
                'label' => $ext_label,
                'message' => $ext_label . ' has already been notified for this call.',
            );
        }
        set_transient($dedupe_key, 1, 90);

        $call_context = $this->get_call_runtime_context($call_sid);
        $record = isset($call_context['record']) ? $call_context['record'] : null;
        $caller = '';
        if (is_object($record) && isset($record->caller_number)) {
            $caller = (string) $record->caller_number;
        }
        $vmo_phone = $this->get_user_phone($user_id);
        if (empty($vmo_phone)) {
            $vmo_phone = (string) get_option('vmo_twilio_phone_number', '');
        }
        if (empty($vmo_phone)) {
            delete_transient($dedupe_key);
            return new WP_Error('missing_caller_id', 'Could not find a VMO phone number to ring the extension from');
        }
        $ext_ring_url = $this->phone_gateway_url('ext_ring', array(
            'uid' => $user_id,
            'call_sid' => $call_sid,
            'caller' => $caller,
        ));

        $recording_enabled = $this->is_recording_enabled($user_id);
        $ext_call_params = array(
            'To' => $ext_phone,
            'From' => $vmo_phone,
            'Url' => $ext_ring_url,
            'StatusCallback' => $this->twilio_public_url('status_callback', array('parent_sid' => $call_sid)),
            'StatusCallbackEvent' => array('initiated', 'ringing', 'answered', 'completed'),
            'Timeout' => 30,
            'MachineDetection' => 'Enable',
        );
        if ($recording_enabled) {
            $ext_call_params['Record'] = 'true';
            $ext_call_params['RecordingStatusCallback'] = $this->twilio_public_url('recording');
            $ext_call_params['RecordingStatusCallbackEvent'] = 'completed';
        }

        $ext_ring_result = $this->twilio_request('POST', '/Calls.json', $ext_call_params);
        if (is_wp_error($ext_ring_result)) {
            delete_transient($dedupe_key);
            error_log('[VMO Live Extension Ring] Failed from ' . $source . ' for ' . $call_sid . ' digit=' . $digit . ': ' . $ext_ring_result->get_error_message());
            return $ext_ring_result;
        }

        $ext_ring_call_sid = (string) ($ext_ring_result['sid'] ?? '');
        if ($ext_ring_call_sid !== '') {
            $this->track_child_ring_call($call_sid, $ext_ring_call_sid);
        }
        error_log('[VMO Live Extension Ring] ' . $source . ' notified ' . $ext_label . ' digit=' . $digit . ' phone=' . $ext_phone . ' parent=' . $call_sid . ($ext_ring_call_sid !== '' ? ' child=' . $ext_ring_call_sid : ''));

        return array(
            'success' => true,
            'digit' => $digit,
            'label' => $ext_label,
            'call_sid' => $call_sid,
            'child_call_sid' => $ext_ring_call_sid,
            'message' => $ext_label . ' has been notified and may join the call at any time.',
        );
    }

    public function handle_premium_extension_ring($request)
    {
        $params = $request->get_params();
        $call_sid = isset($params['call_sid']) ? sanitize_text_field((string) $params['call_sid']) : '';
        $user_id = isset($params['uid']) ? (int) $params['uid'] : 0;
        $digit = isset($params['digit']) ? sanitize_text_field((string) $params['digit']) : '';
        $exp = isset($params['exp']) ? (int) $params['exp'] : 0;
        $sig = isset($params['sig']) ? sanitize_text_field((string) $params['sig']) : '';
        $token = isset($params['token']) ? sanitize_text_field((string) $params['token']) : '';

        if ($this->is_client_mode()) {
            if (!$this->verify_local_bridge_action_token($call_sid, $user_id, 'premium_extension_ring', $token)) {
                return new WP_Error('forbidden_premium_extension_ring', 'Invalid Premium extension ring token', array('status' => 403));
            }
        } elseif ($sig !== '') {
            $secret = $this->get_bridge_secret('media');
            $expected = ($secret !== '' && $call_sid !== '' && $user_id > 0 && $exp > 0)
                ? hash_hmac('sha256', $call_sid . '|' . $user_id . '|' . $exp, $secret)
                : '';
            if ($expected === '' || $sig === '' || $exp < time() || !hash_equals($expected, $sig)) {
                return new WP_Error('forbidden_premium_extension_ring', 'Invalid Premium extension ring signature', array('status' => 403));
            }
        } elseif ($exp > 0) {
            return new WP_Error('forbidden_premium_extension_ring', 'Invalid Premium extension ring signature', array('status' => 403));
        }

        return $this->ring_configured_extension_for_live_call($call_sid, $user_id, $digit, 'premium_bridge');
    }

    private function build_bridge_listen_url(int $user_id, string $call_sid)
    {
        $secret = $this->is_client_mode() ? '' : $this->get_bridge_secret('listen');

        $call_sid = sanitize_text_field($call_sid);
        $exp = time() + 300;
        if ($this->is_client_mode()) {
            $signed = $this->request_master_bridge_signature('listen', array(
                'call_sid' => $call_sid,
                'user_id' => $user_id,
                'exp' => $exp,
            ));
            if (is_wp_error($signed)) {
                $this->trace_voice_call($call_sid, 'premium_bridge_listen_sign_failed code=' . $signed->get_error_code() . ' message=' . $signed->get_error_message());
                $this->record_premium_bridge_status($user_id, $call_sid, 'Premium listen signer failed: ' . $signed->get_error_message(), true);
                return $signed;
            }
            $listen_url = esc_url_raw((string) ($signed['listen_url'] ?? ''));
            if ($listen_url !== '') {
                $this->trace_voice_call($call_sid, 'premium_bridge_listen_sign_ok mode=client');
            } else {
                $this->trace_voice_call($call_sid, 'premium_bridge_listen_sign_failed code=missing_master_listen_url');
                $this->record_premium_bridge_status($user_id, $call_sid, 'Premium listen signer returned no listen URL.', true);
            }
            return $listen_url !== '' ? $listen_url : new WP_Error('missing_master_listen_url', 'Master bridge signer did not return a listen URL');
        }
        if ($secret === '') {
            $this->trace_voice_call($call_sid, 'premium_bridge_listen_sign_failed code=missing_listen_secret');
            $this->record_premium_bridge_status($user_id, $call_sid, 'Premium listen signer failed: bridge listen secret is missing on master runtime.', true);
            return new WP_Error('missing_listen_secret', 'Bridge listen secret is not configured');
        }

        $payload = $call_sid . '|' . $user_id . '|' . $exp;
        $sig = hash_hmac('sha256', $payload, $secret);
        $this->trace_voice_call($call_sid, 'premium_bridge_listen_sign_ok mode=local');

        return add_query_arg(array(
            'call_sid' => $call_sid,
            'user_id' => $user_id,
            'exp' => $exp,
            'sig' => $sig,
        ), $this->get_bridge_ws_url('listen'));
    }

    /**
     * Pull the latest active master brief summary for cached-first phone context.
     */
    private function get_master_business_brief_context(int $user_id): string
    {
        if ($user_id <= 0) {
            return '';
        }

        global $wpdb;
        $table = $wpdb->prefix . 'vmo_master_business_briefs';
        $table_exists = $wpdb->get_var($wpdb->prepare('SHOW TABLES LIKE %s', $table));
        if ($table_exists !== $table) {
            return '';
        }

        $row = $wpdb->get_row($wpdb->prepare(
            "SELECT brief_summary, updated_at FROM {$table} WHERE user_id = %d AND is_active = 1 ORDER BY updated_at DESC, id DESC LIMIT 1",
            $user_id
        ), ARRAY_A);

        if (!is_array($row)) {
            return '';
        }

        $summary = trim(wp_strip_all_tags((string) ($row['brief_summary'] ?? '')));
        if ($summary === '') {
            return '';
        }

        $context = "\nMaster Business Brief (cached canonical context):\n";
        $context .= 'Summary: ' . $summary . "\n";

        if (!empty($row['updated_at'])) {
            $context .= 'Last Synced: ' . trim((string) $row['updated_at']) . "\n";
        }

        return $context;
    }

    /**
     * Estimate whether a phone turn needs GPT-5.5 escalation.
     */
    private function estimate_phone_turn_complexity(string $speech, array $history = array()): float
    {
        $normalized = strtolower(trim(wp_strip_all_tags($speech)));
        if ($normalized === '') {
            return 0.15;
        }

        $complexity = 0.18;
        $length = mb_strlen($normalized);

        if ($length >= 260) {
            $complexity += 0.32;
        } elseif ($length >= 160) {
            $complexity += 0.20;
        } elseif ($length >= 90) {
            $complexity += 0.10;
        }

        $complex_signals = array(
            'contract',
            'legal',
            'attorney',
            'lawsuit',
            'compliance',
            'regulation',
            'permit',
            'license',
            'licence',
            'tax',
            'invoice',
            'refund policy',
            'payment plan',
            'financing',
            'integration',
            'api',
            'migration',
            'custom quote',
            'proposal',
            'partnership',
            'enterprise',
            'wholesale',
            'franchise',
            'multi location',
            'roadmap',
            'strategy',
            'compare',
            'difference between',
            'what happens if',
            'walk me through'
        );

        foreach ($complex_signals as $signal) {
            if (strpos($normalized, $signal) !== false) {
                $complexity += 0.18;
                break;
            }
        }

        if (substr_count($normalized, '?') > 1) {
            $complexity += 0.08;
        }

        $user_turns = count(array_filter($history, static function ($message) {
            return (($message['role'] ?? '') === 'user');
        }));
        if ($user_turns >= 4) {
            $complexity += 0.12;
        }

        return min(1.0, $complexity);
    }

    private function clear_owner_alert_state($call_sid): void
    {
        $safe_call_sid = sanitize_key((string) $call_sid);
        if ($safe_call_sid === '') {
            return;
        }

        delete_transient('vmo_owner_alert_' . $safe_call_sid);
    }

    private function user_ring_signal_key(int $user_id): string
    {
        return ($user_id > 0) ? ('vmo_ring_uid_' . $user_id) : '';
    }

    private function store_user_ring_signal(int $user_id, array $payload): void
    {
        $key = $this->user_ring_signal_key($user_id);
        $call_sid = trim((string) ($payload['call_sid'] ?? ''));
        if ($key === '' || $call_sid === '') {
            return;
        }

        $signal_data = array(
            'call_sid' => $call_sid,
            'from_number' => (string) ($payload['from_number'] ?? ''),
            'to_number' => (string) ($payload['to_number'] ?? ''),
            'status' => (string) ($payload['status'] ?? 'ringing'),
            'created_at' => (string) ($payload['created_at'] ?? current_time('mysql')),
        );
        // Only include owner_alert_ready when explicitly provided.
        // When omitted, ring_signal_owner_alert_ready() falls through
        // to is_owner_alert_ready() which defaults to true — ensuring
        // the browser rings immediately on the very first signal.
        if (array_key_exists('owner_alert_ready', $payload)) {
            $signal_data['owner_alert_ready'] = !empty($payload['owner_alert_ready']) ? 1 : 0;
        }
        set_transient($key, $signal_data, 120);
    }

    private function clear_user_ring_signal(int $user_id, string $call_sid = ''): void
    {
        $key = $this->user_ring_signal_key($user_id);
        if ($key === '') {
            return;
        }

        if ($call_sid === '') {
            delete_transient($key);
            return;
        }

        $signal = get_transient($key);
        if (!is_array($signal)) {
            return;
        }

        if (((string) ($signal['call_sid'] ?? '')) === $call_sid) {
            delete_transient($key);
        }
    }

    private function ring_signal_owner_alert_ready(array $signal, string $call_sid): bool
    {
        if (array_key_exists('owner_alert_ready', $signal)) {
            return !empty($signal['owner_alert_ready']);
        }

        return $this->is_owner_alert_ready($call_sid);
    }

    private function is_owner_ring_enabled(int $user_id): bool
    {
        if ($user_id <= 0) {
            return true;
        }

        $enabled = get_user_meta($user_id, 'vmo_ring_enabled', true);
        return $enabled === '' || $enabled === false || (string) $enabled !== '0';
    }

    private function call_report_email_key(string $call_sid): string
    {
        $safe_call_sid = sanitize_key($call_sid);
        return ($safe_call_sid !== '') ? ('vmo_call_report_email_' . $safe_call_sid) : '';
    }

    private function call_report_retry_key(string $call_sid): string
    {
        $safe_call_sid = sanitize_key($call_sid);
        return ($safe_call_sid !== '') ? ('vmo_call_report_retry_' . $safe_call_sid) : '';
    }

    private function live_supervision_retry_key(string $call_sid): string
    {
        $safe_call_sid = sanitize_key($call_sid);
        return ($safe_call_sid !== '') ? ('vmo_live_supervision_retry_' . $safe_call_sid) : '';
    }

    private function resolve_call_owner_user_id(string $call_sid): int
    {
        global $wpdb;

        $call_sid = sanitize_text_field((string) $call_sid);
        if ($call_sid === '') {
            return 0;
        }

        $table = $wpdb->prefix . 'vmo_phone_calls';
        if ($wpdb->get_var("SHOW TABLES LIKE '{$table}'") !== $table) {
            return 0;
        }

        return (int) $wpdb->get_var($wpdb->prepare(
            "SELECT user_id FROM {$table} WHERE call_sid = %s ORDER BY id DESC LIMIT 1",
            $call_sid
        ));
    }

    private function incoming_call_email_key(string $call_sid): string
    {
        $safe_call_sid = sanitize_key($call_sid);
        return ($safe_call_sid !== '') ? ('vmo_incoming_call_email_' . $safe_call_sid) : '';
    }

    private function maybe_send_incoming_call_alert(string $call_sid, int $user_id, string $caller)
    {
        $call_sid = trim($call_sid);
        if ($call_sid === '' || $user_id <= 0) {
            return null;
        }

        $email_key = $this->incoming_call_email_key($call_sid);
        if ($email_key !== '' && get_transient($email_key)) {
            return null;
        }

        $result = $this->send_call_alert($user_id, 'incoming', array('caller' => $caller));
        if ($result === true && $email_key !== '') {
            set_transient($email_key, 1, HOUR_IN_SECONDS);
        }

        return $result;
    }

    private function call_conference_name(string $call_sid): string
    {
        $safe_call_sid = preg_replace('/[^a-zA-Z0-9_\-]/', '', (string) $call_sid);
        return ($safe_call_sid !== '') ? ('vmo_call_' . $safe_call_sid) : '';
    }

    private function realtime_ai_leg_key(string $parent_call_sid): string
    {
        $safe_call_sid = sanitize_key((string) $parent_call_sid);
        return ($safe_call_sid !== '') ? ('vmo_realtime_ai_leg_' . $safe_call_sid) : '';
    }

    private function realtime_ai_leg_parent_key(string $participant_call_sid): string
    {
        $safe_call_sid = sanitize_key((string) $participant_call_sid);
        return ($safe_call_sid !== '') ? ('vmo_realtime_ai_parent_' . $safe_call_sid) : '';
    }

    private function realtime_ai_disconnect_intent_key(string $parent_call_sid): string
    {
        $safe_call_sid = sanitize_key((string) $parent_call_sid);
        return ($safe_call_sid !== '') ? ('vmo_realtime_ai_disconnect_' . $safe_call_sid) : '';
    }

    private function get_tracked_realtime_ai_leg(string $parent_call_sid): array
    {
        $key = $this->realtime_ai_leg_key($parent_call_sid);
        if ($key === '') {
            return array();
        }

        $state = get_transient($key);
        return is_array($state) ? $state : array();
    }

    private function track_realtime_ai_leg(string $parent_call_sid, array $state, int $ttl = 1800): void
    {
        $key = $this->realtime_ai_leg_key($parent_call_sid);
        if ($key === '') {
            return;
        }

        $existing = $this->get_tracked_realtime_ai_leg($parent_call_sid);
        $merged = array_merge($existing, $state);
        set_transient($key, $merged, max(60, $ttl));

        $participant_call_sid = sanitize_text_field((string) ($merged['call_sid'] ?? ''));
        if ($participant_call_sid !== '') {
            $parent_key = $this->realtime_ai_leg_parent_key($participant_call_sid);
            if ($parent_key !== '') {
                set_transient($parent_key, sanitize_text_field($parent_call_sid), max(60, $ttl));
            }
            // Tag the AI participant leg so a normal disconnect of the AI
            // side (which should end the call) is distinguishable from
            // unrelated child legs disconnecting.
            $this->register_call_leg_role($participant_call_sid, 'premium_ai_bridge', $parent_call_sid);
        }
    }

    private function find_parent_for_realtime_ai_leg(string $participant_call_sid): string
    {
        $key = $this->realtime_ai_leg_parent_key($participant_call_sid);
        if ($key === '') {
            return '';
        }

        return sanitize_text_field((string) get_transient($key));
    }

    // ═══════════════════════════════════════════════════════════
    // CALL-LEG ROLE REGISTRY
    //
    // Every Twilio CallSid that reaches the status-callback handler
    // belongs to one of a small number of *roles*. Treating them
    // all the same (the prior behaviour) was the root cause of the
    // hangup regression — an ext_ring child failing or a listen
    // leg disconnecting would call terminate_call_everywhere()
    // on the parent, nuking the live premium WebSocket bridge.
    //
    // Recognized roles:
    //   caller_parent          — inbound customer call (parent SID)
    //   premium_ai_bridge      — realtime AI participant leg in conference mode
    //   external_owner_ring    — Twilio child leg dialing the owner's phone
    //   outbound_browser_call  — call dialed from the dashboard via Voice SDK
    //   owner_browser_takeover / owner_browser_listen — dashboard-driven
    //     WebSocket legs (these don't hit WP status callbacks; they are
    //     reserved here for completeness / future Twilio Conference legs)
    //   unknown                — fallback when we have no record
    //
    // Plus a per-leg "answered" flag set the first time the leg
    // reaches in-progress / answered. An ext_ring leg that was
    // never answered must NOT take down the parent call.
    // ═══════════════════════════════════════════════════════════

    private function call_leg_role_key(string $call_sid): string
    {
        $safe = sanitize_key($call_sid);
        return $safe !== '' ? ('vmo_call_leg_role_' . $safe) : '';
    }

    private function register_call_leg_role(string $call_sid, string $role, string $parent_sid = '', int $user_id = 0, int $ttl = 7200): void
    {
        $key = $this->call_leg_role_key($call_sid);
        if ($key === '') {
            return;
        }
        $existing = get_transient($key);
        if (!is_array($existing)) {
            $existing = array();
        }
        // Don't downgrade a known specific role back to 'unknown' or overwrite
        // a more specific role with a duplicate registration.
        if (!empty($existing['role']) && $existing['role'] !== 'unknown' && $role === 'unknown') {
            return;
        }
        $record = array_merge($existing, array(
            'role' => sanitize_key($role) ?: 'unknown',
            'parent_sid' => $parent_sid !== '' ? sanitize_text_field($parent_sid) : (string) ($existing['parent_sid'] ?? ''),
            'user_id' => $user_id > 0 ? $user_id : (int) ($existing['user_id'] ?? 0),
            'created_at' => (int) ($existing['created_at'] ?? time()),
        ));
        set_transient($key, $record, max(60, $ttl));
    }

    private function get_call_leg_role(string $call_sid): string
    {
        $key = $this->call_leg_role_key($call_sid);
        if ($key === '') {
            return 'unknown';
        }
        $record = get_transient($key);
        if (!is_array($record) || empty($record['role'])) {
            return 'unknown';
        }
        return (string) $record['role'];
    }

    private function mark_call_leg_answered(string $call_sid): void
    {
        $key = $this->call_leg_role_key($call_sid);
        if ($key === '') {
            return;
        }
        $record = get_transient($key);
        if (!is_array($record)) {
            $record = array('role' => 'unknown');
        }
        $record['answered'] = true;
        $record['answered_at'] = time();
        set_transient($key, $record, 7200);
    }

    private function was_call_leg_answered(string $call_sid): bool
    {
        $key = $this->call_leg_role_key($call_sid);
        if ($key === '') {
            return false;
        }
        $record = get_transient($key);
        return is_array($record) && !empty($record['answered']);
    }

    /**
     * Allow-list of teardown reasons that may end a premium WebSocket
     * bridge call. Anything else (unanswered ring leg, listen leg
     * disconnect, stale-cleanup sweep, diagnostic close) must NOT
     * cascade into terminating the live caller + AI bridge.
     */
    private function reason_can_end_premium_bridge(string $reason): bool
    {
        $reason = sanitize_key($reason);
        return in_array($reason, array(
            'caller_disconnected',
            'caller_parent_disconnected',
            'owner_takeover_disconnected',
            'owner_browser_takeover_disconnected',
            'external_owner_disconnected',
            'external_owner_ring_disconnected',
            'manual',
            'manual_end_call',
            'browser_disconnect',
            'premium_ai_bridge_disconnected',
            'outbound_browser_call_disconnected',
            // User-initiated hangups from the dashboard UI. These are
            // explicit operator intent to end the live call and MUST
            // be allowed to terminate the premium bridge, otherwise
            // the End Call button silently no-ops while the caller
            // and AI bridge keep running (and keep billing).
            'dashboard_drop',
            'dashboard_drop_lost_sid',
            'dashboard_end_call',
            'user_hangup',
            'operator_hangup',
            'ui_end_call',
        ), true);
    }

    /**
     * Whether the target call still has an active premium WebSocket
     * bridge context. Used to guard terminate_call_everywhere()
     * against teardown reasons that are not in the allow-list.
     */
    private function is_premium_call_active(string $call_sid): bool
    {
        if ($call_sid === '') {
            return false;
        }
        $ctx = $this->get_bridge_active_context($call_sid);
        return !empty($ctx);
    }

    private function clear_tracked_realtime_ai_leg(string $parent_call_sid): void
    {
        $tracked = $this->get_tracked_realtime_ai_leg($parent_call_sid);
        $participant_call_sid = sanitize_text_field((string) ($tracked['call_sid'] ?? ''));

        if ($participant_call_sid !== '') {
            $parent_key = $this->realtime_ai_leg_parent_key($participant_call_sid);
            if ($parent_key !== '') {
                delete_transient($parent_key);
            }
            $this->clear_realtime_call_context($participant_call_sid);
        }

        $key = $this->realtime_ai_leg_key($parent_call_sid);
        if ($key !== '') {
            delete_transient($key);
        }

        $disconnect_key = $this->realtime_ai_disconnect_intent_key($parent_call_sid);
        if ($disconnect_key !== '') {
            delete_transient($disconnect_key);
        }
    }

    private function build_realtime_conference_caller_twiml(string $conference_name, int $user_id): string
    {
        $conference_name = sanitize_text_field($conference_name);
        if ($conference_name === '') {
            return '<Hangup/>';
        }

        $recording_enabled = $this->is_recording_enabled($user_id);
        $participant_label = 'caller_' . substr(sanitize_key($conference_name), 0, 96);

        $twiml = '<Dial><Conference startConferenceOnEnter="true" endConferenceOnExit="false" beep="false" waitUrl="" participantLabel="' . esc_xml($participant_label) . '"';
        if ($recording_enabled) {
            $twiml .= ' record="record-from-start" recordingStatusCallback="' . esc_xml($this->twilio_public_url('recording')) . '" recordingStatusCallbackEvent="completed"';
        }
        $twiml .= '>' . esc_xml($conference_name) . '</Conference></Dial>';

        return $twiml;
    }

    private function build_realtime_legacy_fallback_twiml(int $user_id, string $call_sid, array $realtime_ctx = array()): string
    {
        $phone_opening = (isset($realtime_ctx['phone_opening']) && is_array($realtime_ctx['phone_opening']))
            ? $realtime_ctx['phone_opening']
            : $this->get_phone_opening_script($user_id);
        $ai_greeting = (string) ($phone_opening['initial_greeting'] ?? '');
        if ($ai_greeting === '') {
            $ai_greeting = (string) ($phone_opening['ai_greeting'] ?? 'Thank you for calling. How can I help you today?');
        }
        $reprompt_msg = (string) ($phone_opening['reprompt_msg'] ?? 'I\'m still here if you need anything. How can I help you?');
        $gather_timeout = max(10, intval($phone_opening['gather_timeout'] ?? 10));
        $reprompt_timeout = max(10, intval($phone_opening['reprompt_timeout'] ?? 30));
        $recording_enabled = $this->is_recording_enabled($user_id);
        $ai_converse_url = $this->phone_gateway_url('ai_converse', array('uid' => $user_id));

        $twiml = '';
        if ($recording_enabled) {
            $twiml .= '<Say voice="' . $this->tts_voice() . '">This call may be recorded for quality and training purposes.</Say>';
        }

        if ($this->is_whisper_language()) {
            $whisper_url = $this->phone_gateway_url('ai_converse_whisper', array('uid' => $user_id));
            $twiml .= $this->tts_play($ai_greeting, $call_sid, $user_id);
            $twiml .= '<Record action="' . esc_xml($whisper_url) . '" maxLength="30" timeout="' . $gather_timeout . '" playBeep="false" trim="trim-silence" />';
        } else {
            $twiml .= '<Gather input="speech" action="' . esc_xml($ai_converse_url) . '" speechTimeout="2" timeout="' . $gather_timeout . '" enhanced="true" language="' . $this->gather_lang() . '">';
            $twiml .= $this->tts_play($ai_greeting, $call_sid, $user_id);
            $twiml .= '</Gather>';
        }

        if ($this->is_whisper_language()) {
            $whisper_url2 = $this->phone_gateway_url('ai_converse_whisper', array('uid' => $user_id));
            $twiml .= '<Say voice="' . $this->tts_voice() . '">' . esc_xml($reprompt_msg) . '</Say>';
            $twiml .= '<Record action="' . esc_xml($whisper_url2) . '" maxLength="30" timeout="' . $reprompt_timeout . '" playBeep="false" trim="trim-silence" />';
        } else {
            $twiml .= '<Gather input="speech" action="' . esc_xml($ai_converse_url) . '" speechTimeout="2" timeout="' . $reprompt_timeout . '" enhanced="true" language="' . $this->gather_lang() . '">';
            $twiml .= '<Say voice="' . $this->tts_voice() . '">' . esc_xml($reprompt_msg) . '</Say>';
            $twiml .= '</Gather>';
        }

        $twiml .= '<Say voice="' . $this->tts_voice() . '">Let me take a message. Please speak after the beep.</Say>';
        $twiml .= '<Record maxLength="120" timeout="15" transcribe="true" transcribeCallback="' . esc_xml($this->twilio_public_url('transcription')) . '" />';
        $twiml .= '<Say voice="' . $this->tts_voice() . '">Thank you for calling. Goodbye.</Say>';

        return $twiml;
    }

    private function wrap_twiml_response(string $body): string
    {
        return '<Response>' . $body . '</Response>';
    }

    private function build_bridge_media_stream_twiml(int $user_id, string $call_sid, string $voice, array $bridge_context = array())
    {
        $parameters = $this->build_bridge_media_parameters($user_id, $call_sid, $voice, $bridge_context);
        if (is_wp_error($parameters)) {
            return $parameters;
        }

        $stream_url = $this->get_bridge_ws_url('media');
        $stream_status_url = $this->phone_gateway_url('status_callback', array(
            'uid' => $user_id,
            'parent_sid' => $call_sid,
            'bridge_stream' => '1',
        ));
        $twiml = '<Connect><Stream url="' . esc_xml($stream_url) . '" statusCallback="' . esc_xml($stream_status_url) . '" statusCallbackMethod="POST">';
        foreach ($parameters as $name => $value) {
            $twiml .= '<Parameter name="' . esc_xml((string) $name) . '" value="' . esc_xml((string) $value) . '" />';
        }
        $twiml .= '</Stream></Connect>';
        $stream_host = wp_parse_url($stream_url, PHP_URL_HOST);
        $this->trace_voice_call($call_sid, 'premium_bridge_stream_twiml_built host=' . (string) $stream_host . ' params=' . count($parameters));

        return $twiml;
    }

    private function start_realtime_conference_participant(string $parent_call_sid, int $user_id, string $conference_name, string $sip_uri, string $twilio_number)
    {
        $conference_name = sanitize_text_field($conference_name);
        $sip_uri = trim($sip_uri);
        if ($conference_name === '' || $sip_uri === '') {
            return new WP_Error('invalid_realtime_conference', 'Missing conference or SIP target');
        }

        $status_url = $this->phone_gateway_url('realtime_participant_status', array(
            'parent_sid' => $parent_call_sid,
            'uid' => $user_id,
        ));
        $origin_identity = preg_replace('/[^a-zA-Z0-9_.-]/', '', ltrim((string) $twilio_number, '+'));
        if ($origin_identity === '') {
            $origin_identity = 'vmoai';
        }

        $participant = $this->twilio_request('POST', '/Conferences/' . rawurlencode($conference_name) . '/Participants.json', array(
            'To' => $sip_uri,
            'From' => $origin_identity,
            'CallerId' => $origin_identity,
            'Label' => 'ai_' . substr(sanitize_key($parent_call_sid), 0, 96),
            'Muted' => 'false',
            'Beep' => 'false',
            'Timeout' => 30,
            'WaitUrl' => '',
            'StartConferenceOnEnter' => 'true',
            'EndConferenceOnExit' => 'false',
            'StatusCallback' => $status_url,
            'StatusCallbackMethod' => 'POST',
            'StatusCallbackEvent' => array('initiated', 'ringing', 'answered', 'completed'),
        ));
        if (is_wp_error($participant)) {
            return $participant;
        }

        $participant_call_sid = sanitize_text_field((string) ($participant['call_sid'] ?? $participant['sid'] ?? ''));
        if ($participant_call_sid === '') {
            return new WP_Error('missing_participant_call_sid', 'Twilio did not return the AI participant CallSid');
        }

        $this->track_realtime_ai_leg($parent_call_sid, array(
            'call_sid' => $participant_call_sid,
            'conference_name' => $conference_name,
            'user_id' => $user_id,
            'status' => sanitize_text_field((string) ($participant['status'] ?? 'queued')),
            'answered' => false,
            'started_at' => time(),
        ), 1800);

        $parent_context = $this->get_realtime_call_context($parent_call_sid);
        if (!empty($parent_context)) {
            $child_context = $parent_context;
            $child_context['parent_call_sid'] = $parent_call_sid;
            $child_context['sip_leg_sid'] = $participant_call_sid;
            $this->store_realtime_call_context($participant_call_sid, $child_context, 1800);
        }

        return array(
            'call_sid' => $participant_call_sid,
            'status' => sanitize_text_field((string) ($participant['status'] ?? 'queued')),
            'conference_name' => $conference_name,
        );
    }

    private function flush_response_and_continue(): void
    {
        if (!headers_sent()) {
            header('Connection: close');
        }

        $size = ob_get_length();
        if ($size !== false && !headers_sent()) {
            header('Content-Length: ' . $size);
        }

        while (ob_get_level()) {
            ob_end_flush();
        }
        flush();

        if (function_exists('fastcgi_finish_request')) {
            fastcgi_finish_request();
            error_log('[VMO Voice] flush_response_and_continue: fastcgi_finish_request used');
        } else {
            if (function_exists('litespeed_finish_request')) {
                litespeed_finish_request();
            }
            error_log('[VMO Voice] flush_response_and_continue: connection-close fallback used');
        }

        ignore_user_abort(true);
        if (function_exists('set_time_limit')) {
            set_time_limit(60);
        }
    }

    private function schedule_phone_event_now(string $hook, array $args = array(), int $delay_seconds = 1): bool
    {
        $delay_seconds = max(0, $delay_seconds);
        $scheduled_at = wp_next_scheduled($hook, $args);
        if ($scheduled_at === false) {
            $scheduled_at = time() + $delay_seconds;
            $result = wp_schedule_single_event($scheduled_at, $hook, $args);
            if ($result === false) {
                error_log('[VMO Voice] Failed to schedule event ' . $hook . ' args=' . wp_json_encode($args));
                return false;
            }
        }

        if (function_exists('spawn_cron')) {
            spawn_cron($scheduled_at);
        }

        return true;
    }

    private function start_call_level_recording(string $call_sid, int $user_id, string $source = 'call', int $attempt = 1): bool
    {
        $call_sid = trim($call_sid);
        if ($call_sid === '' || $user_id <= 0 || !$this->is_recording_enabled($user_id)) {
            return false;
        }

        $started_key = 'vmo_rec_started_' . $call_sid;
        if (get_transient($started_key) !== false) {
            return true;
        }

        $recording = $this->twilio_request('POST', "/Calls/{$call_sid}/Recordings.json", array(
            'RecordingStatusCallbackEvent' => 'completed',
            'RecordingStatusCallback' => $this->twilio_public_url('recording'),
        ), null, 5);

        if (is_wp_error($recording)) {
            error_log('[VMO Recording] ' . $source . ' start FAILED for ' . $call_sid . ' attempt=' . $attempt . ': ' . $recording->get_error_message());
            if ($attempt < 3) {
                $this->schedule_phone_event_now('vmo_start_call_recording_retry', array($call_sid, $user_id, $attempt + 1), 2 * max(1, $attempt));
            }
            return false;
        }

        $recording_sid = isset($recording['sid']) ? (string) $recording['sid'] : '';
        set_transient($started_key, 1, HOUR_IN_SECONDS);
        delete_transient('vmo_rec_retry_' . $call_sid);
        error_log('[VMO Recording] ' . $source . ' start SUCCEEDED for ' . $call_sid . ($recording_sid !== '' ? ' rec_sid=' . $recording_sid : ''));
        return true;
    }

    public function retry_start_call_recording($call_sid, $user_id = 0, $attempt = 2): void
    {
        $this->start_call_level_recording((string) $call_sid, (int) $user_id, 'retry', max(1, (int) $attempt));
    }

    public function finalize_premium_bridge_call($call_sid, $attempt = 1): void
    {
        $call_sid = trim((string) $call_sid);
        $attempt = max(1, (int) $attempt);
        if ($call_sid === '') {
            return;
        }

        global $wpdb;
        $table = $wpdb->prefix . 'vmo_phone_calls';
        $call = $wpdb->get_row($wpdb->prepare(
            "SELECT user_id, recording_url, transcript, summary FROM {$table} WHERE call_sid = %s LIMIT 1",
            $call_sid
        ));
        if (!$call) {
            if ($attempt < 5) {
                $this->schedule_phone_event_now('vmo_finalize_premium_bridge_call', array($call_sid, $attempt + 1), 20 * $attempt);
            }
            return;
        }

        $recording_url = trim((string) ($call->recording_url ?? ''));
        if ($recording_url === '') {
            $recording = $this->fetch_call_recordings($call_sid);
            if (is_array($recording) && !empty($recording['url'])) {
                $recording_url = (string) $recording['url'];
            }
        }

        if ($recording_url !== '') {
            $this->generate_call_summary($call_sid, $recording_url);
            $this->maybe_send_post_call_summary_email($call_sid);
            return;
        }

        if (!empty($call->transcript) && empty($call->summary)) {
            $this->schedule_phone_event_now('vmo_summarize_call_transcript', array($call_sid), 1);
            $this->schedule_phone_event_now('vmo_send_call_summary_email', array($call_sid), 45);
            return;
        }

        if ($attempt < 5) {
            $this->schedule_phone_event_now('vmo_finalize_premium_bridge_call', array($call_sid, $attempt + 1), 20 * $attempt);
        } else {
            error_log('[VMO Bridge] Premium post-call report finalizer could not find recording or transcript for ' . $call_sid);
            $this->schedule_phone_event_now('vmo_send_call_summary_email', array($call_sid), 1);
        }
    }

    private function maybe_send_post_call_summary_email(string $call_sid): void
    {
        $call_sid = trim($call_sid);
        if ($call_sid === '') {
            return;
        }

        try {
            $this->send_post_call_summary_email($call_sid);
        } catch (Throwable $e) {
            error_log('[VMO Email] Post-call report send failed for ' . $call_sid . ': ' . $e->getMessage());
        }
    }

    private function schedule_post_call_summary_retry(string $call_sid, int $attempt = 1): void
    {
        $call_sid = trim((string) $call_sid);
        if ($call_sid === '') {
            return;
        }

        $delay = max(30, min(300, 30 * max(1, $attempt)));
        $retry_key = $this->call_report_retry_key($call_sid);
        if ($retry_key !== '') {
            set_transient($retry_key, max(1, $attempt), HOUR_IN_SECONDS);
        }

        $this->schedule_phone_event_now('vmo_send_call_summary_email', array($call_sid), $delay);
    }

    private function queue_live_supervision_bridge(string $call_sid): void
    {
        $call_sid = trim((string) $call_sid);
        if ($call_sid === '') {
            return;
        }

        $retry_key = $this->live_supervision_retry_key($call_sid);
        if ($retry_key !== '' && get_transient($retry_key)) {
            return;
        }
        if ($retry_key !== '') {
            set_transient($retry_key, 1, MINUTE_IN_SECONDS);
        }

        $self = $this;
        register_shutdown_function(static function () use ($self, $call_sid, $retry_key) {
            // Buffer all output so stray PHP notices/warnings from Twilio
            // API calls don't leak into the already-sent JSON response body.
            ob_start();
            try {
                $self->prepare_live_supervision_bridge($call_sid);
            } catch (\Throwable $e) {
                error_log('[VMO Voice] Shutdown bridge error for ' . $call_sid . ': ' . $e->getMessage());
            } finally {
                if ($retry_key !== '') {
                    delete_transient($retry_key);
                }
                ob_end_clean();
            }
        });

        $this->schedule_phone_event_now('vmo_prepare_live_supervision', array($call_sid), 1);
    }

    private function update_call_conference_state(string $call_sid, string $conference_name, int $ai_handled = 0, string $context_suffix = ''): void
    {
        global $wpdb;

        $call_sid = sanitize_text_field($call_sid);
        $conference_name = sanitize_text_field($conference_name);
        if ($call_sid === '' || $conference_name === '') {
            return;
        }

        $table = $wpdb->prefix . 'vmo_phone_calls';
        if ($wpdb->get_var("SHOW TABLES LIKE '{$table}'") !== $table) {
            return;
        }

        $update = array();
        $formats = array();

        try {
            $columns = $wpdb->get_col("SHOW COLUMNS FROM {$table}", 0);
            if (is_array($columns) && in_array('topic', $columns, true)) {
                $topic = 'conference:' . $conference_name;
                if ($context_suffix !== '') {
                    $topic .= ' ' . $context_suffix;
                }
                $update['topic'] = $topic;
                $formats[] = '%s';
            }
        } catch (Throwable $e) {
            error_log('[VMO Voice] Could not inspect phone call columns for ' . $call_sid . ': ' . $e->getMessage());
        }

        $update['ai_handled'] = max(0, (int) $ai_handled);
        $formats[] = '%d';

        $updated = $wpdb->update(
            $table,
            $update,
            array('call_sid' => $call_sid),
            $formats,
            array('%s')
        );

        if ($updated === false && !empty($wpdb->last_error)) {
            error_log('[VMO Voice] Failed to mark call ' . $call_sid . ' in conference state: ' . $wpdb->last_error);
        }
    }

    private function mark_call_conference_state(string $call_sid, string $conference_name, string $context_suffix = ''): void
    {
        $this->update_call_conference_state($call_sid, $conference_name, 0, $context_suffix);
    }

    private function child_ring_calls_key(string $parent_call_sid): string
    {
        $safe_parent_sid = sanitize_key($parent_call_sid);
        return ($safe_parent_sid !== '') ? ('vmo_child_ring_calls_' . $safe_parent_sid) : '';
    }

    private function child_ring_parent_key(string $child_call_sid): string
    {
        $safe_child_sid = sanitize_key($child_call_sid);
        return ($safe_child_sid !== '') ? ('vmo_child_ring_parent_' . $safe_child_sid) : '';
    }

    private function track_child_ring_call(string $parent_call_sid, string $child_call_sid): void
    {
        $key = $this->child_ring_calls_key($parent_call_sid);
        $reverse_key = $this->child_ring_parent_key($child_call_sid);
        $child_call_sid = trim($child_call_sid);
        if ($key === '' || $child_call_sid === '') {
            return;
        }

        $children = get_transient($key);
        if (!is_array($children)) {
            $children = array();
        }

        $children[$child_call_sid] = current_time('mysql');
        set_transient($key, $children, 900);
        if ($reverse_key !== '') {
            set_transient($reverse_key, sanitize_text_field($parent_call_sid), 900);
        }

        // Tag every tracked ring-out child as an external_owner_ring leg so
        // the status-callback handler can tell the difference between an
        // owner-side dial-out completing (which should not, by itself, end
        // the customer's call) and the customer parent leg disconnecting.
        $this->register_call_leg_role($child_call_sid, 'external_owner_ring', $parent_call_sid);
    }

    private function untrack_child_ring_call(string $parent_call_sid, string $child_call_sid = ''): void
    {
        $key = $this->child_ring_calls_key($parent_call_sid);
        if ($key === '') {
            return;
        }

        if ($child_call_sid === '') {
            $children = get_transient($key);
            if (is_array($children)) {
                foreach (array_keys($children) as $existing_child_sid) {
                    $reverse_key = $this->child_ring_parent_key((string) $existing_child_sid);
                    if ($reverse_key !== '') {
                        delete_transient($reverse_key);
                    }
                }
            }
            delete_transient($key);
            return;
        }

        $children = get_transient($key);
        if (!is_array($children) || empty($children)) {
            return;
        }

        unset($children[$child_call_sid]);
        $reverse_key = $this->child_ring_parent_key($child_call_sid);
        if ($reverse_key !== '') {
            delete_transient($reverse_key);
        }
        if (empty($children)) {
            delete_transient($key);
            return;
        }

        set_transient($key, $children, 900);
    }

    private function stop_call_leg(string $call_sid): void
    {
        $call_sid = trim($call_sid);
        if ($call_sid === '') {
            return;
        }

        $last_error = '';
        $attempts = array(
            array('Status' => 'completed'),
            array('Status' => 'canceled'),
            array('Twiml' => '<Response><Hangup/></Response>'),
        );
        foreach ($attempts as $attempt) {
            $result = $this->twilio_request('POST', "/Calls/{$call_sid}.json", $attempt);
            if (!is_wp_error($result)) {
                $method = isset($attempt['Status']) ? ('status=' . $attempt['Status']) : 'twiml_hangup';
                error_log('[VMO Voice] Stopped call leg ' . $call_sid . ' via ' . $method);
                return;
            }
            $last_error = $result->get_error_message();
        }

        if ($last_error !== '') {
            error_log('[VMO Voice] Failed to stop child call leg ' . $call_sid . ': ' . $last_error);
        }
    }

    private function cancel_child_ring_calls(string $parent_call_sid, string $exclude_call_sid = ''): void
    {
        $key = $this->child_ring_calls_key($parent_call_sid);
        if ($key === '') {
            return;
        }

        $children = get_transient($key);
        if (!is_array($children) || empty($children)) {
            delete_transient($key);
            return;
        }

        foreach (array_keys($children) as $child_call_sid) {
            if ($exclude_call_sid !== '' && $child_call_sid === $exclude_call_sid) {
                continue;
            }
            $this->stop_call_leg($child_call_sid);
            unset($children[$child_call_sid]);
            $reverse_key = $this->child_ring_parent_key((string) $child_call_sid);
            if ($reverse_key !== '') {
                delete_transient($reverse_key);
            }
        }

        if (empty($children)) {
            delete_transient($key);
            return;
        }

        set_transient($key, $children, 900);
    }

    private function call_teardown_marker_key(string $call_sid): string
    {
        $safe_call_sid = sanitize_key($call_sid);
        return ($safe_call_sid !== '') ? ('vmo_call_teardown_' . $safe_call_sid) : '';
    }

    private function call_teardown_lock_key(string $call_sid): string
    {
        $safe_call_sid = sanitize_key($call_sid);
        return ($safe_call_sid !== '') ? ('vmo_call_teardown_lock_' . $safe_call_sid) : '';
    }

    private function mark_call_teardown(string $call_sid, string $reason): void
    {
        $key = $this->call_teardown_marker_key($call_sid);
        if ($key === '') {
            return;
        }

        set_transient($key, array(
            'reason' => sanitize_key($reason),
            'time' => time(),
        ), DAY_IN_SECONDS);
    }

    private function get_call_teardown_marker(string $call_sid): array
    {
        $key = $this->call_teardown_marker_key($call_sid);
        if ($key === '') {
            return array();
        }

        $marker = get_transient($key);
        return is_array($marker) ? $marker : array();
    }

    private function teardown_reason_skips_billing(string $reason): bool
    {
        return in_array(sanitize_key($reason), array('stale_cleanup', 'live_poll_stale', 'expired_cleanup'), true);
    }

    private function normalize_billable_call_duration(string $call_sid, int $duration_seconds): int
    {
        if ($duration_seconds <= 0) {
            return 0;
        }

        $marker = $this->get_call_teardown_marker($call_sid);
        $reason = (string) ($marker['reason'] ?? '');
        if ($this->teardown_reason_skips_billing($reason)) {
            error_log('[VMO Billing Guard] Skipping phone token deduction for stale teardown call ' . $call_sid . ' reason=' . $reason . ' reported_duration=' . $duration_seconds);
            return 0;
        }

        $max_billable_seconds = (int) apply_filters('vmo_phone_max_billable_call_seconds', 7200, $call_sid, $duration_seconds, $marker);
        if ($max_billable_seconds > 0 && $duration_seconds > $max_billable_seconds) {
            error_log('[VMO Billing Guard] Capping anomalous phone call duration for ' . $call_sid . ' from ' . $duration_seconds . 's to ' . $max_billable_seconds . 's');
            return $max_billable_seconds;
        }

        return $duration_seconds;
    }

    private function add_teardown_sid(array &$sids, string $call_sid): void
    {
        $call_sid = sanitize_text_field(trim($call_sid));
        if ($call_sid !== '') {
            $sids[$call_sid] = true;
        }
    }

    private function terminate_call_everywhere(string $call_sid, string $reason = 'manual', array $extra = array())
    {
        global $wpdb;

        $reason = sanitize_key($reason ?: 'manual');
        $user_id = !empty($extra['user_id']) ? (int) $extra['user_id'] : get_current_user_id();
        $child_sid = !empty($extra['child_sid']) ? sanitize_text_field((string) $extra['child_sid']) : '';
        $parent_sid = !empty($extra['parent_sid']) ? sanitize_text_field((string) $extra['parent_sid']) : '';
        $force_active = !empty($extra['force_active']);
        $call_sid = sanitize_text_field(trim($call_sid));
        if ($call_sid === '' && $parent_sid !== '') {
            $call_sid = $parent_sid;
        }
        if ($call_sid === '' && $child_sid !== '') {
            $call_sid = $child_sid;
        }
        if ($call_sid === '' && $force_active && $user_id > 0) {
            $call_sid = $this->resolve_control_target_call_sid('', '', $user_id);
        }
        if ($call_sid === '') {
            return new WP_Error('invalid_call_sid', 'Missing call identifier');
        }
        if ($parent_sid === '' && $child_sid !== '') {
            $parent_sid = $this->find_tracked_parent_call_sid($child_sid, $user_id);
        }
        if ($parent_sid === '') {
            $parent_sid = $this->find_tracked_parent_call_sid($call_sid, $user_id);
        }

        $target_sid = $parent_sid !== ''
            ? $parent_sid
            : $this->resolve_control_target_call_sid($call_sid, $child_sid, $user_id);
        if ($target_sid === '') {
            $target_sid = $call_sid;
        }

        // ── Premium-bridge safety guard ──
        // The premium WebSocket bridge is the golden path. Only a
        // small allow-list of reasons may end a live premium call.
        // Anything else (unanswered ring-out, listen disconnect,
        // stale-poll cleanup, diagnostic close, etc.) must be a
        // no-op for the parent so the AI bridge keeps running.
        if (!$this->reason_can_end_premium_bridge($reason)) {
            $premium_candidates = array_unique(array_filter(array(
                $target_sid,
                $parent_sid,
                $call_sid,
            )));
            foreach ($premium_candidates as $pc) {
                if ($this->is_premium_call_active((string) $pc)) {
                    error_log('[VMO Voice] terminate_call_everywhere SKIPPED — premium bridge active on '
                        . $pc . ' and reason="' . $reason . '" is not in the allow-list');
                    return array(
                        'success' => true,
                        'ignored' => true,
                        'reason' => $reason,
                        'target' => $target_sid,
                        'message' => 'Premium bridge protected from non-terminal teardown reason',
                    );
                }
            }
        }

        $terminal_status = !empty($extra['status']) ? sanitize_key((string) $extra['status']) : 'completed';
        if ($terminal_status === 'cancelled') {
            $terminal_status = 'canceled';
        }
        if (!in_array($terminal_status, array('completed', 'busy', 'failed', 'canceled', 'no-answer', 'expired'), true)) {
            $terminal_status = 'completed';
        }

        $sids = array();
        $this->add_teardown_sid($sids, $target_sid);
        $this->add_teardown_sid($sids, $call_sid);
        $this->add_teardown_sid($sids, $child_sid);
        $this->add_teardown_sid($sids, $parent_sid);

        $parent_candidates = array_keys($sids);
        foreach ($parent_candidates as $parent_candidate) {
            $children = get_transient($this->child_ring_calls_key((string) $parent_candidate));
            if (is_array($children)) {
                foreach (array_keys($children) as $tracked_child_sid) {
                    $this->add_teardown_sid($sids, (string) $tracked_child_sid);
                }
            }

            $tracked_realtime = $this->get_tracked_realtime_ai_leg((string) $parent_candidate);
            $this->add_teardown_sid($sids, (string) ($tracked_realtime['call_sid'] ?? ''));

            $active_sip_leg = $this->find_active_realtime_sip_leg((string) $parent_candidate);
            $this->add_teardown_sid($sids, $active_sip_leg);

            foreach ($this->get_child_call_legs((string) $parent_candidate) as $child_leg) {
                $this->add_teardown_sid($sids, (string) ($child_leg['sid'] ?? ''));
            }
        }

        $all_sids = array_keys($sids);
        foreach ($all_sids as $sid) {
            $this->mark_call_teardown($sid, $reason);
        }

        $lock_key = $this->call_teardown_lock_key($target_sid);
        $already_tearing_down = ($lock_key !== '' && (bool) get_transient($lock_key));
        if ($lock_key !== '') {
            set_transient($lock_key, 1, 10 * MINUTE_IN_SECONDS);
        }

        if (!$already_tearing_down) {
            foreach ($parent_candidates as $parent_candidate) {
                $this->disconnect_realtime_ai_leg((string) $parent_candidate);
            }

            foreach ($all_sids as $sid) {
                $this->stop_call_leg((string) $sid);
            }
        }

        $table = $wpdb->prefix . 'vmo_phone_calls';
        if ($wpdb->get_var("SHOW TABLES LIKE '{$table}'") === $table) {
            foreach ($all_sids as $sid) {
                $updated = $wpdb->update(
                    $table,
                    array('status' => $terminal_status),
                    array('call_sid' => $sid),
                    array('%s'),
                    array('%s')
                );

                if ($sid === $target_sid && $updated === 0 && !$wpdb->get_var($wpdb->prepare("SELECT id FROM {$table} WHERE call_sid = %s LIMIT 1", $sid))) {
                    $insert_user_id = $user_id > 0 ? $user_id : get_current_user_id();
                    $direction = !empty($extra['direction']) ? sanitize_text_field((string) $extra['direction']) : 'outbound';
                    $caller = !empty($extra['caller_number']) ? sanitize_text_field((string) $extra['caller_number']) : '';
                    $wpdb->insert($table, array(
                        'user_id' => $insert_user_id,
                        'call_sid' => $sid,
                        'caller_number' => $caller,
                        'direction' => $direction,
                        'status' => $terminal_status,
                        'ai_handled' => 0,
                        'created_at' => current_time('mysql'),
                    ), array('%d', '%s', '%s', '%s', '%s', '%d', '%s'));
                    error_log('[VMO end_call] Inserted missing call record for ' . $sid);
                }

                $signal_row = $wpdb->get_row($wpdb->prepare(
                    "SELECT user_id, direction FROM {$table} WHERE call_sid = %s LIMIT 1",
                    $sid
                ));
                if ($signal_row && !empty($signal_row->user_id)) {
                    $row_user_id = (int) $signal_row->user_id;
                    $this->clear_user_ring_signal($row_user_id, $sid);
                    if (($signal_row->direction ?: 'inbound') === 'inbound') {
                        $business_phone = (string) ($this->get_user_phone($row_user_id) ?: '');
                        if ($business_phone !== '') {
                            $this->clear_live_call_signal($business_phone, $sid);
                        }
                    }
                }
            }
        }

        foreach ($all_sids as $sid) {
            $this->cancel_child_ring_calls((string) $sid);
            $this->clear_owner_alert_state((string) $sid);
            $this->clear_bridge_active_context((string) $sid);
            $this->clear_realtime_call_context((string) $sid);
            $this->clear_tracked_realtime_ai_leg((string) $sid);
            delete_transient('vmo_realtime_active_' . sanitize_key((string) $sid));
        }

        if ($parent_sid !== '' && $child_sid !== '') {
            $this->untrack_child_ring_call($parent_sid, $child_sid);
        }

        $pd_table = $wpdb->prefix . 'vmo_power_dialer_calls';
        if ($wpdb->get_var("SHOW TABLES LIKE '{$pd_table}'") === $pd_table) {
            foreach ($all_sids as $sid) {
                $wpdb->update(
                    $pd_table,
                    array('status' => $terminal_status),
                    array('call_sid' => $sid),
                    array('%s'),
                    array('%s')
                );
            }
        }

        return array(
            'message' => 'Call ended successfully',
            'call_sid' => $target_sid,
            'ended_sids' => $all_sids,
            'reason' => $reason,
            'status' => $terminal_status,
        );
    }

    private function get_child_call_legs(string $call_sid): array
    {
        $call_sid = trim($call_sid);
        if ($call_sid === '') {
            return array();
        }

        $call_info = $this->twilio_request('GET', "/Calls/{$call_sid}.json", array(), null, 8);
        if (is_wp_error($call_info)) {
            error_log('[VMO Voice] Failed to load child call legs for ' . $call_sid . ': ' . $call_info->get_error_message());
            return array();
        }

        $children_uri = $call_info['subresource_uris']['calls'] ?? '';
        if ($children_uri === '') {
            return array();
        }

        $children = $this->twilio_request('GET', $children_uri, array('PageSize' => 20), 'https://api.twilio.com', 8);
        if (is_wp_error($children)) {
            error_log('[VMO Voice] Failed to fetch child call leg list for ' . $call_sid . ': ' . $children->get_error_message());
            return array();
        }

        return (isset($children['calls']) && is_array($children['calls'])) ? $children['calls'] : array();
    }

    private function find_tracked_parent_call_sid(string $child_call_sid, int $user_id = 0): string
    {
        global $wpdb;

        $child_call_sid = trim($child_call_sid);
        if ($child_call_sid === '') {
            return '';
        }

        $reverse_key = $this->child_ring_parent_key($child_call_sid);
        if ($reverse_key !== '') {
            $tracked_parent_sid = trim((string) get_transient($reverse_key));
            if ($tracked_parent_sid !== '') {
                return sanitize_text_field($tracked_parent_sid);
            }
        }

        $table = $wpdb->prefix . 'vmo_phone_calls';
        if ($wpdb->get_var("SHOW TABLES LIKE '{$table}'") !== $table) {
            return '';
        }

        if ($user_id > 0) {
            // phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery, WordPress.DB.DirectDatabaseQuery.NoCaching
            $candidate_parent_sids = $wpdb->get_col($wpdb->prepare(
                "SELECT call_sid
                         FROM {$table}
                         WHERE user_id = %d
                           AND created_at > DATE_SUB(NOW(), INTERVAL 30 MINUTE)
                         ORDER BY created_at DESC
                         LIMIT 25",
                $user_id
            ));
        } else {
            // phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery, WordPress.DB.DirectDatabaseQuery.NoCaching
            $candidate_parent_sids = $wpdb->get_col(
                "SELECT call_sid
                         FROM {$table}
                         WHERE created_at > DATE_SUB(NOW(), INTERVAL 30 MINUTE)
                         ORDER BY created_at DESC
                         LIMIT 40"
            );
        }

        foreach ((array) $candidate_parent_sids as $candidate_parent_sid) {
            $candidate_parent_sid = trim((string) $candidate_parent_sid);
            if ($candidate_parent_sid === '') {
                continue;
            }

            $children = get_transient($this->child_ring_calls_key($candidate_parent_sid));
            if (is_array($children) && isset($children[$child_call_sid])) {
                return $candidate_parent_sid;
            }
        }

        return '';
    }

    private function resolve_control_target_call_sid(string $call_sid, string $child_sid = '', int $user_id = 0): string
    {
        $call_sid = trim($call_sid);
        $child_sid = trim($child_sid);
        $candidates = array();
        $seen = array();
        $add_candidate = static function ($sid, $source = '') use (&$candidates, &$seen) {
            $sid = trim((string) $sid);
            if ($sid === '' || isset($seen[$sid])) {
                return;
            }
            $seen[$sid] = true;
            $candidates[] = array(
                'sid' => sanitize_text_field($sid),
                'source' => (string) $source,
            );
        };

        // Log what we're searching for
        error_log("[VMO resolve_control_target_call_sid] Looking for: call_sid='$call_sid' child_sid='$child_sid' user_id=$user_id");

        if ($child_sid !== '') {
            $child_detail = $this->twilio_request('GET', "/Calls/{$child_sid}.json");
            if (!is_wp_error($child_detail)) {
                $parent_call_sid = sanitize_text_field((string) ($child_detail['parent_call_sid'] ?? ''));
                if ($parent_call_sid !== '') {
                    $add_candidate($parent_call_sid, 'child_parent');
                }
            }

            $tracked_parent_sid = $this->find_tracked_parent_call_sid($child_sid, $user_id);
            if ($tracked_parent_sid !== '') {
                $add_candidate($tracked_parent_sid, 'tracked_child_parent');
            }
            $add_candidate($child_sid, 'child');
        }

        if ($call_sid !== '') {
            $call_detail = $this->twilio_request('GET', "/Calls/{$call_sid}.json");
            if (!is_wp_error($call_detail)) {
                $parent_call_sid = sanitize_text_field((string) ($call_detail['parent_call_sid'] ?? ''));
                $direction = strtolower(trim((string) ($call_detail['direction'] ?? '')));
                $to = strtolower(trim((string) ($call_detail['to'] ?? '')));
                $from = strtolower(trim((string) ($call_detail['from'] ?? '')));
                if ($parent_call_sid !== '' && (strpos($direction, 'outbound') !== false || strpos($to, 'client:') === 0 || strpos($from, 'client:') === 0)) {
                    $add_candidate($parent_call_sid, 'requested_parent');
                }
            }
            $tracked_parent_sid = $this->find_tracked_parent_call_sid($call_sid, $user_id);
            if ($tracked_parent_sid !== '') {
                $add_candidate($tracked_parent_sid, 'tracked_requested_parent');
            }
            $add_candidate($call_sid, 'requested');
        }

        global $wpdb;
        $table = $wpdb->prefix . 'vmo_phone_calls';
        if ($user_id > 0 && $wpdb->get_var("SHOW TABLES LIKE '{$table}'") === $table) {
            $recent_sids = $wpdb->get_col($wpdb->prepare(
                "SELECT call_sid
                         FROM {$table}
                         WHERE user_id = %d
                         AND direction = 'inbound'
                         AND (status IS NULL OR status NOT IN ('completed','busy','failed','canceled','cancelled','no-answer'))
                         AND created_at > DATE_SUB(NOW(), INTERVAL 15 MINUTE)
                         ORDER BY created_at DESC
                         LIMIT 5",
                $user_id
            ));
            foreach ((array) $recent_sids as $recent_sid) {
                $add_candidate($recent_sid, 'recent_db_inbound');
            }
        }

        if ($user_id > 0) {
            $ring_signal = get_transient($this->user_ring_signal_key($user_id));
            if (is_array($ring_signal) && !empty($ring_signal['call_sid'])) {
                $add_candidate($ring_signal['call_sid'], 'ring_signal');
            }

            $business_phone = (string) ($this->get_user_phone($user_id) ?: '');
            if ($business_phone !== '') {
                foreach ($this->get_live_call_signals($business_phone) as $signal) {
                    $add_candidate($signal['call_sid'] ?? '', 'phone_signal');
                }
            }
        } else {
            $business_phone = '';
        }

        $active_fallback = '';
        foreach ($candidates as $candidate) {
            $sid = (string) ($candidate['sid'] ?? '');
            if ($sid === '') {
                continue;
            }

            $call_info = $this->twilio_request('GET', "/Calls/{$sid}.json");
            if (is_wp_error($call_info) || !is_array($call_info)) {
                continue;
            }

            $status = strtolower(trim((string) ($call_info['status'] ?? '')));
            if ($status === '' || $this->live_call_status_is_terminal($status)) {
                continue;
            }

            $direction = strtolower(trim((string) ($call_info['direction'] ?? '')));
            $to = strtolower(trim((string) ($call_info['to'] ?? '')));
            $from = strtolower(trim((string) ($call_info['from'] ?? '')));
            $is_client_leg = (strpos($to, 'client:') === 0 || strpos($from, 'client:') === 0);
            $is_inbound = (strpos($direction, 'inbound') !== false) || in_array($candidate['source'], array('child_parent', 'tracked_child_parent', 'requested_parent', 'tracked_requested_parent', 'recent_db_inbound', 'ring_signal', 'phone_signal'), true);
            if ($is_inbound && !$is_client_leg) {
                if ($sid !== $call_sid) {
                    error_log("[VMO call_action] Resolved control SID from {$call_sid} to {$sid} ({$candidate['source']})");
                }
                return $sid;
            }

            if ($active_fallback === '' && !$is_client_leg) {
                $active_fallback = $sid;
            }
        }

        if (!isset($business_phone)) {
            $business_phone = ($user_id > 0) ? (string) ($this->get_user_phone($user_id) ?: '') : '';
        }
        if ($business_phone !== '') {
            foreach (array('in-progress', 'ringing', 'queued') as $status_filter) {
                $list_result = $this->twilio_request('GET', '/Calls.json', array(
                    'To' => $business_phone,
                    'Status' => $status_filter,
                    'PageSize' => 5,
                ));
                if (is_wp_error($list_result) || empty($list_result['calls']) || !is_array($list_result['calls'])) {
                    continue;
                }

                foreach ($list_result['calls'] as $listed_call) {
                    $listed_sid = (string) ($listed_call['sid'] ?? '');
                    $listed_status = strtolower(trim((string) ($listed_call['status'] ?? '')));
                    $listed_direction = strtolower(trim((string) ($listed_call['direction'] ?? '')));
                    if ($listed_sid !== '' && !$this->live_call_status_is_terminal($listed_status) && strpos($listed_direction, 'inbound') !== false) {
                        error_log("[VMO call_action] Resolved active inbound SID from Twilio list: {$listed_sid} for requested {$call_sid}");
                        return sanitize_text_field($listed_sid);
                    }
                }
            }
        }

        $resolved = ($active_fallback !== '') ? $active_fallback : sanitize_text_field($call_sid);
        error_log("[VMO resolve_control_target_call_sid] Result: resolved='$resolved' (active_fallback='$active_fallback' original='$call_sid')");
        return $resolved;
    }

    private function find_active_realtime_sip_leg(string $call_sid): string
    {
        $tracked = $this->get_tracked_realtime_ai_leg($call_sid);
        $tracked_call_sid = trim((string) ($tracked['call_sid'] ?? ''));
        if ($tracked_call_sid !== '') {
            $tracked_call = $this->twilio_request('GET', "/Calls/{$tracked_call_sid}.json", array(), null, 8);
            if (!is_wp_error($tracked_call)) {
                $tracked_status = strtolower(trim((string) ($tracked_call['status'] ?? '')));
                if (!in_array($tracked_status, array('canceled', 'cancelled', 'completed', 'failed', 'busy', 'no-answer', 'expired'), true)) {
                    return $tracked_call_sid;
                }
            }
        }

        $terminal_statuses = array('canceled', 'cancelled', 'completed', 'failed', 'busy', 'no-answer', 'expired');
        foreach ($this->get_child_call_legs($call_sid) as $child) {
            $child_sid = trim((string) ($child['sid'] ?? ''));
            if ($child_sid === '') {
                continue;
            }

            $status = strtolower(trim((string) ($child['status'] ?? '')));
            if (in_array($status, $terminal_statuses, true)) {
                continue;
            }

            $to = strtolower(trim((string) ($child['to'] ?? '')));
            $from = strtolower(trim((string) ($child['from'] ?? '')));
            if (strpos($to, 'sip:') === 0 || strpos($from, 'sip:') === 0) {
                return $child_sid;
            }
        }

        return '';
    }

    private function realtime_context_key(string $call_sid): string
    {
        $safe_sid = preg_replace('/[^a-zA-Z0-9_]/', '', trim($call_sid));
        return ($safe_sid !== '') ? ('vmo_realtime_ctx_' . $safe_sid) : '';
    }

    private function store_realtime_call_context(string $call_sid, array $context, int $ttl = 300): void
    {
        $key = $this->realtime_context_key($call_sid);
        if ($key === '') {
            return;
        }

        set_transient($key, $context, max(60, $ttl));
    }

    private function get_realtime_call_context(string $call_sid): array
    {
        $key = $this->realtime_context_key($call_sid);
        if ($key === '') {
            return array();
        }

        $context = get_transient($key);
        return is_array($context) ? $context : array();
    }

    private function clear_realtime_call_context(string $call_sid): void
    {
        $key = $this->realtime_context_key($call_sid);
        if ($key !== '') {
            delete_transient($key);
        }
    }

    private function bootstrap_realtime_inbound_call(string $call_sid, int $user_id, string $from_number = '', string $to_number = ''): void
    {
        global $wpdb;

        $call_sid = sanitize_text_field((string) $call_sid);
        $user_id = (int) $user_id;
        if ($call_sid === '' || $user_id <= 0) {
            return;
        }

        $from_number = sanitize_text_field((string) $from_number);
        $to_number = sanitize_text_field((string) $to_number);
        if ($to_number === '') {
            $to_number = (string) ($this->get_user_phone($user_id) ?: '');
        }

        $table = $wpdb->prefix . 'vmo_phone_calls';
        if ($wpdb->get_var("SHOW TABLES LIKE '{$table}'") === $table) {
            $existing_call_id = (int) $wpdb->get_var($wpdb->prepare(
                "SELECT id FROM {$table} WHERE call_sid = %s LIMIT 1",
                $call_sid
            ));

            if ($existing_call_id > 0) {
                $wpdb->update(
                    $table,
                    array(
                        'user_id' => $user_id,
                        'caller_number' => $from_number,
                        'direction' => 'inbound',
                        'status' => 'in-progress',
                        'ai_handled' => 1,
                    ),
                    array('id' => $existing_call_id),
                    array('%d', '%s', '%s', '%s', '%d'),
                    array('%d')
                );
            } else {
                $inserted = $wpdb->insert(
                    $table,
                    array(
                        'user_id' => $user_id,
                        'call_sid' => $call_sid,
                        'caller_number' => $from_number,
                        'direction' => 'inbound',
                        'status' => 'in-progress',
                        'ai_handled' => 1,
                        'created_at' => current_time('mysql'),
                    ),
                    array('%d', '%s', '%s', '%s', '%s', '%d', '%s')
                );

                if ($inserted !== false && $from_number !== '') {
                    try {
                        $this->link_call_to_contact((int) $wpdb->insert_id, $from_number, $user_id);
                    } catch (Throwable $e) {
                        error_log('[VMO Realtime] Contact linking failed for ' . $call_sid . ': ' . $e->getMessage());
                    }
                }
            }
        }

        $browser_ringer_enabled = $this->is_owner_ring_enabled($user_id);
        $this->set_owner_alert_state($call_sid, true);
        if ($to_number !== '') {
            $this->store_live_call_signal($to_number, array(
                'call_sid' => $call_sid,
                'from_number' => $from_number,
                'to_number' => $to_number,
                'direction' => 'inbound',
                'status' => 'in-progress',
                'created_at' => current_time('mysql'),
            ));
        }
        $this->store_user_ring_signal($user_id, array(
            'call_sid' => $call_sid,
            'from_number' => $from_number,
            'to_number' => $to_number,
            'status' => 'in-progress',
            'created_at' => current_time('mysql'),
            'owner_alert_ready' => 1,
        ));
        error_log('[VMO Realtime] Owner alert bootstrap ready for ' . $call_sid . ' browser_ringer_enabled=' . var_export($browser_ringer_enabled, true));
        set_transient('vmo_realtime_active_' . sanitize_key($call_sid), array(
            'user_id' => $user_id,
            'started_at' => time(),
        ), 1800);

        if ($from_number !== '') {
            try {
                $this->maybe_send_incoming_call_alert($call_sid, $user_id, $from_number);
            } catch (Throwable $e) {
                error_log('[VMO Realtime] Incoming alert bootstrap failed for ' . $call_sid . ': ' . $e->getMessage());
            }
        }
    }

    private function get_phone_opening_script(int $user_id, bool $did_gather = false, bool $ext_selected = false, string $ext_selected_label = ''): array
    {
        $ai_greeting = ($user_id > 0) ? (string) get_user_meta($user_id, 'vmo_ai_greeting', true) : '';
        if ($ai_greeting === '') {
            $ai_greeting = 'Thank you for calling. How can I help you today?';
        }

        if (!$did_gather) {
            $initial_greeting = $ai_greeting;
        } elseif ($ext_selected) {
            $initial_greeting = 'I\'ve notified ' . $ext_selected_label . ' about your call. They may join at any time. Meanwhile, I\'m here to help. What can I do for you?';
        } else {
            $initial_greeting = 'How can I help you?';
        }

        $reprompt_msg = $ext_selected
            ? 'I\'m still here. ' . $ext_selected_label . ' may still join. Would you like to leave a message, or is there something I can help with?'
            : 'I\'m still here if you need anything. How can I help you?';

        $realtime_instruction = '';
        if ($ext_selected && $ext_selected_label !== '') {
            $realtime_instruction = 'The caller selected the ' . $ext_selected_label . ' department. Let them know that ' . $ext_selected_label . ' has been notified, may join the call at any time, and that you are helping them in the meantime.';
        } elseif ($did_gather) {
            $realtime_instruction = 'The caller already heard the initial phone prompt. Continue naturally without restarting from the beginning.';
        }

        return array(
            'ai_greeting' => $ai_greeting,
            'initial_greeting' => $initial_greeting,
            'reprompt_msg' => $reprompt_msg,
            'gather_timeout' => $ext_selected ? 60 : 10,
            'reprompt_timeout' => 30,
            'realtime_instruction' => $realtime_instruction,
        );
    }

    private function emit_safe_incoming_voice_fallback(int $user_id, string $call_sid = '', bool $recording_enabled = true): void
    {
        while (ob_get_level()) {
            @ob_end_clean();
        }

        $greeting = ($user_id > 0) ? (string) get_user_meta($user_id, 'vmo_ai_greeting', true) : '';
        if ($greeting === '') {
            $greeting = 'Thank you for calling. How can I help you today?';
        }

        header('Content-Type: text/xml; charset=UTF-8');
        echo '<?xml version="1.0" encoding="UTF-8"?>';
        echo '<Response>';

        if ($recording_enabled) {
            echo '<Say voice="' . $this->tts_voice() . '">This call may be recorded for quality and training purposes.</Say>';
        }

        if ($this->is_whisper_language()) {
            $whisper_url = $this->phone_gateway_url('ai_converse_whisper', array('uid' => $user_id));
            echo '<Say voice="' . $this->tts_voice() . '">' . esc_xml($greeting) . '</Say>';
            echo '<Record action="' . esc_xml($whisper_url) . '" maxLength="30" timeout="10" playBeep="false" trim="trim-silence" />';
            echo '<Say voice="' . $this->tts_voice() . '">I am still here if you need anything else.</Say>';
            echo '<Record action="' . esc_xml($whisper_url) . '" maxLength="30" timeout="20" playBeep="false" trim="trim-silence" />';
        } else {
            $ai_converse_url = $this->phone_gateway_url('ai_converse', array('uid' => $user_id));
            echo '<Gather input="speech" action="' . esc_xml($ai_converse_url) . '" speechTimeout="2" timeout="10" enhanced="true" language="' . $this->gather_lang() . '">';
            echo '<Say voice="' . $this->tts_voice() . '">' . esc_xml($greeting) . '</Say>';
            echo '</Gather>';
            echo '<Gather input="speech" action="' . esc_xml($ai_converse_url) . '" speechTimeout="2" timeout="20" enhanced="true" language="' . $this->gather_lang() . '">';
            echo '<Say voice="' . $this->tts_voice() . '">I am still here if you need anything else.</Say>';
            echo '</Gather>';
        }

        echo '<Say voice="' . $this->tts_voice() . '">If you would like to leave a message, please speak after the beep.</Say>';
        echo '<Record maxLength="120" timeout="15" transcribe="true" transcribeCallback="' . esc_xml($this->twilio_public_url('transcription')) . '" />';
        echo '<Say voice="' . $this->tts_voice() . '">Thank you for calling. Goodbye.</Say>';
        echo '</Response>';
        exit;
    }

    private function emit_safe_ai_turn_fallback(int $user_id, bool $whisper_mode = false): void
    {
        while (ob_get_level()) {
            @ob_end_clean();
        }

        header('Content-Type: text/xml; charset=UTF-8');
        echo '<?xml version="1.0" encoding="UTF-8"?>';
        echo '<Response>';

        if ($whisper_mode) {
            $whisper_url = $this->phone_gateway_url('ai_converse_whisper', array('uid' => $user_id));
            echo '<Say voice="' . $this->tts_voice() . '">I am sorry, we had a brief problem. Please say that again after the beep, or leave a message and we will get back to you.</Say>';
            echo '<Record action="' . esc_xml($whisper_url) . '" maxLength="30" timeout="10" playBeep="false" trim="trim-silence" />';
        } else {
            $ai_converse_url = $this->phone_gateway_url('ai_converse', array('uid' => $user_id));
            echo '<Gather input="speech" action="' . esc_xml($ai_converse_url) . '" speechTimeout="2" timeout="10" enhanced="true" language="' . $this->gather_lang() . '">';
            echo '<Say voice="' . $this->tts_voice() . '">I am sorry, we had a brief problem. Please say that again, or stay on the line to leave a message.</Say>';
            echo '</Gather>';
        }

        echo '<Say voice="' . $this->tts_voice() . '">If you would like to leave a message, please speak after the beep.</Say>';
        echo '<Record maxLength="120" timeout="15" transcribe="true" transcribeCallback="' . esc_xml($this->twilio_public_url('transcription')) . '" />';
        echo '<Say voice="' . $this->tts_voice() . '">Thank you for calling. Goodbye.</Say>';
        echo '</Response>';
        exit;
    }

    private function build_conference_twiml(string $conference_name, array $conference_attributes = array(), string $announcement = ''): string
    {
        $conference_name = sanitize_text_field($conference_name);
        $defaults = array(
            'startConferenceOnEnter' => 'true',
            'endConferenceOnExit' => 'false',
            'beep' => 'false',
            'waitUrl' => '',
        );
        $attributes = array_merge($defaults, $conference_attributes);

        $twiml = '<Response>';
        if ($announcement !== '') {
            $twiml .= '<Say voice="' . esc_attr($this->tts_voice()) . '">' . esc_xml($announcement) . '</Say>';
        }
        $twiml .= '<Dial><Conference';
        foreach ($attributes as $name => $value) {
            if ($value === null || $value === false) {
                continue;
            }

            $attr_name = preg_replace('/[^A-Za-z0-9]/', '', (string) $name);
            if ($attr_name === '') {
                continue;
            }

            $twiml .= ' ' . $attr_name . '="' . esc_attr((string) $value) . '"';
        }
        $twiml .= '>' . esc_xml($conference_name) . '</Conference></Dial></Response>';

        return $twiml;
    }

    private function redirect_call_to_conference(string $call_sid, string $conference_name, array $conference_attributes = array(), string $announcement = '')
    {
        $call_sid = trim($call_sid);
        $conference_name = sanitize_text_field($conference_name);
        if ($call_sid === '' || $conference_name === '') {
            return new WP_Error('invalid_conference_redirect', 'Missing call or conference identifier');
        }

        return $this->twilio_request('POST', "/Calls/{$call_sid}.json", array(
            'Twiml' => $this->build_conference_twiml($conference_name, $conference_attributes, $announcement),
        ));
    }

    private function ensure_live_supervision_conference(string $call_sid)
    {
        $call_sid = trim($call_sid);
        if ($call_sid === '') {
            return new WP_Error('invalid_call_sid', 'Missing call identifier');
        }

        $safe_call_sid = sanitize_key($call_sid);
        $last_error = new WP_Error('live_supervision_failed', 'Could not prepare the live supervision bridge');

        // Reduced from 4 to 2 attempts to avoid hitting PHP max_execution_time.
        // Each attempt makes 2-4 Twilio API calls with 30s timeouts; 4 attempts
        // could take 60-120s total, far exceeding the default 30s limit.
        for ($attempt = 0; $attempt < 2; $attempt++) {
            $call_context = $this->get_call_runtime_context($call_sid);
            $conference_name = (string) ($call_context['active_conference_name'] ?? '');
            if ($conference_name === '') {
                $conference_name = $this->call_conference_name($call_sid);
            }
            if ($conference_name === '') {
                return new WP_Error('missing_conference_name', 'Could not resolve a conference name for this call');
            }

            if (!empty($call_context['active_conference_name'])) {
                $this->update_call_conference_state($call_sid, $conference_name, 1, '(realtime-supervision)');
                return array(
                    'conference_name' => $conference_name,
                    'created' => false,
                );
            }

            if (!(bool) get_transient('vmo_realtime_active_' . $safe_call_sid)) {
                return new WP_Error('legacy_supervision_only', 'Live listen is only available when the AI is on the live voice bridge');
            }

            $sip_leg_sid = $this->find_active_realtime_sip_leg($call_sid);
            if ($sip_leg_sid === '') {
                $last_error = new WP_Error('realtime_leg_missing', 'Could not find the live AI voice leg for this call');
            } else {
                $sip_redirect = $this->redirect_call_to_conference($sip_leg_sid, $conference_name);
                if (is_wp_error($sip_redirect)) {
                    $last_error = new WP_Error('sip_redirect_failed', 'Could not move the AI voice leg into live supervision: ' . $sip_redirect->get_error_message());
                } else {
                    $caller_redirect = $this->redirect_call_to_conference($call_sid, $conference_name);
                    if (is_wp_error($caller_redirect)) {
                        $last_error = new WP_Error('caller_redirect_failed', 'Could not move the caller into live supervision: ' . $caller_redirect->get_error_message());
                    } else {
                        $this->update_call_conference_state($call_sid, $conference_name, 1, '(realtime-supervision)');

                        return array(
                            'conference_name' => $conference_name,
                            'sip_leg_sid' => $sip_leg_sid,
                            'created' => true,
                        );
                    }
                }
            }

            if ($attempt < 1) {
                usleep(250000 * ($attempt + 1));
            }
        }

        return $last_error;
    }

    private function disconnect_realtime_ai_leg(string $call_sid): string
    {
        $call_sid = trim($call_sid);
        if ($call_sid === '') {
            return '';
        }

        $sip_leg_sid = $this->find_active_realtime_sip_leg($call_sid);
        if ($sip_leg_sid !== '') {
            $disconnect_key = $this->realtime_ai_disconnect_intent_key($call_sid);
            if ($disconnect_key !== '') {
                set_transient($disconnect_key, array('time' => time(), 'reason' => 'manual_disconnect'), MINUTE_IN_SECONDS * 5);
            }
            $this->stop_call_leg($sip_leg_sid);
            error_log('[VMO Voice] Disconnected realtime AI leg ' . $sip_leg_sid . ' for takeover on ' . $call_sid);
        }

        delete_transient('vmo_realtime_active_' . sanitize_key($call_sid));
        return $sip_leg_sid;
    }

    private function prepare_takeover_conference(string $call_sid, int $user_id = 0)
    {
        $call_sid = trim($call_sid);
        if ($call_sid === '') {
            return new WP_Error('invalid_call_sid', 'Missing call identifier');
        }

        $call_context = $this->get_call_runtime_context($call_sid);
        $conference_name = (string) ($call_context['active_conference_name'] ?? '');
        if ($conference_name === '') {
            $conference_name = $this->call_conference_name($call_sid);
        }
        if ($conference_name === '') {
            return new WP_Error('missing_conference_name', 'Could not resolve a conference name for this call');
        }

        $realtime_active = (bool) get_transient('vmo_realtime_active_' . sanitize_key($call_sid));
        if (!empty($call_context['active_conference_name'])) {
            if ($realtime_active) {
                $this->disconnect_realtime_ai_leg($call_sid);
            }
            $this->mark_call_conference_state($call_sid, $conference_name, '(takeover-ready)');
            return array('conference_name' => $conference_name, 'created' => false);
        }

        $redirect_result = $this->redirect_call_to_conference(
            $call_sid,
            $conference_name,
            array(
                'startConferenceOnEnter' => 'true',
                'endConferenceOnExit' => 'false',
                'beep' => 'false',
                'waitUrl' => '',
            ),
            'Please hold while we connect you.'
        );
        if (is_wp_error($redirect_result)) {
            return $redirect_result;
        }

        if ($realtime_active) {
            $this->disconnect_realtime_ai_leg($call_sid);
        }

        $this->mark_call_conference_state($call_sid, $conference_name, '(takeover-ready)');
        return array('conference_name' => $conference_name, 'created' => true);
    }

    public function prepare_live_supervision_bridge($call_sid): void
    {
        $call_sid = sanitize_text_field((string) $call_sid);
        if ($call_sid === '') {
            return;
        }

        $result = $this->ensure_live_supervision_conference($call_sid);
        if (is_wp_error($result)) {
            error_log('[VMO Voice] Live supervision bridge failed for ' . $call_sid . ': ' . $result->get_error_message());
            return;
        }

        error_log('[VMO Voice] Live supervision bridge ready for ' . $call_sid . ' conference=' . (string) ($result['conference_name'] ?? ''));
    }

    /**
     * Resolve the GPT route for the current phone turn.
     */
    private function get_phone_reasoning_route(int $user_id, string $speech, array $history = array()): array
    {
        $complexity = $this->estimate_phone_turn_complexity($speech, $history);
        $context = array(
            'user_id' => $user_id,
            'prompt' => $speech,
            'complexity' => $complexity,
            'requires_strategic_reasoning' => ($complexity >= 0.65),
        );

        if (class_exists('VMO_Model_Router')) {
            $router = new VMO_Model_Router();
            $route = $router->get_route('phone_reception_reasoning', $context);
            if (($route['kind'] ?? 'chat') === 'chat' && !empty($route['model'])) {
                return $route;
            }
        }

        $use_strategic = ($complexity >= 0.65);
        $strategic_model = function_exists('vmo_get_model') ? vmo_get_model('strategic') : 'gpt-5.5';
        $fast_model = function_exists('vmo_get_model') ? vmo_get_model('fast') : 'gpt-5.5-mini';
        return array(
            'kind' => 'chat',
            'tier' => $use_strategic ? 'strategic' : 'fast',
            'model' => $use_strategic ? $strategic_model : $fast_model,
            'fallback_model' => $use_strategic ? $fast_model : $strategic_model,
        );
    }

    private function extract_openai_chat_text(array $response_json): string
    {
        $content = $response_json['choices'][0]['message']['content'] ?? '';

        if (is_string($content)) {
            return trim($content);
        }

        if (is_array($content)) {
            $parts = array();
            foreach ($content as $part) {
                if (is_string($part) && trim($part) !== '') {
                    $parts[] = trim($part);
                    continue;
                }

                if (!is_array($part)) {
                    continue;
                }

                $text = $part['text'] ?? ($part['content'] ?? '');
                if (is_string($text) && trim($text) !== '') {
                    $parts[] = trim($text);
                }
            }

            if (!empty($parts)) {
                return trim(implode(' ', $parts));
            }
        }

        $output_text = $response_json['output_text'] ?? '';
        if (is_string($output_text) && trim($output_text) !== '') {
            return trim($output_text);
        }

        $output = $response_json['output'] ?? array();
        if (is_array($output)) {
            $parts = array();
            foreach ($output as $item) {
                if (!is_array($item)) {
                    continue;
                }

                $item_content = $item['content'] ?? array();
                if (!is_array($item_content)) {
                    continue;
                }

                foreach ($item_content as $part) {
                    if (!is_array($part)) {
                        continue;
                    }

                    $text = $part['text'] ?? ($part['content'] ?? '');
                    if (is_string($text) && trim($text) !== '') {
                        $parts[] = trim($text);
                    }
                }
            }

            if (!empty($parts)) {
                return trim(implode(' ', $parts));
            }
        }

        return '';
    }

    /**
     * Execute a phone-turn chat completion against the selected GPT route.
     */
    private function run_phone_chat_completion(int $user_id, string $api_key, array $messages, string $model, int $max_tokens, int $timeout, string $call_sid, string $tier): array
    {
        $token_param = function_exists('vmo_get_token_param')
            ? vmo_get_token_param($model, $max_tokens)
            : array('max_tokens' => $max_tokens);

        if (isset($token_param['max_tokens'])) {
            $token_param['max_tokens'] = min($max_tokens, (int) $token_param['max_tokens']);
        }
        if (isset($token_param['max_completion_tokens'])) {
            $token_param['max_completion_tokens'] = min($max_tokens, (int) $token_param['max_completion_tokens']);
        }

        error_log('[VMO Phone AI] Routing phone turn to ' . $model . ' (' . $tier . ')');

        if (function_exists('vmo_openai_chat')) {
            $response = vmo_openai_chat($messages, $model, array(
                'temperature' => ($tier === 'strategic') ? 0.50 : 0.45,
                'max_tokens' => $max_tokens,
                'timeout' => $timeout,
                'vmo_user_id' => $user_id,
            ));
        } else {
            $response = wp_remote_post('https://api.openai.com/v1/chat/completions', array(
                'timeout' => $timeout,
                'headers' => array(
                    'Authorization' => 'Bearer ' . $api_key,
                    'Content-Type' => 'application/json',
                ),
                'body' => wp_json_encode(array_merge(array(
                    'model' => $model,
                    'messages' => $messages,
                    'temperature' => ($tier === 'strategic') ? 0.50 : 0.45,
                ), $token_param)),
            ));
        }

        if (is_wp_error($response)) {
            error_log('[VMO Phone AI] ' . $model . ' error: ' . $response->get_error_message());
            return array(
                'content' => '',
                'model' => $model,
                'tier' => $tier,
                'retryable' => true,
            );
        }

        $raw_body = wp_remote_retrieve_body($response);
        $http_code = wp_remote_retrieve_response_code($response);
        $response_json = json_decode($raw_body, true);
        $content = $this->extract_openai_chat_text(is_array($response_json) ? $response_json : array());

        // Capture why the model stopped so callers can repair a reply that was
        // cut off by the token cap (finish_reason === 'length') instead of
        // speaking a dangling half-sentence to the caller.
        $finish_reason = '';
        if (is_array($response_json)) {
            if (isset($response_json['choices'][0]['finish_reason'])) {
                $finish_reason = (string) $response_json['choices'][0]['finish_reason'];
            } elseif (isset($response_json['finish_reason'])) {
                $finish_reason = (string) $response_json['finish_reason'];
            }
        }

        if ($content !== '') {
            if (!empty($call_sid)) {
                $turns_key = 'vmo_ai_turns_' . preg_replace('/[^a-zA-Z0-9_]/', '', $call_sid);
                $current_turns = (int) get_transient($turns_key);
                set_transient($turns_key, $current_turns + 1, 3600);
            }

            return array(
                'content' => $content,
                'model' => $model,
                'tier' => $tier,
                'retryable' => false,
                'finish_reason' => $finish_reason,
            );
        }

        $error_message = strtolower((string) ($response_json['error']['message'] ?? ''));
        $error_code = strtolower((string) ($response_json['error']['code'] ?? ''));
        $retryable = ($error_message !== '' && strpos($error_message, 'model') !== false)
            || ($error_code !== '' && strpos($error_code, 'model') !== false);

        error_log('[VMO Phone AI] ' . $model . ' unexpected response (HTTP ' . $http_code . '): ' . substr($raw_body, 0, 500));

        return array(
            'content' => '',
            'model' => $model,
            'tier' => $tier,
            'retryable' => $retryable,
        );
    }

    /**
     * Generate a phone AI response using GPT-5.5 by default and GPT-5.5 for complex turns.
     */
    private function request_phone_ai_response(int $user_id, string $system_prompt, array $history, string $speech, string $call_sid = ''): array
    {
        $api_key = get_option('vmo_openai_api_key', '');
        $uses_proxy_helper = function_exists('vmo_openai_chat');
        $is_client_mode = defined('VMO_CLIENT_MODE') && VMO_CLIENT_MODE === true;
        if (empty($api_key) && !$uses_proxy_helper && !$is_client_mode) {
            error_log('[VMO Phone AI] Missing OpenAI API key for call ' . $call_sid);
            return array(
                'content' => '',
                'model' => '',
                'tier' => 'fast',
            );
        }

        if (trim($speech) === '') {
            error_log('[VMO Phone AI] Empty speech payload for call ' . $call_sid);
            return array(
                'content' => '',
                'model' => '',
                'tier' => 'fast',
            );
        }

        $route = $this->get_phone_reasoning_route($user_id, $speech, $history);
        $tier = (string) ($route['tier'] ?? 'fast');
        $model = (string) ($route['model'] ?? 'gpt-5.5-mini');
        $messages = array_merge(
            array(array('role' => 'system', 'content' => $system_prompt)),
            $history
        );

        // Generous per-turn budget so the receptionist can finish a complete
        // thought and deliver all the information the caller needs without being
        // chopped mid-sentence. Kept within Twilio's ~15s webhook reply window:
        // typical completions at these sizes still return well under the limit,
        // and any rare length-cap is repaired to the last full sentence below.
        $max_tokens = ($tier === 'strategic') ? 640 : 480;
        $timeout = ($tier === 'strategic') ? 10 : 8;
        $result = $this->run_phone_chat_completion($user_id, $api_key, $messages, $model, $max_tokens, $timeout, $call_sid, $tier);

        $tried_models = array($model);

        if ($result['content'] === '' && !empty($result['retryable']) && function_exists('vmo_handle_model_failure')) {
            $recovery_model = (string) vmo_handle_model_failure($model, $tier);
            if ($recovery_model !== '' && !in_array($recovery_model, $tried_models, true)) {
                error_log('[VMO Phone AI] Retrying phone turn on recovered model ' . $recovery_model . ' after failure from ' . $model);
                $recovery_result = $this->run_phone_chat_completion($user_id, $api_key, $messages, $recovery_model, $max_tokens, $timeout, $call_sid, $tier);
                $tried_models[] = $recovery_model;
                if ($recovery_result['content'] !== '') {
                    return $recovery_result;
                }
            }
        }

        $fallback_model = (string) ($route['fallback_model'] ?? '');
        if ($result['content'] === '' && $fallback_model !== '' && $fallback_model !== $model) {
            $fallback_tier = ($tier === 'strategic') ? 'fast' : 'strategic';
            $fallback_tokens = ($fallback_tier === 'strategic') ? 640 : 480;
            $fallback_timeout = ($fallback_tier === 'strategic') ? 10 : 8;
            error_log('[VMO Phone AI] Retrying phone turn on fallback model ' . $fallback_model . ' after empty response from ' . $model);
            $fallback_result = $this->run_phone_chat_completion($user_id, $api_key, $messages, $fallback_model, $fallback_tokens, $fallback_timeout, $call_sid, $fallback_tier);
            $tried_models[] = $fallback_model;
            if ($fallback_result['content'] !== '') {
                return $fallback_result;
            }
        }

        $mini_model = function_exists('vmo_get_model') ? (string) vmo_get_model('mini') : 'gpt-5.5-mini';
        if ($result['content'] === '' && $mini_model !== '' && !in_array($mini_model, $tried_models, true)) {
            error_log('[VMO Phone AI] Retrying phone turn on mini model ' . $mini_model);
            $mini_result = $this->run_phone_chat_completion($user_id, $api_key, $messages, $mini_model, 320, 6, $call_sid, 'fast');
            $tried_models[] = $mini_model;
            if ($mini_result['content'] !== '') {
                return $mini_result;
            }
        }

        $emergency_model = function_exists('vmo_get_model') ? vmo_get_model('fallback') : 'gpt-5.5-nano';
        if ($result['content'] === '' && $emergency_model !== '' && !in_array($emergency_model, $tried_models, true)) {
            error_log('[VMO Phone AI] Retrying phone turn on emergency fallback model ' . $emergency_model);
            $emergency_result = $this->run_phone_chat_completion($user_id, $api_key, $messages, $emergency_model, 320, 6, $call_sid, 'fast');
            $tried_models[] = $emergency_model;
            if ($emergency_result['content'] !== '') {
                return $emergency_result;
            }
        }

        // Ultimate fallback: gpt-5.5-nano — lightest model in the GPT-5 family as last resort
        $ultimate_fallback = function_exists('vmo_get_model') ? vmo_get_model('nano') : 'gpt-5.5-nano';
        if ($result['content'] === '' && !in_array($ultimate_fallback, $tried_models, true)) {
            error_log('[VMO Phone AI] All primary GPT-5.x models failed. Retrying on ' . $ultimate_fallback . ' as ultimate fallback for call ' . $call_sid);
            $nano_result = $this->run_phone_chat_completion($user_id, $api_key, $messages, $ultimate_fallback, 320, 8, $call_sid, 'fast');
            if ($nano_result['content'] !== '') {
                return $nano_result;
            }
        }

        return $result;
    }

    /**
     * Repair a reply that the model stopped generating mid-sentence (token cap
     * hit, finish_reason === 'length') so the caller never hears a dangling
     * half-thought. Keeps every complete sentence and drops only the trailing
     * incomplete fragment; if the whole reply is a single unfinished sentence
     * it is closed cleanly instead of discarded.
     */
    private function complete_to_last_sentence(string $text): string
    {
        $text = trim($text);
        if ($text === '') {
            return '';
        }

        // Already ends on terminal punctuation (optionally followed by a quote/bracket) — nothing to repair.
        if (preg_match('/[.!?\x{2026}]["\x{201D}\x{2019}\)\]]?$/u', $text)) {
            return $text;
        }

        $sentences = preg_split('/(?<=[.!?])\s+/', $text, -1, PREG_SPLIT_NO_EMPTY);
        if (is_array($sentences) && count($sentences) > 1) {
            array_pop($sentences); // remove the trailing incomplete fragment
            $rebuilt = trim(implode(' ', $sentences));
            if ($rebuilt !== '') {
                return $rebuilt;
            }
        }

        // Only one (unfinished) sentence available — close it cleanly rather than discard.
        return rtrim($text, " ,;:-\t\n\r\0\x0B") . '.';
    }

    /**
     * Strip formatting so spoken responses stay natural.
     */
    private function trim_phone_response_text(string $text, int $max_chars = 2200, int $max_sentences = 12): string
    {
        $text = trim($text);
        if ($text === '') {
            return '';
        }

        $sentences = preg_split('/(?<=[.!?])\s+/', $text, -1, PREG_SPLIT_NO_EMPTY);
        if (is_array($sentences) && count($sentences) > $max_sentences) {
            $text = implode(' ', array_slice($sentences, 0, $max_sentences));
        }

        if (strlen($text) <= $max_chars) {
            return $text;
        }

        $truncated = $this->safe_substr($text, 0, $max_chars);
        $breaks = array(
            strrpos($truncated, '. '),
            strrpos($truncated, '! '),
            strrpos($truncated, '? '),
            strrpos($truncated, '; '),
            strrpos($truncated, ', '),
        );
        $breaks = array_filter($breaks, static function ($value) {
            return $value !== false && $value >= 180;
        });

        if (!empty($breaks)) {
            $truncated = $this->safe_substr($truncated, 0, max($breaks) + 1);
        } else {
            $last_space = strrpos($truncated, ' ');
            if ($last_space !== false && $last_space >= 180) {
                $truncated = $this->safe_substr($truncated, 0, $last_space);
            }
        }

        $truncated = rtrim($truncated, ",;:- \t\n\r\0\x0B");
        if ($truncated !== '' && !preg_match('/[.!?]$/', $truncated)) {
            $truncated .= '.';
        }

        return $truncated;
    }

    private function sanitize_spoken_text(string $text): string
    {
        $text = preg_replace('/[\*#_~`\[\]|>{}\\^]+/', '', $text);
        $text = preg_replace('/[\x{2022}\x{2023}\x{25E6}\x{2043}\x{2219}\x{25AA}\x{25AB}\x{25CF}\x{25CB}\x{2013}\x{2014}\x{2018}\x{2019}\x{201C}\x{201D}]/u', '', $text);
        $text = preg_replace('/[\x{1F000}-\x{1FFFF}\x{2600}-\x{27BF}\x{FE00}-\x{FE0F}\x{1F900}-\x{1F9FF}]/u', '', $text);
        $text = preg_replace('/^\s*[\d]+[\.\)\:]\s*/m', '', $text);
        $text = preg_replace('/^\s*[-\*\+]\s+/m', '', $text);
        $text = preg_replace('/\(\s*\)/', '', $text);
        $text = trim((string) preg_replace('/\s{2,}/', ' ', $text));
        return $this->trim_phone_response_text($text);
    }

    // =================================================================
    // ARCHITECTURE C: OpenAI TTS for the selected receptionist voice.
    // Falls back gracefully to Polly <Say> if anything goes wrong or the
    // live request budget is too tight, so the phone system never breaks.
    // =================================================================

    /**
     * Generate TTS audio via OpenAI API using the selected receptionist voice.
     *
     * Calls the OpenAI /v1/audio/speech endpoint, saves the MP3 to the
     * WordPress uploads directory, and returns the publicly-accessible URL.
     * Returns an empty string on any failure so callers can fall back to Polly.
     *
     * @param string $text            The text to speak.
     * @param string $call_sid        Optional CallSid for unique filenames.
     * @param int    $user_id         User whose reception voice should be used.
     * @param float  $timeout_seconds Max time to spend generating the audio.
     * @return string Public URL of the MP3, or '' on failure.
     */
    private function openai_tts_url(string $text, string $call_sid = '', int $user_id = 0, float $timeout_seconds = 8.5): string
    {
        $api_key = get_option('vmo_openai_api_key', '');
        if (empty($api_key) || empty(trim($text))) {
            return '';
        }

        // Give the premium OpenAI voice enough time to return before falling
        // back to the robotic Twilio voice.
        $timeout_seconds = min(8.5, max(0.0, $timeout_seconds));
        if ($timeout_seconds < 1.25) {
            return '';
        }

        $voice = $this->get_selected_reception_voice($user_id);

        // ---- Prepare upload directory ----
        $upload_dir = wp_upload_dir();
        $tts_dir = $upload_dir['basedir'] . '/vmo-tts';
        if (!is_dir($tts_dir)) {
            wp_mkdir_p($tts_dir);
            // Prevent directory listing
            vmo_get_filesystem()->put_contents($tts_dir . '/.htaccess', "Options -Indexes\n", FS_CHMOD_FILE);
        }

        $safe_sid = preg_replace('/[^a-zA-Z0-9_]/', '', $call_sid ?: 'tts');
        $safe_voice = preg_replace('/[^a-zA-Z0-9_-]/', '', $voice ?: 'ash');
        $filename = $safe_sid . '-' . $safe_voice . '-' . substr(md5($voice . '|' . $text . microtime(true)), 0, 12) . '.mp3';
        $filepath = $tts_dir . '/' . $filename;
        $fileurl = $upload_dir['baseurl'] . '/vmo-tts/' . $filename;

        // ---- Call OpenAI TTS API ----
        // tts_play() manages the overall webhook budget; this method just uses
        // enough time for the OpenAI voice to return reliably.
        $response = wp_remote_post('https://api.openai.com/v1/audio/speech', array(
            'timeout' => max(1, (int) ceil($timeout_seconds)),
            'headers' => array(
                'Authorization' => 'Bearer ' . $api_key,
                'Content-Type' => 'application/json',
            ),
            'body' => wp_json_encode(array(
                'model' => 'tts-1',
                'voice' => $voice,
                'input' => $text,
                'response_format' => 'mp3',
                'speed' => 1.0,
            )),
        ));

        if (is_wp_error($response)) {
            error_log('[VMO TTS] OpenAI TTS error: ' . $response->get_error_message());
            return '';
        }

        $http_code = wp_remote_retrieve_response_code($response);
        if ($http_code !== 200) {
            error_log('[VMO TTS] OpenAI TTS HTTP ' . $http_code . ': ' . substr(wp_remote_retrieve_body($response), 0, 300));
            return '';
        }

        $audio = wp_remote_retrieve_body($response);
        if (strlen($audio) < 100) {
            error_log('[VMO TTS] OpenAI TTS returned suspiciously small audio (' . strlen($audio) . ' bytes)');
            return '';
        }

        // ---- Write MP3 to disk ----
        if (vmo_get_filesystem()->put_contents($filepath, $audio, FS_CHMOD_FILE) === false) {
            error_log('[VMO TTS] Failed to write audio file: ' . $filepath);
            return '';
        }

        // Lightweight inline cleanup (delete files older than 1 hour)
        $this->cleanup_tts_files($tts_dir);

        // ✅ TOKEN TRACKING: Accumulate TTS characters for post-call billing
        if (!empty($call_sid)) {
            $tts_key = 'vmo_tts_chars_' . preg_replace('/[^a-zA-Z0-9_]/', '', $call_sid);
            $current_chars = (int) get_transient($tts_key);
            set_transient($tts_key, $current_chars + strlen($text), 3600);
        }

        return $fileurl;
    }

    /**
     * Return a TwiML snippet that speaks $text with the best available voice.
     *
     * Tries OpenAI TTS for the selected receptionist voice first → <Play>url</Play>.
     * Falls back to neutral Twilio <Say> only if the OpenAI path genuinely fails.
     *
     * This is a drop-in replacement wherever <Say voice="..."> is used for
     * AI-generated or greeting content. System messages (recording notice,
     * voicemail prompt) should keep using <Say> for speed.
     *
     * @param string     $text               Text to speak (should already be sanitised for TTS).
     * @param string     $call_sid           Optional CallSid for unique filenames.
     * @param int        $user_id            User whose selected reception voice should be used.
     * @param float|null $request_started_at Timestamp used to protect Twilio's response deadline.
     * @return string TwiML snippet: either <Play>url</Play> or <Say>...</Say>.
     */
    private function tts_play(string $text, string $call_sid = '', int $user_id = 0, ?float $request_started_at = null): string
    {
        $text = trim($text);
        if ($text === '') {
            return '';
        }

        // Standard users should always use the configured fast Twilio voice.
        // Premium users get OpenAI TTS (or a voiceless fallback if it fails).
        $is_premium = ($user_id > 0) && $this->is_premium_voice_tier($user_id);
        if (!$is_premium) {
            return '<Say voice="' . esc_attr($this->tts_voice()) . '">' . esc_xml($text) . '</Say>';
        }

        // VOICE CONSISTENCY: Use <Say> WITHOUT a named voice for the fallback.
        // Previously used $this->tts_voice() which returns "Polly.Joanna-Neural"
        // (female), causing a jarring switch from the OpenAI TTS voice "ash"
        // (male) whenever TTS timed out. A voiceless <Say> uses Twilio's
        // neutral default, which is clearly a "system" voice and far less
        // confusing than alternating between two named human-sounding voices.
        $fallback = '<Say>' . esc_xml($text) . '</Say>';

        $tts_timeout = 8.5;
        if ($request_started_at !== null) {
            $remaining_budget = 24.0 - (microtime(true) - $request_started_at);
            if ($remaining_budget <= 3.0) {
                error_log('[VMO TTS] Time budget exhausted (' . round($remaining_budget, 2) . 's left) — using Say fallback');
                return $fallback;
            }
            $tts_timeout = min($tts_timeout, $remaining_budget - 0.25);
        }

        try {
            $audio_url = $this->openai_tts_url($text, $call_sid, $user_id, $tts_timeout);
            if (empty($audio_url) && $tts_timeout >= 5.0) {
                $retry_timeout = min(4.0, $tts_timeout - 1.5);
                if ($retry_timeout >= 2.5) {
                    error_log('[VMO TTS] Retrying OpenAI TTS for call ' . $call_sid . ' with timeout=' . round($retry_timeout, 2));
                    $audio_url = $this->openai_tts_url($text, $call_sid, $user_id, $retry_timeout);
                }
            }
        } catch (Throwable $e) {
            error_log('[VMO TTS] Falling back to Twilio <Say> after TTS exception: ' . $e->getMessage());
            return $fallback;
        }

        if (!empty($audio_url)) {
            return '<Play>' . esc_xml($audio_url) . '</Play>';
        }
        // Graceful fallback — neutral Twilio <Say> (no named voice)
        error_log('[VMO TTS] OpenAI TTS returned empty — using neutral Say fallback for: ' . substr($text, 0, 80));
        return $fallback;
    }

    /**
     * Delete TTS audio files older than 1 hour from the given directory.
     * Called inline during TTS generation so we never accumulate stale files.
     */
    private function cleanup_tts_files(string $dir): void
    {
        $cutoff = time() - 3600; // 1 hour ago
        $files = @glob($dir . '/*.mp3');
        if (!is_array($files))
            return;

        foreach ($files as $file) {
            if (@filemtime($file) < $cutoff) {
                @unlink($file);
            }
        }
    }

    // =================================================================
    // WHISPER STT — For languages Twilio cannot recognise (Yorùbá, Igbo,
    // Pidgin English, etc.) we use <Record> instead of <Gather> and
    // transcribe the recording with OpenAI Whisper.  The Ash TTS voice
    // already speaks these languages natively — Whisper bridges the
    // speech-recognition gap.
    // =================================================================

    /**
     * Check whether the current phone language requires Whisper STT
     * instead of Twilio's built-in <Gather> speech recognition.
     */
    private function is_whisper_language(): bool
    {
        if ($this->lang_config_cache === null) {
            $this->lang_config_cache = self::get_language_config();
        }
        return !empty($this->lang_config_cache['whisper_lang']);
    }

    /**
     * Get the Whisper language hint code for the current language.
     * Returns '' for non-Whisper languages.
     */
    private function whisper_lang_hint(): string
    {
        if ($this->lang_config_cache === null) {
            $this->lang_config_cache = self::get_language_config();
        }
        return $this->lang_config_cache['whisper_lang'] ?? '';
    }

    /**
     * Transcribe audio from a Twilio RecordingUrl using OpenAI Whisper.
     *
     * Downloads the recording from Twilio (adding .mp3 suffix for format),
     * then sends it to the OpenAI /v1/audio/transcriptions endpoint with
     * an optional language hint for improved accuracy.
     *
     * @param string $recording_url  The RecordingUrl from Twilio's <Record> callback.
     * @param string $lang_hint      ISO-639-1 language hint (e.g. 'yo', 'ig', 'en').
     * @return string  The transcribed text, or '' on failure.
     */
    private function whisper_transcribe(string $recording_url, string $lang_hint = ''): string
    {
        $api_key = get_option('vmo_openai_api_key', '');
        if (empty($api_key) || empty($recording_url)) {
            error_log('[VMO Whisper] Missing API key or recording URL');
            return '';
        }

        // Twilio recordings are available at RecordingUrl.mp3
        $audio_url = rtrim($recording_url, '/') . '.mp3';

        // ---- Download the recording from Twilio ----
        $twilio_sid = defined('VMO_TWILIO_ACCOUNT_SID') ? VMO_TWILIO_ACCOUNT_SID : get_option('vmo_twilio_account_sid', '');
        $twilio_token = defined('VMO_TWILIO_AUTH_TOKEN') ? VMO_TWILIO_AUTH_TOKEN : get_option('vmo_twilio_auth_token', '');

        $dl_args = array('timeout' => 30);
        if (!empty($twilio_sid) && !empty($twilio_token)) {
            $dl_args['headers'] = array(
                'Authorization' => 'Basic ' . base64_encode($twilio_sid . ':' . $twilio_token), // phpcs:ignore WordPress.PHP.DiscouragedPHPFunctions.obfuscation_base64_encode -- HTTP Basic auth header
            );
        }
        $dl_response = wp_remote_get($audio_url, $dl_args);

        if (is_wp_error($dl_response)) {
            error_log('[VMO Whisper] Failed to download recording: ' . $dl_response->get_error_message());
            return '';
        }

        $audio_body = wp_remote_retrieve_body($dl_response);
        if (strlen($audio_body) < 100) {
            error_log('[VMO Whisper] Recording too small (' . strlen($audio_body) . ' bytes), may not be ready yet');
            // Retry once after a brief pause — Twilio recordings sometimes need a moment
            usleep(800000); // 0.8 seconds
            $dl_response = wp_remote_get($audio_url, $dl_args);
            if (is_wp_error($dl_response)) {
                return '';
            }
            $audio_body = wp_remote_retrieve_body($dl_response);
            if (strlen($audio_body) < 100) {
                error_log('[VMO Whisper] Recording still too small after retry');
                return '';
            }
        }

        // ---- Save to a temp file (Whisper requires multipart upload) ----
        $tmp_file = wp_tempnam('vmo_whisper_') . '.mp3';
        if (vmo_get_filesystem()->put_contents($tmp_file, $audio_body, FS_CHMOD_FILE) === false) {
            error_log('[VMO Whisper] Failed to write temp audio file');
            return '';
        }

        // ---- Call OpenAI Whisper API ----
        $boundary = wp_generate_password(24, false);
        $body = '';

        // model field
        $body .= '--' . $boundary . "\r\n";
        $body .= 'Content-Disposition: form-data; name="model"' . "\r\n\r\n";
        $body .= "whisper-1\r\n";

        // language hint (improves accuracy)
        if (!empty($lang_hint)) {
            $body .= '--' . $boundary . "\r\n";
            $body .= 'Content-Disposition: form-data; name="language"' . "\r\n\r\n";
            $body .= $lang_hint . "\r\n";
        }

        // audio file
        $body .= '--' . $boundary . "\r\n";
        $body .= 'Content-Disposition: form-data; name="file"; filename="audio.mp3"' . "\r\n";
        $body .= 'Content-Type: audio/mpeg' . "\r\n\r\n";
        $body .= $audio_body . "\r\n";
        $body .= '--' . $boundary . "--\r\n";

        $response = wp_remote_post('https://api.openai.com/v1/audio/transcriptions', array(
            'timeout' => 30,
            'headers' => array(
                'Authorization' => 'Bearer ' . $api_key,
                'Content-Type' => 'multipart/form-data; boundary=' . $boundary,
            ),
            'body' => $body,
        ));

        @unlink($tmp_file); // Clean up temp file

        if (is_wp_error($response)) {
            error_log('[VMO Whisper] API error: ' . $response->get_error_message());
            return '';
        }

        $json = json_decode(wp_remote_retrieve_body($response), true);
        if (empty($json['text'])) {
            error_log('[VMO Whisper] Empty transcription result: ' . wp_remote_retrieve_body($response));
            return '';
        }

        error_log('[VMO Whisper] Transcribed (' . $lang_hint . '): ' . $json['text']);
        return trim($json['text']);
    }

    /**
     * Get singleton instance
     */
    public static function get_instance()
    {
        if (null === self::$instance) {
            self::$instance = new self();
        }
        return self::$instance;
    }

    /**
     * Constructor
     */
    private function __construct()
    {
        // SDK Removed for lightweight performace
        // We now use native WordPress HTTP API (wp_remote_get/post)

        // Get VMO's master Twilio credentials from wp-config.php or options
        $this->account_sid = defined('VMO_TWILIO_ACCOUNT_SID')
            ? VMO_TWILIO_ACCOUNT_SID
            : get_option('vmo_twilio_account_sid');

        $this->auth_token = defined('VMO_TWILIO_AUTH_TOKEN')
            ? VMO_TWILIO_AUTH_TOKEN
            : get_option('vmo_twilio_auth_token');

        if ($this->account_sid && $this->auth_token) {
            $this->simulation_mode = false;
        } else {
            // Enable simulation mode if credentials missing
            $this->simulation_mode = true;
        }

        // Ensure expected phone number schema exists (some environments have legacy columns).
        $this->ensure_phone_numbers_schema();

        // Register AJAX endpoints
        add_action('wp_ajax_vmo_auto_provision_phone', array($this, 'auto_provision_phone'));
        add_action('wp_ajax_vmo_release_phone_number', array($this, 'ajax_release_phone_number')); // ADDED: Manual Release
        add_action('wp_ajax_vmo_reconnect_phone_number', array($this, 'ajax_reconnect_phone_number')); // ADDED: Force Sync
        add_action('wp_ajax_vmo_update_phone_label', array($this, 'ajax_update_phone_label')); // ADDED: Update Label
        add_action('wp_ajax_vmo_search_phone_numbers', array($this, 'ajax_search_numbers'));

        // Public (no-auth) AJAX endpoints for Twilio webhooks/TwiML.
        // getvmo.com currently rewrites REST (/wp-json + rest_route) to the homepage,
        // so Twilio must use admin-ajax.php to reliably fetch TwiML.
        add_action('wp_ajax_nopriv_vmo_twilio_voice_webhook', array($this, 'ajax_twilio_voice_webhook'));
        add_action('wp_ajax_nopriv_vmo_twilio_status_callback', array($this, 'ajax_twilio_status_callback'));
        add_action('wp_ajax_nopriv_vmo_twilio_sms_webhook', array($this, 'ajax_twilio_sms_webhook'));
        add_action('wp_ajax_nopriv_vmo_dial_status_callback', array($this, 'ajax_dial_status_callback'));
        add_action('wp_ajax_nopriv_vmo_twilio_transcription_callback', array($this, 'ajax_twilio_transcription_callback'));
        add_action('wp_ajax_nopriv_vmo_recording_callback', array($this, 'ajax_recording_callback'));
        add_action('wp_ajax_nopriv_vmo_power_dialer_twiml', array($this, 'ajax_power_dialer_twiml'));
        add_action('wp_ajax_nopriv_vmo_power_dialer_outbound_callback', array($this, 'ajax_power_dialer_outbound_callback'));

        // Debug/health check (public)
        add_action('wp_ajax_nopriv_vmo_ping', array($this, 'ajax_ping'));

        add_action('wp_ajax_vmo_test_twilio_connection', array($this, 'test_connection'));
        add_action('wp_ajax_vmo_get_phone_analytics', array($this, 'get_phone_analytics'));
        add_action('wp_ajax_vmo_get_phone_provision_status', array($this, 'get_phone_provision_status'));

        // Register REST API endpoints
        add_action('rest_api_init', array($this, 'register_rest_routes'));

        // Register cron jobs
        add_action('vmo_monthly_billing_cron', array($this, 'process_monthly_billing'));
        add_action('vmo_generate_call_summary', array($this, 'generate_call_summary'), 10, 2);
        add_action('vmo_summarize_call_transcript', array($this, 'summarize_existing_transcript'), 10, 1);
        add_action('vmo_send_call_summary_email', array($this, 'send_post_call_summary_email'), 10, 1);
        add_action('vmo_prepare_live_supervision', array($this, 'prepare_live_supervision_bridge'), 10, 1);
        add_action('vmo_start_call_recording_retry', array($this, 'retry_start_call_recording'), 10, 3);
        add_action('vmo_finalize_premium_bridge_call', array($this, 'finalize_premium_bridge_call'), 10, 2);

        // Daily/Weekly/Monthly report generation crons
        add_action('vmo_generate_daily_report', array($this, 'generate_daily_report'), 10, 1);
        add_action('vmo_generate_periodic_report', array($this, 'generate_periodic_report'), 10, 3);

        // Schedule hourly timezone-aware report check (replaces old daily cron)
        // Each hour we check which users are at 11:55 PM in their local timezone
        $old_schedule = wp_next_scheduled('vmo_generate_daily_report_all');
        if ($old_schedule) {
            wp_unschedule_event($old_schedule, 'vmo_generate_daily_report_all');
        }
        if (!wp_next_scheduled('vmo_hourly_report_check')) {
            wp_schedule_event(time(), 'hourly', 'vmo_hourly_report_check');
        }
        add_action('vmo_hourly_report_check', array($this, 'generate_daily_reports_for_all_users'));

        // AJAX handler: save user timezone (auto-detected from browser)
        add_action('wp_ajax_vmo_save_user_timezone', array($this, 'ajax_save_user_timezone'));

        // AJAX handlers for reports and action items
        add_action('wp_ajax_vmo_get_call_reports', array($this, 'ajax_get_call_reports'));
        add_action('wp_ajax_vmo_get_action_items', array($this, 'ajax_get_action_items'));
        add_action('wp_ajax_vmo_update_action_item', array($this, 'ajax_update_action_item'));
        add_action('wp_ajax_vmo_get_periodic_reports', array($this, 'ajax_get_periodic_reports'));
        add_action('wp_ajax_vmo_generate_custom_report', array($this, 'ajax_generate_custom_report'));
        add_action('wp_ajax_vmo_get_fulfillment_log', array($this, 'ajax_get_fulfillment_log'));
    }

    /**
     * Ensure vmo_phone_numbers has the expected columns.
     *
     * Some deployments may have a legacy schema without `phone_number`, which breaks
     * assigned-number display and lookups.
     */
    private function ensure_phone_numbers_schema(): void
    {
        global $wpdb;

        $table = $wpdb->prefix . 'vmo_phone_numbers';

        // Bail if table doesn't exist.
        $like = $wpdb->esc_like($table);
        $exists = $wpdb->get_var($wpdb->prepare('SHOW TABLES LIKE %s', $like));
        if (!$exists) {
            return;
        }

        $columns = $wpdb->get_results("SHOW COLUMNS FROM {$table}", ARRAY_A);
        if (!is_array($columns) || empty($columns)) {
            return;
        }

        $fields = array();
        foreach ($columns as $col) {
            if (!empty($col['Field'])) {
                $fields[] = (string) $col['Field'];
            }
        }

        if (!in_array('phone_number', $fields, true)) {
            // Add the missing column.
            $wpdb->query("ALTER TABLE {$table} ADD COLUMN phone_number VARCHAR(50) NOT NULL DEFAULT ''");

            // Backfill from likely legacy columns.
            $legacy_candidates = array(
                'phone',
                'number',
                'twilio_number',
                'twilio_phone',
                'assigned_number',
            );
            foreach ($legacy_candidates as $legacy) {
                if (in_array($legacy, $fields, true)) {
                    // Copy only when destination is empty.
                    // phpcs:ignore WordPress.DB.PreparedSQL.InterpolatedNotPrepared, WordPress.DB.DirectDatabaseQuery.DirectQuery, WordPress.DB.DirectDatabaseQuery.NoCaching
                    $wpdb->query("UPDATE {$table} SET phone_number = {$legacy} WHERE phone_number = '' AND {$legacy} <> ''");
                    break;
                }
            }
        }

        // Ensure there's an index on phone_number.
        $idx = $wpdb->get_var($wpdb->prepare(
            "SELECT COUNT(1) FROM INFORMATION_SCHEMA.STATISTICS WHERE TABLE_SCHEMA = DATABASE() AND TABLE_NAME = %s AND COLUMN_NAME = 'phone_number'",
            $table
        ));
        if (!$idx) {
            $wpdb->query("ALTER TABLE {$table} ADD INDEX idx_phone (phone_number)");
        }

        // Cache detected column name.
        $this->phone_numbers_phone_column = null;
    }

    /**
     * Detect which column stores the phone number in vmo_phone_numbers.
     */
    private function phone_numbers_phone_column(): string
    {
        if ($this->phone_numbers_phone_column) {
            return $this->phone_numbers_phone_column;
        }

        global $wpdb;
        $table = $wpdb->prefix . 'vmo_phone_numbers';
        $columns = $wpdb->get_col("SHOW COLUMNS FROM {$table}", 0);
        if (!is_array($columns) || empty($columns)) {
            $this->phone_numbers_phone_column = 'phone_number';
            return $this->phone_numbers_phone_column;
        }

        $columns = array_map('strval', $columns);
        foreach (array('phone_number', 'phone', 'number', 'twilio_number', 'twilio_phone', 'assigned_number') as $candidate) {
            if (in_array($candidate, $columns, true)) {
                $this->phone_numbers_phone_column = $candidate;
                return $this->phone_numbers_phone_column;
            }
        }

        $this->phone_numbers_phone_column = 'phone_number';
        return $this->phone_numbers_phone_column;
    }

    /**
     * Get/create the shared secret key used by the phone webhook gateway.
     */
    private function get_phone_webhook_key(): string
    {
        $key = (string) get_option('vmo_phone_webhook_key', '');
        if ($key === '') {
            $key = wp_generate_password(32, false, false);
            update_option('vmo_phone_webhook_key', $key, true);
        }
        return $key;
    }

    // =====================================================
    // MULTILINGUAL VOICE CONFIGURATION
    // Maps language codes to Twilio Gather language attributes
    // and Amazon Polly Neural TTS voices.
    // =====================================================

    /**
     * Get the Twilio voice + speech recognition config for a given language code.
     *
     * @param string|null $lang_code  Language code (e.g. 'en', 'es', 'fr'). If null, reads from option.
     * @return array{voice: string, gather_lang: string, lang_name: string, prompt_lang: string}
     */
    public static function get_language_config(?string $lang_code = null): array
    {
        if ($lang_code === null) {
            $lang_code = get_option('vmo_receptionist_language', 'en');
        }

        // Map: code => [Polly Neural voice, Twilio Gather language, display name, prompt instruction]
        $map = array(
            'en' => array('Polly.Joanna-Neural', 'en-US', 'English (US)', 'English'),
            'en-gb' => array('Polly.Amy-Neural', 'en-GB', 'English (UK)', 'British English'),
            'en-au' => array('Polly.Olivia-Neural', 'en-AU', 'English (Australian)', 'Australian English'),
            'es' => array('Polly.Lupe-Neural', 'es-US', 'Español (US)', 'Spanish'),
            'es-mx' => array('Polly.Mia-Neural', 'es-MX', 'Español (Mexico)', 'Mexican Spanish'),
            'es-es' => array('Polly.Lucia-Neural', 'es-ES', 'Español (Spain)', 'European Spanish'),
            'fr' => array('Polly.Lea-Neural', 'fr-FR', 'Français', 'French'),
            'fr-ca' => array('Polly.Gabrielle-Neural', 'fr-CA', 'Français (Canadian)', 'Canadian French'),
            'pt' => array('Polly.Camila-Neural', 'pt-BR', 'Português (Brazil)', 'Brazilian Portuguese'),
            'pt-pt' => array('Polly.Ines-Neural', 'pt-PT', 'Português (Portugal)', 'European Portuguese'),
            'de' => array('Polly.Vicki-Neural', 'de-DE', 'Deutsch', 'German'),
            'it' => array('Polly.Bianca-Neural', 'it-IT', 'Italiano', 'Italian'),
            'ja' => array('Polly.Kazuha-Neural', 'ja-JP', '日本語', 'Japanese'),
            'ko' => array('Polly.Seoyeon-Neural', 'ko-KR', '한국어', 'Korean'),
            'zh' => array('Polly.Zhiyu-Neural', 'cmn-Hans-CN', '中文', 'Mandarin Chinese'),
            'ar' => array('Polly.Hala-Neural', 'ar-SA', 'العربية', 'Arabic'),
            'hi' => array('Polly.Kajal-Neural', 'hi-IN', 'हिन्दी', 'Hindi'),
            'nl' => array('Polly.Laura-Neural', 'nl-NL', 'Nederlands', 'Dutch'),
            'pl' => array('Polly.Ola-Neural', 'pl-PL', 'Polski', 'Polish'),
            'ru' => array('Polly.Tatyana', 'ru-RU', 'Русский', 'Russian'),
            'sv' => array('Polly.Elin-Neural', 'sv-SE', 'Svenska', 'Swedish'),
            'da' => array('Polly.Naja', 'da-DK', 'Dansk', 'Danish'),
            'nb' => array('Polly.Liv', 'nb-NO', 'Norsk', 'Norwegian'),
            'tr' => array('Polly.Filiz', 'tr-TR', 'Türkçe', 'Turkish'),
            'ro' => array('Polly.Carmen', 'ro-RO', 'Română', 'Romanian'),
            'ca' => array('Polly.Arlet-Neural', 'ca-ES', 'Català', 'Catalan'),
            'cy' => array('Polly.Gwyneth', 'cy-GB', 'Cymraeg', 'Welsh'),
            'fi' => array('Polly.Suvi-Neural', 'fi-FI', 'Suomi', 'Finnish'),
            'is' => array('Polly.Dora', 'is-IS', 'Íslenska', 'Icelandic'),
            'yue' => array('Polly.Hiujin-Neural', 'yue-Hant-HK', '廣東話', 'Cantonese'),
            'en-nz' => array('Polly.Aria-Neural', 'en-NZ', 'English (New Zealand)', 'New Zealand English'),
            'de-at' => array('Polly.Hannah-Neural', 'de-AT', 'Deutsch (Österreich)', 'Austrian German'),
            // Whisper-only languages: Twilio has NO speech recognition for these.
            // TTS uses OpenAI Ash (speaks any language). STT uses OpenAI Whisper.
            // Gather language falls back to en-US (ignored — Record path used instead).
            'yo' => array('Polly.Joanna-Neural', 'en-US', 'Yorùbá', 'Yoruba', 'yo'),
            'ig' => array('Polly.Joanna-Neural', 'en-US', 'Igbo', 'Igbo', 'ig'),
            'pcm' => array('Polly.Joanna-Neural', 'en-US', 'Pidgin English', 'Nigerian Pidgin English', 'en'),
        );

        $cfg = $map[$lang_code] ?? $map['en'];

        return array(
            'voice' => $cfg[0],
            'gather_lang' => $cfg[1],
            'lang_name' => $cfg[2],
            'prompt_lang' => $cfg[3],
            'whisper_lang' => $cfg[4] ?? '',  // If set, use Whisper STT instead of Twilio Gather
        );
    }

    // =====================================================
    // BROWSER CALLING (Twilio Client SDK / WebRTC)
    // Enables two-way audio in the dashboard dialer so users
    // can listen, join and make calls directly from the browser.
    // =====================================================

    /**
     * Get or create a TwiML Application for browser-originated calls.
     * The TwiML App tells Twilio where to POST when a browser client
     * initiates or receives a call.
     */
    private function get_or_create_twiml_app(): string
    {
        $app_sid = (string) get_option('vmo_twilio_twiml_app_sid', '');

        $voice_url = $this->phone_gateway_url('browser_call');
        $status_url = $this->phone_gateway_url('status');

        // If we already have a cached SID, ensure the VoiceUrl is up-to-date
        // (webhook key or site URL may have changed since the app was created).
        if ($app_sid !== '') {
            $last_url = (string) get_option('vmo_twilio_twiml_app_voice_url', '');
            $remote_voice_url = '';
            $remote_app = $this->twilio_request('GET', '/Applications/' . $app_sid . '.json');
            if (!is_wp_error($remote_app)) {
                $remote_voice_url = (string) ($remote_app['voice_url'] ?? $remote_app['voiceUrl'] ?? '');
            }

            if ($last_url !== $voice_url || ($remote_voice_url !== '' && $remote_voice_url !== $voice_url)) {
                // Update the TwiML App with the current VoiceUrl
                $this->twilio_request('POST', '/Applications/' . $app_sid . '.json', array(
                    'VoiceUrl' => $voice_url,
                    'VoiceMethod' => 'POST',
                    'StatusCallback' => $status_url,
                    'StatusCallbackMethod' => 'POST',
                ));
                update_option('vmo_twilio_twiml_app_voice_url', $voice_url, true);
                error_log('VMO: Updated TwiML App VoiceUrl to: ' . $voice_url);
            }
            return $app_sid;
        }

        $response = $this->twilio_request('POST', '/Applications.json', array(
            'FriendlyName' => 'VMO Browser Calling',
            'VoiceUrl' => $voice_url,
            'VoiceMethod' => 'POST',
            'StatusCallback' => $status_url,
            'StatusCallbackMethod' => 'POST',
        ));

        if (is_wp_error($response)) {
            error_log('VMO: Failed to create TwiML App: ' . $response->get_error_message());
            return '';
        }

        $app_sid = isset($response['sid']) ? (string) $response['sid'] : '';
        if ($app_sid !== '') {
            update_option('vmo_twilio_twiml_app_sid', $app_sid, true);
            update_option('vmo_twilio_twiml_app_voice_url', $voice_url, true);
        }

        return $app_sid;
    }

    /**
     * Generate a Twilio Access Token for the browser Client SDK (2.x).
     *
     * The v2.x JS SDK requires Access Tokens (signed with an API Key pair)
     * instead of the old Capability Tokens. If API Key credentials are not
     * stored yet, we auto-create them via the Twilio API.
     */
    public function generate_browser_token(int $user_id): string
    {
        $twiml_app_sid = $this->get_or_create_twiml_app();
        if (empty($twiml_app_sid) || empty($this->account_sid) || empty($this->auth_token)) {
            return '';
        }

        // Get or auto-create API Key pair for Access Tokens
        $api_key_sid = (string) get_option('vmo_twilio_api_key_sid', '');
        $api_key_secret = (string) get_option('vmo_twilio_api_key_secret', '');

        if (empty($api_key_sid) || empty($api_key_secret)) {
            // Auto-create an API Key via Twilio REST API
            $key_response = $this->twilio_request('POST', '/Keys.json', array(
                'FriendlyName' => 'VMO Browser Calling Key',
            ));

            if (is_wp_error($key_response) || empty($key_response['sid']) || empty($key_response['secret'])) {
                error_log('VMO: Failed to auto-create Twilio API Key: ' . (is_wp_error($key_response) ? $key_response->get_error_message() : 'Unknown error'));
                return '';
            }

            $api_key_sid = (string) $key_response['sid'];
            $api_key_secret = (string) $key_response['secret'];
            update_option('vmo_twilio_api_key_sid', $api_key_sid, true);
            update_option('vmo_twilio_api_key_secret', $api_key_secret, true);
            error_log('VMO: Auto-created Twilio API Key: ' . $api_key_sid);
        }

        $identity = 'vmo_user_' . $user_id;
        $ttl = 3600; // 1 hour
        $now = time();

        // Build Access Token (v2 format)
        // The Access Token contains "grants" for VoiceGrant
        $header = array('typ' => 'JWT', 'alg' => 'HS256', 'cty' => 'twilio-fpa;v=1');
        $grants = array(
            'identity' => $identity,
            'voice' => array(
                'incoming' => array('allow' => true),
                'outgoing' => array(
                    'application_sid' => $twiml_app_sid,
                ),
            ),
        );

        $payload = array(
            'jti' => $api_key_sid . '-' . $now,
            'iss' => $api_key_sid,
            'sub' => $this->account_sid,
            'exp' => $now + $ttl,
            'nbf' => $now,
            'grants' => $grants,
        );

        $segments = array();
        $segments[] = $this->base64url_encode(wp_json_encode($header));
        $segments[] = $this->base64url_encode(wp_json_encode($payload));
        $signing = implode('.', $segments);
        $signature = hash_hmac('sha256', $signing, $api_key_secret, true);
        $segments[] = $this->base64url_encode($signature);

        return implode('.', $segments);
    }

    /** URL-safe Base64 encoding (no padding). */
    private function base64url_encode(string $data): string
    {
        return rtrim(strtr(base64_encode($data), '+/', '-_'), '='); // phpcs:ignore WordPress.PHP.DiscouragedPHPFunctions.obfuscation_base64_encode -- data encoding
    }

    /**
     * Safe substring helper that does not require mbstring.
     */
    private function safe_substr(string $text, int $start, ?int $length = null): string
    {
        if (function_exists('mb_substr')) {
            return ($length === null) ? mb_substr($text, $start) : mb_substr($text, $start, $length);
        }

        return ($length === null) ? substr($text, $start) : substr($text, $start, $length);
    }

    /**
     * Normalize a website URL into a stable base URL for prompt-time scanning.
     */
    private function normalize_site_scan_base_url(string $url): string
    {
        $url = trim($url);
        if ($url === '') {
            return '';
        }

        if (strpos($url, 'http://') !== 0 && strpos($url, 'https://') !== 0) {
            $url = 'https://' . ltrim($url, '/');
        }

        $parts = wp_parse_url($url);
        if (!is_array($parts) || empty($parts['host'])) {
            return rtrim($url, '/');
        }

        $scheme = !empty($parts['scheme']) ? $parts['scheme'] : 'https';
        $host = $parts['host'];
        $port = !empty($parts['port']) ? ':' . $parts['port'] : '';
        $path = isset($parts['path']) ? trim((string) $parts['path']) : '';

        if ($path === '' || $path === '/') {
            return $scheme . '://' . $host . $port;
        }

        $path = preg_replace('#/+#', '/', $path);
        $segments = array_values(array_filter(explode('/', trim($path, '/')), 'strlen'));
        $page_slugs = array('home', 'index', 'index.html', 'index.php', 'about', 'about-us', 'services', 'products', 'pricing', 'plans', 'contact', 'faq', 'faqs', 'frequently-asked-questions', 'questions', 'help', 'support', 'menu');
        if (!empty($segments) && count($segments) === 1 && in_array(strtolower($segments[0]), $page_slugs, true)) {
            $path = '';
        } elseif (preg_match('#/[^/]+\.(html?|php|aspx?)$#i', $path)) {
            $path = dirname($path);
        }

        if ($path === '.' || $path === '/') {
            $path = '';
        }

        $path = trim((string) $path, '/');

        return $scheme . '://' . $host . $port . ($path !== '' ? '/' . $path : '');
    }

    /**
     * Detect whether the current business context is VMO itself.
     */
    private function is_vmo_brand_context(string $business_name, string $website): bool
    {
        $haystack = strtolower(trim($business_name . ' ' . $website . ' ' . home_url('/')));
        return strpos($haystack, 'virtual media office') !== false
            || strpos($haystack, 'getvmo.com') !== false
            || preg_match('/\bvmo\b/', $haystack) === 1;
    }

    /**
     * Authoritative VMO plan pricing used when the business itself is VMO.
     */
    private function get_vmo_plan_pricing_context(): string
    {
        if (function_exists('vmo_get_platform_faq_grounding_text')) {
            return "\n" . vmo_get_platform_faq_grounding_text('phone_receptionist', 10500) . "\n";
        }

        return "\nVMO Product Capabilities (authoritative):\n"
            . "VMO is an AI web business consultant and WordPress plugin/platform. It can consult with a business owner, generate a complete website from scratch, create website pages/content/SEO drafts/business copy, support ecommerce/service pages, and produce premium React-style, componentized, Next.js-compatible public websites that can run outside WordPress without WordPress runtime dependencies. It can also work with an existing WordPress site/domain when the customer already has one.\n"
            . "When callers ask whether VMO can build, create, make, or generate a website from scratch, answer yes. Say that our platform can generate a new website from scratch after the business consultation, and can also help existing-site users improve or rebuild their current site. Do not say a human web-design team is required unless the caller asks for custom human services beyond the platform.\n"
            . "\nVMO Plans and Pricing (authoritative):\n"
            . "Tester — $49.99/month or $549.89/year\n"
            . "Starter — $99.99/month or $1,099.89/year\n"
            . "Pro — $199.99/month or $2,199.89/year\n"
            . "Elite — $499.99/month or $5,499.89/year\n"
            . "Enterprise — $999.99/month or $10,999.89/year\n"
            . "Add-ons: Small — $19.99 one-time, Medium — $49.99 one-time, Large — $99.99 one-time\n"
            . "Phone Packs: M — $99.99/year, L — $249.99/year\n"
            . "Power Dialer — $499.99/year\n"
            . "Pro Call Center — $699.99/year\n"
            . "When callers ask about VMO pricing, answer with these exact plan names and amounts.\n";
    }

    /**
     * Determine whether a page is likely to be FAQ/help knowledge.
     */
    private function is_faq_like_page(string $slug, string $title = ''): bool
    {
        $haystack = strtolower(trim($slug . ' ' . $title));
        return $haystack !== '' && (
            strpos($haystack, 'faq') !== false
            || strpos($haystack, 'frequently asked') !== false
            || strpos($haystack, 'questions') !== false
            || preg_match('/\b(help|support)\b/', $haystack) === 1
        );
    }

    /**
     * Extract plain text from a generated builder page payload.
     *
     * @param mixed $page_data Raw page payload from vmo_pages_map.
     */
    private function extract_builder_page_plain_text($page_data): string
    {
        $content = '';
        if (is_string($page_data)) {
            $content = $page_data;
        } elseif (is_array($page_data)) {
            foreach (array('content', 'html', 'markup', 'body', 'text') as $candidate_key) {
                if (!empty($page_data[$candidate_key]) && is_string($page_data[$candidate_key])) {
                    $content = $page_data[$candidate_key];
                    break;
                }
            }
        }

        if ($content === '') {
            return '';
        }

        $content = wp_strip_all_tags($content);
        $content = preg_replace('/\s+/', ' ', $content);
        return trim((string) $content);
    }

    /**
     * Handle TwiML for browser-originated calls.
     *
     * When a browser client calls device.connect({conference:..., mode:...})
     * Twilio POSTs here.  We return Conference TwiML so the browser audio
     * is bridged into the call's conference room.
     *
     * For outbound dialing from the browser, the JS sends {To:number}
     * and we return <Dial> TwiML with the user's business caller-ID.
     */
    public function handle_browser_call($request)
    {
        while (ob_get_level())
            ob_end_clean();

        $params = $request->get_params();
        $conference_name = isset($params['conference']) ? sanitize_text_field($params['conference']) : '';
        $to = isset($params['To']) ? sanitize_text_field($params['To']) : '';
        $mode = isset($params['mode']) ? sanitize_text_field($params['mode']) : 'join';
        $from_identity = isset($params['From']) ? sanitize_text_field($params['From']) : '';

        error_log('[VMO Browser Call ENTRY] From=' . $from_identity . ' raw_To=' . $to . ' conference=' . $conference_name . ' mode=' . $mode . ' CallSid=' . (isset($params['CallSid']) ? sanitize_text_field($params['CallSid']) : ''));

        // Determine user from client identity (format: client:vmo_user_123)
        $user_id = 0;
        if (preg_match('/vmo_user_(\d+)/', $from_identity, $m)) {
            $user_id = (int) $m[1];
        }
        // Fallback: if From didn't contain identity, try current logged-in user
        if ($user_id === 0 && function_exists('get_current_user_id')) {
            $user_id = get_current_user_id();
        }

        error_log('[VMO Browser Call] Resolved user_id=' . $user_id);

        // E.164-normalize the outbound destination so Twilio doesn't reject
        // 10-digit / loose-formatted input with 21211 ("Invalid 'To' Phone Number").
        // normalize_phone_for_twilio() defaults to +1 for 10-digit US input,
        // honors any explicit + prefix, and converts 00… international dialling.
        $to_original = $to;
        if (!empty($to) && !preg_match('/^client:/', $to)) {
            $normalized_to = $this->normalize_phone_for_twilio($to);
            if ($normalized_to !== '' && $normalized_to !== $to) {
                error_log('[VMO Browser Call] Normalized To: ' . $to . ' → ' . $normalized_to);
                $to = $normalized_to;
            }
        }

        header('Content-Type: text/xml; charset=UTF-8');
        echo '<?xml version="1.0" encoding="UTF-8"?>';
        echo '<Response>';

        if (!empty($conference_name)) {
            // Browser joining an existing conference (for listen / pick-up / join)
            echo '<Dial>';
            if ($mode === 'listen') {
                echo '<Conference startConferenceOnEnter="true" muted="true" endConferenceOnExit="false" beep="false" waitUrl="">' . esc_xml($conference_name) . '</Conference>';
            } else {
                echo '<Conference startConferenceOnEnter="true" endConferenceOnExit="false" beep="false" waitUrl="">' . esc_xml($conference_name) . '</Conference>';
            }
            echo '</Dial>';
        } elseif (!empty($to) && !preg_match('/^client:/', $to)) {
            // Outbound call from browser dialer
            $caller_id = ($user_id > 0) ? $this->get_user_phone($user_id) : '';
            // Fallback: use the Twilio phone number from account settings
            if (!$caller_id) {
                $caller_id = get_option('vmo_twilio_phone_number', '');
            }

            // Validate caller_id is actually owned by this Twilio account.
            // Twilio rejects unowned numbers with error 21210 / 13214 / 13223.
            // We cache the owned-number set for 12h to keep the hot path fast.
            if ($caller_id) {
                $caller_id_normalized = $this->normalize_phone_for_twilio($caller_id);
                if ($caller_id_normalized !== '' && !$this->is_owned_twilio_number($caller_id_normalized)) {
                    error_log('[VMO Browser Call] WARNING configured caller_id ' . $caller_id_normalized . ' is NOT in this Twilio account\'s owned numbers list. Attempting fallback chain.');

                    // Step 1: account-default option.
                    $fallback = get_option('vmo_twilio_phone_number', '');
                    $fallback_norm = $fallback ? $this->normalize_phone_for_twilio($fallback) : '';
                    if ($fallback_norm !== '' && $this->is_owned_twilio_number($fallback_norm)) {
                        $caller_id = $fallback_norm;
                        error_log('[VMO Browser Call] Fallback caller_id (option vmo_twilio_phone_number) accepted: ' . $caller_id);
                    } else {
                        // Step 2: first actually-owned number from Twilio API.
                        $owned_first = $this->get_first_owned_twilio_number();
                        if ($owned_first !== '') {
                            $caller_id = $owned_first;
                            error_log('[VMO Browser Call] Fallback caller_id (first owned Twilio number) accepted: ' . $caller_id);
                        } else {
                            // Nothing valid: refuse to dial with bad callerId
                            // (Twilio would reject with 13214 anyway, and the
                            // caller would hear a generic error).
                            error_log('[VMO Browser Call] ABORT: no valid owned number available to use as callerId. Refusing to dial.');
                            $caller_id = '';
                        }
                    }
                } else {
                    $caller_id = $caller_id_normalized ?: $caller_id;
                }
            }

            error_log('[VMO Browser Call] caller_id=' . ($caller_id ?: '(none)') . ' dialing To=' . $to . ' (raw=' . $to_original . ')');
            if ($caller_id) {
                // ── Log outbound browser call to DB so status_callback can update it ──
                $call_sid = isset($params['CallSid']) ? sanitize_text_field($params['CallSid']) : '';
                if ($call_sid !== '' && $user_id > 0) {
                    global $wpdb;
                    $pc_table = $wpdb->prefix . 'vmo_phone_calls';
                    if ($wpdb->get_var($wpdb->prepare("SHOW TABLES LIKE %s", $wpdb->esc_like($pc_table))) === $pc_table) {
                        // Only insert if not already present (avoid duplicate on re-entry)
                        $exists = $wpdb->get_var($wpdb->prepare("SELECT id FROM {$pc_table} WHERE call_sid=%s LIMIT 1", $call_sid));
                        if (!$exists) {
                            // phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery, WordPress.DB.DirectDatabaseQuery.NoCaching
                            $wpdb->insert($pc_table, array(
                                'user_id' => $user_id,
                                'call_sid' => $call_sid,
                                'caller_number' => $to,
                                'direction' => 'outbound',
                                'status' => 'initiated',
                                'ai_handled' => 0,
                                'created_at' => current_time('mysql'),
                            ), array('%d', '%s', '%s', '%s', '%s', '%d', '%s'));
                            error_log('[VMO Browser Call] Logged outbound call to DB: ' . $call_sid);
                        }
                    }

                    // Tag this leg as an outbound browser call so the status
                    // callback handler doesn't treat its 'completed' event
                    // as an inbound parent call disconnect.
                    $this->register_call_leg_role($call_sid, 'outbound_browser_call', '', $user_id);
                }

                // Check recording preference (defaults ON if never set)
                $rec_enabled = $this->is_recording_enabled($user_id);
                error_log('[VMO Browser Call] Recording enabled=' . var_export($rec_enabled, true) . ' user_id=' . $user_id);
                $rec_attr = '';
                $rec_cb_attr = '';
                if ($rec_enabled) {
                    $rec_attr = ' record="record-from-answer-dual"';
                    $rec_cb_attr = ' recordingStatusCallback="' . esc_xml($this->twilio_public_url('recording')) . '" recordingStatusCallbackEvent="completed"';
                    echo '<Say voice="' . $this->tts_voice() . '">This call may be recorded.</Say>';

                    // Also start a call-level recording via REST API as backup.
                    // <Dial record="..."> sometimes fails silently when the call
                    // routes through multiple legs or bridges.  The REST API
                    // approach is the same one that works reliably for inbound
                    // AI calls and guarantees a recording exists on Twilio.
                    if (!empty($call_sid)) {
                        $rec_rest = $this->twilio_request('POST', "/Calls/{$call_sid}/Recordings.json", array(
                            'RecordingStatusCallbackEvent' => 'completed',
                            'RecordingStatusCallback' => $this->twilio_public_url('recording'),
                            'RecordingChannels' => 'dual',
                        ));
                        if (is_wp_error($rec_rest)) {
                            error_log('[VMO Browser Call] REST recording start FAILED for ' . $call_sid . ': ' . $rec_rest->get_error_message());
                        } else {
                            $rec_sid = $rec_rest['sid'] ?? '(no sid)';
                            error_log('[VMO Browser Call] REST recording started for ' . $call_sid . ' rec_sid=' . $rec_sid);
                        }
                    }
                }
                $dial_status_url = $this->phone_gateway_url('dial_status', array('uid' => $user_id, 'source' => 'browser_outbound'));
                echo '<Dial callerId="' . esc_attr($caller_id) . '"' . $rec_attr . $rec_cb_attr . ' action="' . esc_xml($dial_status_url) . '" timeout="30">';
                echo '<Number>' . esc_xml($to) . '</Number>';
                echo '</Dial>';
                // Explicit hangup after Dial ends — prevents Twilio from keeping
                // the parent call alive after the callee disconnects.
                echo '<Hangup />';
            } else {
                error_log('[VMO Browser Call] ERROR: No valid owned caller ID available for user ' . $user_id);
                echo '<Say voice="' . $this->tts_voice() . '">No verified business phone number is available for outbound calls. Please open your phone settings and confirm a number from your account is selected as the outbound caller ID, then try again.</Say>';
            }
        } else {
            error_log('[VMO Browser Call] ERROR: Invalid request - To=' . $to);
            echo '<Say voice="' . $this->tts_voice() . '">Invalid request.</Say>';
        }

        echo '</Response>';
        exit;
    }

    /**
     * Public phone webhook URL.
     *
     * We intentionally route through the already-allowlisted admin-ajax action `vmo_website_chat`
     * to avoid edge/WAF blocks on other custom action values.
     */
    public function phone_gateway_url(string $kind, array $query_args = array()): string
    {
        $base = admin_url('admin-ajax.php');
        $args = array_merge(
            array(
                'action' => 'vmo_website_chat',
                'vmo_gw' => 'phone',
                'kind' => $kind,
                'k' => $this->get_phone_webhook_key(),
            ),
            $query_args
        );
        return add_query_arg($args, $base);
    }

    /**
     * Back-compat wrapper used throughout the phone system to build Twilio callback URLs.
     */
    private function twilio_public_url(string $kind, array $query_args = array()): string
    {
        return $this->phone_gateway_url($kind, $query_args);
    }

    /**
     * Canonical public URL for the OpenAI Realtime webhook on hosts where
     * normal REST paths are unreliable.
     */
    public function openai_realtime_webhook_url(): string
    {
        return $this->phone_gateway_url('openai_realtime_webhook');
    }

    public function ajax_ping()
    {
        header('Content-Type: text/plain; charset=UTF-8');
        echo 'pong';
        wp_die();
    }

    /**
     * Build a publicly reachable URL for Twilio to call (admin-ajax, no auth).
     */
    private function twilio_ajax_url(string $action, array $query_args = array()): string
    {
        $base = admin_url('admin-ajax.php');
        $args = array_merge(array('action' => $action), $query_args);
        return add_query_arg($args, $base);
    }

    /**
     * Create a WP_REST_Request populated from current globals.
     */
    private function rest_request_from_globals(string $method, string $route)
    {
        if (!class_exists('WP_REST_Request')) {
            return null;
        }
        $req = new WP_REST_Request($method, $route);
        $all = wp_unslash($_REQUEST);
        $req->set_query_params(wp_unslash($_GET));
        $req->set_body_params($all);
        $req->set_param('vmo_source', 'admin_ajax');
        return $req;
    }

    /**
     * Send an appropriate response for admin-ajax based on REST handler return.
     */
    private function send_ajax_response_from_rest($response): void
    {
        if ($response instanceof WP_REST_Response) {
            wp_send_json($response->get_data(), $response->get_status());
        }
        if (is_wp_error($response)) {
            wp_send_json(array('success' => false, 'message' => $response->get_error_message()), 500);
        }
        if (is_array($response)) {
            wp_send_json($response, 200);
        }
        wp_send_json(array('success' => true), 200);
    }

    // === Twilio admin-ajax bridges (public) ===

    public function ajax_twilio_voice_webhook()
    {
        if (!$this->verify_twilio_request(null)) {
            status_header(403);
            echo 'Twilio signature verification failed';
            exit;
        }
        $req = $this->rest_request_from_globals('POST', '/vmo/v1/twilio-voice-webhook');
        if ($req) {
            $this->handle_voice_webhook($req);
            exit;
        }
        status_header(500);
        echo 'REST request class unavailable';
        exit;
    }

    public function ajax_power_dialer_twiml()
    {
        if (!$this->verify_twilio_request(null)) {
            status_header(403);
            echo 'Twilio signature verification failed';
            exit;
        }
        $method = isset($_SERVER['REQUEST_METHOD']) ? strtoupper((string) $_SERVER['REQUEST_METHOD']) : 'POST';
        $req = $this->rest_request_from_globals($method, '/vmo/v1/power-dialer/twiml');
        if ($req) {
            $this->power_dialer_twiml($req);
            exit;
        }
        status_header(500);
        echo 'REST request class unavailable';
        exit;
    }

    public function ajax_power_dialer_outbound_callback()
    {
        if (!$this->verify_twilio_request(null)) {
            status_header(403);
            wp_send_json(['error' => 'Twilio signature verification failed'], 403);
        }
        $method = isset($_SERVER['REQUEST_METHOD']) ? strtoupper((string) $_SERVER['REQUEST_METHOD']) : 'POST';
        $req = $this->rest_request_from_globals($method, '/vmo/v1/power-dialer/outbound-callback');
        $resp = $req ? $this->power_dialer_outbound_callback($req) : new WP_Error('missing_rest', 'REST request class unavailable');
        $this->send_ajax_response_from_rest($resp);
    }

    public function ajax_twilio_status_callback()
    {
        if (!$this->verify_twilio_request(null)) {
            status_header(403);
            wp_send_json(['error' => 'Twilio signature verification failed'], 403);
        }
        $req = $this->rest_request_from_globals('POST', '/vmo/v1/twilio-status-callback');
        $resp = $req ? $this->handle_status_callback($req) : new WP_Error('missing_rest', 'REST request class unavailable');
        $this->send_ajax_response_from_rest($resp);
    }

    public function ajax_twilio_sms_webhook()
    {
        if (!$this->verify_twilio_request(null)) {
            status_header(403);
            wp_send_json(['error' => 'Twilio signature verification failed'], 403);
        }
        $req = $this->rest_request_from_globals('POST', '/vmo/v1/twilio-sms-webhook');
        $resp = $req ? $this->handle_sms_webhook($req) : new WP_Error('missing_rest', 'REST request class unavailable');
        $this->send_ajax_response_from_rest($resp);
    }

    public function ajax_dial_status_callback()
    {
        if (!$this->verify_twilio_request(null)) {
            status_header(403);
            wp_send_json(['error' => 'Twilio signature verification failed'], 403);
        }
        $req = $this->rest_request_from_globals('POST', '/vmo/v1/dial-status-callback');
        $resp = $req ? $this->handle_dial_status($req) : new WP_Error('missing_rest', 'REST request class unavailable');
        $this->send_ajax_response_from_rest($resp);
    }

    public function ajax_twilio_transcription_callback()
    {
        if (!$this->verify_twilio_request(null)) {
            status_header(403);
            wp_send_json(['error' => 'Twilio signature verification failed'], 403);
        }
        $req = $this->rest_request_from_globals('POST', '/vmo/v1/twilio-transcription-callback');
        $resp = $req ? $this->handle_transcription_callback($req) : new WP_Error('missing_rest', 'REST request class unavailable');
        $this->send_ajax_response_from_rest($resp);
    }

    public function ajax_recording_callback()
    {
        if (!$this->verify_twilio_request(null)) {
            status_header(403);
            wp_send_json(['error' => 'Twilio signature verification failed'], 403);
        }
        $req = $this->rest_request_from_globals('POST', '/vmo/v1/recording-callback');
        $resp = $req ? $this->handle_recording_callback($req) : new WP_Error('missing_rest', 'REST request class unavailable');
        $this->send_ajax_response_from_rest($resp);
    }

    /**
     * Unified AJAX security verifier.
     * Accepts both builder/admin nonces used across the dashboard.
     */
    private function verify_ajax_request(): bool
    {
        if (function_exists('vmo_verify_admin_security')) {
            return vmo_verify_admin_security();
        }

        $nonce = sanitize_text_field(wp_unslash($_POST['security'] ?? $_POST['nonce'] ?? ''));
        if (!$nonce || !function_exists('wp_verify_nonce')) {
            return false;
        }

        return (bool) (
            wp_verify_nonce($nonce, 'vmo_admin_nonce') ||
            wp_verify_nonce($nonce, 'vmo_builder_nonce') ||
            wp_verify_nonce($nonce, 'vmo_nonce')
        );
    }

    /**
     * Make a request to the Twilio API
     * 
     * @param string $method GET or POST
     * @param string $endpoint API endpoint (relative to account) e.g. /IncomingPhoneNumbers.json
     * @param array $args Body arguments for POST or query vars for GET
     * @param string $base_url Optional base URL override
     * @return array|WP_Error Response body or error
     */
    private function twilio_request($method, $endpoint, $args = array(), $base_url = null, $timeout = 30)
    {
        if ($this->simulation_mode) {
            return new WP_Error('simulation_mode', 'In simulation mode');
        }

        $url = ($base_url ? $base_url : $this->api_base . '/Accounts/' . $this->account_sid) . $endpoint;

        $request_args = array(
            'headers' => array(
                'Authorization' => 'Basic ' . base64_encode($this->account_sid . ':' . $this->auth_token) // phpcs:ignore WordPress.PHP.DiscouragedPHPFunctions.obfuscation_base64_encode -- HTTP Basic auth header
            ),
            'timeout' => $timeout
        );

        if ($method === 'GET') {
            if (!empty($args)) {
                $url = add_query_arg($args, $url);
            }
            $response = wp_remote_get($url, $request_args);
        } else {
            $request_args['body'] = $args;
            $response = wp_remote_post($url, $request_args);
        }

        if (is_wp_error($response)) {
            return $response;
        }

        $code = wp_remote_retrieve_response_code($response);
        $body = wp_remote_retrieve_body($response);
        $data = json_decode($body, true);

        if ($code >= 400) {
            return new WP_Error('twilio_error', isset($data['message']) ? $data['message'] : 'Unknown Twilio Error', array('status' => $code));
        }

        return $data;
    }

    /**
     * Check if user has an active paid subscription plan
     * Phone features require an active plan - tokens alone are not enough
     * MASTER VERSION: Always returns true for development/testing
     * 
     * @param int $user_id WordPress user ID
     * @return bool|string True if active, or error message if not
     */
    public function user_has_active_plan($user_id = null)
    {
        // MASTER VERSION: Bypass all plan checks for development/testing
        if (defined('VMO_MASTER_VERSION') && VMO_MASTER_VERSION === true) {
            return true;
        }

        if (!$user_id) {
            $user_id = get_current_user_id();
        }

        $plan = get_user_meta($user_id, 'vmo_subscription_status', true);

        // List of active paid plans (current + legacy)
        $active_plans = array(
            'tester',        // $49.99/mo
            'starter',       // $99.99/mo
            'pro',           // $199.99/mo
            'elite',         // $499.99/mo
            'enterprise',    // $999.99/mo
            // Legacy plan keys
            'starter_pack',
            'pro_pack',
            'business_pack',
            'enterprise_pack',
            'business',
        );

        if (in_array(strtolower($plan), $active_plans)) {
            return true;
        }

        // Check if plan is expired or free
        if (empty($plan) || $plan === 'free' || $plan === 'free_trial' || $plan === 'expired_trial' || $plan === 'cancelled') {
            return 'You need an active subscription plan to use phone features. Please upgrade your plan first.';
        }

        return true;
    }

    /**
     * Permission callback that checks for logged in user
     */
    public function check_user_logged_in()
    {
        return is_user_logged_in();
    }

    /**
     * Register REST API routes
     */
    public function register_rest_routes()
    {
        // Business context endpoint for Twilio AI agents
        register_rest_route('vmo/v1', '/business-context/(?P<user_id>\d+)', array(
            'methods' => 'GET',
            'callback' => array($this, 'get_business_context'),
            'permission_callback' => array($this, 'verify_twilio_request'),
        ));

        // Booking endpoint
        register_rest_route('vmo/v1', '/bookings', array(
            'methods' => 'POST',
            'callback' => array($this, 'create_booking'),
            'permission_callback' => array($this, 'verify_twilio_request'),
        ));

        // SMS follow-up endpoint
        register_rest_route('vmo/v1', '/sms-followup', array(
            'methods' => 'POST',
            'callback' => array($this, 'send_sms_followup'),
            'permission_callback' => array($this, 'verify_twilio_request'),
        ));

        // === WEBHOOK ENDPOINTS FOR TWILIO CALLBACKS ===

        // Voice webhook - Called when a call comes in
        register_rest_route('vmo/v1', '/twilio-voice-webhook', array(
            'methods' => 'POST',
            'callback' => array($this, 'handle_voice_webhook'),
            'permission_callback' => array($this, 'verify_twilio_request'),
        ));

        // Status callback - Called when call status changes (completed, failed, etc.)
        register_rest_route('vmo/v1', '/twilio-status-callback', array(
            'methods' => 'POST',
            'callback' => array($this, 'handle_status_callback'),
            'permission_callback' => array($this, 'verify_twilio_request'),
        ));

        // SMS webhook - Called when SMS is received
        register_rest_route('vmo/v1', '/twilio-sms-webhook', array(
            'methods' => 'POST',
            'callback' => array($this, 'handle_sms_webhook'),
            'permission_callback' => array($this, 'verify_twilio_request'),
        ));

        // Dial status callback - Called after dial attempt (user answered or didn't)
        register_rest_route('vmo/v1', '/dial-status-callback', array(
            'methods' => 'POST',
            'callback' => array($this, 'handle_dial_status'),
            'permission_callback' => array($this, 'verify_twilio_request'),
        ));

        // Transcription callback - Called when voicemail transcription completes
        register_rest_route('vmo/v1', '/twilio-transcription-callback', array(
            'methods' => 'POST',
            'callback' => array($this, 'handle_transcription_callback'),
            'permission_callback' => array($this, 'verify_twilio_request'),
        ));

        // Recording status callback - Called when Twilio recording is available
        register_rest_route('vmo/v1', '/recording-callback', array(
            'methods' => 'POST',
            'callback' => array($this, 'handle_recording_callback'),
            'permission_callback' => array($this, 'verify_twilio_request'),
        ));

        // OpenAI Realtime SIP webhook — called by OpenAI when Twilio places the
        // AI SIP participant into the live conference-first inbound call flow.
        register_rest_route('vmo/v1', '/openai-realtime-webhook', array(
            'methods' => 'POST',
            'callback' => array($this, 'handle_openai_realtime_webhook'),
            'permission_callback' => '__return_true', // OpenAI signature verified in callback
        ));

        register_rest_route('vmo/v1', '/realtime-participant-status', array(
            'methods' => 'POST',
            'callback' => array($this, 'handle_realtime_participant_status'),
            'permission_callback' => array($this, 'verify_twilio_request'),
        ));

        // OPTION C: TwiML endpoint that emits <Dial>{ext_phone}</Dial> for in-flight transfers.
        // Twilio calls this URL after we redirect a live call via REST update.
        register_rest_route('vmo/v1', '/realtime-transfer-twiml', array(
            'methods' => array('GET', 'POST'),
            'callback' => array($this, 'handle_realtime_transfer_twiml'),
            'permission_callback' => '__return_true', // Twilio request — token-protected via query param
        ));

        // === POWER DIALER ENDPOINTS ===

        // Power Dialer Balance
        register_rest_route('vmo/v1', '/power-dialer/balance', array(
            'methods' => 'GET',
            'callback' => array($this, 'power_dialer_get_balance'),
            'permission_callback' => function () {
                return is_user_logged_in(); },
        ));

        // Power Dialer Campaigns
        register_rest_route('vmo/v1', '/power-dialer/campaigns', array(
            'methods' => array('GET', 'POST'),
            'callback' => array($this, 'power_dialer_campaigns'),
            'permission_callback' => function () {
                return is_user_logged_in(); },
        ));

        // Power Dialer Campaign Actions (pause/resume)
        register_rest_route('vmo/v1', '/power-dialer/campaigns/(?P<id>\d+)/(?P<action>pause|resume)', array(
            'methods' => 'POST',
            'callback' => array($this, 'power_dialer_campaign_action'),
            'permission_callback' => function () {
                return is_user_logged_in(); },
        ));

        // Power Dialer Analytics
        register_rest_route('vmo/v1', '/power-dialer/analytics', array(
            'methods' => 'GET',
            'callback' => array($this, 'power_dialer_analytics'),
            'permission_callback' => function () {
                return is_user_logged_in(); },
        ));

        // Power Dialer DNC List
        register_rest_route('vmo/v1', '/power-dialer/dnc', array(
            'methods' => 'POST',
            'callback' => array($this, 'power_dialer_save_dnc'),
            'permission_callback' => function () {
                return is_user_logged_in(); },
        ));

        // Power Dialer Outbound Call Callback
        register_rest_route('vmo/v1', '/power-dialer/outbound-callback', array(
            'methods' => 'POST',
            'callback' => array($this, 'power_dialer_outbound_callback'),
            'permission_callback' => array($this, 'verify_twilio_request'),
        ));

        // Power Dialer TwiML Generator
        register_rest_route('vmo/v1', '/power-dialer/twiml', array(
            'methods' => array('GET', 'POST'),
            'callback' => array($this, 'power_dialer_twiml'),
            'permission_callback' => array($this, 'verify_twilio_request'),
        ));

        // Power Dialer User Takeover (add user to call)
        register_rest_route('vmo/v1', '/power-dialer/takeover', array(
            'methods' => 'POST',
            'callback' => array($this, 'power_dialer_user_takeover'),
            'permission_callback' => array($this, 'check_user_logged_in'),
        ));

        // Power Dialer Live Calls (for monitoring)
        register_rest_route('vmo/v1', '/power-dialer/live-calls', array(
            'methods' => 'GET',
            'callback' => array($this, 'power_dialer_live_calls'),
            'permission_callback' => array($this, 'check_user_logged_in'),
        ));
        // Affiliate Dashboard Stats
        register_rest_route('vmo/v1', '/affiliate/stats', array(
            'methods' => 'GET',
            'callback' => array($this, 'get_affiliate_dashboard_data'),
            'permission_callback' => function () {
                return is_user_logged_in(); },
        ));

        // Affiliate Register
        register_rest_route('vmo/v1', '/affiliate/register', array(
            'methods' => 'POST',
            'callback' => array($this, 'api_register_affiliate'),
            'permission_callback' => function () {
                return is_user_logged_in(); },
        ));
    }

    /**
     * Build a public REST endpoint URL in a way that does not depend on rewrite rules.
     * Some servers/plugins redirect /wp-json/* to the front-end; rest_route works regardless.
     */
    private function public_rest_url(string $route, array $query_args = array()): string
    {
        $route = '/' . ltrim($route, '/');
        $base = home_url('/');
        $args = array_merge(array('rest_route' => $route), $query_args);
        return add_query_arg($args, $base);
    }

    /**
     * Handle recording status callbacks from Twilio
     * Stores recording URL/SID against the call when possible.
     */
    public function handle_recording_callback($request)
    {
        try {
            $params = $request->get_params();
            error_log('VMO Twilio Recording Callback: ' . print_r($params, true));

            $call_sid = isset($params['CallSid']) ? sanitize_text_field($params['CallSid']) : '';
            $recording_url = isset($params['RecordingUrl']) ? esc_url_raw($params['RecordingUrl']) : '';
            $recording_status = isset($params['RecordingStatus']) ? sanitize_text_field($params['RecordingStatus']) : '';

            // Only process completed recordings — 'in-progress' recordings may
            // not be downloadable yet and 'absent'/'failed' have no audio.
            if ($recording_status !== '' && $recording_status !== 'completed') {
                error_log('[VMO Recording Callback] Skipping non-completed status: ' . $recording_status . ' for ' . $call_sid);
                return array('ok' => true, 'skipped' => $recording_status);
            }

            $local_url = '';

            if (!empty($call_sid) && !empty($recording_url)) {
                // Attempt to save locally for browser playback (Twilio RecordingUrl requires auth).
                $saved = $this->save_call_recording($call_sid, $recording_url);
                if (is_string($saved) && $saved !== '') {
                    $local_url = $saved;
                }

                $store_url = $local_url ?: $recording_url;
                $summary_call_sid = $call_sid;
                $summary_recording_url = $store_url;

                global $wpdb;

                $phone_calls_table = $wpdb->prefix . 'vmo_phone_calls';
                if ($wpdb->get_var("SHOW TABLES LIKE '{$phone_calls_table}'") === $phone_calls_table) {
                    // phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery, WordPress.DB.DirectDatabaseQuery.NoCaching
                    $updated = $wpdb->update(
                        $phone_calls_table,
                        array('recording_url' => $store_url),
                        array('call_sid' => $call_sid)
                    );
                    error_log('[VMO Recording Callback] vmo_phone_calls update for ' . $call_sid . ': rows=' . var_export($updated, true));

                    // If no rows updated, the CallSid might be a child call from
                    // <Dial record>. Look up the parent call via Twilio API and
                    // update the parent row instead.
                    if ($updated === 0 || $updated === false) {
                        $parent_sid = isset($params['ParentCallSid']) ? sanitize_text_field($params['ParentCallSid']) : '';
                        if ($parent_sid === '') {
                            // Try to find parent via Twilio API
                            $call_detail = $this->twilio_request('GET', "/Calls/{$call_sid}.json");
                            if (!is_wp_error($call_detail)) {
                                $parent_sid = $call_detail['parent_call_sid'] ?? '';
                            }
                        }
                        if ($parent_sid !== '') {
                            // Also save with the parent SID filename
                            $parent_saved = $this->save_call_recording($parent_sid, $recording_url);
                            $parent_url = (is_string($parent_saved) && $parent_saved !== '') ? $parent_saved : $store_url;
                            // phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery, WordPress.DB.DirectDatabaseQuery.NoCaching
                            $parent_updated = $wpdb->update(
                                $phone_calls_table,
                                array('recording_url' => $parent_url),
                                array('call_sid' => $parent_sid)
                            );
                            error_log('[VMO Recording Callback] Parent fallback: parent_sid=' . $parent_sid . ' rows=' . var_export($parent_updated, true));
                            $summary_call_sid = $parent_sid;
                            $summary_recording_url = $parent_url;
                        }
                    }
                }

                $pd_calls_table = $wpdb->prefix . 'vmo_power_dialer_calls';
                if ($wpdb->get_var("SHOW TABLES LIKE '{$pd_calls_table}'") === $pd_calls_table) {
                    // phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery, WordPress.DB.DirectDatabaseQuery.NoCaching
                    $wpdb->update(
                        $pd_calls_table,
                        array('recording_url' => $store_url),
                        array('call_sid' => $call_sid)
                    );
                }

                if ($summary_call_sid !== '' && $summary_recording_url !== '') {
                    // Primary: run summary+email directly via shutdown function (no cron dependency)
                    $mgr = $this;
                    $s_sid = $summary_call_sid;
                    $s_url = $summary_recording_url;
                    register_shutdown_function(function () use ($mgr, $s_sid, $s_url) {
                        try {
                            error_log('[VMO Recording Callback] Shutdown: generating summary for ' . $s_sid);
                            $mgr->generate_call_summary($s_sid, $s_url);
                        } catch (\Throwable $e) {
                            error_log('[VMO Recording Callback] Shutdown summary failed: ' . $e->getMessage());
                        }
                        try {
                            error_log('[VMO Recording Callback] Shutdown: sending email for ' . $s_sid);
                            $mgr->send_post_call_summary_email($s_sid);
                        } catch (\Throwable $e) {
                            error_log('[VMO Recording Callback] Shutdown email failed: ' . $e->getMessage());
                        }
                    });
                    // Belt-and-suspenders: also schedule via cron in case shutdown function can't complete
                    $this->schedule_phone_event_now('vmo_generate_call_summary', array($summary_call_sid, $summary_recording_url), 60);
                    $this->schedule_phone_event_now('vmo_send_call_summary_email', array($summary_call_sid), 90);
                    error_log('[VMO Recording Callback] Shutdown + cron fallback registered for ' . $summary_call_sid);
                }
            } else {
                error_log('[VMO Recording Callback] Missing call_sid or recording_url — call_sid=' . $call_sid . ', url=' . $recording_url);
            }

            return array('ok' => true, 'local_url' => $local_url);
        } catch (Throwable $e) {
            error_log('VMO Recording Callback Error: ' . $e->getMessage());
            return array('ok' => false);
        }
    }

    /**
     * Public API: Fetch recording for a call from Twilio.
     *
     * Tries three tiers:
     *  1. Call-scoped /Calls/{sid}/Recordings.json
     *  2. Account-level /Recordings.json?CallSid={sid} (catches child legs)
     *  3. Broad search of recent recordings with SID matching
     *
     * If a recording is found, downloads it locally and persists to DB.
     *
     * @param string $call_sid  Twilio Call SID.
     * @return array{url:string,duration:int,created:string,source:string}|null
     */
    public function fetch_call_recordings(string $call_sid): ?array
    {
        if ($this->simulation_mode || $call_sid === '') {
            error_log('[VMO fetch_call_recordings] SKIP: simulation_mode=' . ($this->simulation_mode ? 'true' : 'false') . ' call_sid=' . ($call_sid ?: '(empty)'));
            return null;
        }

        $recs = array();
        $source = '';
        $debug = array(
            'account_sid' => substr($this->account_sid, 0, 10) . '...',
            'simulation' => $this->simulation_mode,
        );

        // Tier 1: call-scoped
        $r1 = $this->twilio_request('GET', "/Calls/{$call_sid}/Recordings.json");
        if (is_wp_error($r1)) {
            $debug['tier1_error'] = $r1->get_error_message();
        } else {
            $t1_recs = isset($r1['recordings']) && is_array($r1['recordings']) ? $r1['recordings'] : array();
            $debug['tier1_count'] = count($t1_recs);
            if (!empty($t1_recs)) {
                $recs = $t1_recs;
                $source = 'call_scoped';
            }
        }

        // Tier 2: account-level filtered by CallSid (catches child legs)
        if (empty($recs)) {
            $r2 = $this->twilio_request('GET', '/Recordings.json', array('CallSid' => $call_sid, 'PageSize' => 5));
            if (is_wp_error($r2)) {
                $debug['tier2_error'] = $r2->get_error_message();
            } else {
                $t2_recs = isset($r2['recordings']) && is_array($r2['recordings']) ? $r2['recordings'] : array();
                $debug['tier2_count'] = count($t2_recs);
                if (!empty($t2_recs)) {
                    $recs = $t2_recs;
                    $source = 'account_callsid';
                }
            }
        }

        // Tier 3: broad search — all account recordings, match by call_sid
        if (empty($recs)) {
            $r3 = $this->twilio_request('GET', '/Recordings.json', array('PageSize' => 50));
            if (is_wp_error($r3)) {
                $debug['tier3_error'] = $r3->get_error_message();
            } else {
                $all = isset($r3['recordings']) && is_array($r3['recordings']) ? $r3['recordings'] : array();
                $debug['tier3_total'] = count($all);
                // Log all call_sids found for debugging
                $found_sids = array();
                foreach ($all as $ar) {
                    $sid = $ar['call_sid'] ?? '?';
                    $found_sids[] = $sid;
                    if ($sid === $call_sid) {
                        $recs[] = $ar;
                        $source = 'broad_search';
                        break;
                    }
                }
                $debug['tier3_sids'] = array_slice($found_sids, 0, 10); // first 10 for logging
            }
        }

        // Tier 4: Look up the Call resource itself — get child calls for <Dial> legs
        if (empty($recs)) {
            $call_info = $this->twilio_request('GET', "/Calls/{$call_sid}.json");
            if (!is_wp_error($call_info)) {
                $debug['call_status'] = $call_info['status'] ?? '?';
                $debug['call_direction'] = $call_info['direction'] ?? '?';
                $debug['call_duration'] = $call_info['duration'] ?? '?';

                // Check child calls (the <Dial> creates a child call)
                // subresource_uris.calls is a FULL path like /2010-04-01/Accounts/AC.../Calls/CA.../Calls.json
                // Use the raw Twilio API base (without /Accounts/...) to avoid path duplication.
                $children_uri = $call_info['subresource_uris']['calls'] ?? '';
                if ($children_uri !== '') {
                    $raw_api_base = 'https://api.twilio.com';
                    $children = $this->twilio_request('GET', $children_uri, array('PageSize' => 5), $raw_api_base);
                    if (!is_wp_error($children) && isset($children['calls']) && is_array($children['calls'])) {
                        $debug['child_calls'] = count($children['calls']);
                        foreach ($children['calls'] as $child) {
                            $child_sid = $child['sid'] ?? '';
                            if ($child_sid === '')
                                continue;
                            $debug['child_sid'] = $child_sid;
                            // Check recordings on the child call
                            $cr = $this->twilio_request('GET', "/Calls/{$child_sid}/Recordings.json");
                            if (!is_wp_error($cr) && isset($cr['recordings']) && is_array($cr['recordings']) && !empty($cr['recordings'])) {
                                $recs = $cr['recordings'];
                                $source = 'child_call';
                                $debug['child_rec_count'] = count($recs);
                                break;
                            }
                        }
                    } else {
                        $debug['children_error'] = is_wp_error($children) ? $children->get_error_message() : 'no calls key';
                    }
                }

                // Tier 5: Sibling-call search — for simultaneous ring / outbound-api
                // calls, find the related inbound call that has a recording.
                // The simultaneous ring call is created in context of an inbound
                // call to the same business number within seconds of each other.
                if (empty($recs)) {
                    $call_direction_raw = $call_info['direction'] ?? '';
                    $call_from = $call_info['from'] ?? '';
                    $call_to = $call_info['to'] ?? '';
                    $call_date = $call_info['date_created'] ?? '';

                    // For outbound-api calls (simultaneous ring), search DB
                    // for a related inbound call. For inbound calls, search for
                    // outbound-api sibling. Both directions within 60-second window.
                    $is_outbound_api = ($call_direction_raw === 'outbound-api');
                    $is_inbound = ($call_direction_raw === 'inbound');
                    if (($is_outbound_api || $is_inbound) && $call_date !== '') {
                        $sibling_direction = $is_outbound_api ? 'inbound' : 'outbound';
                        $debug['sibling_search'] = true;
                        global $wpdb;
                        $pc_table = $wpdb->prefix . 'vmo_phone_calls';
                        if ($wpdb->get_var("SHOW TABLES LIKE '{$pc_table}'") === $pc_table) {
                            $ts = strtotime($call_date);
                            if ($ts) {
                                $window_start = date('Y-m-d H:i:s', $ts - 60);
                                $window_end = date('Y-m-d H:i:s', $ts + 60);
                                // Look for a sibling call (opposite direction) near this time that has a recording
                                $sibling = $wpdb->get_row($wpdb->prepare(
                                    "SELECT call_sid, recording_url FROM {$pc_table}
                                     WHERE direction = %s
                                       AND call_sid != %s
                                       AND created_at BETWEEN %s AND %s
                                       AND recording_url IS NOT NULL
                                       AND recording_url != ''
                                     ORDER BY created_at DESC LIMIT 1",
                                    $sibling_direction,
                                    $call_sid,
                                    $window_start,
                                    $window_end
                                ));
                                if ($sibling && !empty($sibling->recording_url)) {
                                    $debug['sibling_found'] = $sibling->call_sid;
                                    return array(
                                        'url' => $sibling->recording_url,
                                        'duration' => 0,
                                        'created' => '',
                                        'source' => 'sibling_' . $sibling_direction,
                                        'debug' => $debug,
                                    );
                                }

                                // Fallback: check the sibling call on Twilio directly
                                $sibling_no_rec = $wpdb->get_row($wpdb->prepare(
                                    "SELECT call_sid FROM {$pc_table}
                                     WHERE direction = %s
                                       AND call_sid != %s
                                       AND created_at BETWEEN %s AND %s
                                     ORDER BY created_at DESC LIMIT 1",
                                    $sibling_direction,
                                    $call_sid,
                                    $window_start,
                                    $window_end
                                ));
                                if ($sibling_no_rec && !empty($sibling_no_rec->call_sid)) {
                                    $debug['sibling_sid'] = $sibling_no_rec->call_sid;
                                    // Check Twilio for the sibling's recordings (tier 1 only)
                                    $sr = $this->twilio_request('GET', "/Calls/{$sibling_no_rec->call_sid}/Recordings.json");
                                    if (!is_wp_error($sr) && isset($sr['recordings']) && is_array($sr['recordings']) && !empty($sr['recordings'])) {
                                        $recs = $sr['recordings'];
                                        $source = 'sibling_twilio';
                                        $debug['sibling_rec_count'] = count($recs);
                                    }
                                }
                            }
                        }
                    }
                }
            } elseif (is_wp_error($call_info)) {
                $debug['call_info_error'] = $call_info->get_error_message();
            }
        }

        error_log('[VMO fetch_call_recordings] ' . $call_sid . ': found=' . count($recs) . ' source=' . $source . ' debug=' . wp_json_encode($debug));

        if (empty($recs)) {
            return array(
                'url' => '',
                'duration' => 0,
                'created' => '',
                'source' => 'none',
                'debug' => $debug,
            );
        }

        $latest = $recs[0];
        $rec_sid = $latest['sid'] ?? '';
        $dur = (int) ($latest['duration'] ?? 0);
        $created = $latest['date_created'] ?? '';

        // Download locally for browser playback
        $twilio_media_url = $this->api_base . '/Accounts/' . $this->account_sid . '/Recordings/' . urlencode($rec_sid) . '.mp3';
        $saved = $this->save_call_recording($call_sid, $twilio_media_url);

        $local_url = (is_string($saved) && $saved !== '') ? $saved : $twilio_media_url;
        $debug['saved_locally'] = (is_string($saved) && $saved !== '');

        return array(
            'url' => $local_url,
            'duration' => $dur,
            'created' => $created,
            'source' => $source,
            'debug' => $debug,
        );
    }

    /**
     * Proxy to Affiliate Manager for Dashboard Data
     */
    public function get_affiliate_dashboard_data()
    {
        global $vmo_affiliate_manager;
        if (!isset($vmo_affiliate_manager)) {
            // Lazy load if needed
            if (class_exists('VMO_Affiliate_Manager')) {
                $vmo_affiliate_manager = new VMO_Affiliate_Manager();
            } else {
                return new WP_Error('affiliate_module_missing', 'Affiliate module not loaded', array('status' => 500));
            }
        }

        return $vmo_affiliate_manager->get_dashboard_stats();
    }

    /**
     * Proxy to Register Affiliate
     */
    public function api_register_affiliate()
    {
        global $vmo_affiliate_manager;
        if (!isset($vmo_affiliate_manager)) {
            $vmo_affiliate_manager = new VMO_Affiliate_Manager();
        }

        // Manually call registration logic and return response-like structure
        ob_start(); // Buffer any output
        $vmo_affiliate_manager->handle_registration();
        $json = ob_get_clean();

        $data = json_decode($json, true);
        return $data['data'] ?? $data;
    }

    /**
     * AJAX Handler: Search available phone numbers
     */
    public function ajax_search_numbers()
    {
        ob_start(); // Safety buffer
        // removed try to fix syntax error - existing code has try/catch later
        if (!$this->verify_ajax_request()) {
            if (ob_get_length())
                ob_clean();
            wp_send_json_error(array('message' => 'Security check failed'), 403);
            return;
        }

        $country = isset($_POST['country']) ? sanitize_text_field(wp_unslash($_POST['country'])) : 'US';
        $area_code = isset($_POST['area_code']) ? sanitize_text_field(wp_unslash($_POST['area_code'])) : '';
        $replace_mode = false;
        if (isset($_POST['replace_mode'])) {
            $raw_val = sanitize_text_field(wp_unslash($_POST['replace_mode']));
            // Check for '1', 1, 'true', true
            if ($raw_val === '1' || $raw_val === 1 || $raw_val === 'true' || $raw_val === true) {
                $replace_mode = true;
            }
        }

        $results = array();

        // SIMULATION MODE
        if ($this->simulation_mode) {
            $prefix = ($country === 'GB') ? '+44' : '+1';
            $ac = $area_code ? $area_code : '555';

            for ($i = 0; $i < 5; $i++) {
                $results[] = array(
                    'phoneNumber' => $prefix . $ac . rand(2000000, 9999999), // e.g. +14152345678
                    'friendlyName' => sprintf("(%s) %s-%s", $ac, rand(200, 999), rand(1000, 9999)),
                    'locality' => 'Simulated City',
                    'region' => 'SIM'
                );
            }

            wp_send_json_success(array(
                'numbers' => $results,
                'message' => 'Found ' . count($results) . ' numbers (Simulation)'
            ));
        }

        // REAL MODE VIA API
        error_log('[VMO Phone Search] Starting search: country=' . $country . ', area_code=' . $area_code . ', replace_mode=' . ($replace_mode ? '1' : '0') . ', simulation=' . ($this->simulation_mode ? '1' : '0') . ', sid_prefix=' . substr($this->account_sid, 0, 6));
        $debug = array('sid_prefix' => substr($this->account_sid, 0, 6));
        try {
            // --- RECOVERY MODE: Check for already owned numbers first ---
            // Only show owned numbers if we are NOT in replace mode
            if (!$replace_mode) {
                $owned_response = $this->twilio_request('GET', '/IncomingPhoneNumbers.json', array('PageSize' => 20));
                if (is_wp_error($owned_response)) {
                    $err = $owned_response->get_error_message();
                    error_log('[VMO Phone Search] Owned numbers lookup failed: ' . $err);
                    $debug['owned_error'] = $err;
                } elseif (!empty($owned_response['incoming_phone_numbers'])) {
                    error_log('[VMO Phone Search] Found ' . count($owned_response['incoming_phone_numbers']) . ' owned numbers');
                    $debug['owned_count'] = count($owned_response['incoming_phone_numbers']);
                    foreach ($owned_response['incoming_phone_numbers'] as $owned) {
                        // We prioritize showing owned numbers to allow recovery
                        $results[] = array(
                            'phoneNumber' => $owned['phone_number'],
                            'friendlyName' => $owned['friendly_name'] . ' [Active]',
                            'locality' => 'Ready to Use',
                            'region' => 'Account',
                            'is_owned' => true
                        );
                    }
                } else {
                    error_log('[VMO Phone Search] No owned numbers on this Twilio account');
                    $debug['owned_count'] = 0;
                }
            }

            // --- BUY MODE: Fetch NEW Available Numbers ---
            // If the user wants to buy/replace, we MUST fetch available numbers.
            // Even if we found owned numbers, we still fetch available ones below them for variety.

            $page_size = 10;
            $args = array(
                'PageSize' => $page_size,
                'VoiceEnabled' => 'true'
                // SmsEnabled removed — too restrictive for many area codes
            );

            if ($area_code) {
                $args['AreaCode'] = $area_code;
            }

            // Endpoint: /AvailablePhoneNumbers/{CountryCode}/Local.json
            // Fallback for some countries that use Mobile instead of Local
            // But we start with Local
            $endpoint = "/AvailablePhoneNumbers/{$country}/Local.json";
            $debug['search_endpoint'] = $endpoint;

            $response = $this->twilio_request('GET', $endpoint, $args);

            if (is_wp_error($response)) {
                $debug['local_error'] = $response->get_error_message();
                error_log('[VMO Phone Search] Local endpoint failed: ' . $response->get_error_message());
                // Try Mobile endpoint fallback if Local fails (common for some EU countries)
                $endpoint = "/AvailablePhoneNumbers/{$country}/Mobile.json";
                $response = $this->twilio_request('GET', $endpoint, $args);
                if (is_wp_error($response)) {
                    $debug['mobile_error'] = $response->get_error_message();
                    error_log('[VMO Phone Search] Mobile endpoint also failed: ' . $response->get_error_message());
                    if (empty($results)) {
                        throw new Exception($response->get_error_message());
                    }
                }
            }

            // Collect available numbers
            $available_count = 0;
            if (!is_wp_error($response) && !empty($response['available_phone_numbers'])) {
                $available_count = count($response['available_phone_numbers']);
                foreach ($response['available_phone_numbers'] as $num) {
                    $results[] = array(
                        'phoneNumber' => $num['phone_number'],
                        'friendlyName' => $num['friendly_name'],
                        'locality' => isset($num['locality']) ? $num['locality'] : '',
                        'region' => isset($num['region']) ? $num['region'] : ''
                    );
                }
            }
            $debug['available_count'] = $available_count;

            // --- AUTO-RETRY: If area code returned 0 new numbers, retry without area code ---
            if ($available_count === 0 && !empty($area_code)) {
                error_log('[VMO Phone Search] Area code ' . $area_code . ' returned 0 — retrying without area code');
                $debug['retry_without_area'] = true;
                $retry_args = array('PageSize' => $page_size, 'VoiceEnabled' => 'true');
                $retry_endpoint = "/AvailablePhoneNumbers/{$country}/Local.json";
                $retry_response = $this->twilio_request('GET', $retry_endpoint, $retry_args);
                if (!is_wp_error($retry_response) && !empty($retry_response['available_phone_numbers'])) {
                    $debug['retry_count'] = count($retry_response['available_phone_numbers']);
                    foreach ($retry_response['available_phone_numbers'] as $num) {
                        $results[] = array(
                            'phoneNumber' => $num['phone_number'],
                            'friendlyName' => $num['friendly_name'],
                            'locality' => isset($num['locality']) ? $num['locality'] : '',
                            'region' => isset($num['region']) ? $num['region'] : ''
                        );
                    }
                } else {
                    $debug['retry_count'] = 0;
                    if (is_wp_error($retry_response)) {
                        $debug['retry_error'] = $retry_response->get_error_message();
                    }
                }
            }

            // SORTING: Owned first, then numbers with repeated digits
            if (count($results) > 1) {
                usort($results, function ($a, $b) {
                    $aOwned = !empty($a['is_owned']) ? 1 : 0;
                    $bOwned = !empty($b['is_owned']) ? 1 : 0;
                    if ($aOwned !== $bOwned)
                        return $bOwned - $aOwned;
                    preg_match_all('/(\d)\1+/', $a['phoneNumber'], $mA);
                    preg_match_all('/(\d)\1+/', $b['phoneNumber'], $mB);
                    $sA = 0;
                    if (!empty($mA[0]))
                        foreach ($mA[0] as $m)
                            $sA += strlen($m);
                    $sB = 0;
                    if (!empty($mB[0]))
                        foreach ($mB[0] as $m)
                            $sB += strlen($m);
                    return $sB - $sA;
                });
            }

            error_log('[VMO Phone Search] Returning ' . count($results) . ' total results');
            if (ob_get_length())
                ob_clean();
            wp_send_json_success(array(
                'numbers' => $results,
                'debug' => $debug
            ));

        } catch (Exception $e) {
            error_log('[VMO Phone Search] Exception: ' . $e->getMessage());
            $debug['exception'] = $e->getMessage();
            if (ob_get_length())
                ob_clean();
            if (!empty($results)) {
                wp_send_json_success(array(
                    'numbers' => $results,
                    'debug' => $debug,
                    'partial_error' => $e->getMessage()
                ));
            } else {
                wp_send_json_error(array(
                    'message' => 'Search failed: ' . $e->getMessage(),
                    'debug' => $debug
                ));
            }
        }
    }

    /**
     * AJAX Handler: Release (Drop) a phone number
     */
    public function ajax_release_phone_number()
    {
        ob_start();
        if (!$this->verify_ajax_request()) {
            if (ob_get_length())
                ob_clean();
            wp_send_json_error(array('message' => 'Security check failed'), 403);
            return;
        }

        $user_id = get_current_user_id();
        $phone_number = isset($_POST['phone_number']) ? sanitize_text_field(wp_unslash($_POST['phone_number'])) : '';

        if (empty($phone_number)) {
            if (ob_get_length())
                ob_clean();
            wp_send_json_error(array('message' => 'Phone number is required.'));
        }

        // Verify ownership in local DB
        global $wpdb;
        $table_name = $wpdb->prefix . 'vmo_phone_numbers';
        $entry = $wpdb->get_row($wpdb->prepare(
            "SELECT * FROM $table_name WHERE user_id = %d AND phone_number = %s AND status = 'active'",
            $user_id,
            $phone_number
        ));

        if (!$entry) {
            if (ob_get_length())
                ob_clean();
            wp_send_json_error(array('message' => 'Number not found or already released.'));
        }

        $sid = $entry->twilio_sid; // Twilio SID

        // Release from Twilio
        if (!$this->simulation_mode && $sid) {
            $result = $this->twilio_request('DELETE', "/IncomingPhoneNumbers/{$sid}.json");
            if (is_wp_error($result)) {
                // Determine if it was already deleted (404 is fine to proceed)
                $error_data = $result->get_error_data();
                if ($error_data['status'] != 404) {
                    if (ob_get_length())
                        ob_clean();
                    wp_send_json_error(array('message' => 'Twilio Error: ' . $result->get_error_message()));
                }
            }
        }

        // Update Local DB
        // phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery, WordPress.DB.DirectDatabaseQuery.NoCaching
        $wpdb->update(
            $table_name,
            array('status' => 'released', 'released_at' => current_time('mysql')),
            array('id' => $entry->id)
        );

        // Clear user meta cache for phone
        delete_user_meta($user_id, 'vmo_phone_number'); // Legacy/Simple
        // Assuming there might be other metas like 'business_phone' depending on implementations, 
        // but 'vmo_phone_numbers' table is the authority now.

        if (ob_get_length())
            ob_clean();
        wp_send_json_success(array(
            'message' => 'Phone number released successfully. You can now provision a new one.',
            'phone' => $phone_number
        ));
    }

    /**
     * AJAX Handler: Reconnect & Repair (Manual Sync of URLs)
     */
    public function ajax_reconnect_phone_number()
    {
        ob_start();
        if (!$this->verify_ajax_request()) {
            if (ob_get_length())
                ob_clean();
            wp_send_json_error(array('message' => 'Security check failed'), 403);
            return;
        }
        $user_id = get_current_user_id();

        // 1. Get current number
        $phone_data = $this->get_user_phone($user_id);
        $phone_number = is_object($phone_data) ? ($phone_data->phone_number ?? '') : (is_string($phone_data) ? $phone_data : '');
        if (!$phone_number) {
            if (ob_get_length())
                ob_clean();
            wp_send_json_error(array('message' => 'No number found to reconnect.'));
            return;
        }

        // 2. Find SID (try Meta then DB)
        $sid = get_user_meta($user_id, 'vmo_phone_sid', true);
        if (!$sid) {
            global $wpdb;
            $row = $wpdb->get_row($wpdb->prepare("SELECT twilio_sid FROM {$wpdb->prefix}vmo_phone_numbers WHERE phone_number = %s LIMIT 1", $phone_number));
            if ($row)
                $sid = $row->twilio_sid;
        }

        if (!$sid) {
            // Try to lookup SID from Twilio if we have the number but lost the SID
            // Not implemented for safety, return error
            if (ob_get_length())
                ob_clean();
            wp_send_json_error(array('message' => 'Critical: Phone SID missing. Please Replace number.'));
            return;
        }

        // 3. Force Update Twilio URLs
        try {
            $response = $this->twilio_request('POST', "/IncomingPhoneNumbers/{$sid}.json", array(
                'VoiceUrl' => $this->phone_gateway_url('voice', array('uid' => $user_id)),
                'SmsUrl' => $this->twilio_public_url('sms_webhook'),
                'StatusCallback' => $this->twilio_public_url('status_callback'),
                'VoiceMethod' => 'POST',
                'SmsMethod' => 'POST'
            ));

            if (is_wp_error($response)) {
                throw new Exception($response->get_error_message());
            }

            error_log("VMO: Manually re-connected number {$phone_number} (SID: {$sid}) for User {$user_id}");

            // 4. Repair Local Persistence (DB + user_meta)
            // This makes the dashboard display consistent after reconnects and fixes drift.
            update_user_meta($user_id, 'vmo_phone_number', $phone_number);
            update_user_meta($user_id, 'vmo_phone_sid', $sid);

            global $wpdb;
            $table_name = $wpdb->prefix . 'vmo_phone_numbers';

            $row_id = $wpdb->get_var($wpdb->prepare(
                "SELECT id FROM {$table_name} WHERE user_id = %d AND phone_number = %s LIMIT 1",
                $user_id,
                $phone_number
            ));

            if ($row_id) {
                // phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery, WordPress.DB.DirectDatabaseQuery.NoCaching
                $wpdb->update(
                    $table_name,
                    array(
                        'twilio_sid' => $sid,
                        'status' => 'active',
                        'updated_at' => current_time('mysql')
                    ),
                    array('id' => (int) $row_id)
                );
            } else {
                // If the DB row is missing (common after schema mismatch or prior failed insert), re-create it.
                // phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery, WordPress.DB.DirectDatabaseQuery.NoCaching
                $wpdb->insert(
                    $table_name,
                    array(
                        'user_id' => $user_id,
                        'phone_number' => $phone_number,
                        'twilio_sid' => $sid,
                        'status' => 'active',
                        'provisioned_at' => current_time('mysql'),
                        'created_at' => current_time('mysql'),
                        'updated_at' => current_time('mysql')
                    ),
                    array('%d', '%s', '%s', '%s', '%s', '%s', '%s')
                );
            }

            if (ob_get_length())
                ob_clean();
            wp_send_json_success(array('message' => 'Connection Refreshed. Call Routing Updated.'));

        } catch (Exception $e) {
            if (ob_get_length())
                ob_clean();
            wp_send_json_error(array('message' => 'Reconnection Failed: ' . $e->getMessage()));
        }
    }

    /**
     * AJAX Handler: Update Phone Friendly Name
     */
    public function ajax_update_phone_label()
    {
        ob_start();

        // Security Check
        if (!$this->verify_ajax_request()) {
            if (ob_get_length())
                ob_clean();
            wp_send_json_error(array('message' => 'Security check failed'), 403);
            return;
        }

        if (!is_user_logged_in()) {
            if (ob_get_length())
                ob_clean();
            wp_send_json_error(array('message' => 'You must be logged in.'));
            return;
        }

        $user_id = get_current_user_id();
        $new_label = isset($_POST['label']) ? sanitize_text_field(wp_unslash($_POST['label'])) : '';

        if (empty($new_label)) {
            if (ob_get_length())
                ob_clean();
            wp_send_json_error(array('message' => 'Label cannot be empty.'));
            return;
        }

        // 1. Get current active number
        global $wpdb;
        $table_name = $wpdb->prefix . 'vmo_phone_numbers';
        $row = $wpdb->get_row($wpdb->prepare(
            "SELECT * FROM $table_name WHERE user_id = %d AND status = 'active' ORDER BY created_at DESC LIMIT 1",
            $user_id
        ));

        if (!$row || empty($row->twilio_sid)) {
            if (ob_get_length())
                ob_clean();
            wp_send_json_error(array('message' => 'No active phone number found to update.'));
            return;
        }

        $sid = $row->twilio_sid;

        try {
            // 2. Update Twilio
            $response = $this->twilio_request('POST', "/IncomingPhoneNumbers/{$sid}.json", array(
                'FriendlyName' => $new_label
            ));

            if (is_wp_error($response)) {
                throw new Exception($response->get_error_message());
            }

            if (ob_get_length())
                ob_clean();
            wp_send_json_success(array(
                'message' => 'Phone label updated successfully.',
                'label' => $new_label
            ));

        } catch (Exception $e) {
            error_log('VMO Update Label Error: ' . $e->getMessage());
            if (ob_get_length())
                ob_clean();
            wp_send_json_error(array('message' => 'Failed to update label: ' . $e->getMessage()));
        }
    }

    /**
     * Helper to safely get property from array/object with case-insensitive fallback
     */
    private function get_twilio_prop($data, $key)
    {
        if (is_array($data)) {
            // Try exact match
            if (isset($data[$key]))
                return $data[$key];
            // Try CamelCase
            $camel = str_replace(' ', '', ucwords(str_replace('_', ' ', $key)));
            if (isset($data[$camel]))
                return $data[$camel];
            // Try snake_case
            $snake = strtolower(preg_replace('/(?<!^)[A-Z]/', '_$0', $key));
            if (isset($data[$snake]))
                return $data[$snake];
            // Try lowercase
            $lower = strtolower($key);
            if (isset($data[$lower]))
                return $data[$lower];
        } elseif (is_object($data)) {
            if (isset($data->$key))
                return $data->$key;
            $camel = str_replace(' ', '', ucwords(str_replace('_', ' ', $key)));
            if (isset($data->$camel))
                return $data->$camel;
        }
        return null;
    }

    /**
     * Autonomously provision phone number and AI agent for user
     * Called by Business Consultation AI when user accepts recommendations
     * 
     * @param int $user_id WordPress user ID
     * @return array|WP_Error Phone details or error
     */
    public function auto_provision_phone()
    {
        // PREVENT FATAL ERRORS: Wrap everything in a Throwable catch
        ob_start(); // Buffer output to prevent HTML leakage
        try {
            // Unified nonce verification (admin/builder)
            if (!$this->verify_ajax_request()) {
                if (ob_get_length())
                    ob_clean();
                wp_send_json_error(array('message' => 'Security check failed. Please refresh the page.'), 403);
                return;
            }

            $user_id = isset($_POST['user_id']) ? intval($_POST['user_id']) : get_current_user_id();

            // Check for active plan - phone provisioning requires subscription
            $plan_check = $this->user_has_active_plan($user_id);
            if ($plan_check !== true) {
                wp_send_json_error(array(
                    'message' => $plan_check,
                    'requires_plan' => true
                ));
            }

            // Check for Replace/Change logic
            $replace_existing = isset($_POST['replace_existing']) && $_POST['replace_existing'] == '1';

            // Check usage limits & existing numbers
            $existing_phone = $this->get_user_phone($user_id);
            $max_allowed = (int) get_user_meta($user_id, 'vmo_allowed_phone_count', true);
            if ($max_allowed < 1)
                $max_allowed = 1; // Default to 1

            // If user has existing phone, and not replacing, check if they can have more
            // BYPASS: Admins (Master Account) can have unlimited numbers or override this check to fix issues
            $is_admin = user_can($user_id, 'manage_options');

            if ($existing_phone && !$replace_existing && !$is_admin) {
                // AUTO-FIX: Check if the number we are requesting is ALREADY owned (Reconnection Scenario)
                $req_number = isset($_POST['selected_number']) ? sanitize_text_field(wp_unslash($_POST['selected_number'])) : '';

                // If the user is asking for the number they currently have active (Idempotency)
                if ($req_number && ($req_number === $existing_phone)) {
                    // Just return success immediately
                    wp_send_json_success(array(
                        'message' => 'Number is already active and connected.',
                        'number' => $existing_phone,
                        'formatted' => $this->format_phone_number($existing_phone)
                    ));
                }

                // Count current numbers (approximation since get_user_phone returns one primary)
                // TO-DO: Implement full multi-number array logic later. For now, we trust the primary.
                // If they have a phone, and max allowed is 1, they are full.
                if ($max_allowed <= 1) {
                    wp_send_json_error(array(
                        'message' => 'You already have an active number. Use "Replace" to switch, or request Enterprise increase.',
                        'phone' => $existing_phone
                    ));
                }
                // If max > 1, we persist success to allow adding another (Multi-number logic stub)
            }

            // ADDED: Charging Logic for Provisioning
            // Use authoritative cost from Token Economics (3,750 tokens = $2.50)
            $activation_cost = class_exists('VMO_Twilio_Token_Economics')
                ? VMO_Twilio_Token_Economics::PHONE_ACTIVATION()
                : (defined('VMO_TOKENS_PHONE_ACTIVATION') ? VMO_TOKENS_PHONE_ACTIVATION : 3750);

            // Check balance
            if (function_exists('vmo_get_user_tokens')) {
                $balance = vmo_get_user_tokens($user_id);
                if ($balance < $activation_cost) {
                    wp_send_json_error(array(
                        'message' => 'Insufficient Tokens. Activation requires ' . number_format($activation_cost) . ' tokens. Please top up.',
                        'requires_topup' => true
                    ));
                }
                // Deduct tokens
                vmo_deduct_tokens($user_id, $activation_cost, 'phone_activation', 'Phone Number Activation Fee');
            } else {
                // Manual fallback if helper missing
                $balance = (int) get_user_meta($user_id, 'vmo_token_balance', true);
                if ($balance < $activation_cost) {
                    wp_send_json_error(array(
                        'message' => 'Insufficient Tokens. Activation requires ' . number_format($activation_cost) . ' tokens. Please top up.',
                        'requires_topup' => true
                    ));
                }
                update_user_meta($user_id, 'vmo_token_balance', max(0, $balance - $activation_cost));
                $this->log_token_usage($user_id, 'phone_activation', $activation_cost, 'Phone Number Activation Fee');
            }

            // Handle Replacement (Deactivate old in DB logic - AND RELEASE FROM TWILIO)
            if ($existing_phone && $replace_existing) {
                global $wpdb;
                $table_name = $wpdb->prefix . 'vmo_phone_numbers';

                // Get the OLD active number details first
                $old_entry = $wpdb->get_row($wpdb->prepare(
                    "SELECT * FROM $table_name WHERE user_id = %d AND status = 'active' LIMIT 1",
                    $user_id
                ));

                if ($old_entry && !empty($old_entry->twilio_sid)) {
                    // Release from Twilio
                    if (!$this->simulation_mode) {
                        $del_res = $this->twilio_request('DELETE', "/IncomingPhoneNumbers/{$old_entry->twilio_sid}.json");
                        if (is_wp_error($del_res)) {
                            error_log("VMO Warning: Failed to release old number {$old_entry->phone_number} from Twilio: " . $del_res->get_error_message());
                        } else {
                            error_log("VMO Info: Successfully released old number {$old_entry->phone_number} from Twilio during replacement.");
                        }
                    }
                }

                // Mark old as 'released'
                // phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery, WordPress.DB.DirectDatabaseQuery.NoCaching
                $wpdb->update(
                    $table_name,
                    array(
                        'status' => 'released',
                        'released_at' => current_time('mysql')
                    ),
                    array('user_id' => $user_id, 'status' => 'active')
                );
            }

            // Get business information
            $business_name = get_user_meta($user_id, 'business_name', true);
            $business_location = get_user_meta($user_id, 'business_location', true);

            // Allow overrides from POST for manual setup
            $country_code = isset($_POST['country']) ? sanitize_text_field(wp_unslash($_POST['country'])) : $this->extract_country_code($business_location);
            $area_code = isset($_POST['area_code']) ? sanitize_text_field(wp_unslash($_POST['area_code'])) : $this->extract_area_code($business_location);

            // If still no country code, default to US
            if (empty($country_code)) {
                $country_code = 'US';
            }

            // =========================================================================
            // FRAUD PREVENTION: COMBINDED OPTION A (BUSINESS MATCH) & C (PAYMENT MATCH)
            // =========================================================================

            // 1. Check if the user is an Administrator (Trust Override)
            // Admins can provision any number. Non-admins are subject to strict fraud checks.
            if (!user_can($user_id, 'manage_options')) {

                // OPTION A: Business Location Match
                // Extract country code from stored business address (e.g. "Atlanta, GA, US" -> "US")
                // Assuming business_location format contains country code or we parse it.
                // Simple strict check: If provisioning US number, Business Locale MUST be US.
                $biz_country_check = $this->extract_country_code($business_location);
                if (!$biz_country_check)
                    $biz_country_check = 'US'; // Default assumption

                if ($country_code !== $biz_country_check) {
                    wp_send_json_error(array(
                        'message' => "Security Restriction: You cannot provision a phone number for {$country_code} because your verified business address is in {$biz_country_check}. Please contact support or update your business profile."
                    ));
                }

                // OPTION C: Billing Country Match (Stripe Logic Validation)
                // We check the user's billing address stored from their subscription.
                // (Assumes WooCommerce/Stripe data is synced to user_meta 'billing_country')
                $billing_country = get_user_meta($user_id, 'billing_country', true);

                // Fallback: If no billing country standard meta, check VMO specific or skip if strictly trusted
                // If billing country is found, it MUST match the requested number country.
                if (!empty($billing_country) && $billing_country !== $country_code) {
                    wp_send_json_error(array(
                        'message' => "Fraud Prevention: Your payment method comes from ($billing_country), but you are trying to register a number in ($country_code). For security, these must match. Please contact support."
                    ));
                }
            }
            // =========================================================================

            $chosen_number = isset($_POST['selected_number']) ? sanitize_text_field(wp_unslash($_POST['selected_number'])) : null;

            // --- SIMULATION MODE START ---
            if ($this->simulation_mode) {
                // Use selected number if provided, else random
                $fake_number = $chosen_number ? $chosen_number : ('+1' . ($area_code ? $area_code : '555') . rand(1000000, 9999999));

                // Save as if real
                update_user_meta($user_id, 'phone', $fake_number);
                update_user_meta($user_id, 'twilio_phone_sid', 'PN_' . uniqid('sim_'));

                // Log for audit
                error_log("VMO: SIMULATED phone provision for user {$user_id}: {$fake_number}");

                wp_send_json_success(array(
                    'message' => 'Phone number provisioned (SIMULATION)',
                    'phone' => $fake_number,
                    'sid' => 'PN_SIMULATION',
                    'is_simulation' => true
                ));
            }
            // --- SIMULATION MODE END ---

            // Step 1: Determine number to buy
            if ($chosen_number) {
                // Trust the user selection (validation happens by Twilio on purchase)
                $selected_number = $chosen_number;
            } else {
                // Auto-search logic
                $available_numbers = $this->search_available_numbers($country_code, $area_code);

                if (empty($available_numbers)) {
                    wp_send_json_error(array('message' => 'No phone numbers available in your area'));
                }

                // search_available_numbers returns objects with properties in my refactor
                $selected_number = $available_numbers[0]->phoneNumber;
            }

            // --- STEP 1.5: CHECK IF WE ALREADY OWN THIS NUMBER (RECOVERY) ---
            $existing_owned = $this->twilio_request('GET', '/IncomingPhoneNumbers.json', array('PhoneNumber' => $selected_number));
            $phone_number_data = null; // Will hold the final phone number object/array

            $owned_list = $this->get_twilio_prop($existing_owned, 'incoming_phone_numbers');

            // Determine friendly label (Domain + Business Name or Custom)
            $host = parse_url(get_home_url(), PHP_URL_HOST);
            $host = str_replace('www.', '', $host); // Clean www

            // Allow user to specify a custom label
            if (isset($_POST['phone_label']) && !empty($_POST['phone_label'])) {
                $custom_label = sanitize_text_field(wp_unslash($_POST['phone_label']));
                // Ensure it meets Twilio standards (max 64 chars)
                $friendly_id = substr($custom_label, 0, 60);
            } else {
                // Prefer user's explicitly set business name, fallback to site name
                $clean_biz_name = $business_name ? $business_name : get_bloginfo('name');
                $clean_biz_name = preg_replace('/[^A-Za-z0-9 ]/', '', $clean_biz_name); // Alphanumeric only
                $clean_biz_name = trim(substr($clean_biz_name, 0, 30)); // Max 30 chars for Twilio FriendlyName constraints

                $friendly_id = "{$clean_biz_name} ({$host})";
            }

            if (!is_wp_error($existing_owned) && !empty($owned_list)) {
                // We found it! We own it.
                $phone_number_data = $owned_list[0];
                $sid = $this->get_twilio_prop($phone_number_data, 'sid');

                // Update its URLs just to be safe it points to THIS site
                $this->twilio_request('POST', "/IncomingPhoneNumbers/{$sid}.json", array(
                    'VoiceUrl' => $this->phone_gateway_url('voice', array('uid' => $user_id)),
                    'SmsUrl' => $this->twilio_public_url('sms_webhook'),
                    'StatusCallback' => $this->twilio_public_url('status_callback'),
                    'StatusCallbackMethod' => 'POST',
                    'FriendlyName' => $friendly_id
                ));
            } else {
                // Step 2: Purchase phone number (Standard Path)
                $buy_args = array(
                    'PhoneNumber' => $selected_number,
                    'FriendlyName' => $friendly_id,
                    'VoiceUrl' => $this->phone_gateway_url('voice', array('uid' => $user_id)),
                    'SmsUrl' => $this->twilio_public_url('sms_webhook'),
                    'StatusCallback' => $this->twilio_public_url('status_callback'),
                    'VoiceMethod' => 'POST',
                    'SmsMethod' => 'POST'
                );

                $phone_number_data = $this->twilio_request('POST', '/IncomingPhoneNumbers.json', $buy_args);
            }

            if (is_wp_error($phone_number_data)) {
                throw new Exception($phone_number_data->get_error_message());
            }

            // EXTRACT PROPERTIES SAFELY
            $final_number = $this->get_twilio_prop($phone_number_data, 'phone_number');
            $final_sid = $this->get_twilio_prop($phone_number_data, 'sid');

            if (!$final_number || !$final_sid) {
                error_log('VMO Critical: Phone number or SID missing from Twilio response: ' . print_r($phone_number_data, true));
                throw new Exception('Invalid response from Phone Provider');
            }

            // Step 3: Create AI Agent with business context
            $ai_agent = $this->create_ai_agent($user_id, $business_name, $final_number);

            // Expecting object or array with 'sid'
            $ai_agent_sid = (is_object($ai_agent) && isset($ai_agent->sid)) ? $ai_agent->sid : '';

            // IMPORTANT: Voice webhook reads assistant SID from user meta.
            // Sync it here so inbound calls can immediately connect to the assistant.
            if (!empty($ai_agent_sid)) {
                update_user_meta($user_id, 'twilio_ai_agent_sid', $ai_agent_sid);
            }

            // Step 5: Save to database
            global $wpdb;
            // FIX: Use vmo_phone_numbers (schema compliant) instead of vmo_user_phone_numbers
            $table_name = $wpdb->prefix . 'vmo_phone_numbers';

            $settings = json_encode(array(
                'twilio_ai_agent_sid' => $ai_agent_sid,
                'monthly_cost' => 29.00,
                'next_billing_date' => date('Y-m-d H:i:s', strtotime('+1 month'))
            ));

            // phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery, WordPress.DB.DirectDatabaseQuery.NoCaching
            $inserted = $wpdb->insert($table_name, array(
                'user_id' => $user_id,
                'phone_number' => $final_number,
                'twilio_sid' => $final_sid,
                'status' => 'active',
                'provisioned_at' => current_time('mysql'),
                'settings' => $settings
            ));

            // Backup: Sync to user_meta for redundancy and fast access
            update_user_meta($user_id, 'vmo_phone_number', $final_number);
            update_user_meta($user_id, 'vmo_phone_sid', $final_sid);

            if ($inserted === false) {
                error_log("VMO DB Error: Failed to insert phone number {$final_number}. DB Error: " . $wpdb->last_error);

                // Fallback Logic:
                // 1. Check if ANY row exists with this number (deleted, released, etc)
                $existing = $wpdb->get_row($wpdb->prepare("SELECT * FROM $table_name WHERE phone_number = %s", $final_number));

                if ($existing) {
                    // UPDATE existing record
                    // phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery, WordPress.DB.DirectDatabaseQuery.NoCaching
                    $updated = $wpdb->update(
                        $table_name,
                        array(
                            'status' => 'active',
                            'user_id' => $user_id,
                            'twilio_sid' => $final_sid,
                            'provisioned_at' => current_time('mysql'),
                            'settings' => $settings
                        ),
                        array('phone_number' => $final_number)
                    );
                    if ($updated === false) {
                        throw new Exception("Database Critical: Could not update existing phone record.");
                    }
                } else {
                    // SELF-HEALING: Attempt to recreate tables if missing
                    if (function_exists('vmo_create_plugin_tables')) {
                        vmo_create_plugin_tables();
                    }

                    // Retry Insert after schema fix
                    // phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery, WordPress.DB.DirectDatabaseQuery.NoCaching
                    $minimal_insert = $wpdb->insert($table_name, array(
                        'user_id' => $user_id,
                        'phone_number' => $final_number,
                        'twilio_sid' => $final_sid,
                        'status' => 'active',
                        'provisioned_at' => current_time('mysql'),
                        'settings' => $settings
                    ));

                    if (!$minimal_insert) {
                        // If it fails again, log it but don't fatal crash - rely on User Meta backup
                        error_log("Database Fatal: Schema mismatch persist. Phone saved to User Meta only.");
                    }
                }
            }

            // Step 6: Auto-inject phone into website pages
            // WRAP IN TRY CATCH TO PREVENT CRASH
            try {
                $this->inject_phone_into_website($user_id, $final_number);
            } catch (Throwable $e) {
                error_log('VMO Warning: Website injection failed: ' . $e->getMessage());
            }

            // Step 7: Send welcome SMS to business owner
            // WRAP IN TRY CATCH
            try {
                $this->send_welcome_sms($user_id, $final_number);
            } catch (Throwable $e) {
                error_log('VMO Warning: SMS failed: ' . $e->getMessage());
            }

            // Step 8: Log provisioning event
            $this->log_provisioning_event($user_id, $final_sid, 'provisioned');

            // Legacy OpenAI SIP auto-assignment is permanently disabled. The
            // active Standard and Premium phone paths both require numbers to
            // stay on the Voice URL webhook so the plugin can choose the
            // standard Gather flow or emit signed WebSocket bridge TwiML.

            if (ob_get_length())
                ob_clean(); // Clean before success
            wp_send_json_success(array(
                'number' => $final_number,
                'formatted' => $this->format_phone_number($final_number),
                'ai_agent_id' => $ai_agent_sid,
                'status' => 'active',
                'message' => 'Phone number provisioned successfully!'
            ));

        } catch (Throwable $t) {
            // CATCH ALL FATAL ERRORS TO PREVENT HTML OUTPUT
            error_log('VMO Critical Auto Provision Validation Error: ' . $t->getMessage());

            // Refund activation tokens on failure
            if (isset($activation_cost) && $activation_cost > 0 && isset($user_id) && $user_id > 0) {
                if (function_exists('vmo_add_tokens')) {
                    vmo_add_tokens($user_id, $activation_cost, 'phone_activation_refund', 'Refund: Phone provisioning failed - ' . $t->getMessage());
                } else {
                    $current = (int) get_user_meta($user_id, 'vmo_token_balance', true);
                    update_user_meta($user_id, 'vmo_token_balance', $current + $activation_cost);
                }
                error_log("VMO: Refunded {$activation_cost} tokens to user {$user_id} after provisioning failure.");
            }

            // Ensure any previous output is cleared
            if (ob_get_length())
                ob_clean();

            wp_send_json_error(array(
                'message' => 'System error during provisioning: ' . $t->getMessage(),
                'code' => $t->getCode()
            ));
        } catch (Exception $e) {
            error_log('VMO Twilio Provisioning Error: ' . $e->getMessage());

            // Refund activation tokens on failure
            if (isset($activation_cost) && $activation_cost > 0 && isset($user_id) && $user_id > 0) {
                if (function_exists('vmo_add_tokens')) {
                    vmo_add_tokens($user_id, $activation_cost, 'phone_activation_refund', 'Refund: Phone provisioning failed - ' . $e->getMessage());
                } else {
                    $current = (int) get_user_meta($user_id, 'vmo_token_balance', true);
                    update_user_meta($user_id, 'vmo_token_balance', $current + $activation_cost);
                }
                error_log("VMO: Refunded {$activation_cost} tokens to user {$user_id} after provisioning failure.");
            }

            if (ob_get_length())
                ob_clean();
            wp_send_json_error(array(
                'message' => 'Could not provision phone number. Please try again.',
                'error' => $e->getMessage()
            ));
        }
    }

    /**
     * Provision a business phone number for non-AJAX callers such as the
     * consultation and website generation flows.
     *
     * This reuses the same Twilio manager backend as dashboard provisioning,
     * but returns structured arrays instead of terminating with JSON output.
     */
    public function provision_business_phone_number($user_id, array $responses = array(), array $options = array())
    {
        try {
            $user_id = intval($user_id);
            if ($user_id <= 0) {
                return array(
                    'success' => false,
                    'error' => 'User not authenticated.'
                );
            }

            $plan_check = $this->user_has_active_plan($user_id);
            if ($plan_check !== true) {
                return array(
                    'success' => false,
                    'error' => is_string($plan_check) ? $plan_check : 'An active plan is required for phone provisioning.',
                    'requires_plan' => true
                );
            }

            $replace_existing = !empty($options['replace_existing']);
            $existing_phone = $this->get_user_phone($user_id);
            $max_allowed = (int) get_user_meta($user_id, 'vmo_allowed_phone_count', true);
            if ($max_allowed < 1) {
                $max_allowed = 1;
            }

            if ($existing_phone && !$replace_existing && $max_allowed <= 1) {
                return array(
                    'success' => true,
                    'already_provisioned' => true,
                    'phone_number' => $existing_phone,
                    'formatted' => $this->format_phone_number($existing_phone),
                    'status' => 'active',
                    'message' => 'Business phone number already active.'
                );
            }

            $business_name = '';
            foreach (array(
                $options['business_name'] ?? null,
                $responses['business_name'] ?? null,
                $responses['company_name'] ?? null,
                get_user_meta($user_id, 'business_name', true),
                get_user_meta($user_id, 'company_name', true),
                get_bloginfo('name')
            ) as $candidate) {
                if (is_string($candidate) && trim($candidate) !== '') {
                    $business_name = trim($candidate);
                    break;
                }
            }

            $business_location = '';
            foreach (array(
                $options['business_location'] ?? null,
                $responses['business_location'] ?? null,
                $responses['location'] ?? null,
                get_user_meta($user_id, 'business_location', true)
            ) as $candidate) {
                if (is_string($candidate) && trim($candidate) !== '') {
                    $business_location = trim($candidate);
                    break;
                }
            }

            $country_code = strtoupper(sanitize_text_field((string) (
                $options['country']
                ?? $responses['country_code']
                ?? $responses['country']
                ?? ''
            )));
            if ($country_code === '') {
                $country_code = strtoupper((string) $this->extract_country_code($business_location));
            }
            if ($country_code === '') {
                $country_code = 'US';
            }

            $area_code = sanitize_text_field((string) (
                $options['area_code']
                ?? $responses['area_code']
                ?? ''
            ));
            if ($area_code === '') {
                $area_code = (string) $this->extract_area_code($business_location);
            }
            $area_code = $area_code !== '' ? preg_replace('/\D+/', '', $area_code) : null;

            $selected_number = sanitize_text_field((string) (
                $options['selected_number']
                ?? $responses['selected_number']
                ?? $responses['requested_phone_number']
                ?? ''
            ));
            $selected_number = $selected_number !== '' ? $selected_number : null;

            if (!user_can($user_id, 'manage_options')) {
                $biz_country_check = $this->extract_country_code($business_location);
                if (!$biz_country_check) {
                    $biz_country_check = 'US';
                }

                if ($country_code !== strtoupper((string) $biz_country_check)) {
                    return array(
                        'success' => false,
                        'error' => sprintf(
                            'Security Restriction: You cannot provision a phone number for %s because your verified business address is in %s. Please contact support or update your business profile.',
                            $country_code,
                            strtoupper((string) $biz_country_check)
                        )
                    );
                }

                $billing_country = strtoupper((string) get_user_meta($user_id, 'billing_country', true));
                if ($billing_country !== '' && $billing_country !== $country_code) {
                    return array(
                        'success' => false,
                        'error' => sprintf(
                            'Fraud Prevention: Your payment method comes from (%s), but you are trying to register a number in (%s). For security, these must match. Please contact support.',
                            $billing_country,
                            $country_code
                        )
                    );
                }
            }

            if ($this->simulation_mode) {
                $fake_number = $selected_number ? $selected_number : ('+1' . ($area_code ? $area_code : '555') . rand(1000000, 9999999));
                update_user_meta($user_id, 'vmo_phone_number', $fake_number);
                update_user_meta($user_id, 'vmo_twilio_phone_number', $fake_number);
                update_user_meta($user_id, 'business_phone', $fake_number);
                update_user_meta($user_id, 'vmo_business_phone', $fake_number);
                update_user_meta($user_id, 'vmo_phone_sid', 'PN_' . uniqid('sim_'));
                update_user_meta($user_id, 'vmo_phone_activation_date', current_time('mysql'));

                return array(
                    'success' => true,
                    'phone_number' => $fake_number,
                    'formatted' => $this->format_phone_number($fake_number),
                    'status' => 'active',
                    'is_simulation' => true,
                    'message' => 'Phone number provisioned (simulation mode).'
                );
            }

            if (!$selected_number) {
                $available_numbers = $this->search_available_numbers($country_code, $area_code);
                if (empty($available_numbers)) {
                    return array(
                        'success' => false,
                        'error' => 'No phone numbers available in your area.'
                    );
                }

                $selected_number = $available_numbers[0]->phoneNumber ?? '';
                if ($selected_number === '') {
                    return array(
                        'success' => false,
                        'error' => 'Unable to resolve an available phone number for provisioning.'
                    );
                }
            }

            if ($existing_phone && $selected_number === $existing_phone) {
                return array(
                    'success' => true,
                    'already_provisioned' => true,
                    'phone_number' => $existing_phone,
                    'formatted' => $this->format_phone_number($existing_phone),
                    'status' => 'active',
                    'message' => 'Business phone number already active.'
                );
            }

            if ($existing_phone && $replace_existing) {
                global $wpdb;
                $table_name = $wpdb->prefix . 'vmo_phone_numbers';
                $old_entry = $wpdb->get_row($wpdb->prepare(
                    "SELECT * FROM $table_name WHERE user_id = %d AND status = 'active' LIMIT 1",
                    $user_id
                ));

                if ($old_entry && !empty($old_entry->twilio_sid)) {
                    $del_res = $this->twilio_request('DELETE', "/IncomingPhoneNumbers/{$old_entry->twilio_sid}.json");
                    if (is_wp_error($del_res)) {
                        error_log('VMO Warning: Failed to release old number ' . $old_entry->phone_number . ' from Twilio: ' . $del_res->get_error_message());
                    }
                }

                $wpdb->update(
                    $table_name,
                    array(
                        'status' => 'released',
                        'released_at' => current_time('mysql')
                    ),
                    array(
                        'user_id' => $user_id,
                        'status' => 'active'
                    )
                );
            }

            $existing_owned = $this->twilio_request('GET', '/IncomingPhoneNumbers.json', array(
                'PhoneNumber' => $selected_number
            ));
            $phone_number_data = null;
            $owned_list = $this->get_twilio_prop($existing_owned, 'incoming_phone_numbers');

            $host = parse_url(get_home_url(), PHP_URL_HOST);
            $host = str_replace('www.', '', (string) $host);
            $custom_label = sanitize_text_field((string) ($options['phone_label'] ?? ''));
            if ($custom_label !== '') {
                $friendly_id = substr($custom_label, 0, 60);
            } else {
                $clean_biz_name = preg_replace('/[^A-Za-z0-9 ]/', '', $business_name);
                $clean_biz_name = trim(substr((string) $clean_biz_name, 0, 30));
                $friendly_id = $clean_biz_name !== '' ? "{$clean_biz_name} ({$host})" : $host;
            }

            if (!is_wp_error($existing_owned) && !empty($owned_list)) {
                $phone_number_data = $owned_list[0];
                $sid = $this->get_twilio_prop($phone_number_data, 'sid');
                $this->twilio_request('POST', "/IncomingPhoneNumbers/{$sid}.json", array(
                    'VoiceUrl' => $this->phone_gateway_url('voice', array('uid' => $user_id)),
                    'SmsUrl' => $this->twilio_public_url('sms_webhook'),
                    'StatusCallback' => $this->twilio_public_url('status_callback'),
                    'StatusCallbackMethod' => 'POST',
                    'FriendlyName' => $friendly_id
                ));
            } else {
                $phone_number_data = $this->twilio_request('POST', '/IncomingPhoneNumbers.json', array(
                    'PhoneNumber' => $selected_number,
                    'FriendlyName' => $friendly_id,
                    'VoiceUrl' => $this->phone_gateway_url('voice', array('uid' => $user_id)),
                    'SmsUrl' => $this->twilio_public_url('sms_webhook'),
                    'StatusCallback' => $this->twilio_public_url('status_callback'),
                    'VoiceMethod' => 'POST',
                    'SmsMethod' => 'POST'
                ));
            }

            if (is_wp_error($phone_number_data)) {
                return array(
                    'success' => false,
                    'error' => $phone_number_data->get_error_message()
                );
            }

            $final_number = $this->get_twilio_prop($phone_number_data, 'phone_number');
            $final_sid = $this->get_twilio_prop($phone_number_data, 'sid');
            if (!$final_number || !$final_sid) {
                error_log('VMO Critical: Phone number or SID missing from Twilio response: ' . print_r($phone_number_data, true));
                return array(
                    'success' => false,
                    'error' => 'Invalid response from Phone Provider.'
                );
            }

            $ai_agent = $this->create_ai_agent($user_id, $business_name !== '' ? $business_name : get_bloginfo('name'), $final_number);
            $ai_agent_sid = (is_object($ai_agent) && isset($ai_agent->sid)) ? $ai_agent->sid : '';
            if (!empty($ai_agent_sid)) {
                update_user_meta($user_id, 'twilio_ai_agent_sid', $ai_agent_sid);
            }

            global $wpdb;
            $table_name = $wpdb->prefix . 'vmo_phone_numbers';
            $settings = json_encode(array(
                'twilio_ai_agent_sid' => $ai_agent_sid,
                'monthly_cost' => 29.00,
                'next_billing_date' => date('Y-m-d H:i:s', strtotime('+1 month'))
            ));

            $inserted = $wpdb->insert($table_name, array(
                'user_id' => $user_id,
                'phone_number' => $final_number,
                'twilio_sid' => $final_sid,
                'status' => 'active',
                'provisioned_at' => current_time('mysql'),
                'settings' => $settings
            ));

            update_user_meta($user_id, 'vmo_phone_number', $final_number);
            update_user_meta($user_id, 'vmo_twilio_phone_number', $final_number);
            update_user_meta($user_id, 'vmo_phone_sid', $final_sid);
            update_user_meta($user_id, 'business_phone', $final_number);
            update_user_meta($user_id, 'vmo_business_phone', $final_number);
            update_user_meta($user_id, 'vmo_phone_activation_date', current_time('mysql'));

            if ($inserted === false) {
                $existing = $wpdb->get_row($wpdb->prepare(
                    "SELECT * FROM $table_name WHERE phone_number = %s",
                    $final_number
                ));

                if ($existing) {
                    $updated = $wpdb->update(
                        $table_name,
                        array(
                            'status' => 'active',
                            'user_id' => $user_id,
                            'twilio_sid' => $final_sid,
                            'provisioned_at' => current_time('mysql'),
                            'settings' => $settings
                        ),
                        array(
                            'phone_number' => $final_number
                        )
                    );
                    if ($updated === false) {
                        return array(
                            'success' => false,
                            'error' => 'Database Critical: Could not update existing phone record.'
                        );
                    }
                } else {
                    if (function_exists('vmo_create_plugin_tables')) {
                        vmo_create_plugin_tables();
                    }

                    $minimal_insert = $wpdb->insert($table_name, array(
                        'user_id' => $user_id,
                        'phone_number' => $final_number,
                        'twilio_sid' => $final_sid,
                        'status' => 'active',
                        'provisioned_at' => current_time('mysql'),
                        'settings' => $settings
                    ));

                    if (!$minimal_insert) {
                        error_log('Database Fatal: Schema mismatch persists. Phone saved to user meta only.');
                    }
                }
            }

            try {
                $this->inject_phone_into_website($user_id, $final_number);
            } catch (\Throwable $e) {
                error_log('VMO Warning: Website injection failed: ' . $e->getMessage());
            }

            try {
                $this->send_welcome_sms($user_id, $final_number);
            } catch (\Throwable $e) {
                error_log('VMO Warning: SMS failed: ' . $e->getMessage());
            }

            $this->log_provisioning_event($user_id, $final_sid, 'provisioned');

            return array(
                'success' => true,
                'phone_number' => $final_number,
                'formatted' => $this->format_phone_number($final_number),
                'sid' => $final_sid,
                'ai_agent_id' => $ai_agent_sid,
                'status' => 'active',
                'message' => 'Phone number provisioned successfully!'
            );
        } catch (\Throwable $e) {
            error_log('VMO Consultation Phone Provisioning Error: ' . $e->getMessage());
            return array(
                'success' => false,
                'error' => $e->getMessage()
            );
        }
    }

    /**
     * Return all active phone numbers for a user in newest-first order.
     */
    public function get_all_active_phone_numbers($user_id)
    {
        $user_id = intval($user_id);
        if ($user_id <= 0) {
            return array();
        }

        global $wpdb;
        $table_name = $wpdb->prefix . 'vmo_phone_numbers';
        $phone_col = $this->phone_numbers_phone_column();

        $rows = $wpdb->get_col($wpdb->prepare(
            "SELECT {$phone_col} FROM {$table_name} WHERE user_id = %d AND status IN ('active', 'pending_port') AND {$phone_col} != '' ORDER BY created_at DESC",
            $user_id
        ));

        $numbers = array();
        foreach ((array) $rows as $phone) {
            $phone = is_string($phone) ? trim($phone) : '';
            if ($phone !== '' && !in_array($phone, $numbers, true)) {
                $numbers[] = $phone;
            }
        }

        if (empty($numbers)) {
            $fallback = $this->get_user_phone($user_id);
            if (is_string($fallback) && trim($fallback) !== '') {
                $numbers[] = trim($fallback);
            }
        }

        return $numbers;
    }

    /**
     * Create Twilio AI Agent for user
     */
    private function create_ai_agent($user_id, $business_name, $phone_number)
    {
        $instructions = $this->generate_receptionist_instructions($user_id);

        $args = array(
            'FriendlyName' => $business_name . ' AI Receptionist',
            'Voice' => $this->tts_voice(), // Dynamic multilingual voice
            'Instructions' => $instructions,
            'Tools' => json_encode(array(
                array(
                    'type' => 'function',
                    'function' => array(
                        'name' => 'get_business_context',
                        'description' => 'Fetches current business information, services, pricing, and availability',
                        'parameters' => array(
                            'type' => 'object',
                            'properties' => array(
                                'user_id' => array(
                                    'type' => 'integer',
                                    'description' => 'The VMO user ID'
                                )
                            ),
                            'required' => array('user_id')
                        ),
                        'url' => $this->public_rest_url("/vmo/v1/business-context/{$user_id}"),
                        'method' => 'GET'
                    )
                ),
                array(
                    'type' => 'function',
                    'function' => array(
                        'name' => 'create_booking',
                        'description' => 'Creates an appointment booking for the caller',
                        'parameters' => array(
                            'type' => 'object',
                            'properties' => array(
                                'customer_name' => array('type' => 'string'),
                                'customer_phone' => array('type' => 'string'),
                                'service' => array('type' => 'string'),
                                'date' => array('type' => 'string'),
                                'time' => array('type' => 'string')
                            ),
                            'required' => array('customer_name', 'customer_phone', 'service', 'date', 'time')
                        ),
                        'url' => $this->public_rest_url('/vmo/v1/bookings'),
                        'method' => 'POST'
                    )
                ),
                array(
                    'type' => 'function',
                    'function' => array(
                        'name' => 'send_sms_followup',
                        'description' => 'Sends SMS with business information to the caller',
                        'parameters' => array(
                            'type' => 'object',
                            'properties' => array(
                                'to_number' => array('type' => 'string'),
                                'message' => array('type' => 'string')
                            ),
                            'required' => array('to_number', 'message')
                        ),
                        'url' => $this->public_rest_url('/vmo/v1/sms-followup'),
                        'method' => 'POST'
                    )
                )
            ))
        );

        // Making request to AI Assistants API (Alpha/Beta)
        // Adjust endpoint as needed if Twilio changes path
        $response = $this->twilio_request('POST', '/Assistants', $args, 'https://assistants.twilio.com/v1');

        if (is_wp_error($response)) {
            error_log('VMO AI Agent Creation Failed: ' . $response->get_error_message());
            // CRITICAL FIX: Do NOT return a dummy SID. Return null so the system falls back
            // to standard voicemail instead of crashing the call with an invalid Assistant ID.
            return null;
        }

        $obj = new stdClass();
        $obj->sid = isset($response['sid']) ? $response['sid'] : null;
        return $obj;
    }

    /**
     * Generate AI receptionist instructions based on business info
     */
    private function generate_receptionist_instructions($user_id)
    {
        $business_name = get_user_meta($user_id, 'business_name', true) ?: 'our business';
        $business_type = get_user_meta($user_id, 'industry_type', true) ?: '';
        $website = get_user_meta($user_id, 'website_url', true) ?: home_url('/');
        $domain = parse_url($website, PHP_URL_HOST) ?: $website;
        $language_policy = $this->receptionist_language_policy();

        $type_str = $business_type ? ", a {$business_type} business" : '';
        $is_vmo_brand = function_exists('vmo_platform_faq_should_ground_current_site')
            ? vmo_platform_faq_should_ground_current_site((string) $business_name, (string) $website)
            : $this->is_vmo_brand_context((string) $business_name, (string) $website);
        $identity_rules = $is_vmo_brand
            ? "YOUR IDENTITY: You are a professional VMO support staff member. You speak for VMO/Virtual Media Office in first person plural as our team. You may discuss our plugin, our platform, our plans, our hosted option, our direct WordPress option, and our capabilities using the FAQ source of truth."
            : "YOUR IDENTITY: You ARE {$business_name}. You speak as an employee of this business. NEVER mention VMO, Virtual Media Office, or any platform. NEVER list AI features as services. You represent the business and its actual products/services only.";
        $platform_faq_context = ($is_vmo_brand && function_exists('vmo_get_platform_faq_grounding_text'))
            ? "\n" . vmo_get_platform_faq_grounding_text('twilio_assistant_receptionist', 8500) . "\n"
            : '';
        $instructions = "You are the professional phone receptionist for {$business_name}{$type_str}.
{$identity_rules}

    LANGUAGE: {$language_policy} Fluent in all global languages.
{$platform_faq_context}

IMPORTANT: When a call comes in, IMMEDIATELY use the get_business_context function to fetch current business information from {$domain}.

Your responsibilities:
1. Greet callers warmly in English when you speak first, then match the caller's language after they speak.
2. Answer questions about the business's actual services, products, pricing, and hours.
3. TAKE ORDERS & RESERVATIONS using the create_booking function.
4. HANDLE EXTENSIONS: If the user asks for a department, acknowledge and look up specific info.
5. Provide directions and contact information.
6. Handle common inquiries using business context data.
7. Send SMS follow-ups when requested using send_sms_followup.

Communication style:
- Professional, warm, and human-sounding. Use contractions.
- Always finish your complete thought before stopping — never cut yourself off mid-sentence. Speak naturally: usually a few sentences, but take the space you need to fully answer and give every detail the caller needs, then invite the next question.
- Never make up information — use the business context data.
- If you don't have information, offer to take a message or have the team follow up.

For Orders/Appointments:
- Collect: name, phone, specific items/service, date/time.
- Confirm details clearly and create the booking.

Remember: You represent {$business_name}. Never reveal you are AI unless directly asked.";

        return $instructions;
    }

    /**
     * Search for available phone numbers
     */
    private function search_available_numbers($country_code = 'US', $area_code = null)
    {
        try {
            $args = array(
                'PageSize' => 5,
                'VoiceEnabled' => 'true',
                'SmsEnabled' => 'true'
            );

            if ($area_code) {
                $args['AreaCode'] = $area_code;
            }

            $endpoint = "/AvailablePhoneNumbers/{$country_code}/Local.json";

            $response = $this->twilio_request('GET', $endpoint, $args);

            if (is_wp_error($response)) {
                error_log('VMO Phone Search Error: ' . $response->get_error_message());
                return array();
            }

            // Format to match old SDK object structure (simulated objects)
            $formatted = array();
            if (!empty($response['available_phone_numbers'])) {
                foreach ($response['available_phone_numbers'] as $num) {
                    $obj = new stdClass();
                    $obj->phoneNumber = $num['phone_number'];
                    $obj->friendlyName = $num['friendly_name'];
                    $formatted[] = $obj;
                }
            }

            return $formatted;

        } catch (Exception $e) {
            error_log('VMO Phone Search Error: ' . $e->getMessage());
            return array();
        }
    }

    /**
     * Inject phone number into user's website
     */
    private function inject_phone_into_website($user_id, $phone_number)
    {
        $formatted = $this->format_phone_number($phone_number);

        // Save to user meta
        update_user_meta($user_id, 'business_phone', $phone_number);
        update_user_meta($user_id, 'business_phone_formatted', $formatted);
        update_user_meta($user_id, 'show_phone_in_header', true);

        // Find contact page and inject phone
        $args = array(
            'post_type' => 'page',
            'author' => $user_id,
            'meta_key' => '_vmo_page_type',
            'meta_value' => 'contact',
            'posts_per_page' => 1
        );

        $pages = get_posts($args);

        if (!empty($pages)) {
            $page = $pages[0];
            $content = $page->post_content;

            // Create phone section HTML
            $phone_html = "\n\n" . '<!-- VMO Business Phone -->
<div class="vmo-business-phone" style="background: #f8f9fa; padding: 30px; border-radius: 10px; margin: 30px 0; text-align: center;">
    <div style="font-size: 48px; margin-bottom: 10px;">📞</div>
    <h3 style="margin: 10px 0; color: #333;">Call Us Now</h3>
    <a href="tel:' . esc_attr($phone_number) . '" style="font-size: 32px; font-weight: bold; color: #0066cc; text-decoration: none; display: inline-block; margin: 10px 0;">' . esc_html($formatted) . '</a>
    <p style="color: #666; margin: 10px 0;"><em>AI-powered receptionist available 24/7</em></p>
    <p style="color: #999; font-size: 14px;">We\'re here to help! Call anytime.</p>
</div>
<!-- /VMO Business Phone -->' . "\n\n";

            // Insert before contact form or at the beginning
            if (strpos($content, '[contact') !== false) {
                $content = preg_replace('/(\[contact[^\]]*\])/', $phone_html . '$1', $content, 1);
            } else {
                $content = $phone_html . $content;
            }

            // Update page
            wp_update_post(array(
                'ID' => $page->ID,
                'post_content' => $content
            ));
        }
    }

    /**
     * Send welcome SMS to business owner
     */
    private function send_welcome_sms($user_id, $from_number)
    {
        $owner_phone = get_user_meta($user_id, 'owner_phone', true);
        $business_name = get_user_meta($user_id, 'business_name', true);

        if (!$owner_phone) {
            return;
        }

        try {
            $message = "🎉 {$business_name} - Your AI business phone is ready!\n\n" .
                "📞 Number: {$this->format_phone_number($from_number)}\n\n" .
                "Your AI receptionist is now active 24/7, answering calls professionally with your business information.\n\n" .
                "Test it: Call your new number!\n\n" .
                "- VMO Team";

            $this->twilio_request('POST', '/Messages.json', array(
                'To' => $owner_phone,
                'From' => $from_number,
                'Body' => $message
            ));

            // ✅ TOKEN DEDUCTION: Charge for outbound SMS
            if (class_exists('VMO_Twilio_Token_Economics')) {
                VMO_Twilio_Token_Economics::get_instance()->deduct_sms_outbound($user_id);
            }

        } catch (Exception $e) {
            error_log('VMO Welcome SMS Error: ' . $e->getMessage());
        }
    }

    /**
     * Get business context for Twilio AI agent (REST API endpoint)
     */
    public function get_business_context($request)
    {
        $user_id = $request['user_id'];

        // Fetch all business information
        $business_name = get_user_meta($user_id, 'business_name', true);
        $business_description = get_user_meta($user_id, 'business_description', true);
        $industry_type = get_user_meta($user_id, 'industry_type', true);
        $business_hours = get_user_meta($user_id, 'business_hours', true);
        $business_address = get_user_meta($user_id, 'business_address', true);
        $website_url = get_user_meta($user_id, 'website_url', true);
        if (empty($website_url)) {
            $website_url = home_url('/');
        }
        $user_data = get_userdata($user_id);

        $context = array(
            'business_name' => $business_name,
            'business_description' => $business_description,
            'industry_type' => $industry_type,
            'business_hours' => $business_hours,
            'business_address' => $business_address,
            'services' => $this->get_user_services($user_id),
            'pricing' => $this->get_user_pricing($user_id),
            'faq' => $this->get_user_faq($user_id),
            'booking_availability' => $this->get_booking_availability($user_id),
            'website_url' => $website_url,
            'email' => $user_data ? $user_data->user_email : ''
        );

        $is_vmo_brand = function_exists('vmo_platform_faq_should_ground_current_site')
            ? vmo_platform_faq_should_ground_current_site((string) $business_name, (string) $website_url)
            : $this->is_vmo_brand_context((string) $business_name, (string) $website_url);
        if ($is_vmo_brand && function_exists('vmo_get_platform_faq_grounding_text')) {
            $context['vmo_faq_source_of_truth'] = vmo_get_platform_faq_grounding_text('twilio_business_context', 9000);
        }

        // Include cached website content if available
        $cache_key = 'vmo_site_scan_' . md5($website_url) . '_' . $user_id;
        $cached_site = get_transient($cache_key);
        if ($cached_site) {
            $context['website_content'] = $cached_site;
        }

        // Include store products
        global $wpdb;
        $store_table = $wpdb->prefix . 'vmo_store_products';
        if ($wpdb->get_var("SHOW TABLES LIKE '{$store_table}'") === $store_table) {
            $products = $wpdb->get_results($wpdb->prepare(
                "SELECT name, description, price_cents, currency, product_type, stock_qty FROM {$store_table} WHERE user_id = %d AND status = 'active' ORDER BY name ASC LIMIT 30",
                $user_id
            ));
            if (!empty($products)) {
                $catalog = array();
                foreach ($products as $p) {
                    $catalog[] = array(
                        'name' => $p->name,
                        'price' => number_format($p->price_cents / 100, 2),
                        'currency' => strtoupper($p->currency ?: 'USD'),
                        'type' => $p->product_type,
                        'description' => wp_strip_all_tags($p->description ?: ''),
                        'in_stock' => ($p->stock_qty > 0 || $p->stock_qty === null),
                    );
                }
                $context['products'] = $catalog;
            }
        }

        return new WP_REST_Response($context, 200);
    }

    /**
     * Create booking (REST API endpoint)
     */
    public function create_booking($request)
    {
        $params = $request->get_json_params();

        // Save booking to database
        global $wpdb;
        $table_name = $wpdb->prefix . 'vmo_bookings';

        // phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery, WordPress.DB.DirectDatabaseQuery.NoCaching
        $wpdb->insert($table_name, array(
            'user_id' => $request['user_id'],
            'customer_name' => sanitize_text_field($params['customer_name']),
            'customer_phone' => sanitize_text_field($params['customer_phone']),
            'service' => sanitize_text_field($params['service']),
            'booking_date' => sanitize_text_field($params['date']),
            'booking_time' => sanitize_text_field($params['time']),
            'status' => 'confirmed',
            'source' => 'phone_ai',
            'created_at' => current_time('mysql')
        ));

        $booking_id = $wpdb->insert_id;

        // Send confirmation email
        $this->send_booking_confirmation($booking_id);

        return new WP_REST_Response(array(
            'success' => true,
            'booking_id' => $booking_id,
            'message' => 'Booking created successfully'
        ), 200);
    }

    /**
     * Send SMS follow-up (REST API endpoint)
     */
    public function send_sms_followup($request)
    {
        $params = $request->get_json_params();

        $user_id = $request['user_id'];
        $phone_data = $this->get_user_phone($user_id);
        $from_number = is_object($phone_data) ? (string) ($phone_data->phone_number ?? '') : (string) $phone_data;
        $to_number = sanitize_text_field((string) ($params['to_number'] ?? ''));
        $message_body = sanitize_textarea_field((string) ($params['message'] ?? ''));

        if ($from_number === '') {
            return new WP_REST_Response(array('error' => 'No phone number found'), 400);
        }

        if ($to_number === '' || $message_body === '') {
            return new WP_REST_Response(array('error' => 'Recipient and message are required'), 400);
        }

        // ✅ TOKEN CHECK: Verify balance before sending SMS
        if (class_exists('VMO_Twilio_Token_Economics')) {
            $econ = VMO_Twilio_Token_Economics::get_instance();
            if (!$econ->can_send_sms($user_id)) {
                return new WP_REST_Response(array('error' => 'Insufficient tokens to send SMS'), 402);
            }
        }

        try {
            $response = $this->twilio_request('POST', '/Messages.json', array(
                'To' => $to_number,
                'From' => $from_number,
                'Body' => $message_body,
                'StatusCallback' => $this->phone_gateway_url('status')
            ));

            if (is_wp_error($response))
                throw new Exception($response->get_error_message());

            // ✅ TOKEN DEDUCTION: Charge for outbound SMS
            if (class_exists('VMO_Twilio_Token_Economics')) {
                VMO_Twilio_Token_Economics::get_instance()->deduct_sms_outbound($user_id);
            }

            $this->log_sms_message(
                $user_id,
                sanitize_text_field((string) ($response['sid'] ?? '')),
                $from_number,
                $to_number,
                'outbound',
                $message_body,
                sanitize_text_field((string) ($response['status'] ?? 'queued'))
            );

            return new WP_REST_Response(array('success' => true), 200);

        } catch (Exception $e) {
            return new WP_REST_Response(array('error' => $e->getMessage()), 500);
        }
    }

    /**
     * Handle incoming voice call webhook from Twilio
     * 
     * Called when a call comes in to the Twilio number
     * Logs the call to database and returns TwiML for call handling
     */
    public function handle_voice_webhook($request)
    {
        // Ensure no previous output
        while (ob_get_level())
            ob_end_clean();
        ob_start();

        $call_sid = '';
        $from = '';
        $to = '';
        $call_status = 'in-progress';
        $requested_user_id = 0;
        $user_id = 0;
        $call_language = $this->normalize_call_language_context(null);

        try {
            $params = $request->get_params();

            // Log EVERYTHING for debugging
            $log_prefix = '[VMO Voice Webhook] ';
            error_log($log_prefix . 'Params: ' . print_r($params, true));

            // Extract Twilio call data
            $call_sid = isset($params['CallSid']) ? sanitize_text_field($params['CallSid']) : '';
            $from = isset($params['From']) ? sanitize_text_field($params['From']) : '';
            $to = isset($params['To']) ? sanitize_text_field($params['To']) : '';
            $call_status = isset($params['CallStatus']) ? sanitize_text_field($params['CallStatus']) : 'in-progress';
            $premium_bridge_fallback = (isset($params['premium_bridge_fallback']) && (string) $params['premium_bridge_fallback'] === '1');

            if (empty($call_sid)) {
                // Return 400 Bad Request
                status_header(400);
                return 'Missing CallSid';
            }

            // Tag this CallSid as the customer-side caller_parent leg. The
            // status-callback handler relies on this role to know that a
            // 'completed' status on this SID — and only this SID — should
            // end the full call. Child legs (ring-out, premium AI bridge,
            // listen, etc.) get their own roles when they are created.
            $this->register_call_leg_role($call_sid, 'caller_parent', $call_sid);

            try {
                $this->set_owner_alert_state($call_sid, false);
            } catch (Throwable $e) {
                error_log($log_prefix . 'Initial owner alert hold failed for ' . $call_sid . ': ' . $e->getMessage());
            }

            $requested_user_id = isset($params['uid']) ? intval($params['uid']) : 0;
            $user_id = 0;
            if ($requested_user_id > 0) {
                $bound_phone = (string) ($this->get_user_phone($requested_user_id) ?: '');
                if ($bound_phone !== '' && $this->normalize_live_call_phone($bound_phone) === $this->normalize_live_call_phone($to)) {
                    $user_id = $requested_user_id;
                    error_log($log_prefix . 'Using uid-bound user ' . $user_id . ' for inbound call to ' . $to);
                } else {
                    error_log($log_prefix . 'Ignoring mismatched uid=' . $requested_user_id . ' for To=' . $to . ' bound_phone=' . $bound_phone);
                }
            }

            // Validate User ID Lookup
            if (!$user_id) {
                $user_id = $this->get_user_id_from_phone($to);
                error_log($log_prefix . "Lookup for {$to} returned User ID: " . var_export($user_id, true));
            }

            if (!$user_id) {
                // If checking +1 failed, try without formatted country code if US
                if (strpos($to, '+1') === 0) {
                    $alt_to = substr($to, 2);
                    $user_id = $this->get_user_id_from_phone($alt_to);
                    error_log($log_prefix . "Retry lookup for {$alt_to} returned User ID: " . var_export($user_id, true));
                }
            }

            if (!$user_id) {
                error_log($log_prefix . 'CRITICAL: User ID not found. Attempting Admin fallback.');
                // MASTER ACCOUNT FALLBACK: Route to the site's primary administrator
                $admins = get_users(array('role' => 'administrator', 'number' => 1, 'orderby' => 'ID', 'order' => 'ASC'));
                $admin_id = !empty($admins) ? $admins[0]->ID : 1;
                $user_id = $admin_id;
                error_log($log_prefix . "Routing unmatched call to Admin ID: {$user_id}");
            }

            // Service-lock guard: only block when the call resolves to a REAL bound user.
            // Skip when $user_id is the admin fallback (catch-all routing for unmatched numbers),
            // otherwise a single billing flag on the admin account silences EVERY inbound call.
            $is_admin_fallback = empty($requested_user_id) && ($user_id > 0)
                && user_can($user_id, 'manage_options');
            if (!$is_admin_fallback && $user_id > 0 && function_exists('vmo_is_user_service_locked') && vmo_is_user_service_locked($user_id)) {
                error_log($log_prefix . 'Rejecting inbound call for service-locked user ' . $user_id . ' CallSid=' . $call_sid);
                while (ob_get_level())
                    ob_end_clean();
                status_header(200);
                header('Content-Type: text/xml; charset=utf-8');
                // CRITICAL: must echo + exit. The AJAX dispatcher in
                // class-vmo-website-ai-assistant.php discards any value returned from
                // handle_voice_webhook() and immediately exits, which would leave Twilio
                // with an empty response body ("application error"). Echo the TwiML
                // directly to the wire and terminate the request.
                echo '<?xml version="1.0" encoding="UTF-8"?><Response><Reject reason="busy" /></Response>';
                exit;
            }

            // OPTION C DIAGNOSTIC: trace webhook entry with routing-source marker.
            $webhook_source = ($requested_user_id > 0) ? 'voice_url_uid' : 'lookup_or_disaster_recovery';
            $is_canonical_realtime_source = ($webhook_source === 'voice_url_uid');
            $this->trace_voice_call($call_sid, 'webhook_started source=' . $webhook_source . ' requested_uid=' . $requested_user_id . ' user_id=' . $user_id . ' to=' . $to . ' from=' . $from . ' premium_bridge_fallback=' . ($premium_bridge_fallback ? '1' : '0') . ' codever=2026-04-24-trace-v2');
            $call_language = $this->infer_initial_call_language($params, $from);
            $this->store_call_language_context($call_sid, $call_language);
            $initial_tts_voice = (string) ($call_language['voice'] ?? 'Polly.Joanna-Neural');
            $initial_gather_lang = (string) ($call_language['gather_lang'] ?? 'en-US');
            $initial_language_policy = $this->receptionist_language_policy($call_language);
            error_log($log_prefix . 'Initial call language for ' . $call_sid . ': code=' . ($call_language['code'] ?? 'en') . ' name=' . ($call_language['name'] ?? 'English') . ' country=' . ($call_language['country'] ?? '') . ' source=' . ($call_language['source'] ?? 'default'));


            // Log call to database
            global $wpdb;
            $table = $wpdb->prefix . 'vmo_phone_calls';

            // ── Self-healing: ensure table exists before INSERT ──
            if ($wpdb->get_var($wpdb->prepare("SHOW TABLES LIKE %s", $wpdb->esc_like($table))) !== $table) {
                $charset_collate = $wpdb->get_charset_collate();
                require_once ABSPATH . 'wp-admin/includes/upgrade.php';
                dbDelta("CREATE TABLE IF NOT EXISTS {$table} (
                id BIGINT UNSIGNED AUTO_INCREMENT PRIMARY KEY,
                user_id BIGINT UNSIGNED NOT NULL,
                call_sid VARCHAR(255) NOT NULL,
                caller_number VARCHAR(50) DEFAULT NULL,
                direction ENUM('inbound','outbound') DEFAULT 'inbound',
                status VARCHAR(50) DEFAULT 'completed',
                duration INT UNSIGNED DEFAULT 0,
                recording_url VARCHAR(2083) DEFAULT NULL,
                transcript LONGTEXT DEFAULT NULL,
                summary LONGTEXT DEFAULT NULL,
                topic VARCHAR(255) DEFAULT NULL,
                ai_handled TINYINT(1) DEFAULT 0,
                contact_id BIGINT UNSIGNED DEFAULT NULL,
                tokens_used INT UNSIGNED DEFAULT 0,
                created_at DATETIME DEFAULT CURRENT_TIMESTAMP,
                INDEX idx_user_id (user_id),
                INDEX idx_call_sid (call_sid),
                INDEX idx_status (status),
                INDEX idx_contact (contact_id)
            ) {$charset_collate};");
                error_log('VMO Twilio: Self-healed vmo_phone_calls table (was missing during call insertion)');
            } else {
                // Table exists but may be missing newer columns — heal in-place
                $existing_cols = $wpdb->get_col("SHOW COLUMNS FROM {$table}", 0);
                if (is_array($existing_cols) && !in_array('ai_handled', $existing_cols, true)) {
                    $wpdb->query("ALTER TABLE {$table} ADD COLUMN ai_handled TINYINT(1) DEFAULT 0 AFTER duration");
                    error_log('VMO Twilio: Added missing ai_handled column to vmo_phone_calls');
                }
            }

            // ── Prevent duplicate records on IVR re-entry ──
            // When a caller presses an extension digit, Twilio POSTs back to this
            // same webhook with the same CallSid + the Digits param.  Without this
            // guard the second invocation would INSERT a duplicate row, producing
            // double call-history entries and double email alerts.
            $existing_call_id = $wpdb->get_var($wpdb->prepare(
                "SELECT id FROM {$table} WHERE call_sid = %s LIMIT 1",
                $call_sid
            ));

            if ($existing_call_id) {
                $call_id = (int) $existing_call_id;
                error_log('VMO Twilio: Call record already exists for ' . $call_sid . ' (ID ' . $call_id . ') — skipping duplicate insert');
            } else {
                // phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery, WordPress.DB.DirectDatabaseQuery.NoCaching
                $result = $wpdb->insert($table, array(
                    'user_id' => $user_id,
                    'call_sid' => $call_sid,
                    'caller_number' => $from,
                    'direction' => 'inbound',
                    'status' => $call_status,
                    'ai_handled' => 1,
                    'created_at' => current_time('mysql')
                ), array('%d', '%s', '%s', '%s', '%s', '%d', '%s'));

                if ($result === false) {
                    error_log('VMO Twilio: Failed to insert call record: ' . $wpdb->last_error . ' | Table: ' . $table . ' | User: ' . $user_id);
                } else {
                    $call_id = $wpdb->insert_id;
                    error_log('VMO Twilio: Call logged successfully - CallSid: ' . $call_sid . ' ID: ' . $call_id);

                    // Link call to CRM contact (auto-create if needed)
                    if ($user_id > 0) {
                        try {
                            $this->link_call_to_contact($call_id, $from, $user_id);
                        } catch (Throwable $e) {
                            error_log('[VMO Voice Webhook] Contact linking failed for ' . $call_sid . ': ' . $e->getMessage());
                        }
                    }
                }
            }

            // ── INCOMING CALL EMAIL ALERT ──
            // Send once per parent call SID, even when the DB row already exists.
            // This keeps IVR re-entry / duplicate rows from suppressing the first
            // real incoming-call email.
            if ($user_id > 0) {
                try {
                    $mail_result = $this->maybe_send_incoming_call_alert($call_sid, $user_id, $from);
                    if ($mail_result === true) {
                        error_log('[VMO Voice] Incoming email alert sent for user ' . $user_id . ' caller=' . $from);
                    } elseif ($mail_result === false) {
                        error_log('[VMO Voice] Incoming email alert FAILED for user ' . $user_id . ' caller=' . $from);
                    } else {
                        error_log('[VMO Voice] Incoming email alert skipped for user ' . $user_id . ' caller=' . $from);
                    }
                } catch (Throwable $e) {
                    error_log('[VMO Voice Webhook] Incoming alert failed for ' . $call_sid . ': ' . $e->getMessage());
                }
            } else {
                error_log('[VMO Voice] Incoming email alert SKIPPED — user_id=' . $user_id . ' (not resolved)');
            }

            // ── SELF-HEALING: Ensure phone number has StatusCallback URL ──
            // Without a StatusCallback, Twilio won't send call-completed events,
            // which prevents post-call summary emails from being triggered.
            // Run once per day per user to avoid slowing down every call.
            if ($user_id > 0) {
                $sc_heal_key = 'vmo_sc_heal_' . $user_id;
                if (!get_transient($sc_heal_key)) {
                    try {
                        $phone_sid = get_user_meta($user_id, 'vmo_phone_sid', true);
                        if (!empty($phone_sid)) {
                            $expected_status_url = $this->twilio_public_url('status_callback');
                            $num_info = $this->twilio_request('GET', "/IncomingPhoneNumbers/{$phone_sid}.json");
                            if (!is_wp_error($num_info)) {
                                $current_sc = $this->get_twilio_prop($num_info, 'status_callback');
                                if (empty($current_sc) || strpos($current_sc, 'status') === false) {
                                    $this->twilio_request('POST', "/IncomingPhoneNumbers/{$phone_sid}.json", array(
                                        'StatusCallback' => $expected_status_url,
                                        'StatusCallbackMethod' => 'POST',
                                    ));
                                    error_log('[VMO Voice] Self-healed StatusCallback URL for phone SID ' . $phone_sid);
                                }
                            }
                        }
                        set_transient($sc_heal_key, 1, DAY_IN_SECONDS);
                    } catch (Throwable $e) {
                        error_log('[VMO Voice] StatusCallback heal failed: ' . $e->getMessage());
                        set_transient($sc_heal_key, 1, HOUR_IN_SECONDS);
                    }
                }
            }

            try {
                $this->store_live_call_signal($to, array(
                    'call_sid' => $call_sid,
                    'from_number' => $from,
                    'to_number' => $to,
                    'direction' => 'inbound',
                    'status' => ($call_status !== '' ? $call_status : 'in-progress'),
                    'created_at' => current_time('mysql'),
                ));

                // ── RING SIGNAL: User-ID-keyed transient for guaranteed ring ──
                // The phone-keyed signal above depends on phone-number normalization
                // matching between store and retrieve. This user-ID-keyed signal is
                // simpler and more reliable — get_live_calls reads it directly.
                if ($user_id > 0) {
                    $this->store_user_ring_signal($user_id, array(
                        'call_sid' => $call_sid,
                        'from_number' => $from,
                        'to_number' => $to,
                        'status' => ($call_status !== '' ? $call_status : 'ringing'),
                        'created_at' => current_time('mysql'),
                    ));
                    error_log('[VMO Voice] Ring signal stored for user ' . $user_id . ' call ' . $call_sid);
                }
            } catch (Throwable $e) {
                error_log('[VMO Voice Webhook] Ring signal storage failed for ' . $call_sid . ': ' . $e->getMessage());
            }

            // Get user's external phone number for simultaneous ring
            $external_phone = '';
            if ($user_id > 0) {
                $external_phone = get_user_meta($user_id, 'vmo_external_phone', true);
            }

            // =====================================================
            // POWER DIALER RETURN CALL DETECTION
            // Check if this caller was recently contacted via Power Dialer
            // =====================================================
            $power_dialer_campaign = null;
            $power_dialer_callback_extension = 0;
            if ($user_id > 0) {
                // Check if caller was called by Power Dialer in last 72 hours
                $pd_calls_table = $wpdb->prefix . 'vmo_power_dialer_calls';
                $pd_campaigns_table = $wpdb->prefix . 'vmo_power_dialer_campaigns';

                if ($wpdb->get_var("SHOW TABLES LIKE '{$pd_calls_table}'") === $pd_calls_table) {
                    $cleaned_from = preg_replace('/[^0-9]/', '', $from);
                    $recent_pd_call = $wpdb->get_row($wpdb->prepare(
                        "SELECT c.*, camp.callback_extension, camp.name as campaign_name 
                     FROM {$pd_calls_table} c
                     JOIN {$pd_campaigns_table} camp ON c.campaign_id = camp.id
                     WHERE c.user_id = %d 
                     AND c.phone LIKE %s
                     AND c.created_at >= DATE_SUB(NOW(), INTERVAL 72 HOUR)
                     ORDER BY c.created_at DESC LIMIT 1",
                        $user_id,
                        '%' . $wpdb->esc_like($cleaned_from) . '%'
                    ));

                    if ($recent_pd_call) {
                        $power_dialer_campaign = $recent_pd_call;
                        $power_dialer_callback_extension = intval($recent_pd_call->callback_extension);
                        error_log("VMO: Power Dialer return call detected from {$from} - Campaign: {$recent_pd_call->campaign_name}, Extension: {$power_dialer_callback_extension}");

                        // Update call record to note this is a return call
                        // phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery, WordPress.DB.DirectDatabaseQuery.NoCaching
                        $wpdb->update(
                            $wpdb->prefix . 'vmo_phone_calls',
                            array('topic' => 'Power Dialer Return Call - ' . $recent_pd_call->campaign_name),
                            array('call_sid' => $call_sid)
                        );
                    }
                }
            }

            // Get AI greeting
            $ai_greeting = get_user_meta($user_id, 'vmo_ai_greeting', true);
            if (empty($ai_greeting)) {
                $ai_greeting = 'Thank you for calling. How can I help you today?';
            }

            // Build TwiML response
            // CRITICAL FIX: Ensure no whitespace before XML
            if (ob_get_length())
                ob_clean();

            header('Content-Type: text/xml');
            echo '<?xml version="1.0" encoding="UTF-8"?>';
            echo '<Response>';

            // ── LOW-BALANCE FALLBACK: Skip AI features if user cannot afford the call ──
            $economics = class_exists('VMO_Twilio_Token_Economics') ? VMO_Twilio_Token_Economics::get_instance() : null;
            if ($economics && !$economics->can_make_call((int) $user_id, 'inbound')) {
                // Insufficient tokens — skip AI greeting/TTS and go straight to voicemail
                error_log('[VMO Voice Webhook] LOW BALANCE for user #' . $user_id . ' — skipping AI, voicemail only');

                // Still ring the browser so the owner knows about the call
                try {
                    $this->set_owner_alert_state($call_sid, true);
                    if ($user_id > 0) {
                        $this->store_user_ring_signal($user_id, array(
                            'call_sid' => $call_sid,
                            'from_number' => $from,
                            'to_number' => $to,
                            'status' => ($call_status !== '' ? $call_status : 'ringing'),
                            'created_at' => current_time('mysql'),
                            'owner_alert_ready' => 1,
                        ));
                        $client_identity = 'vmo_user_' . $user_id;
                        $browser_ring_url = $this->phone_gateway_url('ext_ring', array(
                            'uid' => $user_id,
                            'call_sid' => $call_sid,
                            'caller' => $from,
                        ));
                        $ring_result = $this->twilio_request('POST', '/Calls.json', array(
                            'To' => 'client:' . $client_identity,
                            'From' => $to,
                            'Url' => $browser_ring_url,
                            'StatusCallback' => $this->twilio_public_url('status_callback', array('parent_sid' => $call_sid)),
                            'StatusCallbackEvent' => array('initiated', 'ringing', 'answered', 'completed'),
                            'Timeout' => 25,
                            'Parameters' => wp_json_encode(array(
                                'ParentCallSid' => $call_sid,
                                'CallerNumber' => $from,
                            )),
                        ));
                        if (is_wp_error($ring_result)) {
                            error_log('[VMO Voice] ✗ Low-balance browser ring FAILED: ' . $ring_result->get_error_message());
                        } else {
                            $ring_call_sid = isset($ring_result['sid']) ? (string) $ring_result['sid'] : '';
                            if ($ring_call_sid !== '') {
                                $this->track_child_ring_call($call_sid, $ring_call_sid);
                            }
                            error_log('[VMO Voice] ✓ Low-balance browser ring initiated to client:' . $client_identity);
                        }
                    }
                } catch (Throwable $e) {
                    error_log('[VMO Voice] Low-balance browser ring exception: ' . $e->getMessage());
                }

                echo '<Say voice="' . esc_attr($initial_tts_voice) . '">We are unable to take your call right now. Please leave a message after the beep.</Say>';
                echo '<Record maxLength="120" transcribe="true" playBeep="true" />';
                echo '</Response>';
                ob_end_flush();
                exit;
            }

            // Check recording preference (defaults ON if never set)
            $recording_enabled = $this->is_recording_enabled($user_id);

            // ── Recording start DEFERRED to handle_ai_conversation() ──
            // Starting call-level recording here is almost always wasted time:
            // 1. The call is typically still in 'ringing' state → Twilio API rejects it
            // 2. The Twilio API call takes 1-3s, eating into the 15s TwiML deadline
            // 3. handle_ai_conversation() already retries on the first turn when the
            //    call is definitely in-progress and the recording will succeed
            // Removing this saves 1-3s of processing time for the critical initial
            // webhook response, preventing "application error" on slow connections.

            // Check for Extensions & Extension Labels
            $extensions_raw = ($user_id > 0) ? get_user_meta($user_id, 'vmo_phone_extensions', true) : array();
            $extensions = (is_array($extensions_raw)) ? $extensions_raw : array();
            $extension_labels_raw = ($user_id > 0) ? get_user_meta($user_id, 'vmo_phone_extension_labels', true) : array();
            $extension_labels = (is_array($extension_labels_raw)) ? $extension_labels_raw : array();
            $incoming_digits = isset($params['Digits']) ? sanitize_text_field($params['Digits']) : '';
            $incoming_speech = isset($params['SpeechResult']) ? sanitize_text_field($params['SpeechResult']) : '';
            // Detect whether this is a Gather re-entry (Twilio posting back after
            // digit input / timeout) vs a truly fresh incoming call.
            //
            // ROBUST DETECTION: We add an explicit 'ivr=1' marker to the Gather
            // action URL. This is far more reliable than checking isset($params['Digits'])
            // because Twilio may omit the Digits parameter entirely on timeout
            // depending on the SDK version and transport.
            $ivr_marker = isset($params['ivr']) && $params['ivr'] === '1';
            $premium_ivr_marker = isset($params['premium_ivr']) && $params['premium_ivr'] === '1';
            $is_gather_reentry = $ivr_marker || isset($params['Digits']) || isset($params['SpeechResult']);

            $did_gather = false;
            $has_extensions = !empty($extensions);
            $has_external_only = (!$has_extensions && !empty($external_phone));
            $premium_bridge_enabled_for_call = (!$premium_bridge_fallback) && $this->is_premium_bridge_enabled($user_id) && !isset($power_dialer_campaign);
            error_log('[VMO Voice CKPT-A] after_premium_flag user_id=' . $user_id . ' premium=' . var_export($premium_bridge_enabled_for_call, true) . ' has_extensions=' . var_export($has_extensions, true) . ' is_gather_reentry=' . var_export($is_gather_reentry, true) . ' call_sid=' . $call_sid);
            if ($premium_bridge_fallback) {
                $this->trace_voice_call($call_sid, 'premium_bridge_redirected_to_standard_fallback');
                $this->record_premium_bridge_status($user_id, $call_sid, 'Premium bridge stream ended and Twilio redirected this call to the Standard fallback path.', true);
                try {
                    global $wpdb;
                    $wpdb->update(
                        $wpdb->prefix . 'vmo_phone_calls',
                        array('topic' => 'premium_bridge_fallback_standard'),
                        array('call_sid' => $call_sid),
                        array('%s'),
                        array('%s')
                    );
                } catch (Throwable $e) {
                    error_log('[VMO Bridge] Could not tag Premium fallback call ' . $call_sid . ': ' . $e->getMessage());
                }
            }

            // Compute the premium-bridge intent EARLY so the owner-ring REST
            // calls below can defer themselves when premium is about to fire.
            // We need the TwiML response to reach Twilio before we burn 500ms-1s
            // on each ring REST call — otherwise the AI receptionist appears to
            // "not answer" because the bridge bytes are stuck behind the rings.
            $premium_ivr_marker_for_defer = isset($params['premium_ivr']) && $params['premium_ivr'] === '1';
            $premium_will_attempt_bridge = $premium_bridge_enabled_for_call
                && (!$is_gather_reentry || ($premium_bridge_enabled_for_call && $premium_ivr_marker_for_defer));
            $initial_ivr_key = ($call_sid !== '') ? 'vmo_initial_ivr_seen_' . sanitize_key($call_sid) : '';
            $initial_ivr_already_seen = ($initial_ivr_key !== '' && get_transient($initial_ivr_key));

            if ($incoming_digits === '' && $incoming_speech !== '' && $has_extensions) {
                $spoken_extension_digit = $this->find_extension_digit_from_speech($incoming_speech, $extensions, $extension_labels);
                if ($spoken_extension_digit !== '') {
                    $incoming_digits = $spoken_extension_digit;
                }
            }

            // Did the caller press a VALID extension digit?
            $selected_valid_extension = ($incoming_digits !== '' && isset($extensions[$incoming_digits]));

            // IVR TIMEOUT: caller heard the menu and either:
            //   a) Twilio posted back with Digits='' (standard timeout), OR
            //   b) Twilio posted back via the action URL (ivr=1) but without
            //      a valid extension digit (covers edge cases where Digits
            //      param is missing entirely on timeout).
            // In either case, the caller wants to speak to someone → ring the owner.
            $is_ivr_timeout = ($is_gather_reentry && !$selected_valid_extension && $has_extensions);

            error_log('[VMO Voice] IVR detection: ivr_marker=' . var_export($ivr_marker, true)
                . ' premium_ivr_marker=' . var_export($premium_ivr_marker, true)
                . ' isset_Digits=' . var_export(isset($params['Digits']), true)
                . ' isset_SpeechResult=' . var_export(isset($params['SpeechResult']), true)
                . ' incoming_digits="' . $incoming_digits . '"'
                . ' incoming_speech="' . $incoming_speech . '"'
                . ' is_gather_reentry=' . var_export($is_gather_reentry, true)
                . ' selected_valid_extension=' . var_export($selected_valid_extension, true)
                . ' has_extensions=' . var_export($has_extensions, true)
                . ' initial_ivr_already_seen=' . var_export((bool) $initial_ivr_already_seen, true)
                . ' is_ivr_timeout=' . var_export($is_ivr_timeout, true));

            if ($is_ivr_timeout || ($initial_ivr_already_seen && !$selected_valid_extension && $has_extensions)) {
                $did_gather = true;
            }

            if (!$is_gather_reentry && !$initial_ivr_already_seen && $has_extensions && !isset($power_dialer_campaign) && $premium_bridge_enabled_for_call) {
                if ($initial_ivr_key !== '') {
                    set_transient($initial_ivr_key, 1, 3600);
                }
            }

            if (!$is_gather_reentry && !$initial_ivr_already_seen && $has_extensions && !isset($power_dialer_campaign) && !$premium_bridge_enabled_for_call) {
                // Legacy Standard IVR uses a DTMF Gather before the AI pipeline.
                // Premium must not sit behind this blocking Gather because it
                // delays the realtime media stream; Premium receives the same
                // extension menu through the bridge greeting/instructions below.
                $did_gather = true;
                if ($initial_ivr_key !== '') {
                    set_transient($initial_ivr_key, 1, 3600);
                }
                // ACTION URL: Posts back to this webhook with ivr=1 marker.
                // The ivr=1 marker reliably identifies Gather re-entry regardless
                // of whether Twilio includes the Digits parameter on timeout.
                $ivr_action_url = $this->phone_gateway_url('voice', array('uid' => $user_id, 'ivr' => '1'));
                echo '<Gather input="dtmf" numDigits="1" timeout="8" actionOnEmptyResult="true" action="' . esc_xml($ivr_action_url) . '">';
                if ($recording_enabled) {
                    echo '<Say voice="' . esc_attr($initial_tts_voice) . '">' . esc_xml($this->localized_recording_notice($call_language)) . '</Say>';
                    echo '<Pause length="1"/>';
                }
                // Build IVR menu from labels
                $ivr_parts = array();
                foreach ($extensions as $digit => $phone) {
                    $digit_str = preg_replace('/\D+/', '', (string) $digit);
                    if ($digit_str === '' || trim((string) $phone) === '') {
                        continue;
                    }
                    $label = isset($extension_labels[$digit]) ? $extension_labels[$digit] : 'extension ' . $digit_str;
                    $label = preg_replace('/\s+/', ' ', wp_strip_all_tags((string) $label));
                    $label = trim(is_string($label) ? $label : ('extension ' . $digit_str));
                    if ($label === '') {
                        $label = 'extension ' . $digit_str;
                    }
                    $ivr_parts[] = $this->extension_menu_item_text($label, $digit_str, $call_language, false);
                }
                $ivr_parts[] = 'To speak with a representative directly, please remain on the line.';

                // Use greeting as intro, then IVR menu
                $ivr_text = $this->localized_initial_greeting($call_language, $ai_greeting) . ' ' . implode(' ', $ivr_parts);
                echo '<Say voice="' . esc_attr($initial_tts_voice) . '">' . esc_xml($ivr_text) . '</Say>';
                echo '</Gather>';
            }

            // =====================================================
            // CALL ROUTING LOGIC
            // Priority: Extension Input > Power Dialer Extension > External Phone > AI Agent
            //
            // DESIGN: When a caller selects an IVR extension, we do NOT route them
            // directly.  Instead, we fire a simultaneous ring to the extension phone
            // (with Press 1 Listen / Press 2 Take Over menu) and let the AI
            // receptionist handle the caller.  The extension person can listen to
            // the AI conversation or jump in whenever they choose.  This ensures
            // every call benefits from the AI attendant even when a department is
            // explicitly chosen.  Power Dialer return calls still route directly
            // (they are callback scenarios, not IVR-routed).
            // =====================================================

            $route_to_phone = null;
            $route_reason = '';
            $ext_selected = false;
            $ext_selected_label = '';

            // PRIORITY 0: User selected IVR extension
            // ── Fire simultaneous ring to the extension phone (listen/takeover)
            //    instead of direct dial. The AI receptionist continues handling
            //    the caller. The extension person gets the DTMF menu.
            if ($incoming_digits !== '' && isset($extensions[$incoming_digits])) {
                $ext_selected_phone = $extensions[$incoming_digits];
                $ext_label = isset($extension_labels[$incoming_digits]) ? $extension_labels[$incoming_digits] : 'extension ' . $incoming_digits;
                $ext_selected = true;
                $ext_selected_label = $ext_label;
                $vmo_phone = $this->get_user_phone($user_id);
                $ext_ring_url = $this->phone_gateway_url('ext_ring', array(
                    'uid' => $user_id,
                    'call_sid' => $call_sid,
                    'caller' => $from,
                ));
                try {
                    $ext_call_params = array(
                        'To' => $ext_selected_phone,
                        'From' => $vmo_phone ?: $to,
                        'Url' => $ext_ring_url,
                        'StatusCallback' => $this->twilio_public_url('status_callback', array('parent_sid' => $call_sid)),
                        'StatusCallbackEvent' => array('initiated', 'ringing', 'answered', 'completed'),
                        'Timeout' => 30,
                        'MachineDetection' => 'Enable',
                    );
                    if ($recording_enabled) {
                        $ext_call_params['Record'] = 'true';
                        $ext_call_params['RecordingStatusCallback'] = $this->twilio_public_url('recording');
                        $ext_call_params['RecordingStatusCallbackEvent'] = 'completed';
                    }
                    $ext_ring_result = $this->twilio_request('POST', '/Calls.json', $ext_call_params);
                    if (is_wp_error($ext_ring_result)) {
                        error_log("VMO: Failed to ring extension {$ext_selected_phone}: " . $ext_ring_result->get_error_message());
                    } else {
                        $ext_ring_call_sid = (string) ($ext_ring_result['sid'] ?? '');
                        if ($ext_ring_call_sid !== '') {
                            $this->track_child_ring_call($call_sid, $ext_ring_call_sid);
                        }
                        error_log("VMO: Extension ring (listen/takeover) initiated to {$ext_selected_phone} ({$ext_label}) for call {$call_sid}" . ($ext_ring_call_sid !== '' ? " child={$ext_ring_call_sid}" : ''));
                    }
                } catch (Throwable $e) {
                    error_log("VMO: Failed to ring extension {$ext_selected_phone}: " . $e->getMessage());
                }
                // Mark $did_gather so the AI uses a short prompt ("How can I help you?")
                // instead of repeating the full greeting the caller already heard in the IVR.
                $did_gather = true;
                // DO NOT set $route_to_phone — let AI handle below
            }

            // PRIORITY 1: Power Dialer return call with specific extension
            if (!$route_to_phone && $power_dialer_campaign && $power_dialer_callback_extension > 0) {

                // Route to specific extension (1-7) — read from $extensions array
                $pd_ext_key = (string) $power_dialer_callback_extension;
                $extension_phone = isset($extensions[$pd_ext_key]) ? $extensions[$pd_ext_key] : '';
                if (!empty($extension_phone)) {
                    $route_to_phone = $extension_phone;
                    $route_reason = 'Power Dialer Extension ' . $power_dialer_callback_extension;
                }
            }

            // PRIORITY 2: Power Dialer return call with extension 0 (default) - route to user's phone
            if ($power_dialer_campaign && $power_dialer_callback_extension == 0 && !$route_to_phone) {
                // Extension 0 means route to user's own phone number
                if (!empty($external_phone)) {
                    $route_to_phone = $external_phone;
                    $route_reason = 'Power Dialer Return - User Phone';
                } else {
                    // Check for user's mobile/personal phone
                    $user_phone = get_user_meta($user_id, 'vmo_personal_phone', true);
                    if (empty($user_phone)) {
                        $user_phone = get_user_meta($user_id, 'phone', true);
                    }
                    if (empty($user_phone)) {
                        // Try to get from user profile
                        $user_data = get_userdata($user_id);
                        if ($user_data) {
                            $user_phone = get_user_meta($user_id, 'billing_phone', true); // WooCommerce
                        }
                    }
                    if (!empty($user_phone)) {
                        $route_to_phone = $user_phone;
                        $route_reason = 'Power Dialer Return - Personal Phone';
                    }
                }
            }

            // Owner-facing browser/external ringing should only start when the
            // call is actually on the owner path:
            // - no extensions configured: ring immediately
            // - extensions configured: wait until the IVR times out / caller stays
            //   on the line for the owner path
            // Never alert the owner UI during department selection or department
            // routing, and never for power-dialer callback routes that already
            // have a direct destination.
            // OPTION C: When Realtime SIP is enabled, the legacy IVR <Gather> is
            // skipped — so $is_ivr_timeout never fires. Owner ring must instead
            // start on the fresh inbound leg (gated only on no power-dialer / no
            // pre-routed phone / no skip flag) so the dashboard rings immediately
            // while the AI receptionist greets the caller in parallel.
            $browser_ringer_enabled = $this->is_owner_ring_enabled($user_id);
            $premium_ai_handles_extension_menu = $premium_bridge_enabled_for_call && !$is_gather_reentry;
            $owner_alert_ready_now = !isset($power_dialer_campaign)
                && empty($route_to_phone)
                && empty($params['skip_ring'])
                && !$selected_valid_extension
                && ($has_extensions ? ($is_ivr_timeout || $premium_ai_handles_extension_menu) : !$is_gather_reentry);
            $should_alert_owner_now = $owner_alert_ready_now;
            try {
                $this->set_owner_alert_state($call_sid, $should_alert_owner_now);
            } catch (Throwable $e) {
                error_log('[VMO Voice Webhook] Owner alert state update failed for ' . $call_sid . ': ' . $e->getMessage());
            }
            if ($user_id > 0) {
                try {
                    $this->store_user_ring_signal($user_id, array(
                        'call_sid' => $call_sid,
                        'from_number' => $from,
                        'to_number' => $to,
                        'status' => ($call_status !== '' ? $call_status : 'ringing'),
                        'created_at' => current_time('mysql'),
                        'owner_alert_ready' => $should_alert_owner_now ? 1 : 0,
                    ));
                } catch (Throwable $e) {
                    error_log('[VMO Voice Webhook] Owner-path ring signal refresh failed for ' . $call_sid . ': ' . $e->getMessage());
                }
            }
            error_log('[VMO Voice] Owner alert gate: should_alert_owner_now=' . var_export($should_alert_owner_now, true)
                . ' has_extensions=' . var_export($has_extensions, true)
                . ' premium_ai_handles_extension_menu=' . var_export($premium_ai_handles_extension_menu, true)
                . ' is_gather_reentry=' . var_export($is_gather_reentry, true)
                . ' is_ivr_timeout=' . var_export($is_ivr_timeout, true)
                . ' selected_valid_extension=' . var_export($selected_valid_extension, true)
                . ' route_to_phone=' . var_export($route_to_phone, true)
                . ' browser_ringer_enabled=' . var_export($browser_ringer_enabled, true)
                . ' power_dialer=' . var_export(isset($power_dialer_campaign), true));

            // PRIORITY 3: Removed — previously did a sequential <Dial> to external phone
            // which blocked for 25s before AI could respond. Now replaced by simultaneous
            // ring. With extensions configured, IVR handles routing first and the
            // owner path only starts ringing after IVR timeout/fallthrough.

            // If we have a phone to route to (from extension or power dialer), connect
            // the caller directly using <Dial> so they hear ringing and get connected
            // when the extension answers. If nobody answers within 25s, the action URL
            // (handle_dial_status) falls through to the AI receptionist.
            if (!empty($route_to_phone)) {
                error_log("VMO: Routing call to {$route_to_phone} ({$route_reason})");

                if ($recording_enabled) {
                    echo '<Say voice="' . esc_attr($initial_tts_voice) . '">' . esc_xml($this->localized_recording_notice($call_language)) . '</Say>';
                }
                $dial_status_url = $this->phone_gateway_url('dial_status', array('uid' => $user_id));
                $rec_cb_url = $this->twilio_public_url('recording');
                $dial_attrs = 'timeout="25" action="' . esc_xml($dial_status_url) . '" callerId="' . esc_xml($to) . '"';
                if ($recording_enabled) {
                    $dial_attrs .= ' record="record-from-answer-dual" recordingStatusCallback="' . esc_xml($rec_cb_url) . '" recordingStatusCallbackEvent="completed"';
                }
                echo '<Dial ' . $dial_attrs . '>';
                echo '<Number>' . esc_xml($route_to_phone) . '</Number>';
                echo '</Dial>';
                // Close response and exit — the <Dial action> callback handles
                // AI fallback if the extension doesn't answer.
                echo '</Response>';
                exit;
            }

            // =====================================================
            // SIMULTANEOUS RING: External phone
            // Ring the external phone on ALL fresh incoming calls (initial
            // webhook request) only when the call is already on the owner path.
            // With IVR extensions enabled, this remains silent until the caller
            // stays on the line and the IVR times out.
            // =====================================================
            $has_external_phone = !empty($external_phone);
            $should_ring_external = $has_external_phone && $owner_alert_ready_now;
            // When premium bridge is about to fire, defer the ring REST calls
            // until AFTER the bridge TwiML has been flushed to Twilio so the AI
            // receptionist answers instantly (sub-second). The deferred rings
            // run from inside the premium emit branch via dispatch_owner_rings_now().
            if ($should_ring_external && !$premium_will_attempt_bridge) {
                $vmo_phone = $this->get_user_phone($user_id);
                $ext_ring_url = $this->phone_gateway_url('ext_ring', array(
                    'uid' => $user_id,
                    'call_sid' => $call_sid,
                    'caller' => $from,
                ));
                try {
                    $ext_call_params = array(
                        'To' => $external_phone,
                        'From' => $vmo_phone ?: $to,
                        'Url' => $ext_ring_url,
                        'StatusCallback' => $this->twilio_public_url('status_callback', array('parent_sid' => $call_sid)),
                        'StatusCallbackEvent' => array('initiated', 'ringing', 'answered', 'completed'),
                        'Timeout' => 25,
                        'MachineDetection' => 'Enable',
                    );
                    // Record the simultaneous ring call so Listen button works
                    if ($recording_enabled) {
                        $ext_call_params['Record'] = 'true';
                        $ext_call_params['RecordingStatusCallback'] = $this->twilio_public_url('recording');
                        $ext_call_params['RecordingStatusCallbackEvent'] = 'completed';
                    }
                    $ext_ring_result = $this->twilio_request('POST', '/Calls.json', $ext_call_params);
                    if (is_wp_error($ext_ring_result)) {
                        error_log("VMO: Failed to ring external phone {$external_phone}: " . $ext_ring_result->get_error_message());
                    } else {
                        $ext_ring_call_sid = (string) ($ext_ring_result['sid'] ?? '');
                        if ($ext_ring_call_sid !== '') {
                            $this->track_child_ring_call($call_sid, $ext_ring_call_sid);
                        }
                        error_log("VMO: Simultaneous ring initiated to external phone {$external_phone} for call {$call_sid} record=" . ($recording_enabled ? 'true' : 'false') . ($ext_ring_call_sid !== '' ? ' child=' . $ext_ring_call_sid : ''));
                    }
                } catch (Throwable $e) {
                    error_log("VMO: Exception ringing external phone {$external_phone}: " . $e->getMessage());
                }
            }

            // AI Receptionist: Interactive speech-based conversation via Gather + GPT.
            // The previous <Connect><Assistant> approach required Twilio Alpha access and
            // used an incompatible SID format, causing calls to fall back to a static
            // greeting that looped. This production-ready alternative uses <Gather input="speech">
            // to capture caller speech, sends it to GPT for a conversational response,
            // and returns the reply via <Say> with another <Gather> to continue the dialogue.

            // =====================================================
            // BROWSER RING: Via REST API (simultaneous, non-blocking)
            // 
            // ARCHITECTURE FIX: Previously used <Dial><Client> which blocked
            // the TwiML for 15s. When the browser answered, DialCallStatus=
            // completed → <Hangup/>, and the AI receptionist NEVER activated.
            // Now we ring the browser via REST API (same as external phone)
            // so the AI is always active for the caller from the start.
            // The dashboard treats this as a notification/ring leg only; plugin
            // buttons join directly via vmo_call_action/browser_call. External
            // phones still use ext_ring's press-1/press-2 menu.
            // =====================================================
            $ai_converse_url = $this->phone_gateway_url('ai_converse', array('uid' => $user_id));
            // Browser ring follows the same owner-path gate as the external phone.
            // In the IVR/extensions scenario it must stay silent until the caller
            // falls through to the owner path.
            $should_ring_browser = $owner_alert_ready_now;
            // Phase 4: honor the dashboard "Silent" toggle. When the user has
            // toggled their browser ringer OFF (vmo_ring_enabled === '0'), don't
            // even send the Twilio child-call invite to the browser client \u2014 the
            // external phone still rings, and the AI receptionist still answers.
            // The toggle defaults to ON (ringing) when the meta is empty/unset.
            if ($should_ring_browser && $user_id > 0) {
                $ring_enabled_meta = get_user_meta($user_id, 'vmo_ring_enabled', true);
                if ($ring_enabled_meta === '0') {
                    $should_ring_browser = false;
                    error_log('[VMO Voice] Browser ring SUPPRESSED by Silent toggle for user ' . $user_id);
                }
            }
            error_log('[VMO Voice] Browser ring check: should_ring_browser=' . var_export($should_ring_browser, true)
                . ' user_id=' . $user_id
                . ' did_gather=' . var_export($did_gather, true)
                . ' has_extensions=' . var_export($has_extensions, true)
                . ' is_gather_reentry=' . var_export($is_gather_reentry, true)
                . ' is_ivr_timeout=' . var_export($is_ivr_timeout, true)
                . ' skip_ring=' . var_export(!empty($params['skip_ring']), true)
                . ' route_to_phone=' . var_export($route_to_phone, true)
                . ' power_dialer=' . var_export(isset($power_dialer_campaign), true));
            if ($should_ring_browser && $user_id > 0 && !$premium_will_attempt_bridge) {
                // Ring the browser client via REST API (non-blocking). The dashboard
                // rejects this leg when a direct plugin listen/takeover bridge starts.
                $client_identity = 'vmo_user_' . $user_id;
                $browser_ring_url = $this->phone_gateway_url('ext_ring', array(
                    'uid' => $user_id,
                    'call_sid' => $call_sid,
                    'caller' => $from,
                ));
                try {
                    $browser_call_params = array(
                        'To' => 'client:' . $client_identity,
                        'From' => $to, // The Twilio number being called
                        'Url' => $browser_ring_url,
                        'StatusCallback' => $this->twilio_public_url('status_callback', array('parent_sid' => $call_sid)),
                        'StatusCallbackEvent' => array('initiated', 'ringing', 'answered', 'completed'),
                        'Timeout' => 25,
                        // Pass parent call SID + caller number to the browser SDK
                        // so device.on('incoming') can read them from call.customParameters
                        'Parameters' => wp_json_encode(array(
                            'ParentCallSid' => $call_sid,
                            'CallerNumber' => $from,
                        )),
                    );
                    $ring_result = $this->twilio_request('POST', '/Calls.json', $browser_call_params);
                    if (is_wp_error($ring_result)) {
                        error_log('[VMO Voice] ✗ Browser ring (REST API) FAILED: ' . $ring_result->get_error_message());
                    } else {
                        $ring_call_sid = isset($ring_result['sid']) ? $ring_result['sid'] : '(no sid)';
                        if ($ring_call_sid !== '(no sid)') {
                            $this->track_child_ring_call($call_sid, (string) $ring_call_sid);
                        }
                        error_log('[VMO Voice] ✓ Browser ring (REST API) initiated to client:' . $client_identity . ' for call ' . $call_sid . ' ring_call=' . $ring_call_sid);
                    }
                } catch (Throwable $e) {
                    error_log('[VMO Voice] ✗ Browser ring (REST API) EXCEPTION: ' . $e->getMessage());
                }
            } else {
                error_log('[VMO Voice] ✗ Browser ring SKIPPED — should_ring_browser=' . var_export($should_ring_browser, true) . ' user_id=' . $user_id);
            }
            // ✅ RECORDING ANNOUNCEMENT - plays AFTER the ring phase so callers
            // hear ringing first (simulates a real phone connection attempt).
            error_log('[VMO Voice CKPT-B] before_recording_notice call_sid=' . $call_sid . ' recording_enabled=' . var_export($recording_enabled, true) . ' did_gather=' . var_export($did_gather, true));
            if ($recording_enabled && !$did_gather && !$premium_will_attempt_bridge) {
                echo '<Say voice="' . esc_attr($initial_tts_voice) . '">' . esc_xml($this->localized_recording_notice($call_language)) . '</Say>';
            }
            error_log('[VMO Voice CKPT-C] after_recording_notice call_sid=' . $call_sid);

            error_log('[VMO Voice CKPT-D] before_phone_opening call_sid=' . $call_sid);
            try {
                $phone_opening = $this->get_phone_opening_script($user_id, $did_gather, $ext_selected, $ext_selected_label);
            } catch (Throwable $e) {
                $fallback_label = ($ext_selected_label !== '') ? $ext_selected_label : 'the selected department';
                $phone_opening = array(
                    'ai_greeting' => $ai_greeting,
                    'initial_greeting' => !$did_gather
                        ? $ai_greeting
                        : ($ext_selected
                            ? 'I\'ve notified ' . $fallback_label . ' about your call. They may join at any time. Meanwhile, I\'m here to help. What can I do for you?'
                            : 'How can I help you?'),
                    'reprompt_msg' => $ext_selected
                        ? 'I\'m still here. ' . $fallback_label . ' may still join. Would you like to leave a message, or is there something I can help with?'
                        : 'I\'m still here if you need anything. How can I help you?',
                    'gather_timeout' => $ext_selected ? 60 : 10,
                    'reprompt_timeout' => 30,
                    'realtime_instruction' => $ext_selected
                        ? 'The caller selected ' . $fallback_label . '. Let them know that ' . $fallback_label . ' has been notified and may join the call at any time while you assist them.'
                        : ($did_gather ? 'The caller already heard the initial phone prompt. Continue naturally without restarting from the beginning.' : ''),
                );
                error_log('[VMO Voice Webhook] Phone opening fallback activated for ' . $call_sid . ': ' . $e->getMessage());
            }
            if (!$did_gather) {
                $phone_opening['initial_greeting'] = $this->localized_initial_greeting($call_language, (string) ($phone_opening['initial_greeting'] ?? ($phone_opening['ai_greeting'] ?? $ai_greeting)));
            }

            // ═══════════════════════════════════════════════════════════════
            // PREMIUM REALTIME BRIDGE: Twilio Media Streams → VMO bridge → OpenAI Realtime
            // Premium voice quality is the true low-latency speech-to-speech path.
            // Standard calls intentionally fall through to the legacy transcript/text/TTS
            // pipeline unless a future lower-cost realtime tier is explicitly added.
            // ═══════════════════════════════════════════════════════════════
            error_log('[VMO Voice CKPT-E] before_premium_decision call_sid=' . $call_sid);
            $premium_ivr_reentry = $premium_bridge_enabled_for_call && $premium_ivr_marker;
            $should_attempt_bridge = $premium_bridge_enabled_for_call
                && (!$is_gather_reentry || $premium_ivr_reentry);

            error_log('[VMO Premium Bridge] Decision for call=' . $call_sid
                . ' user_id=' . $user_id
                . ' premium_enabled=' . var_export($premium_bridge_enabled_for_call, true)
                . ' is_gather_reentry=' . var_export($is_gather_reentry, true)
                . ' premium_ivr_marker=' . var_export($premium_ivr_marker, true)
                . ' premium_ivr_reentry=' . var_export($premium_ivr_reentry, true)
                . ' should_attempt_bridge=' . var_export($should_attempt_bridge, true));

            if ($should_attempt_bridge) {
                $bridge_extension_map = array();
                if ($has_extensions) {
                    foreach ($extensions as $digit => $target_number) {
                        $digit_str = (string) $digit;
                        $phone_str = trim((string) $target_number);
                        if ($phone_str === '') {
                            continue;
                        }
                        $label = isset($extension_labels[$digit]) && $extension_labels[$digit] !== ''
                            ? (string) $extension_labels[$digit]
                            : ('Extension ' . $digit_str);
                        $bridge_extension_map[$digit_str] = array(
                            'label' => $label,
                            'phone' => $phone_str,
                        );
                    }

                    $phone_opening = $this->build_realtime_extension_opening($phone_opening, $bridge_extension_map, $call_language);
                }

                $bridge_voice = $this->get_selected_reception_voice($user_id);
                if ($recording_enabled && !$did_gather) {
                    $recording_notice = trim($this->localized_recording_notice($call_language));
                    if ($recording_notice !== '') {
                        $existing_initial = trim((string) ($phone_opening['initial_greeting'] ?? ($phone_opening['ai_greeting'] ?? $ai_greeting)));
                        $phone_opening['initial_greeting'] = trim($recording_notice . ' ' . $existing_initial);
                        $existing_realtime_instruction = trim((string) ($phone_opening['realtime_instruction'] ?? ''));
                        $phone_opening['realtime_instruction'] = trim('Start by clearly saying this recording notice before the greeting: "' . $recording_notice . '". ' . $existing_realtime_instruction);
                    }
                }
                $bridge_initial_instruction = trim($initial_language_policy . ' ' . (string) ($phone_opening['realtime_instruction'] ?? ''));
                $bridge_context = array(
                    'user_id' => $user_id,
                    'voice' => $bridge_voice,
                    'tier' => 'premium',
                    'engine' => 'premium_realtime_bridge',
                    'started_at' => time(),
                    'phone_opening' => $phone_opening,
                    'extension_map' => $bridge_extension_map,
                    'call_language' => $call_language,
                    'language_policy' => $initial_language_policy,
                    'initial_instruction' => $bridge_initial_instruction,
                );
                $bridge_twiml = $this->build_bridge_media_stream_twiml($user_id, $call_sid, $bridge_voice, $bridge_context);
                if (is_wp_error($bridge_twiml)) {
                    $this->trace_voice_call($call_sid, 'premium_bridge_unavailable error=' . $bridge_twiml->get_error_message());
                    $this->record_premium_bridge_status($user_id, $call_sid, 'Premium bridge unavailable before TwiML: ' . $bridge_twiml->get_error_message(), true);
                    set_transient('vmo_realtime_last_error_' . $user_id, array(
                        'call_sid' => $call_sid,
                        'message' => 'Premium bridge unavailable: ' . $bridge_twiml->get_error_message(),
                        'file' => __FILE__ . ':' . __LINE__,
                        'time' => time(),
                    ), 3600);
                    error_log('[VMO Bridge] Premium bridge unavailable for ' . $call_sid . ': ' . $bridge_twiml->get_error_message());
                } else {
                    try {
                        global $wpdb;
                        $wpdb->update(
                            $wpdb->prefix . 'vmo_phone_calls',
                            array('topic' => 'premium_realtime_bridge'),
                            array('call_sid' => $call_sid),
                            array('%s'),
                            array('%s')
                        );
                    } catch (Throwable $e) {
                        error_log('[VMO Bridge] Could not tag Premium bridge call ' . $call_sid . ': ' . $e->getMessage());
                    }
                    $this->set_bridge_active_context($call_sid, $bridge_context, 1800);
                    $this->store_realtime_call_context($call_sid, $bridge_context, 1800);
                    $this->trace_voice_call($call_sid, 'premium_bridge_twiml_emitted_exit voice=' . $bridge_voice . ' recording_deferred=' . var_export($recording_enabled, true));
                    $this->record_premium_bridge_status($user_id, $call_sid, 'Premium bridge TwiML emitted; waiting for Twilio Stream status callback.', false);
                    error_log('[VMO Bridge] Premium media stream TwiML emitted for ' . $call_sid . ' voice=' . $bridge_voice . ' user=' . $user_id);
                    $fallback_url = $this->phone_gateway_url('voice', array(
                        'uid' => $user_id,
                        'premium_bridge_fallback' => '1',
                    ));
                    echo $bridge_twiml;
                    echo '<Redirect method="POST">' . esc_xml($fallback_url) . '</Redirect>';
                    echo '</Response>';
                    $this->flush_response_and_continue();
                    if ($recording_enabled) {
                        $this->start_call_level_recording($call_sid, $user_id, 'premium_bridge_after_twiml_flush');
                    }
                    $this->dispatch_owner_rings_now(
                        $call_sid,
                        $user_id,
                        (string) $from,
                        (string) $to,
                        (string) $external_phone,
                        (bool) $owner_alert_ready_now,
                        (bool) $recording_enabled
                    );
                    exit;
                }
            }

            // Legacy OpenAI SIP is no longer an active runtime route. Premium uses
            // the WebSocket bridge above; Standard falls through to Gather/GPT/TTS.
            $realtime_eligible = false;
            $realtime_rescue_key = 'vmo_realtime_rescue_' . sanitize_key($call_sid);
            $should_try_realtime_rescue = (
                $realtime_eligible
                && !isset($power_dialer_campaign)
                && !$is_canonical_realtime_source
                && $webhook_source === 'lookup_or_disaster_recovery'
                && !$is_gather_reentry
                && !get_transient($realtime_rescue_key)
            );
            $should_attempt_realtime = $realtime_eligible
                && !isset($power_dialer_campaign)
                && ($is_canonical_realtime_source || $should_try_realtime_rescue);
            $this->trace_voice_call($call_sid, 'realtime_eligibility_check eligible=' . var_export($realtime_eligible, true) . ' canonical_source=' . var_export($is_canonical_realtime_source, true) . ' rescue=' . var_export($should_try_realtime_rescue, true) . ' power_dialer=' . var_export(isset($power_dialer_campaign), true));
            // Power Dialer campaigns continue to use their dedicated TwiML path
            // (the campaign script is purpose-built and not appropriate for
            // Realtime). Everything else — including users with extensions —
            // engages Realtime on the initial leg. If the trunk fell through to
            // disaster recovery before OpenAI answered, make one conference-first
            // Realtime attempt from the active call before giving up to legacy.
            if ($should_attempt_realtime) {
                if ($should_try_realtime_rescue) {
                    set_transient($realtime_rescue_key, array('time' => time()), 900);
                    $this->trace_voice_call($call_sid, 'realtime_block_entered source=disaster_recovery_rescue');
                } else {
                    $this->trace_voice_call($call_sid, 'realtime_block_entered source=canonical_voice_webhook');
                }
                // Provide the AI with conversational context about available
                // extensions so it can route the caller naturally instead of
                // playing a DTMF menu.
                //
                // OPTION C: Build the structured extension map (digit → label → phone number)
                // and the "spoken menu" sentence appended to the user's custom greeting.
                // The custom greeting is preserved verbatim — the menu is appended only.
                $extension_map = array(); // [digit => ['label' => ..., 'phone' => ...]]
                if ($has_extensions && !$is_gather_reentry) {
                    foreach ($extensions as $digit => $target_number) {
                        $digit_str = (string) $digit;
                        $phone_str = trim((string) $target_number);
                        if ($phone_str === '')
                            continue; // skip empty rows
                        $label = isset($extension_labels[$digit]) && $extension_labels[$digit] !== ''
                            ? (string) $extension_labels[$digit]
                            : ('Extension ' . $digit_str);
                        $extension_map[$digit_str] = array(
                            'label' => $label,
                            'phone' => $phone_str,
                        );
                    }
                    $phone_opening = $this->build_realtime_extension_opening($phone_opening, $extension_map, $call_language);
                }
                try {
                    $rt_project_id = $this->get_openai_realtime_project_id();
                    // OpenAI Realtime SIP URI MUST be exactly this format — appending custom
                    // URI parameters/headers (e.g. ?X-Vmo-Uid=...) causes the SIP gateway to
                    // silently reject the INVITE (no SIP response, Twilio reports sip_code=(none)
                    // after a 20-second timeout). User identification happens in the webhook
                    // via the To header (which contains the dialed Twilio number) and via the
                    // pre-stored call context keyed by Twilio CallSid.
                    $rt_sip_uri = "sip:{$rt_project_id}@sip.api.openai.com;transport=tls";

                    $rt_context = array(
                        'user_id' => $user_id,
                        'phone_opening' => $phone_opening,
                        'extension_map' => $extension_map, // available to OpenAI webhook for tool execution
                        'call_language' => $call_language,
                        'language_policy' => $initial_language_policy,
                    );
                    if (!empty($phone_opening['realtime_instruction'])) {
                        $rt_context['initial_instruction'] = (string) $phone_opening['realtime_instruction'];
                    }
                    $conference_name = $this->call_conference_name($call_sid);
                    if ($conference_name === '') {
                        throw new \RuntimeException('Could not resolve a live conference name for this call');
                    }

                    $rt_context['conference_name'] = $conference_name;
                    $this->store_realtime_call_context($call_sid, $rt_context, 1800); // 30 min TTL — covers the call lifetime

                    error_log('[VMO Realtime] Starting conference-first bridge for ' . $call_sid . ' conference=' . $conference_name . ' sip=' . $rt_sip_uri);

                    set_transient('vmo_realtime_active_' . sanitize_key($call_sid), array(
                        'user_id' => $user_id,
                        'started_at' => time(),
                        'conference_name' => $conference_name,
                    ), 1800); // 30-minute TTL
                    $this->trace_voice_call($call_sid, 'realtime_active_transient_set conference=' . $conference_name);

                    $participant = $this->start_realtime_conference_participant($call_sid, $user_id, $conference_name, $rt_sip_uri, (string) $to);
                    if (is_wp_error($participant)) {
                        throw new \RuntimeException($participant->get_error_message());
                    }

                    $this->mark_call_conference_state($call_sid, $conference_name, '(realtime-conference)');

                    echo $this->build_realtime_conference_caller_twiml($conference_name, $user_id);
                    echo '</Response>';
                    $this->trace_voice_call($call_sid, 'realtime_conference_twiml_emitted_exit conference=' . $conference_name . ' ai_call=' . (string) ($participant['call_sid'] ?? ''));
                    exit;
                } catch (Throwable $e) {
                    $this->trace_voice_call($call_sid, 'realtime_caught_exception msg=' . $e->getMessage() . ' at=' . $e->getFile() . ':' . $e->getLine());
                    delete_transient('vmo_realtime_active_' . sanitize_key($call_sid));
                    $this->clear_realtime_call_context($call_sid);
                    $this->clear_tracked_realtime_ai_leg($call_sid);
                    // OPTION C DIAGNOSTIC: persist the failure reason so the dashboard
                    // Listen diagnostic can surface it without WP error log access.
                    set_transient('vmo_realtime_last_error_' . $user_id, array(
                        'call_sid' => $call_sid,
                        'message' => $e->getMessage(),
                        'file' => $e->getFile() . ':' . $e->getLine(),
                        'time' => time(),
                    ), 3600);
                    error_log('[VMO Realtime] Falling back to legacy voice pipeline for ' . $call_sid . ': ' . $e->getMessage() . ' @ ' . $e->getFile() . ':' . $e->getLine());
                }
            } else {
                $skip_reason = !$realtime_eligible
                    ? 'legacy_sip_removed_websocket_bridge_only'
                    : (isset($power_dialer_campaign)
                        ? 'power_dialer_call'
                        : (get_transient($realtime_rescue_key)
                            ? 'disaster_recovery_rescue_already_attempted'
                            : 'disaster_recovery_or_lookup_path'));
                $this->trace_voice_call($call_sid, 'realtime_block_skipped reason=' . $skip_reason . ' eligible=' . var_export($realtime_eligible, true));
                error_log('[VMO Realtime] Block SKIPPED for ' . $call_sid . ' — reason=' . $skip_reason . ' realtime_eligible=' . var_export($realtime_eligible, true) . ' canonical_source=' . var_export($is_canonical_realtime_source, true) . ' rescue=' . var_export($should_try_realtime_rescue, true) . ' power_dialer=' . var_export(isset($power_dialer_campaign), true));
            }

            $this->trace_voice_call($call_sid, 'falling_through_to_legacy_pipeline');

            // ── LEGACY PIPELINE: Gather + GPT + TTS (5-15s latency per turn) ──
            // Used when Realtime SIP is not configured, on gather re-entry,
            // or when an extension was selected (listen/takeover compatibility).

            // ── Whisper-only languages use <Record> (Twilio has no STT for them).
            // ── All other languages use <Gather input="speech"> (fast, real-time).
            //
            // TIMEOUT STRATEGY:
            //   - Extension selected: timeout="60" — the caller expects to wait
            //     while the extension phone rings (~20-30s). Keep the line alive
            //     so the extension person can listen/take over. The AI is also
            //     available if the caller speaks.
            //   - Normal call (no extension): timeout="10" — give caller a
            //     comfortable window to start speaking after the greeting.
            //
            // After the first Gather times out, a RE-PROMPT Gather follows
            // (another 30s) before voicemail. This prevents the call from
            // dying ~14s after extension selection.
            $gather_timeout = max(10, intval($phone_opening['gather_timeout'] ?? 10));
            $initial_greeting = (string) ($phone_opening['initial_greeting'] ?? $ai_greeting);

            if ($this->is_whisper_language()) {
                $whisper_converse_url = $this->phone_gateway_url('ai_converse_whisper', array('uid' => $user_id));
                // phpcs:ignore WordPress.Security.EscapeOutput.OutputNotEscaped -- TwiML XML response
                echo $this->tts_play($initial_greeting, $call_sid, $user_id); // phpcs:ignore WordPress.Security.EscapeOutput.OutputNotEscaped -- TwiML XML output
                echo '<Record action="' . esc_xml($whisper_converse_url) . '" maxLength="30" timeout="3" playBeep="false" trim="trim-silence" />';
            } else {
                echo '<Gather input="speech" action="' . esc_xml($ai_converse_url) . '" speechTimeout="2" timeout="' . $gather_timeout . '" enhanced="true" language="' . esc_attr($initial_gather_lang) . '">';
                // phpcs:ignore WordPress.Security.EscapeOutput.OutputNotEscaped -- TwiML XML response
                echo $this->tts_play($initial_greeting, $call_sid, $user_id); // phpcs:ignore WordPress.Security.EscapeOutput.OutputNotEscaped -- TwiML XML output
                echo '</Gather>';
            }
            // ── RE-PROMPT: Give the caller another chance before voicemail.
            // This is critical when an extension was selected — the caller may
            // be silently waiting for the extension person to join. The second
            // Gather keeps the call alive for another 30 seconds.
            $reprompt_msg = (string) ($phone_opening['reprompt_msg'] ?? 'I\'m still here if you need anything. How can I help you?');
            if ($this->is_whisper_language()) {
                $whisper_converse_url2 = $this->phone_gateway_url('ai_converse_whisper', array('uid' => $user_id));
                // phpcs:ignore WordPress.Security.EscapeOutput.OutputNotEscaped -- TwiML XML response
                echo $this->tts_play($reprompt_msg, $call_sid, $user_id); // phpcs:ignore WordPress.Security.EscapeOutput.OutputNotEscaped -- TwiML XML output
                echo '<Record action="' . esc_xml($whisper_converse_url2) . '" maxLength="30" timeout="' . max(10, intval($phone_opening['reprompt_timeout'] ?? 30)) . '" playBeep="false" trim="trim-silence" />';
            } else {
                echo '<Gather input="speech" action="' . esc_xml($ai_converse_url) . '" speechTimeout="2" timeout="' . max(10, intval($phone_opening['reprompt_timeout'] ?? 30)) . '" enhanced="true" language="' . esc_attr($initial_gather_lang) . '">';
                echo '<Say voice="' . esc_attr($initial_tts_voice) . '">' . esc_xml($reprompt_msg) . '</Say>';
                echo '</Gather>';
            }
            // Voicemail fallback — only reached after BOTH Gathers time out
            echo '<Say voice="' . esc_attr($initial_tts_voice) . '">Let me take a message for you. Please speak after the beep.</Say>';
            echo '<Record maxLength="120" timeout="15" transcribe="true" transcribeCallback="' . esc_xml($this->twilio_public_url('transcription')) . '" />';
            echo '<Say voice="' . esc_attr($initial_tts_voice) . '">Thank you for calling. Goodbye.</Say>';

            echo '</Response>';
            exit; // Ensure no trailing output

        } catch (Throwable $e) {
            // Fatal Error Handling within XML
            error_log('VMO Twilio Webhook Critical Error: ' . $e->getMessage() . ' in ' . $e->getFile() . ':' . $e->getLine());
            error_log('VMO Twilio Webhook Critical Trace: ' . $e->getTraceAsString());
            if (ob_get_length()) {
                ob_clean();
            }

            try {
                if ($to !== '') {
                    $this->clear_live_call_signal($to, $call_sid);
                }
                if ($user_id > 0) {
                    $this->clear_user_ring_signal($user_id, $call_sid);
                }
                if ($call_sid !== '') {
                    $this->cancel_child_ring_calls($call_sid);
                    $this->clear_owner_alert_state($call_sid);
                    delete_transient('vmo_realtime_active_' . sanitize_key($call_sid));
                    $this->clear_realtime_call_context($call_sid);
                }
            } catch (Throwable $cleanup_error) {
                error_log('[VMO Voice Webhook] Catch cleanup failed for ' . $call_sid . ': ' . $cleanup_error->getMessage());
            }

            $safe_recording_enabled = ($user_id > 0) ? $this->is_recording_enabled($user_id) : true;
            $this->emit_safe_incoming_voice_fallback($user_id, $call_sid, $safe_recording_enabled);
        }
    }

    /**
     * Handle AI conversation callback from Twilio <Gather input="speech">
     *
     * Receives the caller's speech, sends it to OpenAI GPT for a conversational
     * response, and returns TwiML with <Say> + another <Gather> to continue the
     * dialogue. Falls through to voicemail when the caller says goodbye or on error.
     *
     * Conversation history is stored in a short-lived WP transient keyed by CallSid.
     */
    public function handle_ai_conversation($request)
    {
        // Ensure clean output
        while (ob_get_level())
            ob_end_clean();

        // Extend PHP execution limit — GPT + TTS can take 10-15s per turn.
        // Default max_execution_time (30s) can kill the script mid-response.
        if (function_exists('set_time_limit')) {
            @set_time_limit(60);
        }

        $request_started_at = microtime(true);

        $params = $request->get_params();
        $speech = isset($params['SpeechResult']) ? sanitize_text_field($params['SpeechResult']) : '';
        $call_sid = isset($params['CallSid']) ? sanitize_text_field($params['CallSid']) : '';
        $user_id = isset($params['uid']) ? intval($params['uid']) : 0;
        $confidence = isset($params['Confidence']) ? floatval($params['Confidence']) : 1.0;
        $call_language = $this->get_call_language_context($call_sid);
        $turn_tts_voice = (string) ($call_language['voice'] ?? 'Polly.Joanna-Neural');
        $turn_gather_lang = (string) ($call_language['gather_lang'] ?? 'en-US');

        error_log('[VMO AI Converse] CallSid=' . $call_sid . ' uid=' . $user_id . ' Speech="' . $speech . '" Confidence=' . $confidence);
        try {
            // --- Conversation history (stored per call, 10 min TTL) ---
            $history_key = 'vmo_ai_call_' . preg_replace('/[^a-zA-Z0-9_]/', '', $call_sid);
            $history = get_transient($history_key);
            if (!is_array($history))
                $history = array();

            // ── Ensure call-level recording is active ──
            // On the FIRST conversation turn (empty history) the call is definitely
            // in-progress, so the REST API recording request will succeed even if
            // the initial attempt during the webhook handler failed (the call may
            // have still been in 'ringing' state at that point).
            if (empty($history) && !empty($call_sid) && $user_id > 0 && $this->is_recording_enabled($user_id)) {
                $rec_check_key = 'vmo_rec_started_' . $call_sid;
                if (get_transient($rec_check_key) === false) {
                    $rec2 = $this->twilio_request('POST', "/Calls/{$call_sid}/Recordings.json", array(
                        'RecordingStatusCallbackEvent' => 'completed',
                        'RecordingStatusCallback' => $this->twilio_public_url('recording'),
                    ));
                    if (is_wp_error($rec2)) {
                        error_log('[VMO Recording] ai_converse first-turn start FAILED for ' . $call_sid . ': ' . $rec2->get_error_message());
                    } else {
                        error_log('[VMO Recording] ai_converse first-turn start SUCCEEDED for ' . $call_sid);
                        set_transient($rec_check_key, 1, 600);
                    }
                }
                // Clean up any retry transient
                delete_transient('vmo_rec_retry_' . $call_sid);
            }

            if (!empty($speech)) {
                $history[] = array('role' => 'user', 'content' => $speech);
            }

            // --- Build system prompt with RICH business context ---
            $system_prompt = $this->build_receptionist_prompt($user_id, $call_language);
            if (trim($speech) !== '') {
                $system_prompt .= "\nCURRENT TURN LANGUAGE: Detect the caller's language from this SpeechResult and answer in that same language unless they ask you to switch: " . $speech . "\n";
            }

            if (trim($speech) === '') {
                error_log('[VMO AI Converse] Empty SpeechResult for call ' . $call_sid);
                $ai_response = "I didn't hear anything. Please say that again, or you can leave a message.";
            } else {
                $ai_generation = $this->request_phone_ai_response($user_id, $system_prompt, $history, $speech, $call_sid);
                $ai_response = (string) ($ai_generation['content'] ?? '');

                // If the model was cut off by the token cap, repair to the last
                // complete sentence so the caller never hears a half-finished thought.
                if ($ai_response !== '' && ($ai_generation['finish_reason'] ?? '') === 'length') {
                    $ai_response = $this->complete_to_last_sentence($ai_response);
                }

                if ($ai_response === '') {
                    error_log('[VMO AI Converse] Empty OpenAI response for call ' . $call_sid . ' after captured speech');
                    $ai_response = "I'm having a little trouble accessing our system right now. Please say that again, or you can leave a message.";
                }
            }

            // Store AI response in history
            $history[] = array('role' => 'assistant', 'content' => $ai_response);
            set_transient($history_key, $history, 600);

            // Also persist the conversation transcript in the DB for later review
            if (!empty($call_sid)) {
                global $wpdb;
                $table = $wpdb->prefix . 'vmo_phone_calls';
                $transcript_parts = array();
                foreach ($history as $msg) {
                    $role_label = ($msg['role'] === 'user') ? 'Caller' : 'AI';
                    $transcript_parts[] = $role_label . ': ' . $msg['content'];
                }
                // phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery, WordPress.DB.DirectDatabaseQuery.NoCaching
                $wpdb->update(
                    $table,
                    array('transcript' => implode("\n", $transcript_parts)),
                    array('call_sid' => $call_sid),
                    array('%s'),
                    array('%s')
                );
            }

            // --- Detect end-of-conversation signals ---
            $end_signals = array('goodbye', 'bye', 'hang up', "that's all", 'no thanks', 'thank you bye', 'good night', 'have a good day');
            $is_ending = false;
            $speech_lower = strtolower($speech);
            foreach ($end_signals as $signal) {
                if (strpos($speech_lower, $signal) !== false) {
                    $is_ending = true;
                    break;
                }
            }

            // Safety guard against a runaway loop only — callers can talk as long
            // as they need. This ceiling is deliberately high so normal long
            // conversations are never cut short.
            $turn_count = count(array_filter($history, function ($m) {
                return $m['role'] === 'user'; }));
            if ($turn_count >= 40) {
                $is_ending = true;
                $ai_response .= ' Thank you for calling. Goodbye!';
            }

            // --- Build TwiML response ---
            header('Content-Type: text/xml; charset=UTF-8');
            echo '<?xml version="1.0" encoding="UTF-8"?>';
            echo '<Response>';

            $ai_response = $this->sanitize_spoken_text($ai_response);
            if (!$is_ending) {
                if ($this->is_whisper_language()) {
                    $whisper_converse_url = $this->phone_gateway_url('ai_converse_whisper', array('uid' => $user_id));
                    echo $this->tts_play($ai_response, $call_sid, $user_id, $request_started_at); // phpcs:ignore WordPress.Security.EscapeOutput.OutputNotEscaped -- TwiML XML output
                    echo '<Record action="' . esc_xml($whisper_converse_url) . '" maxLength="30" timeout="3" playBeep="false" trim="trim-silence" />';
                } else {
                    $converse_url = $this->phone_gateway_url('ai_converse', array('uid' => $user_id));
                    echo '<Gather input="speech" action="' . esc_xml($converse_url) . '" speechTimeout="2" timeout="10" enhanced="true" language="' . esc_attr($turn_gather_lang) . '">';
                    echo $this->tts_play($ai_response, $call_sid, $user_id, $request_started_at); // phpcs:ignore WordPress.Security.EscapeOutput.OutputNotEscaped -- TwiML XML output
                    echo '</Gather>';
                }
                // Re-prompt before voicemail — keep the call alive longer
                if ($this->is_whisper_language()) {
                    echo '<Say voice="' . esc_attr($turn_tts_voice) . '">Are you still there?</Say>';
                    echo '<Record action="' . esc_xml($whisper_converse_url) . '" maxLength="30" timeout="15" playBeep="false" trim="trim-silence" />';
                } else {
                    echo '<Gather input="speech" action="' . esc_xml($converse_url) . '" speechTimeout="2" timeout="20" enhanced="true" language="' . esc_attr($turn_gather_lang) . '">';
                    echo '<Say voice="' . esc_attr($turn_tts_voice) . '">Are you still there? I\'m here to help.</Say>';
                    echo '</Gather>';
                }
                // Voicemail fallback — only after BOTH Gathers time out
                echo '<Say voice="' . esc_attr($turn_tts_voice) . '">If you\'d like to leave a message, please speak after the beep.</Say>';
                echo '<Record maxLength="120" timeout="15" transcribe="true" transcribeCallback="' . esc_xml($this->twilio_public_url('transcription')) . '" />';
                echo '<Say voice="' . esc_attr($turn_tts_voice) . '">Thank you for calling. Goodbye.</Say>';
            } else {
                echo $this->tts_play('Thank you for calling. Have a great day!', $call_sid, $user_id, $request_started_at); // phpcs:ignore WordPress.Security.EscapeOutput.OutputNotEscaped -- TwiML XML output
                echo '<Hangup />';
            }

            echo '</Response>';
            exit;
        } catch (Throwable $e) {
            error_log('[VMO AI Converse] Fallback after exception for call ' . $call_sid . ': ' . $e->getMessage());
            $this->emit_safe_ai_turn_fallback($user_id, false);
        }
    }

    /**
     * Handle AI conversation for Whisper-only languages.
     *
     * Called by Twilio's <Record> action for languages where Twilio has
     * no built-in speech recognition (Yorùbá, Igbo, Pidgin English, etc.).
     *
     * Flow: <Record> → RecordingUrl → Whisper API transcription → GPT → Ash TTS → <Record>
     *
     * This mirrors handle_ai_conversation() but replaces the Twilio
     * SpeechResult with OpenAI Whisper transcription.
     */
    public function handle_ai_conversation_whisper($request)
    {
        while (ob_get_level())
            ob_end_clean();

        // Extend PHP execution limit — Whisper + GPT can take 15-20s per turn.
        if (function_exists('set_time_limit')) {
            @set_time_limit(60);
        }

        $request_started_at = microtime(true);

        $params = $request->get_params();
        $call_sid = isset($params['CallSid']) ? sanitize_text_field($params['CallSid']) : '';
        $user_id = isset($params['uid']) ? intval($params['uid']) : 0;
        $recording_url = isset($params['RecordingUrl']) ? esc_url_raw($params['RecordingUrl']) : '';
        $recording_sid = isset($params['RecordingSid']) ? sanitize_text_field($params['RecordingSid']) : '';
        $recording_duration = isset($params['RecordingDuration']) ? intval($params['RecordingDuration']) : 0;
        $call_language = $this->get_call_language_context($call_sid);
        $turn_tts_voice = (string) ($call_language['voice'] ?? 'Polly.Joanna-Neural');

        error_log('[VMO Whisper Converse] CallSid=' . $call_sid . ' uid=' . $user_id . ' RecordingSid=' . $recording_sid . ' Duration=' . $recording_duration . 's');
        try {
            // --- Transcribe with Whisper ---
            $speech = '';
            if (!empty($recording_url) && $recording_duration > 0) {
                $speech = $this->whisper_transcribe($recording_url, $this->whisper_lang_hint());
            }

            // Delete the short recording from Twilio immediately (save storage)
            if (!empty($recording_sid) && !empty($this->account_sid) && !empty($this->auth_token)) {
                $this->twilio_request('DELETE', '/Recordings/' . $recording_sid . '.json');
            }

            // --- Conversation history (stored per call, 10 min TTL) ---
            $history_key = 'vmo_ai_call_' . preg_replace('/[^a-zA-Z0-9_]/', '', $call_sid);
            $history = get_transient($history_key);
            if (!is_array($history))
                $history = array();

            // ── Ensure call-level recording is active (same as handle_ai_conversation) ──
            if (empty($history) && !empty($call_sid) && $user_id > 0 && $this->is_recording_enabled($user_id)) {
                $rec_check_key = 'vmo_rec_started_' . $call_sid;
                if (get_transient($rec_check_key) === false) {
                    $rec2 = $this->twilio_request('POST', "/Calls/{$call_sid}/Recordings.json", array(
                        'RecordingStatusCallbackEvent' => 'completed',
                        'RecordingStatusCallback' => $this->twilio_public_url('recording'),
                    ));
                    if (!is_wp_error($rec2)) {
                        set_transient($rec_check_key, 1, 600);
                    }
                }
                delete_transient('vmo_rec_retry_' . $call_sid);
            }

            if (!empty($speech)) {
                $history[] = array('role' => 'user', 'content' => $speech);
            }

            // --- Build system prompt with RICH business context ---
            $system_prompt = $this->build_receptionist_prompt($user_id, $call_language);
            if (trim($speech) !== '') {
                $system_prompt .= "\nCURRENT TURN LANGUAGE: Detect the caller's language from this transcription and answer in that same language unless they ask you to switch: " . $speech . "\n";
            }

            if (trim($speech) === '') {
                error_log('[VMO Whisper Converse] Empty transcription for call ' . $call_sid);
                $ai_response = "I didn't hear anything. Please say that again, or you can leave a message.";
            } else {
                $ai_generation = $this->request_phone_ai_response($user_id, $system_prompt, $history, $speech, $call_sid);
                $ai_response = (string) ($ai_generation['content'] ?? '');

                // If the model was cut off by the token cap, repair to the last
                // complete sentence so the caller never hears a half-finished thought.
                if ($ai_response !== '' && ($ai_generation['finish_reason'] ?? '') === 'length') {
                    $ai_response = $this->complete_to_last_sentence($ai_response);
                }

                if ($ai_response === '') {
                    error_log('[VMO Whisper Converse] Empty OpenAI response for call ' . $call_sid . ' after transcription');
                    $ai_response = "I'm having a little trouble accessing our system right now. Please say that again, or you can leave a message.";
                }
            }

            // Store AI response in history
            $history[] = array('role' => 'assistant', 'content' => $ai_response);
            set_transient($history_key, $history, 600);

            // Also persist the conversation transcript in the DB
            if (!empty($call_sid)) {
                global $wpdb;
                $table = $wpdb->prefix . 'vmo_phone_calls';
                $transcript_parts = array();
                foreach ($history as $msg) {
                    $role_label = ($msg['role'] === 'user') ? 'Caller' : 'AI';
                    $transcript_parts[] = $role_label . ': ' . $msg['content'];
                }
                // phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery, WordPress.DB.DirectDatabaseQuery.NoCaching
                $wpdb->update(
                    $table,
                    array('transcript' => implode("\n", $transcript_parts)),
                    array('call_sid' => $call_sid),
                    array('%s'),
                    array('%s')
                );
            }

            // --- Detect end-of-conversation signals ---
            $end_signals = array('goodbye', 'bye', 'hang up', "that's all", 'no thanks', 'thank you bye', 'good night', 'have a good day');
            $is_ending = false;
            $speech_lower = strtolower($speech);
            foreach ($end_signals as $signal) {
                if (strpos($speech_lower, $signal) !== false) {
                    $is_ending = true;
                    break;
                }
            }

            // Safety guard against a runaway loop only — callers can talk as long
            // as they need. This ceiling is deliberately high so normal long
            // conversations are never cut short.
            $turn_count = count(array_filter($history, function ($m) {
                return $m['role'] === 'user'; }));
            if ($turn_count >= 40) {
                $is_ending = true;
                $ai_response .= ' Thank you for calling. Goodbye!';
            }

            // --- Build TwiML response ---
            header('Content-Type: text/xml; charset=UTF-8');
            echo '<?xml version="1.0" encoding="UTF-8"?>';
            echo '<Response>';

            $ai_response = $this->sanitize_spoken_text($ai_response);

            if (!$is_ending) {
                // Continue the Whisper conversation loop: Speak → Record → Whisper → GPT → Speak.
                $whisper_converse_url = $this->phone_gateway_url('ai_converse_whisper', array('uid' => $user_id));
                echo $this->tts_play($ai_response, $call_sid, $user_id, $request_started_at); // phpcs:ignore WordPress.Security.EscapeOutput.OutputNotEscaped -- TwiML XML output
                echo '<Record action="' . esc_xml($whisper_converse_url) . '" maxLength="30" timeout="3" playBeep="false" trim="trim-silence" />';
                // Re-prompt before voicemail — keep the call alive
                echo '<Say voice="' . esc_attr($turn_tts_voice) . '">Are you still there? I\'m here to help.</Say>';
                echo '<Record action="' . esc_xml($whisper_converse_url) . '" maxLength="30" timeout="20" playBeep="false" trim="trim-silence" />';
                // Voicemail fallback — only after both Records time out
                echo '<Say voice="' . esc_attr($turn_tts_voice) . '">If you\'d like to leave a message, please speak after the beep.</Say>';
                echo '<Record maxLength="120" timeout="15" transcribe="true" transcribeCallback="' . esc_xml($this->twilio_public_url('transcription')) . '" />';
                echo '<Say voice="' . esc_attr($turn_tts_voice) . '">Thank you for calling. Goodbye.</Say>';
            } else {
                echo $this->tts_play('Thank you for calling. Have a great day!', $call_sid, $user_id, $request_started_at); // phpcs:ignore WordPress.Security.EscapeOutput.OutputNotEscaped -- TwiML XML output
                echo '<Hangup />';
            }

            echo '</Response>';
            exit;
        } catch (Throwable $e) {
            error_log('[VMO Whisper Converse] Fallback after exception for call ' . $call_sid . ': ' . $e->getMessage());
            $this->emit_safe_ai_turn_fallback($user_id, true);
        }
    }

    /**
     * Handle Power Dialer AI conversation callback.
     *
     * Same Gather + GPT loop as the inbound AI receptionist but uses the
     * sales-focused system prompt stored in a transient by power_dialer_twiml().
     * Supports user takeover when the callee asks to speak to a real person.
     */
    public function handle_pd_ai_conversation($request)
    {
        while (ob_get_level())
            ob_end_clean();

        $params = $request->get_params();
        $speech = isset($params['SpeechResult']) ? sanitize_text_field($params['SpeechResult']) : '';
        $call_sid = isset($params['CallSid']) ? sanitize_text_field($params['CallSid']) : '';
        $user_id = isset($params['uid']) ? intval($params['uid']) : 0;
        $campaign_id = isset($params['campaign_id']) ? intval($params['campaign_id']) : 0;
        $user_phone = isset($params['user_phone']) ? sanitize_text_field(urldecode($params['user_phone'])) : '';

        error_log('[VMO PD AI Converse] CallSid=' . $call_sid . ' uid=' . $user_id . ' Speech="' . $speech . '"');

        // --- Conversation history (stored per call, 30 min TTL) ---
        $history_key = 'vmo_pd_call_' . preg_replace('/[^a-zA-Z0-9_]/', '', $call_sid);
        $history = get_transient($history_key);
        if (!is_array($history))
            $history = array();

        if (!empty($speech)) {
            $history[] = array('role' => 'user', 'content' => $speech);
        }

        // --- Retrieve sales system prompt from transient ---
        $prompt_key = 'vmo_pd_prompt_' . preg_replace('/[^a-zA-Z0-9_]/', '', $call_sid);
        $system_prompt = get_transient($prompt_key);
        if (empty($system_prompt)) {
            // Minimal fallback
            $business_name = ($user_id > 0) ? (get_user_meta($user_id, 'business_name', true) ?: 'Our Company') : 'Our Company';
            $system_prompt = "You are a professional sales representative calling from {$business_name}. Be friendly, professional, and helpful. Keep responses concise (1-3 sentences) since this is a phone call.";
        }

        // --- Detect takeover request ---
        $speech_lower = strtolower($speech);
        $wants_human = false;
        $transfer_signals = array('speak to someone', 'speak with someone', 'talk to a person', 'talk to a human', 'real person', 'transfer me', 'connect me', 'speak to a representative', 'manager', 'supervisor');
        foreach ($transfer_signals as $signal) {
            if (strpos($speech_lower, $signal) !== false) {
                $wants_human = true;
                break;
            }
        }

        if ($wants_human && !empty($user_phone)) {
            // Transfer to human — redirect to takeover TwiML
            header('Content-Type: text/xml; charset=UTF-8');
            echo '<?xml version="1.0" encoding="UTF-8"?>';
            echo '<Response>';
            echo '<Say voice="' . $this->tts_voice() . '">Absolutely! Let me connect you with our specialist right now.</Say>';
            echo '<Dial timeout="30"><Number>' . esc_xml($user_phone) . '</Number></Dial>';
            echo '<Say voice="' . $this->tts_voice() . '">I\'m sorry, our specialist isn\'t available right now. Thank you for your time!</Say>';
            echo '</Response>';
            exit;
        }

        // --- Call OpenAI GPT ---
        $api_key = get_option('vmo_openai_api_key', '');
        $ai_response = '';

        if (!empty($api_key) && !empty($speech)) {
            $messages = array_merge(
                array(array('role' => 'system', 'content' => $system_prompt)),
                $history
            );

            // Use centralized model registry for phone AI
            $model = function_exists('vmo_get_model') ? vmo_get_model('fast') : 'gpt-5.5-mini';
            $token_param = function_exists('vmo_get_token_param')
                ? vmo_get_token_param($model, 500)
                : array('max_tokens' => 500);

            $gpt_body = wp_remote_post('https://api.openai.com/v1/chat/completions', array(
                'timeout' => 12,
                'headers' => array(
                    'Authorization' => 'Bearer ' . $api_key,
                    'Content-Type' => 'application/json',
                ),
                'body' => wp_json_encode(array_merge(array(
                    'model' => $model,
                    'messages' => $messages,
                    'temperature' => 0.8,
                ), $token_param)),
            ));

            if (!is_wp_error($gpt_body)) {
                $raw_body = wp_remote_retrieve_body($gpt_body);
                $http_code = wp_remote_retrieve_response_code($gpt_body);
                $gpt_json = json_decode($raw_body, true);
                if (isset($gpt_json['choices'][0]['message']['content'])) {
                    $ai_response = trim($gpt_json['choices'][0]['message']['content']);

                    // ✅ TOKEN TRACKING: Accumulate GPT turns for post-call billing (Power Dialer)
                    if (!empty($call_sid)) {
                        $turns_key = 'vmo_ai_turns_' . preg_replace('/[^a-zA-Z0-9_]/', '', $call_sid);
                        $current_turns = (int) get_transient($turns_key);
                        set_transient($turns_key, $current_turns + 1, 3600);
                    }
                } else {
                    error_log('[VMO PD AI Converse] GPT unexpected response (HTTP ' . $http_code . '): ' . substr($raw_body, 0, 500));
                }
            } else {
                error_log('[VMO PD AI Converse] GPT Error: ' . $gpt_body->get_error_message());
            }
        }

        if ($ai_response === '') {
            $ai_response = "I'm sorry, I didn't quite catch that. Could you say that again?";
        }

        // Store AI response in history
        $history[] = array('role' => 'assistant', 'content' => $ai_response);
        set_transient($history_key, $history, 1800);

        // Persist transcript to power dialer calls table
        if (!empty($call_sid)) {
            global $wpdb;
            $table = $wpdb->prefix . 'vmo_power_dialer_calls';
            if ($wpdb->get_var("SHOW TABLES LIKE '{$table}'") === $table) {
                $transcript_parts = array();
                foreach ($history as $msg) {
                    $role_label = ($msg['role'] === 'user') ? 'Prospect' : 'AI';
                    $transcript_parts[] = $role_label . ': ' . $msg['content'];
                }
                // phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery, WordPress.DB.DirectDatabaseQuery.NoCaching
                $wpdb->update(
                    $table,
                    array('summary' => implode("\n", $transcript_parts)),
                    array('call_sid' => $call_sid),
                    array('%s'),
                    array('%s')
                );
            }
        }

        // --- Detect end-of-conversation ---
        $end_signals = array('goodbye', 'bye', 'no thanks', 'not interested', 'remove me', 'do not call', 'stop calling', 'take me off');
        $is_ending = false;
        foreach ($end_signals as $signal) {
            if (strpos($speech_lower, $signal) !== false) {
                $is_ending = true;
                break;
            }
        }

        $turn_count = count(array_filter($history, function ($m) {
            return $m['role'] === 'user'; }));
        if ($turn_count >= 20) {
            $is_ending = true;
        }

        // --- Build TwiML response ---
        header('Content-Type: text/xml; charset=UTF-8');
        echo '<?xml version="1.0" encoding="UTF-8"?>';
        echo '<Response>';

        // Strip ALL non-speech formatting so TTS never says "asterisk" etc.
        $ai_response = preg_replace('/[\*#_~`\[\]|>{}\\^]+/', '', $ai_response);
        $ai_response = preg_replace('/[\x{2022}\x{2023}\x{25E6}\x{2043}\x{2219}\x{25AA}\x{25AB}\x{25CF}\x{25CB}\x{2013}\x{2014}\x{2018}\x{2019}\x{201C}\x{201D}]/u', '', $ai_response);
        $ai_response = preg_replace('/[\x{1F000}-\x{1FFFF}\x{2600}-\x{27BF}\x{FE00}-\x{FE0F}\x{1F900}-\x{1F9FF}]/u', '', $ai_response);
        $ai_response = preg_replace('/^\s*[\d]+[\.\)\:]\s*/m', '', $ai_response);
        $ai_response = preg_replace('/^\s*[-\*\+]\s+/m', '', $ai_response);
        $ai_response = preg_replace('/\(\s*\)/', '', $ai_response);
        $ai_response = trim(preg_replace('/\s{2,}/', ' ', $ai_response));

        if (!$is_ending) {
            $converse_url = $this->phone_gateway_url('pd_ai_converse', array(
                'campaign_id' => $campaign_id,
                'uid' => $user_id,
                'user_phone' => urlencode($user_phone),
            ));
            // Architecture C: Ash TTS inside <Gather> for barge-in
            echo '<Gather input="speech" action="' . esc_xml($converse_url) . '" speechTimeout="2" enhanced="true" language="' . $this->gather_lang() . '">';
            // phpcs:ignore WordPress.Security.EscapeOutput.OutputNotEscaped -- TwiML XML response
            echo $this->tts_play($ai_response, $call_sid); // phpcs:ignore WordPress.Security.EscapeOutput.OutputNotEscaped -- TwiML XML output
            echo '</Gather>';
            echo '<Say voice="' . $this->tts_voice() . '">I hope I haven\'t caught you at a bad time. Thank you for your time. Have a great day!</Say>';
        } else {
            // Check if they asked to be removed (DNC)
            $dnc_signals = array('remove me', 'do not call', 'stop calling', 'take me off');
            $is_dnc = false;
            foreach ($dnc_signals as $d) {
                if (strpos($speech_lower, $d) !== false) {
                    $is_dnc = true;
                    break;
                }
            }
            if ($is_dnc) {
                echo '<Say voice="' . $this->tts_voice() . '">Absolutely, I\'ve noted your request. You won\'t receive any more calls from us. I apologize for the inconvenience. Have a great day!</Say>';
                // Add to DNC list
                if ($user_id > 0 && !empty($call_sid)) {
                    global $wpdb;
                    $pd_table = $wpdb->prefix . 'vmo_power_dialer_calls';
                    $phone = $wpdb->get_var($wpdb->prepare("SELECT phone FROM {$pd_table} WHERE call_sid = %s LIMIT 1", $call_sid));
                    if ($phone) {
                        $existing_dnc = get_user_meta($user_id, 'vmo_power_dialer_dnc', true) ?: '';
                        if (strpos($existing_dnc, $phone) === false) {
                            update_user_meta($user_id, 'vmo_power_dialer_dnc', trim($existing_dnc . "\n" . $phone));
                        }
                    }
                }
            } else {
                echo '<Say voice="' . $this->tts_voice() . '">Thank you for your time! Have a wonderful day!</Say>';
            }
            echo '<Hangup />';
        }

        echo '</Response>';
        exit;
    }

    /**
     * Handle call status callback from Twilio
     * 
     * Called when call completes or status changes
     * Updates database with final call data and triggers summarization
     */
    public function handle_status_callback($request)
    {
        $params = $request->get_params();

        // Log webhook for debugging
        error_log('VMO Twilio Status Callback: ' . print_r($params, true));

        $call_sid = isset($params['CallSid']) ? sanitize_text_field($params['CallSid']) : '';
        $call_status = isset($params['CallStatus']) ? sanitize_text_field($params['CallStatus']) : '';
        $call_duration = isset($params['CallDuration']) ? intval($params['CallDuration']) : 0;
        $twilio_error_code = isset($params['ErrorCode']) ? sanitize_text_field($params['ErrorCode']) : '';
        $twilio_error_message = isset($params['ErrorMessage']) ? sanitize_text_field($params['ErrorMessage']) : '';
        $stream_sid = isset($params['StreamSid']) ? sanitize_text_field($params['StreamSid']) : '';
        $stream_event = isset($params['StreamEvent']) ? sanitize_text_field($params['StreamEvent']) : '';
        $stream_error = isset($params['StreamError']) ? sanitize_text_field($params['StreamError']) : '';
        $recording_url = isset($params['RecordingUrl']) ? esc_url_raw($params['RecordingUrl']) : '';
        $from = isset($params['From']) ? sanitize_text_field($params['From']) : '';
        $to = isset($params['To']) ? sanitize_text_field($params['To']) : '';
        $message_sid = isset($params['MessageSid']) ? sanitize_text_field($params['MessageSid']) : (isset($params['SmsSid']) ? sanitize_text_field($params['SmsSid']) : '');
        $message_status = isset($params['MessageStatus']) ? sanitize_text_field($params['MessageStatus']) : (isset($params['SmsStatus']) ? sanitize_text_field($params['SmsStatus']) : '');
        $next_message_status = ($message_status !== '' ? $message_status : 'sent');

        if ($message_sid !== '') {
            global $wpdb;
            $sms_table = $wpdb->prefix . 'vmo_sms_logs';
            $existing_sms_id = 0;

            if ($wpdb->get_var($wpdb->prepare('SHOW TABLES LIKE %s', $sms_table)) !== $sms_table && function_exists('vmo_create_twilio_tables')) {
                vmo_create_twilio_tables();
            }

            if ($wpdb->get_var($wpdb->prepare('SHOW TABLES LIKE %s', $sms_table)) === $sms_table) {
                $existing_sms_id = (int) $wpdb->get_var($wpdb->prepare(
                    "SELECT id FROM {$sms_table} WHERE twilio_message_sid = %s LIMIT 1",
                    $message_sid
                ));

                if ($existing_sms_id > 0) {
                    $wpdb->update(
                        $sms_table,
                        array('status' => $next_message_status),
                        array('id' => $existing_sms_id),
                        array('%s'),
                        array('%d')
                    );
                }
            }

            return new WP_REST_Response(array(
                'success' => true,
                'message' => ($existing_sms_id > 0 ? 'SMS status updated' : 'SMS status acknowledged')
            ), 200);
        }

        // Also check for recording in RecordingSid format
        if (empty($recording_url) && isset($params['RecordingSid'])) {
            $recording_sid = sanitize_text_field($params['RecordingSid']);
            $account_sid = isset($params['AccountSid']) ? sanitize_text_field($params['AccountSid']) : $this->account_sid;
            $recording_url = "https://api.twilio.com/2010-04-01/Accounts/{$account_sid}/Recordings/{$recording_sid}";
        }

        if (empty($call_sid)) {
            return new WP_REST_Response(array('error' => 'Missing CallSid'), 400);
        }

        // Update database with final call data
        global $wpdb;
        $table = $wpdb->prefix . 'vmo_phone_calls';

        $is_bridge_stream_callback = (isset($params['bridge_stream']) && (string) $params['bridge_stream'] === '1') || $stream_sid !== '' || $stream_event !== '' || $stream_error !== '';
        if ($is_bridge_stream_callback) {
            $stream_user_id = isset($params['uid']) ? intval($params['uid']) : 0;
            if ($stream_user_id <= 0) {
                $stream_user_id = (int) $wpdb->get_var($wpdb->prepare(
                    "SELECT user_id FROM {$table} WHERE call_sid = %s LIMIT 1",
                    $call_sid
                ));
            }
            $stream_message_parts = array();
            if ($call_status !== '') {
                $stream_message_parts[] = 'CallStatus=' . $call_status;
            }
            if ($stream_event !== '') {
                $stream_message_parts[] = 'StreamEvent=' . $stream_event;
            }
            if ($stream_sid !== '') {
                $stream_message_parts[] = 'StreamSid=' . $stream_sid;
            }
            if ($stream_error !== '') {
                $stream_message_parts[] = 'StreamError=' . $stream_error;
            }
            if ($twilio_error_code !== '') {
                $stream_message_parts[] = 'ErrorCode=' . $twilio_error_code;
            }
            if ($twilio_error_message !== '') {
                $stream_message_parts[] = 'ErrorMessage=' . $twilio_error_message;
            }
            if (empty($stream_message_parts)) {
                $stream_message_parts[] = 'Premium stream status callback received';
            }
            $stream_message = implode(' ', $stream_message_parts);
            $this->trace_voice_call($call_sid, 'premium_bridge_stream_status ' . $stream_message);
            if ($stream_user_id > 0) {
                $stream_payload = array(
                    'call_sid' => $call_sid,
                    'message' => $stream_message,
                    'time' => time(),
                );
                set_transient('vmo_premium_bridge_last_stream_' . $stream_user_id, $stream_payload, 2 * HOUR_IN_SECONDS);
                if ($stream_error !== '' || $twilio_error_code !== '' || $twilio_error_message !== '' || stripos($stream_event, 'error') !== false) {
                    set_transient('vmo_premium_bridge_last_error_' . $stream_user_id, $stream_payload, 2 * HOUR_IN_SECONDS);
                }
            }
            error_log('[VMO Premium Bridge] Stream status callback: call=' . $call_sid . ' ' . $stream_message);

            return new WP_REST_Response(array(
                'success' => true,
                'message' => 'Premium stream status acknowledged',
            ), 200);
        }

        $parent_sid = isset($params['parent_sid']) ? sanitize_text_field($params['parent_sid']) : '';
        if ($parent_sid !== '' && $call_sid !== '' && $parent_sid !== $call_sid) {
            $this->track_child_ring_call($parent_sid, $call_sid);

            $parent_row = $wpdb->get_row($wpdb->prepare(
                "SELECT user_id, duration, recording_url FROM {$table} WHERE call_sid = %s LIMIT 1",
                $parent_sid
            ));
            $parent_user_id = $parent_row ? (int) ($parent_row->user_id ?? 0) : 0;
            $normalized_child_status = strtolower(trim($call_status));

            if (in_array($normalized_child_status, array('in-progress', 'answered'), true)) {
                $this->mark_call_leg_answered($call_sid);
                $this->cancel_child_ring_calls($parent_sid, $call_sid);
                if ($parent_user_id > 0) {
                    $this->clear_user_ring_signal($parent_user_id, $parent_sid);
                }
            } elseif ($this->live_call_status_is_terminal($normalized_child_status)) {
                $this->untrack_child_ring_call($parent_sid, $call_sid);
                $remaining_child_calls = get_transient($this->child_ring_calls_key($parent_sid));
                if ((!is_array($remaining_child_calls) || empty($remaining_child_calls)) && $parent_user_id > 0) {
                    $this->clear_user_ring_signal($parent_user_id, $parent_sid);
                }
            }

            if ($call_status === 'completed' && $parent_row) {
                $parent_update = array();
                $parent_format = array();
                if ($call_duration > 0 && $call_duration > (int) ($parent_row->duration ?? 0)) {
                    $parent_update['duration'] = $call_duration;
                    $parent_format[] = '%d';
                }
                if ($recording_url !== '' && empty($parent_row->recording_url)) {
                    $parent_update['recording_url'] = $recording_url;
                    $parent_format[] = '%s';
                }
                if (!empty($parent_update)) {
                    $wpdb->update($table, $parent_update, array('call_sid' => $parent_sid), $parent_format, array('%s'));
                }

                if ($recording_url !== '') {
                    $this->save_call_recording($parent_sid, $recording_url);
                    $this->schedule_phone_event_now('vmo_generate_call_summary', array($parent_sid, $recording_url), 30);
                    $this->schedule_phone_event_now('vmo_send_call_summary_email', array($parent_sid), 90);
                }
            }

            if ($normalized_child_status === 'completed' && $call_duration > 0) {
                // ── Role-aware teardown ──
                // Previously this unconditionally called terminate_call_everywhere()
                // for ANY child-sid completion, which killed the premium WebSocket
                // bridge whenever a monitoring/diagnostic/ring leg finished. Only
                // a true active participant ending the call should end the full
                // call. Unanswered ring legs, listen legs, and similar must NOT
                // tear down the parent.
                $child_role = $this->get_call_leg_role($call_sid);
                $was_answered = $this->was_call_leg_answered($call_sid);
                error_log('[VMO Status Callback] Child completed: parent=' . $parent_sid
                    . ' child=' . $call_sid . ' role=' . $child_role
                    . ' answered=' . ($was_answered ? '1' : '0')
                    . ' duration=' . $call_duration);

                if ($child_role === 'owner_browser_listen') {
                    // Listen leg ending is harmless — never end the main call.
                    return new WP_REST_Response(array(
                        'success' => true,
                        'message' => 'Listen leg disconnected without ending main call',
                    ), 200);
                }

                if ($child_role === 'external_owner_ring' && !$was_answered) {
                    // Owner's phone never picked up — clean up the ring leg but
                    // do not end the caller's parent call (so the AI bridge can
                    // keep handling them).
                    return new WP_REST_Response(array(
                        'success' => true,
                        'message' => 'Unanswered ring leg cleaned without ending main call',
                    ), 200);
                }

                if (
                    in_array($child_role, array(
                        'owner_browser_takeover',
                        'external_owner_ring',     // answered → real participant ended call
                        'premium_ai_bridge',
                    ), true)
                ) {
                    $this->terminate_call_everywhere($parent_sid, $child_role . '_disconnected', array(
                        'status' => 'completed',
                        'parent_sid' => $parent_sid,
                        'child_sid' => $call_sid,
                    ));
                } elseif ($child_role === 'caller_parent') {
                    // The caller themselves disconnected — end everything.
                    $this->terminate_call_everywhere($parent_sid, 'caller_disconnected', array(
                        'status' => 'completed',
                        'parent_sid' => $parent_sid,
                        'child_sid' => $call_sid,
                    ));
                }
                // Unknown roles: be conservative and do NOTHING. The premium
                // bridge guard inside terminate_call_everywhere() would have
                // blocked the prior blind teardown anyway, but skipping the
                // call entirely also keeps the logs clean.
            }

            error_log('[VMO Status Callback] Internal ring child status handled without call-history insert: child=' . $call_sid . ' parent=' . $parent_sid . ' status=' . $call_status);
            return new WP_REST_Response(array('success' => true, 'message' => 'Internal ring child status updated'), 200);
        }

        $update_data = array(
            'status' => $call_status,
            'duration' => $call_duration,
        );

        if (!empty($recording_url)) {
            $update_data['recording_url'] = $recording_url;
        }

        // Build format specifiers to match the data array exactly
        $format = array('%s', '%d');
        if (!empty($recording_url)) {
            $format[] = '%s';
        }

        // phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery, WordPress.DB.DirectDatabaseQuery.NoCaching
        $result = $wpdb->update(
            $table,
            $update_data,
            array('call_sid' => $call_sid),
            $format,
            array('%s')
        );

        if ($result === false) {
            error_log('VMO Twilio: Failed to update call record for ' . $call_sid . ': ' . $wpdb->last_error);
        }

        // ── If no row was updated (browser-originated call may not have been INSERT'd yet) ──
        if ($result === 0) {
            $existing_call_id = (int) $wpdb->get_var($wpdb->prepare(
                "SELECT id FROM {$table} WHERE call_sid = %s LIMIT 1",
                $call_sid
            ));
            if ($existing_call_id > 0) {
                error_log('VMO Twilio: Status callback matched existing call record with unchanged values: ' . $call_sid . ' id=' . $existing_call_id);
            } else {
                // Determine direction from Twilio params
                $direction = 'inbound';
                $raw_dir = isset($params['Direction']) ? strtolower($params['Direction']) : '';
                if (strpos($raw_dir, 'outbound') !== false) {
                    $direction = 'outbound';
                }
                $caller = ($direction === 'outbound') ? $to : $from;

                // Resolve user_id for this call
                $insert_user_id = 0;
                if ($direction === 'inbound' && !empty($to)) {
                    $insert_user_id = $this->get_user_id_from_phone($to);
                }
                if (!$insert_user_id && $direction === 'outbound' && !empty($from)) {
                    $insert_user_id = $this->get_user_id_from_phone($from);
                }
                if (!$insert_user_id) {
                    $insert_user_id = 1; // Admin fallback
                }

                // phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery, WordPress.DB.DirectDatabaseQuery.NoCaching
                $ins = $wpdb->insert($table, array(
                    'user_id' => $insert_user_id,
                    'call_sid' => $call_sid,
                    'caller_number' => $caller,
                    'direction' => $direction,
                    'status' => $call_status,
                    'duration' => $call_duration,
                    'recording_url' => $recording_url ?: null,
                    'ai_handled' => ($direction === 'inbound') ? 1 : 0,
                    'created_at' => current_time('mysql'),
                ), array('%d', '%s', '%s', '%s', '%s', '%d', '%s', '%d', '%s'));

                if ($ins) {
                    error_log('VMO Twilio: Inserted missing call record via status_callback: ' . $call_sid . ' dir=' . $direction);
                } else {
                    error_log('VMO Twilio: Failed to insert call record via status_callback: ' . $wpdb->last_error);
                }
            }
        }

        $premium_status_row = $wpdb->get_row($wpdb->prepare(
            "SELECT user_id, topic FROM {$table} WHERE call_sid = %s LIMIT 1",
            $call_sid
        ));
        $premium_trace = get_transient('vmo_voice_trace_' . sanitize_key($call_sid));
        $premium_trace_text = is_array($premium_trace) ? implode(' | ', $premium_trace) : '';
        $premium_topic = $premium_status_row ? (string) ($premium_status_row->topic ?? '') : '';
        $premium_user_id = $premium_status_row ? (int) ($premium_status_row->user_id ?? 0) : 0;
        $premium_callback_is_terminal_problem = in_array(strtolower(trim($call_status)), array('failed', 'busy', 'no-answer', 'canceled'), true);
        $premium_callback_has_error = ($twilio_error_code !== '' || $twilio_error_message !== '');
        $premium_callback_is_bridge = ($premium_topic === 'premium_realtime_bridge' || strpos($premium_trace_text, 'premium_bridge_twiml_emitted_exit') !== false || strpos($premium_trace_text, 'premium_bridge_stream_status') !== false);
        if ($premium_user_id > 0 && $premium_callback_is_bridge && ($premium_callback_has_error || $premium_callback_is_terminal_problem)) {
            $premium_status_parts = array('CallStatus=' . ($call_status !== '' ? $call_status : '(empty)'));
            $premium_status_parts[] = 'CallDuration=' . (string) $call_duration;
            if ($twilio_error_code !== '') {
                $premium_status_parts[] = 'ErrorCode=' . $twilio_error_code;
            }
            if ($twilio_error_message !== '') {
                $premium_status_parts[] = 'ErrorMessage=' . $twilio_error_message;
            }
            $premium_status_message = implode(' ', $premium_status_parts);
            set_transient('vmo_premium_bridge_last_error_' . $premium_user_id, array(
                'call_sid' => $call_sid,
                'message' => $premium_status_message,
                'time' => time(),
            ), 2 * HOUR_IN_SECONDS);
            $this->trace_voice_call($call_sid, 'premium_bridge_call_status ' . $premium_status_message);
            error_log('[VMO Premium Bridge] Parent call status after Premium bridge: call=' . $call_sid . ' ' . $premium_status_message);
        }

        $signal_phone = '';
        if ($to !== '' && strpos($to, 'client:') !== 0 && strpos($to, 'sip:') !== 0) {
            $signal_phone = $to;
        } else {
            $signal_row = $wpdb->get_row($wpdb->prepare(
                "SELECT user_id, direction, caller_number FROM {$table} WHERE call_sid = %s LIMIT 1",
                $call_sid
            ));
            if ($signal_row && (($signal_row->direction ?: 'inbound') === 'inbound')) {
                $signal_phone = (string) ($this->get_user_phone((int) $signal_row->user_id) ?: '');
                if ($from === '' && !empty($signal_row->caller_number)) {
                    $from = (string) $signal_row->caller_number;
                }
            }
        }

        $parent_sid = isset($params['parent_sid']) ? sanitize_text_field($params['parent_sid']) : '';
        if ($parent_sid !== '' && $call_sid !== '' && $parent_sid !== $call_sid) {
            $this->track_child_ring_call($parent_sid, $call_sid);
        }
        $cleanup_user_id = 0;
        if (!empty($signal_row) && !empty($signal_row->user_id)) {
            $cleanup_user_id = (int) $signal_row->user_id;
        } elseif ($signal_phone !== '') {
            $cleanup_user_id = (int) $this->get_user_id_from_phone($signal_phone);
        }

        $parent_user_id = 0;
        if ($parent_sid !== '') {
            $parent_user_id = (int) $wpdb->get_var($wpdb->prepare(
                "SELECT user_id FROM {$table} WHERE call_sid = %s LIMIT 1",
                $parent_sid
            ));
        }
        $ring_owner_user_id = ($parent_user_id > 0) ? $parent_user_id : $cleanup_user_id;

        if ($signal_phone !== '') {
            if ($this->live_call_status_is_terminal($call_status)) {
                $this->clear_live_call_signal($signal_phone, $call_sid);
            } else {
                $this->store_live_call_signal($signal_phone, array(
                    'call_sid' => $call_sid,
                    'from_number' => $from,
                    'to_number' => $signal_phone,
                    'direction' => 'inbound',
                    'status' => $call_status,
                    'created_at' => current_time('mysql'),
                ));
            }
        }

        if ($parent_sid !== '') {
            $normalized_child_status = strtolower(trim($call_status));
            if (in_array($normalized_child_status, array('in-progress', 'answered'), true)) {
                $this->mark_call_leg_answered($call_sid);
                $this->cancel_child_ring_calls($parent_sid, $call_sid);
                if ($ring_owner_user_id > 0) {
                    $this->clear_user_ring_signal($ring_owner_user_id, $parent_sid);
                }
            } elseif ($this->live_call_status_is_terminal($call_status)) {
                $this->untrack_child_ring_call($parent_sid, $call_sid);
                $remaining_child_calls = get_transient($this->child_ring_calls_key($parent_sid));
                if ((!is_array($remaining_child_calls) || empty($remaining_child_calls)) && $ring_owner_user_id > 0) {
                    $this->clear_user_ring_signal($ring_owner_user_id, $parent_sid);
                }
            }
        } elseif ($this->live_call_status_is_terminal($call_status)) {
            if ($cleanup_user_id > 0) {
                $this->clear_user_ring_signal($cleanup_user_id, $call_sid);
            }
            $this->cancel_child_ring_calls($call_sid);
            $this->clear_owner_alert_state($call_sid);
        }

        // ── PARENT-CHILD DURATION PROPAGATION ──
        // When a simultaneous ring (ext_ring) or extension ring creates a child
        // call via Twilio REST API, the child has its own CallSid.  We pass the
        // parent's inbound CallSid in the StatusCallback URL as `parent_sid`.
        // When the child call completes, propagate the LONGER duration and any
        // recording URL to the parent record so the post-call email shows the
        // full conversation length, not just the IVR phase.
        if ($parent_sid !== '' && $call_status === 'completed' && $call_duration > 0) {
            $parent_row = $wpdb->get_row($wpdb->prepare(
                "SELECT id, duration, recording_url FROM {$table} WHERE call_sid = %s LIMIT 1",
                $parent_sid
            ));
            if ($parent_row) {
                $parent_update = array();
                $parent_format = array();
                // Use the longer duration (parent IVR phase vs child conversation)
                if ($call_duration > (int) $parent_row->duration) {
                    $parent_update['duration'] = $call_duration;
                    $parent_format[] = '%d';
                    error_log("VMO: Propagating child duration ({$call_duration}s) to parent {$parent_sid} (was {$parent_row->duration}s)");
                }
                // Propagate recording URL if parent has none
                if (!empty($recording_url) && empty($parent_row->recording_url)) {
                    $parent_update['recording_url'] = $recording_url;
                    $parent_format[] = '%s';
                    error_log("VMO: Propagating child recording to parent {$parent_sid}");
                }
                if (!empty($parent_update)) {
                    // phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery, WordPress.DB.DirectDatabaseQuery.NoCaching
                    $wpdb->update($table, $parent_update, array('call_sid' => $parent_sid), $parent_format, array('%s'));
                }
            }
        }

        $report_call_sid = $call_sid;
        if ($parent_sid !== '') {
            $parent_exists = (string) $wpdb->get_var($wpdb->prepare(
                "SELECT call_sid FROM {$table} WHERE call_sid = %s LIMIT 1",
                $parent_sid
            ));
            if ($parent_exists !== '') {
                $report_call_sid = $parent_exists;
                error_log('[VMO Email] Using parent call ' . $report_call_sid . ' for summary/reporting; child=' . $call_sid);
            }
        }

        if ($this->live_call_status_is_terminal($call_status)) {
            // Pick a teardown reason that reflects the actual leg role so the
            // premium-bridge guard inside terminate_call_everywhere() can
            // correctly distinguish a real caller disconnect from an
            // unrelated leg (outbound browser call, etc.) ending.
            $report_role = $this->get_call_leg_role($report_call_sid);
            $teardown_reason = 'status_callback';
            if ($report_role === 'caller_parent') {
                $teardown_reason = 'caller_disconnected';
            } elseif ($report_role === 'outbound_browser_call') {
                $teardown_reason = 'outbound_browser_call_disconnected';
            } elseif ($report_role === 'premium_ai_bridge') {
                $teardown_reason = 'premium_ai_bridge_disconnected';
            }
            error_log('[VMO Status Callback] Terminal status for ' . $report_call_sid
                . ' role=' . $report_role . ' status=' . $call_status
                . ' → reason=' . $teardown_reason);
            $this->terminate_call_everywhere($report_call_sid, $teardown_reason, array(
                'status' => $call_status,
                'parent_sid' => $parent_sid,
                'child_sid' => ($parent_sid !== '' ? $call_sid : ''),
            ));
        }

        error_log('VMO Twilio: Call status processed - SID: ' . $call_sid . ' Status: ' . $call_status . ', Duration: ' . $call_duration . 's');

        // ✅ TOKEN DEDUCTION v9.0.0: Comprehensive post-call billing via Token Economics
        // Charges: talk time (directional), recording, AI turns, TTS characters
        $call_record = $wpdb->get_row($wpdb->prepare("SELECT user_id, direction FROM $table WHERE call_sid = %s", $call_sid));
        $billable_call_duration = $this->normalize_billable_call_duration($call_sid, $call_duration);

        if ($call_record && $call_status === 'completed' && $billable_call_duration > 0) {
            $user_id = $call_record->user_id;
            $call_direction = $call_record->direction ?: 'inbound';
            $has_recording = !empty($recording_url);

            // Retrieve accumulated AI/TTS usage tracked during the call
            $safe_sid = preg_replace('/[^a-zA-Z0-9_]/', '', $call_sid);
            $ai_turns = (int) get_transient('vmo_ai_turns_' . $safe_sid);
            $tts_chars = (int) get_transient('vmo_tts_chars_' . $safe_sid);

            if (class_exists('VMO_Twilio_Token_Economics')) {
                // Use the new comprehensive billing system
                VMO_Twilio_Token_Economics::get_instance()->deduct_call_tokens(
                    $user_id,
                    $billable_call_duration,
                    $call_direction,
                    $has_recording,
                    $ai_turns,
                    $tts_chars,
                    $call_sid
                );
            } else {
                // Fallback: Old-style deduction (directional rates instead of flat 300)
                $minutes = ceil($billable_call_duration / 60);
                $per_min = ($call_direction === 'outbound') ? 53 : 33;
                $total_tokens = $minutes * $per_min;
                if ($has_recording) {
                    $rec_minutes = ceil($billable_call_duration / 60);
                    $total_tokens += $rec_minutes * 11;
                }
                $current_balance = (int) get_user_meta($user_id, 'vmo_token_balance', true);
                $new_balance = max(0, $current_balance - $total_tokens);
                update_user_meta($user_id, 'vmo_token_balance', $new_balance);
                $this->log_token_usage($user_id, 'phone_call', $total_tokens, "Phone Call: {$minutes} min ({$call_direction})");
                // Centralized billing log for fallback path
                if (function_exists('vmo_log_billing_event')) {
                    vmo_log_billing_event($user_id, 'phone_call', $total_tokens, "Phone Call: {$minutes} min ({$call_direction})", 'twilio', 0.0, $call_sid);
                }
            }

            // Clean up accumulated transients
            delete_transient('vmo_ai_turns_' . $safe_sid);
            delete_transient('vmo_tts_chars_' . $safe_sid);
        }

        // If call completed and has recording, generate AI summary
        if ($call_status === 'completed' && !empty($recording_url)) {
            // Save recording locally
            $local_url = $this->save_call_recording($report_call_sid, $recording_url);

            // Primary: run summary directly via shutdown function (no cron dependency)
            $mgr = $this;
            $sc_sid = $report_call_sid;
            $sc_url = $recording_url;
            register_shutdown_function(function () use ($mgr, $sc_sid, $sc_url) {
                try {
                    error_log('[VMO Status Callback] Shutdown: generating summary for ' . $sc_sid);
                    $mgr->generate_call_summary($sc_sid, $sc_url);
                } catch (\Throwable $e) {
                    error_log('[VMO Status Callback] Shutdown summary failed: ' . $e->getMessage());
                }
            });
            // Belt-and-suspenders: cron fallback
            $this->schedule_phone_event_now('vmo_generate_call_summary', array($report_call_sid, $recording_url), 60);
        }

        if ($call_status === 'completed') {
            $bridge_context_for_report = $this->get_bridge_active_context($report_call_sid);
            $call_topic_for_report = (string) $wpdb->get_var($wpdb->prepare(
                "SELECT topic FROM {$table} WHERE call_sid = %s LIMIT 1",
                $report_call_sid
            ));
            $is_premium_bridge_report = !empty($bridge_context_for_report) || $call_topic_for_report === 'premium_realtime_bridge';
            if ($is_premium_bridge_report) {
                if (empty($recording_url)) {
                    $this->schedule_phone_event_now('vmo_finalize_premium_bridge_call', array($report_call_sid, 1), 30);
                    error_log('[VMO Bridge] Premium post-call report finalizer scheduled for ' . $report_call_sid);
                }
                $this->clear_bridge_active_context($report_call_sid);
                $this->clear_realtime_call_context($report_call_sid);
            }
        }

        // If call completed but NO recording, generate summary from the AI transcript if one exists
        if ($call_status === 'completed' && empty($recording_url)) {
            $existing = $wpdb->get_row($wpdb->prepare(
                "SELECT transcript, summary FROM {$table} WHERE call_sid = %s",
                $report_call_sid
            ));
            if ($existing && !empty($existing->transcript) && empty($existing->summary)) {
                // Generate summary directly from transcript (no Whisper needed)
                $this->schedule_phone_event_now('vmo_summarize_call_transcript', array($report_call_sid), 1);
            }
        }

        // ── Send Call Summary Email ──
        // For COMPLETED calls: summary generation sends the report as soon as the
        // AI summary is ready. Keep a short fallback in case that pipeline lags.
        // For MISSED / short calls: send immediately since there's no summary to wait for.
        $report_call_record = ($report_call_sid === $call_sid)
            ? $call_record
            : $wpdb->get_row($wpdb->prepare("SELECT user_id, direction FROM {$table} WHERE call_sid = %s", $report_call_sid));

        $alert_user_id = isset($report_call_record->user_id) ? (int) $report_call_record->user_id : 0;
        if ($alert_user_id <= 0 && !empty($to)) {
            $alert_user_id = (int) $this->get_user_id_from_phone($to);
        }
        if ($alert_user_id > 0) {
            $caller_number = !empty($from) ? $from : 'Unknown';
            if ($caller_number === 'Unknown') {
                $stored_caller = $wpdb->get_var($wpdb->prepare(
                    "SELECT caller_number FROM {$table} WHERE call_sid = %s LIMIT 1",
                    $report_call_sid
                ));
                if (!empty($stored_caller))
                    $caller_number = $stored_caller;
            }

            if ($call_status === 'completed' && $call_duration > 5) {
                // Primary: send email via shutdown function (no cron dependency)
                $mgr2 = $this;
                $email_sid = $report_call_sid;
                register_shutdown_function(function () use ($mgr2, $email_sid) {
                    // Brief pause to let summary generation (also in shutdown) finish first
                    sleep(3);
                    try {
                        error_log('[VMO Email] Shutdown: sending post-call email for ' . $email_sid);
                        $mgr2->send_post_call_summary_email($email_sid);
                    } catch (\Throwable $e) {
                        error_log('[VMO Email] Shutdown email failed: ' . $e->getMessage());
                    }
                });
                // Belt-and-suspenders: cron fallback
                $this->schedule_phone_event_now('vmo_send_call_summary_email', array($report_call_sid), 90);
                error_log('[VMO Email] Shutdown + cron fallback registered for post-call email: ' . $report_call_sid);
            } else {
                // Missed or very short call — send immediately
                $alert_type = ($call_status === 'completed') ? 'completed' : 'missed';
                $this->send_call_alert($alert_user_id, $alert_type, array(
                    'caller' => $caller_number,
                    'call_sid' => $report_call_sid,
                    'status' => $call_status,
                    'duration' => $call_duration,
                    'recording_url' => isset($local_url) ? $local_url : $recording_url
                ));
            }
        }

        return new WP_REST_Response(array('success' => true, 'message' => 'Status updated'), 200);
    }

    /**
     * Handle SMS webhook from Twilio
     * 
     * Called when an SMS is received
     */
    public function handle_sms_webhook($request)
    {
        $params = $request->get_params();

        // Log webhook for debugging
        error_log('VMO Twilio SMS Webhook: ' . print_r($params, true));

        $message_sid = isset($params['MessageSid']) ? sanitize_text_field($params['MessageSid']) : '';
        $from = isset($params['From']) ? sanitize_text_field($params['From']) : '';
        $to = isset($params['To']) ? sanitize_text_field($params['To']) : '';
        $body = isset($params['Body']) ? sanitize_textarea_field($params['Body']) : '';
        $message_status = isset($params['SmsStatus']) ? sanitize_text_field($params['SmsStatus']) : (isset($params['MessageStatus']) ? sanitize_text_field($params['MessageStatus']) : 'received');
        $opt_action = $this->detect_sms_opt_action($params, $body);

        // Find user_id from phone number
        $user_id = $this->get_user_id_from_phone($to);

        if ($user_id) {
            if (function_exists('vmo_is_user_service_locked') && vmo_is_user_service_locked($user_id)) {
                error_log("VMO Twilio SMS: Blocked inbound SMS for service-locked user {$user_id} from {$from}");
                return new WP_REST_Response(array('success' => true, 'message' => 'SMS service paused'), 200);
            }

            error_log("VMO Twilio SMS: From {$from} to User {$user_id}: {$body}");

            // ✅ TOKEN DEDUCTION: Charge for inbound SMS (Twilio charges us for receiving)
            if (class_exists('VMO_Twilio_Token_Economics')) {
                VMO_Twilio_Token_Economics::get_instance()->deduct_sms_inbound($user_id);
            }

            $external_phone = trim((string) get_user_meta($user_id, 'vmo_external_phone', true));
            $is_external_owner = ($external_phone !== '')
                && ($this->normalize_phone_digits($external_phone) === $this->normalize_phone_digits($from));

            if ($is_external_owner) {
                $this->log_sms_message($user_id, $message_sid, $from, $to, 'relay', $body, 'received');
                $this->relay_external_sms_reply($user_id, $from, $to, $body);
                return new WP_REST_Response(array('success' => true, 'message' => 'Owner relay processed'), 200);
            }

            $this->log_sms_message($user_id, $message_sid, $from, $to, 'inbound', $body, $message_status ?: 'received');
            if ($opt_action !== '') {
                $this->update_sms_contact_preference($user_id, $from, $opt_action, $body);
                return new WP_REST_Response(array('success' => true, 'message' => 'SMS preference updated'), 200);
            }

            update_user_meta($user_id, 'vmo_sms_last_contact', $from);
            $this->maybe_forward_sms_to_external_phone($user_id, $from, $to, $body);

            // Could trigger AI response or add to CRM contact interactions
            do_action('vmo_sms_received', $user_id, $from, $body, $message_sid);
        }

        return new WP_REST_Response(array('success' => true, 'message' => 'SMS received'), 200);
    }

    /**
     * Verify Twilio request signature
     */
    public function verify_twilio_request($request)
    {
        // Get the auth token for HMAC-SHA1 signature verification
        $auth_token = $this->auth_token ?? '';
        if (empty($auth_token)) {
            error_log('VMO Twilio: Cannot verify request — no auth_token available');
            return false; // Fail closed: reject if we can't verify
        }

        // Twilio sends X-Twilio-Signature header
        $signature = '';
        if (isset($_SERVER['HTTP_X_TWILIO_SIGNATURE'])) {
            $signature = $_SERVER['HTTP_X_TWILIO_SIGNATURE'];
        }
        if (empty($signature)) {
            error_log('VMO Twilio: Missing X-Twilio-Signature header');
            return false;
        }

        // Build the full URL Twilio used to call us
        $protocol = (!empty($_SERVER['HTTPS']) && $_SERVER['HTTPS'] !== 'off') ? 'https' : 'http';
        $url = $protocol . '://' . ($_SERVER['HTTP_HOST'] ?? '') . ($_SERVER['REQUEST_URI'] ?? '');

        // Collect POST parameters (Twilio signs over sorted POST params)
        $params = $_POST;
        ksort($params);

        // Build the data string: URL + sorted key=value pairs
        $data_string = $url;
        foreach ($params as $key => $value) {
            $data_string .= $key . $value;
        }

        // Compute expected signature
        $expected = base64_encode(hash_hmac('sha1', $data_string, $auth_token, true)); // phpcs:ignore WordPress.PHP.DiscouragedPHPFunctions.obfuscation_base64_encode -- data encoding

        if (!hash_equals($expected, $signature)) {
            error_log('VMO Twilio: Signature verification FAILED for URL: ' . $url);
            return false;
        }

        return true;
    }

    /**
     * Ensure the Twilio phone number's webhook URLs point to the current
     * gateway endpoints. This auto-repairs stale URLs after plugin updates,
     * option changes, or domain migrations.
     *
     * Throttled by a 12-hour transient so it only runs infrequently.
     *
     * @param int $user_id
     */
    public function sync_phone_webhooks(int $user_id): void
    {
        if ($this->simulation_mode || $user_id <= 0) {
            return;
        }

        $transient_key = 'vmo_webhook_sync_' . $user_id;
        if (get_transient($transient_key)) {
            return; // Already synced recently
        }

        global $wpdb;
        $table = $wpdb->prefix . 'vmo_phone_numbers';
        $row = $wpdb->get_row($wpdb->prepare(
            "SELECT twilio_sid, {$this->phone_numbers_phone_column()} AS phone FROM {$table} WHERE user_id = %d AND status IN ('active','pending_port') ORDER BY created_at DESC LIMIT 1",
            $user_id
        ));

        if (!$row || empty($row->twilio_sid)) {
            // Nothing to sync
            set_transient($transient_key, 1, 12 * HOUR_IN_SECONDS);
            return;
        }

        $sid = $row->twilio_sid;
        $expected_voice_url = $this->phone_gateway_url('voice', array('uid' => $user_id));

        // Quick check: fetch the number's current config from Twilio
        $info = $this->twilio_request('GET', "/IncomingPhoneNumbers/{$sid}.json");
        if (is_wp_error($info)) {
            error_log('[VMO Webhook Sync] Failed to fetch phone config for ' . $sid . ': ' . $info->get_error_message());
            set_transient($transient_key, 1, HOUR_IN_SECONDS); // Retry sooner on error
            return;
        }

        $current_voice_url = $this->get_twilio_prop($info, 'voice_url');
        $current_status_url = $this->get_twilio_prop($info, 'status_callback');
        $expected_status_url = $this->twilio_public_url('status_callback');
        if ($current_voice_url === $expected_voice_url && $current_status_url !== '' && strpos($current_status_url, 'status') !== false) {
            // URLs match — no update needed
            set_transient($transient_key, 1, 12 * HOUR_IN_SECONDS);
            return;
        }

        // URLs differ — update Twilio
        error_log('[VMO Webhook Sync] Updating stale webhook URLs for ' . ($row->phone ?: $sid));
        error_log('[VMO Webhook Sync] Old: ' . ($current_voice_url ?: '(empty)'));
        error_log('[VMO Webhook Sync] New: ' . $expected_voice_url);

        $update = $this->twilio_request('POST', "/IncomingPhoneNumbers/{$sid}.json", array(
            'VoiceUrl' => $expected_voice_url,
            'VoiceMethod' => 'POST',
            'SmsUrl' => $this->twilio_public_url('sms_webhook'),
            'SmsMethod' => 'POST',
            'StatusCallback' => $this->twilio_public_url('status_callback'),
            'StatusCallbackMethod' => 'POST',
        ));

        if (is_wp_error($update)) {
            error_log('[VMO Webhook Sync] Update FAILED: ' . $update->get_error_message());
            set_transient($transient_key, 1, HOUR_IN_SECONDS); // Retry sooner
        } else {
            error_log('[VMO Webhook Sync] Webhook URLs updated successfully');
            set_transient($transient_key, 1, 12 * HOUR_IN_SECONDS);
        }

        // Also sync the TwiML App VoiceUrl (for outbound browser calls)
        $this->get_or_create_twiml_app();
    }

    /**
     * Get user's phone number data (Standardized to return String E.164)
     * Updated to support returning the phone string directly or object based on usage context
     */
    public function get_user_phone($user_id)
    {
        global $wpdb;
        $table_name = $wpdb->prefix . 'vmo_phone_numbers';

        $phone_col = $this->phone_numbers_phone_column();

        // Robustness: Only select if phone_number is not empty
        $row = $wpdb->get_row($wpdb->prepare(
            "SELECT * FROM $table_name WHERE user_id = %d AND status IN ('active', 'pending_port') AND {$phone_col} != '' ORDER BY created_at DESC LIMIT 1",
            $user_id
        ));

        if ($row && !empty($row->{$phone_col})) {
            return $row->{$phone_col};
        }

        // Fallback: Check user_meta (Safety Net for legacy or failed-insert numbers)
        $meta_phone = get_user_meta($user_id, 'vmo_phone_number', true);
        if ($meta_phone) {
            return $meta_phone;
        }

        $twilio_meta_phone = get_user_meta($user_id, 'vmo_twilio_phone_number', true);
        if ($twilio_meta_phone) {
            return $twilio_meta_phone;
        }

        foreach (array('vmo_business_phone', 'business_phone', 'vmo_twilio_number', 'phone') as $meta_key) {
            $legacy_meta_phone = get_user_meta($user_id, $meta_key, true);
            if (!empty($legacy_meta_phone)) {
                return is_array($legacy_meta_phone)
                    ? (string) ($legacy_meta_phone['number'] ?? $legacy_meta_phone['phone_number'] ?? '')
                    : (string) $legacy_meta_phone;
            }
        }

        $legacy_table = $wpdb->prefix . 'vmo_user_phone_numbers';
        // phpcs:ignore WordPress.DB.PreparedSQL.InterpolatedNotPrepared -- table name from plugin constant
        if ($wpdb->get_var("SHOW TABLES LIKE '{$legacy_table}'") === $legacy_table) {
            $legacy_phone = $wpdb->get_var($wpdb->prepare(
                "SELECT phone_number FROM {$legacy_table} WHERE user_id = %d AND status = 'active' AND phone_number <> '' ORDER BY created_at DESC LIMIT 1",
                $user_id
            ));
            if (!empty($legacy_phone)) {
                return (string) $legacy_phone;
            }
        }

        return false;
    }

    /**
     * Helper functions
     */
    private function extract_country_code($location)
    {
        // Simple country detection - enhance as needed
        return 'US'; // Default to US
    }

    private function extract_area_code($location)
    {
        // Extract area code from location string
        // e.g., "New York, NY" -> "212"
        // This would need a proper lookup table
        return null;
    }

    private function format_phone_number($number)
    {
        // Format: +12125551234 -> (212) 555-1234
        $cleaned = preg_replace('/[^0-9]/', '', $number);
        if (strlen($cleaned) === 11 && substr($cleaned, 0, 1) === '1') {
            $cleaned = substr($cleaned, 1);
        }
        return '(' . substr($cleaned, 0, 3) . ') ' . substr($cleaned, 3, 3) . '-' . substr($cleaned, 6);
    }

    /**
     * Get user ID from phone number
     * 
     * @param string $phone_number The phone number to lookup
     * @return int|false User ID or false if not found
     */
    private function get_user_id_from_phone($phone_number)
    {
        global $wpdb;
        $table = $wpdb->prefix . 'vmo_phone_numbers';

        $phone_col = $this->phone_numbers_phone_column();

        // CASE 1: Exact Match (E.164: +12125551234)
        // Twilio sends 'To' as +1555...
        $user_id = $wpdb->get_var($wpdb->prepare(
            "SELECT user_id FROM $table WHERE {$phone_col} = %s AND status IN ('active', 'pending_port') LIMIT 1",
            $phone_number
        ));

        if ($user_id)
            return intval($user_id);

        // CASE 2: Cleaned Match (12125551234 vs +12125551234)
        $cleaned = preg_replace('/[^0-9]/', '', $phone_number);
        if (empty($cleaned))
            return false;

        // In DB, phone might be +1..., check if it ends with our cleaned number
        // (suffix match is safer for international formats)
        $user_id = $wpdb->get_var($wpdb->prepare(
            "SELECT user_id FROM $table WHERE 
             (REPLACE(REPLACE(REPLACE({$phone_col}, '+', ''), '-', ''), ' ', '') = %s
             OR {$phone_col} LIKE %s)
             AND status IN ('active', 'pending_port') LIMIT 1",
            $cleaned,
            '%' . $wpdb->esc_like($cleaned)
        ));

        if ($user_id)
            return intval($user_id);

        // CASE 3: Meta Fallback (Critical for Legacy/Failed-DB numbers)
        // Search user_meta for legacy and Twilio-backed phone keys.
        $meta_users = $wpdb->get_col($wpdb->prepare(
            "SELECT user_id FROM {$wpdb->usermeta}
             WHERE (meta_key = 'vmo_phone_number' OR meta_key = 'vmo_twilio_phone_number' OR meta_key = 'phone')
             AND meta_value LIKE %s
             LIMIT 1",
            '%' . $cleaned
        ));

        if (!empty($meta_users)) {
            return intval($meta_users[0]);
        }

        $meta_users = $wpdb->get_col($wpdb->prepare(
            "SELECT user_id FROM {$wpdb->usermeta}
             WHERE meta_key IN ('vmo_business_phone', 'business_phone', 'vmo_twilio_number')
             AND REPLACE(REPLACE(REPLACE(REPLACE(meta_value, '+', ''), '-', ''), ' ', ''), '(', '') LIKE %s
             LIMIT 1",
            '%' . $cleaned
        ));

        if (!empty($meta_users)) {
            return intval($meta_users[0]);
        }

        $legacy_table = $wpdb->prefix . 'vmo_user_phone_numbers';
        // phpcs:ignore WordPress.DB.PreparedSQL.InterpolatedNotPrepared -- table name from plugin constant
        if ($wpdb->get_var("SHOW TABLES LIKE '{$legacy_table}'") === $legacy_table) {
            $legacy_user_id = $wpdb->get_var($wpdb->prepare(
                "SELECT user_id FROM {$legacy_table}
                 WHERE (REPLACE(REPLACE(REPLACE(phone_number, '+', ''), '-', ''), ' ', '') = %s
                 OR phone_number LIKE %s)
                 AND status = 'active'
                 LIMIT 1",
                $cleaned,
                '%' . $wpdb->esc_like($cleaned)
            ));

            if ($legacy_user_id) {
                return intval($legacy_user_id);
            }
        }

        return false;
    }

    private function normalize_phone_digits(string $phone): string
    {
        return preg_replace('/\D+/', '', $phone);
    }

    private function normalize_live_call_phone(string $phone): string
    {
        $digits = $this->normalize_phone_digits($phone);
        if ($digits === '') {
            return '';
        }
        if (strlen($digits) === 11 && strpos($digits, '1') === 0) {
            return substr($digits, 1);
        }
        return $digits;
    }

    private function live_call_signal_key(string $phone): string
    {
        $normalized = $this->normalize_live_call_phone($phone);
        return ($normalized !== '') ? ('vmo_live_inbound_' . $normalized) : '';
    }

    private function live_call_status_is_terminal(string $status): bool
    {
        return in_array(strtolower(trim($status)), array('completed', 'busy', 'failed', 'canceled', 'cancelled', 'no-answer', 'expired'), true);
    }

    private function store_live_call_signal(string $phone, array $payload): void
    {
        $key = $this->live_call_signal_key($phone);
        $call_sid = isset($payload['call_sid']) ? trim((string) $payload['call_sid']) : '';
        if ($key === '' || $call_sid === '') {
            return;
        }

        $signals = get_transient($key);
        if (!is_array($signals)) {
            $signals = array();
        }

        $signals[$call_sid] = array(
            'call_sid' => $call_sid,
            'from_number' => (string) ($payload['from_number'] ?? ''),
            'to_number' => (string) ($payload['to_number'] ?? $phone),
            'direction' => (string) ($payload['direction'] ?? 'inbound'),
            'status' => (string) ($payload['status'] ?? 'in-progress'),
            'created_at' => (string) ($payload['created_at'] ?? current_time('mysql')),
        );

        $fresh = array();
        foreach ($signals as $sid => $signal) {
            $status = (string) ($signal['status'] ?? '');
            $created = strtotime((string) ($signal['created_at'] ?? ''));
            $age = $created ? (time() - $created) : 0;
            if ($this->live_call_status_is_terminal($status) || $age > 900) {
                continue;
            }
            $fresh[$sid] = $signal;
        }

        if (empty($fresh)) {
            delete_transient($key);
            return;
        }

        set_transient($key, $fresh, 15 * MINUTE_IN_SECONDS);
    }

    private function clear_live_call_signal(string $phone, string $call_sid = ''): void
    {
        $key = $this->live_call_signal_key($phone);
        if ($key === '') {
            return;
        }

        if ($call_sid === '') {
            delete_transient($key);
            return;
        }

        $signals = get_transient($key);
        if (!is_array($signals) || empty($signals)) {
            return;
        }

        unset($signals[$call_sid]);
        if (empty($signals)) {
            delete_transient($key);
        } else {
            set_transient($key, $signals, 15 * MINUTE_IN_SECONDS);
        }
    }

    private function get_live_call_signals(string $phone): array
    {
        $key = $this->live_call_signal_key($phone);
        if ($key === '') {
            return array();
        }

        $signals = get_transient($key);
        if (!is_array($signals) || empty($signals)) {
            return array();
        }

        $fresh = array();
        foreach ($signals as $sid => $signal) {
            $status = (string) ($signal['status'] ?? '');
            $created = strtotime((string) ($signal['created_at'] ?? ''));
            $age = $created ? (time() - $created) : 0;
            if ($this->live_call_status_is_terminal($status) || $age > 900) {
                continue;
            }
            $fresh[$sid] = $signal;
        }

        if (count($fresh) !== count($signals)) {
            if (empty($fresh)) {
                delete_transient($key);
            } else {
                set_transient($key, $fresh, 15 * MINUTE_IN_SECONDS);
            }
        }

        return array_values($fresh);
    }

    private function normalize_phone_for_twilio(string $phone): string
    {
        $clean = preg_replace('/[^0-9+]/', '', $phone);
        if ($clean === '') {
            return '';
        }

        if (strpos($clean, '+') === 0) {
            return $clean;
        }

        $digits = $this->normalize_phone_digits($clean);
        if ($digits === '') {
            return '';
        }

        if (strpos($digits, '00') === 0) {
            return '+' . substr($digits, 2);
        }

        if (strlen($digits) === 10) {
            return '+1' . $digits;
        }

        return '+' . $digits;
    }

    /**
     * Validate that an E.164 phone number is owned by the configured Twilio
     * account, so we don't pass an invalid callerId to <Dial> and trigger
     * Twilio error 21210 ("Caller phone number not Twilio number").
     *
     * Result is cached for 12h per account to keep the outbound hot path fast;
     * a missing-or-empty cache forces a single REST API refresh.
     */
    private function is_owned_twilio_number(string $e164): bool
    {
        if ($e164 === '') {
            return false;
        }
        $sid = (string) get_option('vmo_twilio_account_sid', '');
        if ($sid === '') {
            // No SID configured \u2014 fail open so we don't block development setups.
            return true;
        }
        $cache_key = 'vmo_twilio_owned_numbers_' . md5($sid);
        $owned = get_transient($cache_key);
        if (!is_array($owned)) {
            $owned = array();
            $resp = $this->twilio_request('GET', '/IncomingPhoneNumbers.json', array('PageSize' => 100));
            if (!is_wp_error($resp) && is_array($resp) && !empty($resp['incoming_phone_numbers'])) {
                foreach ($resp['incoming_phone_numbers'] as $row) {
                    $num = isset($row['phone_number']) ? (string) $row['phone_number'] : '';
                    if ($num !== '') {
                        $owned[] = $num;
                    }
                }
            }
            // Cache positive AND empty results (5min on empty to avoid hammering).
            set_transient($cache_key, $owned, empty($owned) ? 5 * MINUTE_IN_SECONDS : 12 * HOUR_IN_SECONDS);
        }
        if (empty($owned)) {
            // Couldn't reach Twilio \u2014 fail open rather than block outbound calls.
            return true;
        }
        return in_array($e164, $owned, true);
    }

    /**
     * Return the first phone number actually owned by this Twilio account
     * (uses the same 12h-cached owned-number set as is_owned_twilio_number).
     * Returns '' if none are owned or the API can't be reached.
     */
    private function get_first_owned_twilio_number(): string
    {
        $sid = (string) get_option('vmo_twilio_account_sid', '');
        if ($sid === '') {
            return '';
        }
        $cache_key = 'vmo_twilio_owned_numbers_' . md5($sid);
        $owned = get_transient($cache_key);
        if (!is_array($owned)) {
            // Prime the cache via the existing helper (any number triggers the fetch).
            $this->is_owned_twilio_number('+10000000000');
            $owned = get_transient($cache_key);
        }
        if (!is_array($owned) || empty($owned)) {
            return '';
        }
        return (string) $owned[0];
    }

    public function get_sms_phone_number_id(int $user_id, string $phone_number = ''): int
    {
        global $wpdb;

        $table = $wpdb->prefix . 'vmo_phone_numbers';
        if ($wpdb->get_var($wpdb->prepare('SHOW TABLES LIKE %s', $table)) !== $table) {
            return 0;
        }

        $phone_col = $this->phone_numbers_phone_column();
        $row_id = 0;
        $cleaned = $this->normalize_phone_digits($phone_number);

        if ($user_id > 0 && $cleaned !== '') {
            $row_id = (int) $wpdb->get_var($wpdb->prepare(
                "SELECT id FROM {$table}
                 WHERE user_id = %d
                 AND status IN ('active', 'pending_port')
                 AND (
                    {$phone_col} = %s
                    OR REPLACE(REPLACE(REPLACE({$phone_col}, '+', ''), '-', ''), ' ', '') = %s
                    OR {$phone_col} LIKE %s
                 )
                 ORDER BY created_at DESC
                 LIMIT 1",
                $user_id,
                $phone_number,
                $cleaned,
                '%' . $wpdb->esc_like($cleaned)
            ));
        }

        if ($row_id > 0) {
            return $row_id;
        }

        if ($user_id <= 0) {
            return 0;
        }

        return (int) $wpdb->get_var($wpdb->prepare(
            "SELECT id FROM {$table}
             WHERE user_id = %d AND status IN ('active', 'pending_port')
             ORDER BY created_at DESC
             LIMIT 1",
            $user_id
        ));
    }

    public function log_sms_message(int $user_id, string $message_sid, string $from, string $to, string $direction, string $body, string $status = ''): bool
    {
        global $wpdb;

        if ($user_id <= 0) {
            return false;
        }

        $table = $wpdb->prefix . 'vmo_sms_logs';
        if ($wpdb->get_var($wpdb->prepare('SHOW TABLES LIKE %s', $table)) !== $table) {
            if (function_exists('vmo_create_twilio_tables')) {
                vmo_create_twilio_tables();
            }
        }
        if ($wpdb->get_var($wpdb->prepare('SHOW TABLES LIKE %s', $table)) !== $table) {
            return false;
        }

        $resolved_sid = trim($message_sid) !== ''
            ? trim($message_sid)
            : 'local_' . md5($user_id . '|' . $from . '|' . $to . '|' . $direction . '|' . microtime(true));
        $phone_number_id = $this->get_sms_phone_number_id($user_id, $direction === 'inbound' ? $to : $from);
        $status_value = $status !== '' ? $status : ($direction === 'inbound' ? 'received' : 'queued');

        $data = array(
            'user_id' => $user_id,
            'phone_number_id' => $phone_number_id,
            'twilio_message_sid' => $resolved_sid,
            'from_number' => $from,
            'to_number' => $to,
            'direction' => $direction,
            'body' => $body,
            'status' => $status_value,
        );

        $existing_id = (int) $wpdb->get_var($wpdb->prepare(
            "SELECT id FROM {$table} WHERE twilio_message_sid = %s LIMIT 1",
            $resolved_sid
        ));

        if ($existing_id > 0) {
            return $wpdb->update(
                $table,
                $data,
                array('id' => $existing_id),
                array('%d', '%d', '%s', '%s', '%s', '%s', '%s', '%s'),
                array('%d')
            ) !== false;
        }

        return $wpdb->insert(
            $table,
            array_merge($data, array('created_at' => current_time('mysql'))),
            array('%d', '%d', '%s', '%s', '%s', '%s', '%s', '%s', '%s')
        ) !== false;
    }

    private function detect_sms_opt_action(array $params, string $body): string
    {
        $opt_out_type = strtoupper(sanitize_text_field((string) ($params['OptOutType'] ?? '')));
        if (in_array($opt_out_type, array('STOP', 'START', 'HELP'), true)) {
            return $opt_out_type;
        }

        $normalized = strtoupper((string) preg_replace('/[^A-Z0-9]/', '', trim($body)));
        if ($normalized === '') {
            return '';
        }

        if (in_array($normalized, array('STOP', 'UNSUBSCRIBE', 'END', 'QUIT', 'STOPALL', 'REVOKE', 'OPTOUT', 'CANCEL'), true)) {
            return 'STOP';
        }

        if (in_array($normalized, array('START', 'UNSTOP', 'YES', 'SUBSCRIBE'), true)) {
            return 'START';
        }

        if (in_array($normalized, array('HELP', 'INFO'), true)) {
            return 'HELP';
        }

        return '';
    }

    private function get_or_create_sms_contact_uuid(int $user_id, string $phone_number): string
    {
        if ($user_id <= 0) {
            return '';
        }
        if (!function_exists('vmo_crm_upsert_contact')) {
            return '';
        }

        $normalized_digits = $this->normalize_phone_digits($phone_number);
        if ($normalized_digits === '') {
            return '';
        }

        $display_phone = $this->normalize_phone_for_twilio($phone_number);
        if ($display_phone === '') {
            $display_phone = $phone_number;
        }

        $contact = vmo_crm_upsert_contact($user_id, array(
            'phone' => $display_phone,
            'full_name' => 'SMS Contact ' . substr($normalized_digits, -4),
        ), array(
            'source' => 'other',
            'primary_category' => 'lead',
            'lifecycle_stage' => 'new',
            'tags' => array('sms_contact'),
            'acquisition_details' => array('source' => 'sms_phone_number'),
        ));

        return !empty($contact['contact_id']) ? (string) $contact['contact_id'] : '';
    }

    private function update_sms_contact_preference(int $user_id, string $phone_number, string $opt_action, string $body = ''): bool
    {
        global $wpdb;

        if ($user_id <= 0 || $opt_action === '') {
            return false;
        }

        $contact_uuid = $this->get_or_create_sms_contact_uuid($user_id, $phone_number);
        if ($contact_uuid === '') {
            return false;
        }

        $now = current_time('mysql');
        $preferences_table = $wpdb->prefix . 'vmo_contact_preferences';
        if ($wpdb->get_var($wpdb->prepare('SHOW TABLES LIKE %s', $wpdb->esc_like($preferences_table))) === $preferences_table) {
            $existing_id = (int) $wpdb->get_var($wpdb->prepare(
                "SELECT id FROM {$preferences_table} WHERE user_id = %d AND contact_id = %s LIMIT 1",
                $user_id,
                $contact_uuid
            ));

            $preference_data = array(
                'preferences_updated' => $now,
                'updated_at' => $now,
            );
            $preference_formats = array('%s', '%s');

            if ($opt_action === 'STOP') {
                $preference_data['sms_subscribed'] = 0;
                $preference_formats[] = '%d';
            } elseif ($opt_action === 'START') {
                $preference_data['sms_subscribed'] = 1;
                $preference_formats[] = '%d';
            }

            if ($existing_id > 0) {
                $wpdb->update(
                    $preferences_table,
                    $preference_data,
                    array('id' => $existing_id),
                    $preference_formats,
                    array('%d')
                );
            } elseif ($opt_action === 'STOP' || $opt_action === 'START') {
                $insert_data = array(
                    'user_id' => $user_id,
                    'contact_id' => $contact_uuid,
                    'preferences_updated' => $now,
                    'updated_at' => $now,
                    'sms_subscribed' => ($opt_action === 'START' ? 1 : 0),
                    'created_at' => $now,
                );

                $wpdb->insert(
                    $preferences_table,
                    $insert_data,
                    array('%d', '%s', '%s', '%s', '%d', '%s')
                );
            }
        }

        $interactions_table = $wpdb->prefix . 'vmo_contact_interactions';
        if ($wpdb->get_var($wpdb->prepare('SHOW TABLES LIKE %s', $wpdb->esc_like($interactions_table))) === $interactions_table) {
            $wpdb->insert(
                $interactions_table,
                array(
                    'user_id' => $user_id,
                    'contact_id' => $contact_uuid,
                    'interaction_type' => 'other',
                    'interaction_subtype' => 'sms_' . strtolower($opt_action),
                    'direction' => 'inbound',
                    'subject' => 'SMS ' . $opt_action,
                    'summary' => 'Contact sent ' . $opt_action . ' via SMS',
                    'full_details' => wp_json_encode(array(
                        'phone_number' => $phone_number,
                        'body' => $body,
                        'opt_action' => $opt_action,
                    )),
                    'outcome' => 'successful',
                    'created_at' => $now,
                    'updated_at' => $now,
                ),
                array('%d', '%s', '%s', '%s', '%s', '%s', '%s', '%s', '%s', '%s', '%s')
            );
        }

        return true;
    }

    private function get_latest_sms_contact(int $user_id): string
    {
        global $wpdb;

        $table = $wpdb->prefix . 'vmo_sms_logs';
        if ($wpdb->get_var($wpdb->prepare('SHOW TABLES LIKE %s', $table)) !== $table) {
            return '';
        }

        $row = $wpdb->get_row($wpdb->prepare(
            "SELECT from_number, to_number, direction
             FROM {$table}
             WHERE user_id = %d AND direction IN ('inbound', 'outbound')
             ORDER BY created_at DESC, id DESC
             LIMIT 1",
            $user_id
        ));

        if (!$row) {
            return '';
        }

        return ($row->direction === 'inbound') ? (string) $row->from_number : (string) $row->to_number;
    }

    private function send_sms_relay_help_message(int $user_id, string $business_phone, string $external_phone): void
    {
        $help_body = 'VMO SMS relay: reply with your message to answer the most recent customer, or start with "to +15551234567: your message" to choose a specific contact.';

        $result = $this->twilio_request('POST', '/Messages.json', array(
            'To' => $external_phone,
            'From' => $business_phone,
            'Body' => $help_body,
        ));

        if (!is_wp_error($result) && class_exists('VMO_Twilio_Token_Economics')) {
            VMO_Twilio_Token_Economics::get_instance()->deduct_sms_outbound($user_id);
        }
    }

    private function maybe_forward_sms_to_external_phone(int $user_id, string $customer_from, string $business_to, string $body): void
    {
        $external_phone = trim((string) get_user_meta($user_id, 'vmo_external_phone', true));
        if ($external_phone === '') {
            return;
        }

        if ($this->normalize_phone_digits($external_phone) === $this->normalize_phone_digits($customer_from)) {
            return;
        }

        $business_name = (string) get_user_meta($user_id, 'business_name', true);
        if ($business_name === '') {
            $business_name = 'Your business';
        }

        update_user_meta($user_id, 'vmo_sms_last_contact', $customer_from);

        $preview = trim((string) preg_replace('/\s+/', ' ', $body));
        if (strlen($preview) > 180) {
            $preview = substr($preview, 0, 177) . '...';
        }

        $forward_body = "VMO SMS for {$business_name}\nFrom {$customer_from}\n\n{$preview}\n\nReply here to answer the most recent customer, or start with \"to {$customer_from}:\" to choose a specific contact.";

        $result = $this->twilio_request('POST', '/Messages.json', array(
            'To' => $external_phone,
            'From' => $business_to,
            'Body' => $forward_body,
        ));

        if (!is_wp_error($result) && class_exists('VMO_Twilio_Token_Economics')) {
            VMO_Twilio_Token_Economics::get_instance()->deduct_sms_outbound($user_id);
        }
    }

    private function parse_external_sms_reply(int $user_id, string $body): array
    {
        $clean_body = trim($body);
        if ($clean_body === '') {
            return array('', '');
        }

        $patterns = array(
            '/^\s*(?:to\s+)?(\+?[0-9][0-9\-\(\)\s]{6,})\s*[:\-]\s*(.+)$/is',
            '/^\s*@?(\+?[0-9][0-9\-\(\)\s]{6,})\s+(.+)$/is',
        );

        foreach ($patterns as $pattern) {
            if (preg_match($pattern, $clean_body, $matches)) {
                return array(
                    $this->normalize_phone_for_twilio((string) $matches[1]),
                    trim((string) $matches[2]),
                );
            }
        }

        $target = (string) get_user_meta($user_id, 'vmo_sms_last_contact', true);
        if ($target === '') {
            $target = $this->get_latest_sms_contact($user_id);
        }

        return array(
            $this->normalize_phone_for_twilio($target),
            $clean_body,
        );
    }

    private function relay_external_sms_reply(int $user_id, string $external_from, string $business_to, string $body): void
    {
        list($target, $relay_body) = $this->parse_external_sms_reply($user_id, $body);
        if ($target === '' || $relay_body === '') {
            $this->send_sms_relay_help_message($user_id, $business_to, $external_from);
            return;
        }

        $result = $this->twilio_request('POST', '/Messages.json', array(
            'To' => $target,
            'From' => $business_to,
            'Body' => $relay_body,
            'StatusCallback' => $this->phone_gateway_url('status'),
        ));

        if (is_wp_error($result)) {
            error_log('VMO SMS relay error: ' . $result->get_error_message());
            $this->send_sms_relay_help_message($user_id, $business_to, $external_from);
            return;
        }

        if (class_exists('VMO_Twilio_Token_Economics')) {
            VMO_Twilio_Token_Economics::get_instance()->deduct_sms_outbound($user_id);
        }

        $this->log_sms_message(
            $user_id,
            sanitize_text_field((string) ($result['sid'] ?? '')),
            $business_to,
            $target,
            'outbound',
            $relay_body,
            sanitize_text_field((string) ($result['status'] ?? 'queued'))
        );

        update_user_meta($user_id, 'vmo_sms_last_contact', $target);
    }

    private function get_user_services($user_id)
    {
        // Fetch user's services
        return get_user_meta($user_id, 'business_services', true) ?: array();
    }

    private function get_user_pricing($user_id)
    {
        // Fetch pricing information
        return get_user_meta($user_id, 'business_pricing', true) ?: array();
    }

    private function get_user_faq($user_id)
    {
        // Fetch FAQ data
        return get_user_meta($user_id, 'business_faq', true) ?: array();
    }

    private function get_booking_availability($user_id)
    {
        // Check available booking slots
        return array('status' => 'available');
    }

    private function send_booking_confirmation($booking_id)
    {
        global $wpdb;
        $table = $wpdb->prefix . 'vmo_bookings';
        $booking = $wpdb->get_row($wpdb->prepare("SELECT * FROM $table WHERE id = %d", $booking_id));

        if (!$booking)
            return;

        $user = get_userdata($booking->user_id);
        if (!$user)
            return;

        $to = $user->user_email;
        $subject = "📅 New Booking: " . $booking->customer_name;

        $message = "<h3>New Booking Received via AI Phone Agent</h3>";
        $message .= "<p><strong>Customer:</strong> " . esc_html($booking->customer_name) . "</p>";
        $message .= "<p><strong>Phone:</strong> " . esc_html($booking->customer_phone) . "</p>";
        $message .= "<p><strong>Service:</strong> " . esc_html($booking->service) . "</p>";
        $message .= "<p><strong>Date:</strong> " . esc_html($booking->booking_date) . " at " . esc_html($booking->booking_time) . "</p>";
        $message .= "<p><strong>Status:</strong> " . ucfirst($booking->status) . "</p>";

        $headers = array('Content-Type: text/html; charset=UTF-8');
        wp_mail($to, $subject, $message, $headers);
    }

    private function log_provisioning_event($user_id, $phone_sid, $event_type)
    {
        // Log provisioning events for tracking
        error_log("VMO Twilio: {$event_type} - User {$user_id} - Phone {$phone_sid}");
    }

    /**
     * Process monthly billing for phone subscriptions
     */
    public function process_monthly_billing()
    {
        global $wpdb;
        $table_name = $wpdb->prefix . 'vmo_user_phone_numbers';

        // Get all phones due for billing
        // phpcs:ignore WordPress.DB.PreparedSQL.InterpolatedNotPrepared, WordPress.DB.DirectDatabaseQuery.DirectQuery, WordPress.DB.DirectDatabaseQuery.NoCaching
        $phones = $wpdb->get_results(
            "SELECT * FROM $table_name 
             WHERE status = 'active' 
             AND next_billing_date <= NOW()"
        );

        foreach ($phones as $phone) {
            // Charge user via Stripe
            $this->charge_monthly_fee($phone->user_id, $phone->monthly_cost, $phone->phone_number);

            // Update next billing date
            // phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery, WordPress.DB.DirectDatabaseQuery.NoCaching
            $wpdb->update(
                $table_name,
                array('next_billing_date' => date('Y-m-d H:i:s', strtotime('+1 month'))),
                array('id' => $phone->id),
                array('%s'),
                array('%d')
            );
        }
    }

    private function charge_monthly_fee($user_id, $amount, $phone_number)
    {
        $stripe_secret = get_option('vmo_stripe_secret_key', '');
        if (empty($stripe_secret) || !class_exists('Stripe\\Stripe')) {
            error_log("VMO Billing: Cannot charge user {$user_id} \${$amount} for {$phone_number} — Stripe not configured");
            return;
        }

        try {
            \Stripe\Stripe::setApiKey($stripe_secret);

            // Get the user's Stripe customer ID (stored during plan purchase)
            $customer_id = get_user_meta($user_id, 'vmo_stripe_customer_id', true);
            if (empty($customer_id)) {
                error_log("VMO Billing: No Stripe customer ID for user {$user_id}, skipping charge for {$phone_number}");
                return;
            }

            // Get default payment method
            $customer = \Stripe\Customer::retrieve($customer_id);
            $payment_method = $customer->invoice_settings->default_payment_method ?? $customer->default_source ?? null;
            if (empty($payment_method)) {
                error_log("VMO Billing: No payment method on file for user {$user_id}, skipping charge for {$phone_number}");
                return;
            }

            // Create a one-off invoice item + invoice for the phone fee
            \Stripe\InvoiceItem::create([
                'customer' => $customer_id,
                'amount' => (int) round($amount * 100), // cents
                'currency' => 'usd',
                'description' => "VMO Phone Monthly Fee — {$phone_number}",
            ]);

            $invoice = \Stripe\Invoice::create([
                'customer' => $customer_id,
                'auto_advance' => true, // auto-finalize and attempt payment
                'collection_method' => 'charge_automatically',
                'metadata' => [
                    'vmo_user_id' => $user_id,
                    'phone_number' => $phone_number,
                    'type' => 'phone_monthly_fee',
                ],
            ]);

            $invoice->pay();
            error_log("VMO Billing: Successfully charged user {$user_id} \${$amount} for {$phone_number} (Invoice: {$invoice->id})");

        } catch (\Exception $e) {
            error_log("VMO Billing: Failed to charge user {$user_id} \${$amount} for {$phone_number}: " . $e->getMessage());
        }
    }

    /**
     * Test Twilio connection
     */
    public function test_connection()
    {
        if (!$this->verify_ajax_request()) {
            wp_send_json_error(array('message' => 'Security check failed'), 403);
            return;
        }

        // Remove client check

        try {
            // Fetch Account info: GET /Accounts/{SID}.json
            // twilio_request appends to base 
            $account = $this->twilio_request('GET', '.json');

            if (is_wp_error($account)) {
                throw new Exception($account->get_error_message());
            }

            wp_send_json_success(array(
                'message' => 'Connected to Twilio successfully!',
                'account_name' => isset($account['friendly_name']) ? $account['friendly_name'] : 'VMO Master Account',
                'status' => isset($account['status']) ? $account['status'] : 'active'
            ));
        } catch (Exception $e) {
            wp_send_json_error(array('message' => $e->getMessage()));
        }
    }

    /**
     * Get phone analytics
     * Also acts as a "Self-Healing" check for the AI Agent
     */
    public function get_phone_analytics()
    {
        // Prevent any accidental output from corrupting JSON response
        ob_start();

        if (!$this->verify_ajax_request()) {
            ob_end_clean();
            wp_send_json_error(array('message' => 'Security check failed'), 403);
            return;
        }

        $user_id = get_current_user_id();
        if (!$user_id) {
            ob_end_clean();
            wp_send_json_error(array('message' => 'Not authenticated'), 401);
            return;
        }

        $phone_data = $this->get_user_phone($user_id);
        $phone_number = is_object($phone_data) ? ($phone_data->phone_number ?? '') : (is_string($phone_data) ? $phone_data : '');

        // Continue even if no phone number (user may be viewing analytics before provisioning)
        // if (!$phone_data) {
        //     wp_send_json_error(array('message' => 'No phone number found'));
        //     return;
        // }

        // --- SELF-HEALING: Verify AI Agent Exists (only if phone number is set) ---
        if (!empty($phone_number)) {
            $existing_agent = get_user_meta($user_id, 'twilio_ai_agent_sid', true);
            if (empty($existing_agent)) {
                $business_name = get_user_meta($user_id, 'business_name', true) ?: 'Business';

                // Try to create it now
                $new_agent_obj = $this->create_ai_agent($user_id, $business_name, $phone_number);

                if ($new_agent_obj && !empty($new_agent_obj->sid)) {
                    update_user_meta($user_id, 'twilio_ai_agent_sid', $new_agent_obj->sid);

                    // Update DB record settings if exists
                    global $wpdb;
                    $table_name = $wpdb->prefix . 'vmo_phone_numbers';
                    $row = $wpdb->get_row($wpdb->prepare(
                        "SELECT id, settings FROM $table_name WHERE user_id = %d AND status = 'active' ORDER BY id DESC LIMIT 1",
                        $user_id
                    ));
                    if ($row) {
                        $settings = array();
                        if (!empty($row->settings)) {
                            $decoded = json_decode($row->settings, true);
                            if (is_array($decoded)) {
                                $settings = $decoded;
                            }
                        }
                        $settings['twilio_ai_agent_sid'] = $new_agent_obj->sid;
                        // phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery, WordPress.DB.DirectDatabaseQuery.NoCaching
                        $wpdb->update(
                            $table_name,
                            array('settings' => function_exists('wp_json_encode') ? wp_json_encode($settings) : json_encode($settings)),
                            array('id' => (int) $row->id)
                        );
                    }

                    error_log("VMO: Self-Healing fixed missing AI Agent for user {$user_id}");
                }
            }
        }
        // ------------------------------------------

        // Strategic Dashboard expects aggregated analytics (last ~30 days) from our DB.
        // Avoid relying on Twilio Calls API here: it can omit outbound legs and is prone to edge/WAF blocks.
        global $wpdb;

        $since = date('Y-m-d H:i:s', strtotime('-30 days', current_time('timestamp')));

        // Initialize all values with defaults
        $total_calls = 0;
        $answered_calls = 0;
        $missed_calls = 0;
        $sum_duration = 0;
        $count_duration = 0;
        $top_intent = '—';
        $new_customers_count = 0;
        $return_customers_count = 0;

        // 1) Primary call log table
        $phone_calls_table = $wpdb->prefix . 'vmo_phone_calls';
        $is_admin = current_user_can('manage_options');

        if ($wpdb->get_var("SHOW TABLES LIKE '{$phone_calls_table}'") === $phone_calls_table) {
            $row = $wpdb->get_row(
                $wpdb->prepare(
                    "SELECT 
                        COUNT(*) AS total,
                        SUM(CASE WHEN (duration > 0 OR status IN ('completed','in-progress','answered')) THEN 1 ELSE 0 END) AS answered,
                        SUM(CASE WHEN status IN ('no-answer','busy','failed','canceled','expired') THEN 1 ELSE 0 END) AS missed,
                        SUM(CASE WHEN duration > 0 THEN duration ELSE 0 END) AS sum_dur,
                        SUM(CASE WHEN duration > 0 THEN 1 ELSE 0 END) AS cnt_dur
                     FROM {$phone_calls_table}
                     WHERE user_id = %d AND created_at >= %s",
                    $user_id,
                    $since
                ),
                ARRAY_A
            );

            if (is_array($row)) {
                $total_calls += (int) ($row['total'] ?? 0);
                $answered_calls += (int) ($row['answered'] ?? 0);
                $missed_calls += (int) ($row['missed'] ?? 0);
                $sum_duration += (int) ($row['sum_dur'] ?? 0);
                $count_duration += (int) ($row['cnt_dur'] ?? 0);
            }

            // Admin fallback: if zero calls for this user but calls exist under others
            if ($total_calls === 0 && $is_admin) {
                $row = $wpdb->get_row(
                    $wpdb->prepare(
                        "SELECT 
                            COUNT(*) AS total,
                            SUM(CASE WHEN (duration > 0 OR status IN ('completed','in-progress','answered')) THEN 1 ELSE 0 END) AS answered,
                            SUM(CASE WHEN status IN ('no-answer','busy','failed','canceled','expired') THEN 1 ELSE 0 END) AS missed,
                            SUM(CASE WHEN duration > 0 THEN duration ELSE 0 END) AS sum_dur,
                            SUM(CASE WHEN duration > 0 THEN 1 ELSE 0 END) AS cnt_dur
                         FROM {$phone_calls_table}
                         WHERE created_at >= %s",
                        $since
                    ),
                    ARRAY_A
                );
                if (is_array($row)) {
                    $total_calls += (int) ($row['total'] ?? 0);
                    $answered_calls += (int) ($row['answered'] ?? 0);
                    $missed_calls += (int) ($row['missed'] ?? 0);
                    $sum_duration += (int) ($row['sum_dur'] ?? 0);
                    $count_duration += (int) ($row['cnt_dur'] ?? 0);
                }
            }
        }

        // 2) Include Power Dialer campaign calls (campaign_id > 0) but avoid double-counting direct dialer calls (campaign_id = 0)
        $pd_calls_table = $wpdb->prefix . 'vmo_power_dialer_calls';
        if ($wpdb->get_var("SHOW TABLES LIKE '{$pd_calls_table}'") === $pd_calls_table) {
            $row = $wpdb->get_row(
                $wpdb->prepare(
                    "SELECT 
                        COUNT(*) AS total,
                        SUM(CASE WHEN (duration > 0 OR status IN ('connected','completed','in-progress','answered')) THEN 1 ELSE 0 END) AS answered,
                        SUM(CASE WHEN status IN ('noanswer','no-answer','busy','failed','canceled','expired','voicemail') THEN 1 ELSE 0 END) AS missed,
                        SUM(CASE WHEN duration > 0 THEN duration ELSE 0 END) AS sum_dur,
                        SUM(CASE WHEN duration > 0 THEN 1 ELSE 0 END) AS cnt_dur
                     FROM {$pd_calls_table}
                     WHERE user_id = %d AND campaign_id > 0 AND created_at >= %s",
                    $user_id,
                    $since
                ),
                ARRAY_A
            );

            if (is_array($row)) {
                $total_calls += (int) ($row['total'] ?? 0);
                $answered_calls += (int) ($row['answered'] ?? 0);
                $missed_calls += (int) ($row['missed'] ?? 0);
                $sum_duration += (int) ($row['sum_dur'] ?? 0);
                $count_duration += (int) ($row['cnt_dur'] ?? 0);
            }
        }

        // Ensure missed calls never exceeds or is greater than total calls
        if ($missed_calls <= 0 && $total_calls > 0) {
            $missed_calls = max(0, $total_calls - $answered_calls);
        }

        // Calculate average duration in seconds
        $avg = ($count_duration > 0) ? (int) round($sum_duration / max(1, $count_duration)) : 0;

        // Derive top_intent from call summaries (if available)
        $calls_table_name = $wpdb->prefix . 'vmo_call_history';
        if ($wpdb->get_var("SHOW TABLES LIKE '{$calls_table_name}'") === $calls_table_name) {
            // Top intent: most common category from AI call summaries
            // Using a more robust query for broader compatibility with MariaDB/Older MySQL
            $top_cat = $wpdb->get_var($wpdb->prepare(
                "SELECT JSON_UNQUOTE(JSON_EXTRACT(ai_summary, '$.category'))
                 FROM {$calls_table_name}
                 WHERE user_id = %d AND ai_summary IS NOT NULL AND ai_summary != '' AND created_at >= %s
                 GROUP BY 1
                 ORDER BY COUNT(*) DESC LIMIT 1",
                $user_id,
                $since
            ));

            // Fallback: If JSON_EXTRACT failed (returns NULL or error), try a simpler LIKE query for common categories
            if ((null === $top_cat || $top_cat === 'null') && $total_calls > 0) {
                foreach (array('Lead', 'Support', 'Customer', 'Inquiry') as $cat) {
                    $count = $wpdb->get_var($wpdb->prepare(
                        "SELECT COUNT(*) FROM {$calls_table_name} WHERE user_id = %d AND ai_summary LIKE %s AND created_at >= %s",
                        $user_id,
                        '%' . $cat . '%',
                        $since
                    ));
                    if ($count > 0) {
                        $top_intent = $cat;
                        break;
                    }
                }
            } elseif (!empty($top_cat) && $top_cat !== 'null') {
                $top_intent = ucfirst($top_cat);
            }
        }

        // New vs return customers from CRM contacts
        $contacts_table = $wpdb->prefix . 'vmo_contacts';
        if ($wpdb->get_var("SHOW TABLES LIKE '{$contacts_table}'") === $contacts_table) {
            $new_customers_count = (int) $wpdb->get_var($wpdb->prepare(
                "SELECT COUNT(*) FROM {$contacts_table} WHERE user_id = %d AND source = 'phone_call' AND created_at >= %s",
                $user_id,
                $since
            ));
            $return_customers_count = (int) $wpdb->get_var($wpdb->prepare(
                "SELECT COUNT(DISTINCT c.id) FROM {$contacts_table} c
                 INNER JOIN {$wpdb->prefix}vmo_contact_interactions ci ON c.id = ci.contact_id
                 WHERE c.user_id = %d AND ci.interaction_type = 'phone_call' AND ci.created_at >= %s
                 AND c.created_at < %s",
                $user_id,
                $since,
                $since
            ));
        }

        // Return all values, ensuring they are all set and properly typed
        ob_end_clean();
        wp_send_json_success(array(
            'total_calls' => (int) $total_calls,
            'answered_calls' => (int) $answered_calls,
            'missed_calls' => (int) max(0, $missed_calls),  // Ensure non-negative
            'average_duration' => (int) $avg,  // in seconds
            'top_intent' => (string) $top_intent,
            'new_customers' => (int) $new_customers_count,
            'return_customers' => (int) $return_customers_count,
        ));
    }

    /**
     * Get current phone provisioning status for the logged-in user.
     *
     * Used by consultation flow to detect dashboard-provisioned numbers and
     * avoid recommending duplicate business phone setup.
     */
    public function get_phone_provision_status()
    {
        if (!$this->verify_ajax_request()) {
            wp_send_json_error(array('message' => 'Security check failed'), 403);
            return;
        }

        $user_id = isset($_POST['user_id']) ? intval($_POST['user_id']) : get_current_user_id();
        if ($user_id <= 0) {
            wp_send_json_error(array('message' => 'User not authenticated'), 401);
            return;
        }

        $phone = (string) ($this->get_user_phone($user_id) ?: '');
        $has_phone = $phone !== '';

        $max_allowed = (int) get_user_meta($user_id, 'vmo_allowed_phone_count', true);
        if ($max_allowed < 1) {
            $max_allowed = 1;
        }
        $can_add_additional = $max_allowed > 1;

        $policy_message = $can_add_additional
            ? sprintf('Your account is approved for up to %d business phone numbers.', $max_allowed)
            : 'VMO policy allows 1 business phone number per user by default.';

        $exception_message = 'Need more than one number? Contact VMO directly for an approved exception.';

        $formatted_phone = '';
        if ($has_phone) {
            $digits = preg_replace('/[^0-9]/', '', $phone);
            if (is_string($digits) && strlen($digits) >= 10) {
                $formatted_phone = $this->format_phone_number($phone);
            } else {
                $formatted_phone = $phone;
            }
        }

        wp_send_json_success(array(
            'has_phone' => $has_phone,
            'phone_number' => $phone,
            'phone_formatted' => $formatted_phone,
            'allowed_phone_count' => $max_allowed,
            'can_add_additional' => $can_add_additional,
            'policy_message' => $policy_message,
            'exception_message' => $exception_message,
        ));
    }

    /**
     * Generate AI summary of call using OpenAI
     * 
     * Enhanced with structured JSON report generation, CRM auto-categorization,
     * action item creation, and AI fulfillment processing.
     *
     * @param string $call_sid Twilio call SID
     * @param string $recording_url URL to the call recording
     */
    public function generate_call_summary($call_sid, $recording_url)
    {
        error_log('VMO Twilio: Generating call summary for CallSid: ' . $call_sid);

        global $wpdb;
        $table = $wpdb->prefix . 'vmo_phone_calls';
        $call_row = $wpdb->get_row($wpdb->prepare(
            "SELECT user_id, transcript, summary FROM {$table} WHERE call_sid = %s ORDER BY id DESC LIMIT 1",
            $call_sid
        ));
        $user_id = $call_row ? (int) ($call_row->user_id ?? 0) : $this->resolve_call_owner_user_id((string) $call_sid);

        if ($call_row && !empty($call_row->summary) && !empty($call_row->transcript)) {
            error_log('VMO Twilio: Summary already exists for CallSid: ' . $call_sid . ' — skipping duplicate generation');
            $this->maybe_send_post_call_summary_email((string) $call_sid);
            return;
        }

        // Get OpenAI API key
        $api_key = get_option('vmo_openai_api_key');
        if (empty($api_key)) {
            error_log('VMO Twilio: OpenAI API key not configured');
            return;
        }

        // Download recording from Twilio
        $recording_data = $this->download_recording($recording_url);

        if (is_wp_error($recording_data)) {
            error_log('VMO Twilio: Failed to download recording: ' . $recording_data->get_error_message());
            return;
        }

        // Transcribe using OpenAI Whisper
        $transcription = $this->transcribe_call_recording($recording_data, $api_key, $user_id, (string) $call_sid);

        if (is_wp_error($transcription)) {
            error_log('VMO Twilio: Failed to transcribe: ' . $transcription->get_error_message());
            return;
        }

        // Generate structured JSON summary using GPT
        $summary = $this->summarize_transcription($transcription, $api_key, $user_id, (string) $call_sid);

        if (is_wp_error($summary)) {
            error_log('VMO Twilio: Failed to generate summary: ' . $summary->get_error_message());
            return;
        }

        // Save to database
        // phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery, WordPress.DB.DirectDatabaseQuery.NoCaching
        $wpdb->update(
            $table,
            array(
                'transcript' => $transcription,
                'summary' => $summary,
            ),
            array('call_sid' => $call_sid),
            array('%s', '%s'),
            array('%s')
        );

        // Process the structured report — creates call_report, action_items, CRM update, fulfillment
        $this->process_post_call_intelligence($call_sid, $summary, $transcription);

        $this->maybe_send_post_call_summary_email((string) $call_sid);

        error_log('VMO Twilio: Call summary + intelligence pipeline completed for CallSid: ' . $call_sid);
    }

    /**
     * Process post-call intelligence pipeline.
     *
     * Takes the structured JSON summary from GPT and:
     *  1. Creates a structured call report in vmo_call_reports
     *  2. Creates action items with deadlines in vmo_action_items
     *  3. Updates CRM contact with intelligent categorization
     *  4. Fulfills AI promises (sends SMS/email with business info)
     *
     * @param string $call_sid  Twilio Call SID
     * @param string $summary   JSON or plain-text summary from GPT
     * @param string $transcript Full call transcript
     */
    private function process_post_call_intelligence($call_sid, $summary, $transcript = '')
    {
        global $wpdb;
        $calls_table = $wpdb->prefix . 'vmo_phone_calls';

        $call = $wpdb->get_row($wpdb->prepare(
            "SELECT * FROM {$calls_table} WHERE call_sid = %s LIMIT 1",
            $call_sid
        ));
        if (!$call)
            return;

        $user_id = (int) $call->user_id;

        // Parse structured JSON — if summary is not valid JSON, create a basic structure
        $report_data = json_decode($summary, true);
        if (!is_array($report_data) || !isset($report_data['caller_name'])) {
            // Fallback: extract CALLER_NAME for backward compatibility
            $caller_name = '';
            if (preg_match('/CALLER_NAME:\s*(.+)/i', $summary, $m)) {
                $name = trim($m[1]);
                if ($name !== '' && strtoupper($name) !== 'UNKNOWN') {
                    $caller_name = sanitize_text_field($name);
                }
            }
            $report_data = array(
                'caller_name' => $caller_name ?: 'UNKNOWN',
                'caller_email' => null,
                'caller_intent' => 'General inquiry',
                'call_type' => 'inbound_inquiry',
                'crm_category' => 'lead',
                'sentiment' => 'neutral',
                'urgency' => 'medium',
                'summary' => $summary,
                'products_discussed' => array(),
                'actions_taken' => array(),
                'actions_required' => array(),
                'actions_recommended' => array(),
                'promises_made' => array(),
                'sales_signals' => array('interest_level' => 'warm', 'objections' => array(), 'buying_indicators' => array()),
                'financial_events' => array(),
                'key_info_collected' => array(),
            );
        }

        $this->record_cfo_financial_event_from_call($user_id, (string) $call_sid, $call, $report_data, (string) $transcript);

        $reports_table = $wpdb->prefix . 'vmo_call_reports';
        $report_id = 0;
        $existing_report = null;

        // ── 1. Create structured call report ──
        if ($wpdb->get_var($wpdb->prepare("SHOW TABLES LIKE %s", $wpdb->esc_like($reports_table))) === $reports_table) {
            $existing_report = $wpdb->get_row($wpdb->prepare(
                "SELECT * FROM {$reports_table} WHERE call_sid = %s ORDER BY id DESC LIMIT 1",
                $call_sid
            ));
        }

        if ($existing_report) {
            $report_id = (int) $existing_report->id;
            error_log('VMO: Post-call intelligence already exists for ' . $call_sid . ' — reusing report #' . $report_id);
        } elseif ($wpdb->get_var($wpdb->prepare("SHOW TABLES LIKE %s", $wpdb->esc_like($reports_table))) === $reports_table) {
            $caller_name = sanitize_text_field($report_data['caller_name'] ?? 'UNKNOWN');
            if (strtoupper($caller_name) === 'UNKNOWN')
                $caller_name = null;

            // phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery, WordPress.DB.DirectDatabaseQuery.NoCaching
            $wpdb->insert($reports_table, array(
                'user_id' => $user_id,
                'call_sid' => $call_sid,
                'contact_id' => $call->contact_id,
                'report_type' => ($call->direction ?? 'inbound') === 'outbound' ? 'outbound' : 'inbound',
                'caller_name' => $caller_name,
                'caller_phone' => $call->caller_number,
                'caller_email' => sanitize_email($report_data['caller_email'] ?? ''),
                'caller_intent' => sanitize_text_field($this->safe_substr((string) ($report_data['caller_intent'] ?? ''), 0, 255)),
                'call_sentiment' => in_array($report_data['sentiment'] ?? '', array('positive', 'neutral', 'negative', 'frustrated')) ? $report_data['sentiment'] : 'neutral',
                'urgency' => in_array($report_data['urgency'] ?? '', array('low', 'medium', 'high', 'critical')) ? $report_data['urgency'] : 'medium',
                'crm_category' => in_array($report_data['crm_category'] ?? '', array('lead', 'customer', 'support', 'inquiry', 'vendor', 'spam')) ? $report_data['crm_category'] : 'lead',
                'summary_text' => $report_data['summary'] ?? $summary,
                'actions_taken' => wp_json_encode($report_data['actions_taken'] ?? array()),
                'actions_required' => wp_json_encode($report_data['actions_required'] ?? array()),
                'actions_recommended' => wp_json_encode($report_data['actions_recommended'] ?? array()),
                'promises_made' => wp_json_encode($report_data['promises_made'] ?? array()),
                'fulfillment_status' => !empty($report_data['promises_made']) ? 'pending' : 'none',
                'products_discussed' => wp_json_encode($report_data['products_discussed'] ?? array()),
                'recording_url' => $call->recording_url,
                'call_duration' => (int) ($call->duration ?? 0),
                'ai_confidence' => 85,
                'created_at' => current_time('mysql'),
            ));
            $report_id = $wpdb->insert_id;
            error_log('VMO: Call report #' . $report_id . ' created for ' . $call_sid);
        }

        // ── 2. Create action items with deadlines ──
        $actions_table = $wpdb->prefix . 'vmo_action_items';
        if ($wpdb->get_var($wpdb->prepare("SHOW TABLES LIKE %s", $wpdb->esc_like($actions_table))) === $actions_table) {
            $existing_actions = (int) $wpdb->get_var($wpdb->prepare(
                "SELECT COUNT(*) FROM {$actions_table} WHERE call_sid = %s AND auto_generated = 1",
                $call_sid
            ));
            $actions_required = $report_data['actions_required'] ?? array();
            if ($existing_actions > 0) {
                error_log('VMO: Auto-generated action items already exist for ' . $call_sid . ' — skipping duplicate inserts');
            } else {
                foreach ($actions_required as $action) {
                    $deadline_hours = (int) ($action['deadline_hours'] ?? 24);
                    $deadline = date('Y-m-d H:i:s', time() + ($deadline_hours * 3600));
                    $priority = in_array($action['priority'] ?? '', array('low', 'medium', 'high', 'critical')) ? $action['priority'] : 'medium';
                    $category = in_array($action['category'] ?? '', array('follow_up', 'send_info', 'callback', 'order', 'booking', 'support', 'custom')) ? $action['category'] : 'follow_up';

                    // phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery, WordPress.DB.DirectDatabaseQuery.NoCaching
                    $wpdb->insert($actions_table, array(
                        'user_id' => $user_id,
                        'call_sid' => $call_sid,
                        'report_id' => $report_id,
                        'contact_id' => $call->contact_id,
                        'title' => sanitize_text_field($this->safe_substr((string) ($action['action'] ?? 'Follow up with caller'), 0, 500)),
                        'description' => sanitize_text_field($action['details'] ?? ''),
                        'priority' => $priority,
                        'category' => $category,
                        'deadline' => $deadline,
                        'status' => 'pending',
                        'auto_generated' => 1,
                        'created_at' => current_time('mysql'),
                    ));
                }
                error_log('VMO: ' . count($actions_required) . ' action items created for ' . $call_sid);
            }

            // Mirror phone actions into unified vmo_actions table for Today tab
            $unified_actions = $wpdb->prefix . 'vmo_actions';
            if ($wpdb->get_var($wpdb->prepare("SHOW TABLES LIKE %s", $wpdb->esc_like($unified_actions))) === $unified_actions) {
                foreach ($actions_required as $action) {
                    $action_title = sanitize_text_field($this->safe_substr((string) ($action['action'] ?? 'Follow up with caller'), 0, 500));
                    $exists = $wpdb->get_var($wpdb->prepare(
                        "SELECT id FROM {$unified_actions} WHERE user_id = %d AND source = 'phone_report' AND source_id = %s AND title = %s LIMIT 1",
                        $user_id,
                        $call_sid,
                        $action_title
                    ));
                    if (!$exists) {
                        $p = $action['priority'] ?? 'medium';
                        $priority_map = ['critical' => 'urgent', 'high' => 'high', 'medium' => 'normal', 'low' => 'low'];
                        $cat_map = ['follow_up' => 'follow-up', 'send_info' => 'sales', 'callback' => 'follow-up', 'order' => 'sales', 'booking' => 'sales', 'support' => 'support', 'custom' => 'general'];
                        $deadline_hours = (int) ($action['deadline_hours'] ?? 24);
                        $wpdb->insert($unified_actions, array(
                            'user_id' => $user_id,
                            'title' => $action_title,
                            'description' => sanitize_text_field($action['details'] ?? ''),
                            'source' => 'phone_report',
                            'source_id' => $call_sid,
                            'priority' => $priority_map[$p] ?? 'normal',
                            'category' => $cat_map[$action['category'] ?? 'follow_up'] ?? 'general',
                            'status' => 'pending',
                            'due_date' => date('Y-m-d H:i:s', time() + ($deadline_hours * 3600)),
                            'created_at' => current_time('mysql'),
                            'updated_at' => current_time('mysql'),
                        ));
                    }
                }
            }
        }

        // ── 3. Enhanced CRM auto-categorization ──
        $this->update_contact_from_report($call_sid, $report_data);

        // ── 4. Fulfill AI promises (send SMS/email with business info) ──
        $promises = $report_data['promises_made'] ?? array();
        if (!empty($promises)) {
            $fulfillment_table = $wpdb->prefix . 'vmo_fulfillment_log';
            $existing_fulfillment = 0;
            if ($wpdb->get_var($wpdb->prepare("SHOW TABLES LIKE %s", $wpdb->esc_like($fulfillment_table))) === $fulfillment_table) {
                $existing_fulfillment = (int) $wpdb->get_var($wpdb->prepare(
                    "SELECT COUNT(*) FROM {$fulfillment_table} WHERE call_sid = %s",
                    $call_sid
                ));
            }

            if ($existing_fulfillment > 0) {
                error_log('VMO Fulfillment: Existing fulfillment found for ' . $call_sid . ' — skipping duplicate delivery');
            } else {
                $this->fulfill_ai_promises($call_sid, $user_id, $call->caller_number, $report_data, $report_id);
            }
        }
    }

    /**
     * Enhanced CRM contact update using structured report data.
     *
     * Replaces the basic regex approach with structured data from GPT analysis.
     *
     * @param string $call_sid    Twilio Call SID
     * @param array  $report_data Structured report from GPT
     */
    private function update_contact_from_report($call_sid, array $report_data)
    {
        global $wpdb;
        $calls_table = $wpdb->prefix . 'vmo_phone_calls';
        $contacts_table = $wpdb->prefix . 'vmo_contacts';

        $call = $wpdb->get_row($wpdb->prepare(
            "SELECT user_id, caller_number, contact_id FROM {$calls_table} WHERE call_sid = %s LIMIT 1",
            $call_sid
        ));
        if (!$call || empty($call->caller_number))
            return;

        $user_id = (int) $call->user_id;
        if ($wpdb->get_var($wpdb->prepare("SHOW TABLES LIKE %s", $wpdb->esc_like($contacts_table))) !== $contacts_table)
            return;

        $caller_name = sanitize_text_field($report_data['caller_name'] ?? '');
        $email = sanitize_email($report_data['caller_email'] ?? ($report_data['key_info_collected']['email'] ?? ''));
        $key_info = is_array($report_data['key_info_collected'] ?? null) ? $report_data['key_info_collected'] : array();
        $company = sanitize_text_field($key_info['company'] ?? '');
        $interest = sanitize_key($report_data['sales_signals']['interest_level'] ?? '');
        $crm_cat = sanitize_key($report_data['crm_category'] ?? '');
        $valid_cats = array('lead', 'customer', 'support', 'inquiry', 'vendor');
        if (!in_array($crm_cat, $valid_cats, true) || $crm_cat === 'customer') {
            $crm_cat = 'lead';
        }

        $lifecycle_stage = 'engaged';
        $lead_score = 50;
        if ($interest === 'hot') {
            $lifecycle_stage = 'active_relationship';
            $lead_score = 80;
        } elseif ($interest === 'warm') {
            $lifecycle_stage = 'nurturing';
            $lead_score = 60;
        } elseif ($interest === 'closed') {
            $lifecycle_stage = 'active_relationship';
            $lead_score = 90;
        }

        $identity = array(
            'phone' => $call->caller_number,
            'email' => $email,
            'full_name' => (strtoupper($caller_name) !== 'UNKNOWN') ? $caller_name : '',
            'company' => $company,
        );
        if (!empty($call->contact_id) && is_numeric($call->contact_id)) {
            $identity['id'] = (int) $call->contact_id;
        } elseif (!empty($call->contact_id)) {
            $identity['contact_id'] = (string) $call->contact_id;
        }

        $crm_context = array(
            'source' => 'phone_call',
            'primary_category' => $crm_cat,
            'lifecycle_stage' => $lifecycle_stage,
            'lead_score' => $lead_score,
            'engagement_level' => $lead_score >= 80 ? 'high' : 'medium',
            'address' => sanitize_text_field($key_info['address'] ?? ''),
            'city' => sanitize_text_field($key_info['city'] ?? ''),
            'state' => sanitize_text_field($key_info['state'] ?? ''),
            'zip' => sanitize_text_field($key_info['zip'] ?? ''),
            'country' => sanitize_text_field($key_info['country'] ?? ''),
            'notes' => sanitize_textarea_field($report_data['summary'] ?? ''),
            'tags' => array_filter(array('phone_call', $interest ? 'interest_' . $interest : '', $crm_cat)),
            'acquisition_details' => array(
                'call_sid' => $call_sid,
                'caller_intent' => $report_data['caller_intent'] ?? '',
                'products_discussed' => $report_data['products_discussed'] ?? array(),
                'actions_required' => $report_data['actions_required'] ?? array(),
            ),
            'categorization_analysis' => array(
                'crm_category' => $report_data['crm_category'] ?? '',
                'interest_level' => $interest,
                'sentiment' => $report_data['sentiment'] ?? '',
                'urgency' => $report_data['urgency'] ?? '',
                'customer_status_rule' => 'AI call analysis may not create customer status without payment/order/manual evidence.',
            ),
        );

        if (function_exists('vmo_crm_upsert_contact')) {
            $updated_contact = vmo_crm_upsert_contact($user_id, $identity, $crm_context);
            if (!empty($updated_contact['id'])) {
                error_log('VMO: Enhanced CRM update for contact #' . $updated_contact['id'] . ' — category=' . ($updated_contact['primary_category'] ?? $crm_cat));
            }
        }
    }

    /**
     * Fulfill promises the AI made during the call.
     *
     * When the AI told a caller "I'll send you our pricing" or "Let me text you
     * the details", this method actually composes and delivers that information
     * via SMS and/or email using the business's real data.
     *
     * @param string $call_sid      Twilio Call SID
     * @param int    $user_id       Business owner user ID
     * @param string $caller_number Caller's phone number
     * @param array  $report_data   Structured report with promises_made
     * @param int    $report_id     vmo_call_reports.id
     */
    private function fulfill_ai_promises($call_sid, $user_id, $caller_number, array $report_data, $report_id = 0)
    {
        $promises = $report_data['promises_made'] ?? array();
        if (empty($promises))
            return;

        error_log('VMO Fulfillment: Processing ' . count($promises) . ' promises for ' . $call_sid);

        // Gather business data to compose fulfillment messages
        $business_name = get_user_meta($user_id, 'business_name', true) ?: get_bloginfo('name');
        $business_phone = $this->get_user_phone($user_id);
        $business_email = get_user_meta($user_id, 'business_email', true) ?: '';
        $website = get_user_meta($user_id, 'website_url', true) ?: home_url('/');

        // Build rich context from same sources as receptionist prompt
        $context_parts = array();
        $context_parts[] = "Business: {$business_name}";
        if ($business_phone)
            $context_parts[] = "Phone: {$business_phone}";
        if ($business_email)
            $context_parts[] = "Email: {$business_email}";
        if ($website)
            $context_parts[] = "Website: {$website}";

        // Get store products
        global $wpdb;
        $store_table = $wpdb->prefix . 'vmo_store_products';
        if ($wpdb->get_var($wpdb->prepare("SHOW TABLES LIKE %s", $wpdb->esc_like($store_table))) === $store_table) {
            $products = $wpdb->get_results($wpdb->prepare(
                "SELECT name, description, price_cents, currency, product_type FROM {$store_table} WHERE user_id = %d AND status = 'active' ORDER BY name ASC LIMIT 20",
                $user_id
            ));
            if (!empty($products)) {
                $plines = array();
                foreach ($products as $p) {
                    $price = number_format($p->price_cents / 100, 2);
                    $plines[] = "{$p->name} — \${$price}";
                }
                $context_parts[] = "\nProducts/Services:\n" . implode("\n", $plines);
            }
        }

        // Get hours/address
        $hours = get_user_meta($user_id, 'business_hours', true);
        $address = get_user_meta($user_id, 'business_address', true);
        if ($hours)
            $context_parts[] = "Hours: {$hours}";
        if ($address)
            $context_parts[] = "Address: {$address}";

        // Get cached website content
        $cache_key = 'vmo_site_scan_' . md5($website) . '_' . $user_id;
        $cached_site = get_transient($cache_key);
        if ($cached_site) {
            $context_parts[] = $this->safe_substr($cached_site, 0, 2000);
        }

        $business_context = implode("\n", $context_parts);

        // Get API key for GPT composition
        $api_key = get_option('vmo_openai_api_key');

        $fulfillment_table = $wpdb->prefix . 'vmo_fulfillment_log';
        $reports_table = $wpdb->prefix . 'vmo_call_reports';
        $fulfillment_results = array();

        foreach ($promises as $promise) {
            $promise_type = $promise['type'] ?? 'sms';
            $content_needed = $promise['content_needed'] ?? $promise['promise'] ?? '';

            // Use GPT to compose a professional fulfillment message
            $message_body = $this->compose_fulfillment_message($api_key, $business_name, $business_context, $content_needed, $promise_type, $report_data);

            // ✅ TOKEN DEDUCTION: Charge for GPT turn used in fulfillment composition
            if (!empty($message_body) && class_exists('VMO_Twilio_Token_Economics')) {
                VMO_Twilio_Token_Economics::get_instance()->deduct_gpt_turn($user_id);
            }

            if (empty($message_body)) {
                error_log('VMO Fulfillment: Could not compose message for promise: ' . ($promise['promise'] ?? ''));
                continue;
            }

            $sent_via = array();

            // Send SMS if we have caller's number and Twilio is configured
            if (in_array($promise_type, array('sms', 'info', 'callback', 'both'), true) && !empty($caller_number)) {
                $sms_result = $this->send_fulfillment_sms($user_id, $caller_number, $message_body, $business_name);
                $sent_via[] = 'sms';

                // Log SMS fulfillment
                if ($wpdb->get_var($wpdb->prepare("SHOW TABLES LIKE %s", $wpdb->esc_like($fulfillment_table))) === $fulfillment_table) {
                    // phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery, WordPress.DB.DirectDatabaseQuery.NoCaching
                    $wpdb->insert($fulfillment_table, array(
                        'user_id' => $user_id,
                        'call_sid' => $call_sid,
                        'report_id' => $report_id,
                        'action_type' => 'sms',
                        'recipient_phone' => $caller_number,
                        'subject' => 'Follow-up: ' . $this->safe_substr((string) $content_needed, 0, 200),
                        'message_body' => $message_body,
                        'data_sources' => wp_json_encode(array('store_products', 'website_content', 'business_meta')),
                        'status' => is_wp_error($sms_result) ? 'failed' : 'sent',
                        'error_message' => is_wp_error($sms_result) ? $sms_result->get_error_message() : null,
                        'twilio_message_sid' => is_string($sms_result) ? $sms_result : null,
                        'created_at' => current_time('mysql'),
                    ));
                }
            }

            // Send email if we have caller's email
            $caller_email = $report_data['caller_email'] ?? ($report_data['key_info_collected']['email'] ?? '');
            if (in_array($promise_type, array('email', 'info', 'both'), true) && !empty($caller_email) && is_email($caller_email)) {
                $email_subject = "Information from {$business_name}";
                $email_html = $this->build_fulfillment_email_html($business_name, $message_body, $website);
                $email_result = wp_mail($caller_email, $email_subject, $email_html, array('Content-Type: text/html; charset=UTF-8'));
                $sent_via[] = 'email';

                // Log email fulfillment
                if ($wpdb->get_var($wpdb->prepare("SHOW TABLES LIKE %s", $wpdb->esc_like($fulfillment_table))) === $fulfillment_table) {
                    // phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery, WordPress.DB.DirectDatabaseQuery.NoCaching
                    $wpdb->insert($fulfillment_table, array(
                        'user_id' => $user_id,
                        'call_sid' => $call_sid,
                        'report_id' => $report_id,
                        'action_type' => 'email',
                        'recipient_email' => $caller_email,
                        'subject' => $email_subject,
                        'message_body' => $message_body,
                        'data_sources' => wp_json_encode(array('store_products', 'website_content', 'business_meta')),
                        'status' => $email_result ? 'sent' : 'failed',
                        'created_at' => current_time('mysql'),
                    ));
                }
            }

            $fulfillment_results[] = array(
                'promise' => $promise['promise'] ?? '',
                'sent_via' => $sent_via,
                'success' => !empty($sent_via),
            );
        }

        // Update report fulfillment status
        if ($report_id > 0 && $wpdb->get_var($wpdb->prepare("SHOW TABLES LIKE %s", $wpdb->esc_like($reports_table))) === $reports_table) {
            $all_sent = true;
            $any_sent = false;
            foreach ($fulfillment_results as $r) {
                if ($r['success'])
                    $any_sent = true;
                else
                    $all_sent = false;
            }
            $status = $all_sent ? 'complete' : ($any_sent ? 'partial' : 'failed');
            // phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery, WordPress.DB.DirectDatabaseQuery.NoCaching
            $wpdb->update($reports_table, array(
                'fulfillment_status' => $status,
                'fulfillment_log' => wp_json_encode($fulfillment_results),
            ), array('id' => $report_id));
        }

        error_log('VMO Fulfillment: Completed — ' . count($fulfillment_results) . ' promises processed for ' . $call_sid);
    }

    /**
     * Compose a professional fulfillment message using GPT.
     *
     * @param string $api_key         OpenAI API key
     * @param string $business_name   Business name
     * @param string $business_context All available business data
     * @param string $content_needed  What info was promised
     * @param string $channel         sms|email
     * @param array  $report_data     Full report data for context
     * @return string Composed message body
     */
    private function compose_fulfillment_message($api_key, $business_name, $business_context, $content_needed, $channel = 'sms', $report_data = array())
    {
        if (empty($api_key))
            return '';

        if (function_exists('vmo_platform_faq_should_ground_current_site') && function_exists('vmo_get_platform_faq_grounding_text') && vmo_platform_faq_should_ground_current_site((string) $business_name, (string) $business_context)) {
            $business_context = vmo_get_platform_faq_grounding_text('phone_followup', 8000) . "\n\n" . $business_context;
        }

        $caller_name = ($report_data['caller_name'] ?? 'UNKNOWN');
        if (strtoupper($caller_name) === 'UNKNOWN')
            $caller_name = 'there';
        $greeting = "Hi {$caller_name}";

        $format_instruction = ($channel === 'sms')
            ? 'Keep the message under 300 characters for SMS. Be concise but include the key info requested. No markdown.'
            : 'Write a professional but warm email body. Include all relevant details. No markdown formatting — use plain text with line breaks.';

        $prompt = "You are composing a follow-up message from {$business_name} to a caller.

The caller was promised this information: {$content_needed}

Business data available:
{$business_context}

{$format_instruction}

Start with \"{$greeting}\" and end with the business name. Include real data from the business context above — actual prices, hours, addresses, product details. Do NOT say \"I'll look into it\" — provide the actual information NOW.";

        $fulfillment_model = function_exists('vmo_get_model') ? vmo_get_model('nano') : 'gpt-5.5-nano';
        $token_limit = ($channel === 'sms') ? 150 : 500;
        $token_param = function_exists('vmo_get_token_param')
            ? vmo_get_token_param($fulfillment_model, $token_limit)
            : array('max_completion_tokens' => $token_limit);

        $payload = array_merge(array(
            'model' => $fulfillment_model,
            'messages' => array(
                array('role' => 'system', 'content' => "You compose professional business follow-up messages. Use ONLY real data provided. Be specific with prices, hours, addresses. If a VMO FAQ SOURCE OF TRUTH is provided, use it before any other website text for VMO product, pricing, plan, hosted/direct, and capability questions."),
                array('role' => 'user', 'content' => $prompt)
            ),
            'temperature' => 0.3,
        ), $token_param);

        $response = wp_remote_post('https://api.openai.com/v1/chat/completions', array(
            'headers' => array(
                'Authorization' => 'Bearer ' . $api_key,
                'Content-Type' => 'application/json'
            ),
            'body' => json_encode($payload),
            'timeout' => 30
        ));

        if (is_wp_error($response))
            return '';
        $body = json_decode(wp_remote_retrieve_body($response), true);
        return $body['choices'][0]['message']['content'] ?? '';
    }

    /**
     * Send a fulfillment SMS via Twilio.
     *
     * @param int    $user_id       Business owner user ID
     * @param string $to            Recipient phone number
     * @param string $message       Message body
     * @param string $business_name Business name for logging
     * @return string|WP_Error      Twilio Message SID or error
     */
    private function send_fulfillment_sms($user_id, $to, $message, $business_name = '')
    {
        if (empty($this->account_sid) || empty($this->auth_token)) {
            return new WP_Error('no_twilio', 'Twilio not configured');
        }

        // Get user's Twilio phone number
        $from = $this->get_user_phone($user_id);
        if (empty($from)) {
            return new WP_Error('no_phone', 'No Twilio phone number found for user');
        }

        $url = "https://api.twilio.com/2010-04-01/Accounts/{$this->account_sid}/Messages.json";
        $response = wp_remote_post($url, array(
            'headers' => array(
                'Authorization' => 'Basic ' . base64_encode($this->account_sid . ':' . $this->auth_token), // phpcs:ignore WordPress.PHP.DiscouragedPHPFunctions.obfuscation_base64_encode -- HTTP Basic auth header
            ),
            'body' => array(
                'To' => $to,
                'From' => $from,
                'Body' => $message,
            ),
            'timeout' => 15,
        ));

        if (is_wp_error($response)) {
            error_log('VMO Fulfillment SMS Error: ' . $response->get_error_message());
            return $response;
        }

        $body = json_decode(wp_remote_retrieve_body($response), true);
        if (isset($body['sid'])) {
            error_log('VMO Fulfillment: SMS sent to ' . $to . ' — SID: ' . $body['sid']);

            // ✅ TOKEN DEDUCTION: Charge for outbound fulfillment SMS
            if (class_exists('VMO_Twilio_Token_Economics')) {
                VMO_Twilio_Token_Economics::get_instance()->deduct_sms_outbound($user_id);
            }

            $this->log_sms_message(
                $user_id,
                sanitize_text_field((string) $body['sid']),
                (string) $from,
                (string) $to,
                'outbound',
                (string) $message,
                sanitize_text_field((string) ($body['status'] ?? 'queued'))
            );

            return $body['sid'];
        }

        $err_msg = $body['message'] ?? 'Unknown Twilio error';
        error_log('VMO Fulfillment SMS Failed: ' . $err_msg);
        return new WP_Error('sms_failed', $err_msg);
    }

    /**
     * Build HTML email for fulfillment delivery.
     */
    private function build_fulfillment_email_html($business_name, $message_body, $website = '')
    {
        $html = '<div style="max-width:580px;margin:0 auto;font-family:Arial,Helvetica,sans-serif;color:#333;">';
        $html .= '<div style="background:#1a1a2e;color:#fff;padding:16px 20px;border-radius:10px 10px 0 0;">';
        $html .= '<strong style="font-size:16px;">' . esc_html($business_name) . '</strong>';
        $html .= '</div>';
        $html .= '<div style="padding:20px;border:1px solid #e0e0e0;border-top:none;border-radius:0 0 10px 10px;background:#fff;">';
        $html .= '<div style="font-size:14px;line-height:1.7;white-space:pre-line;">' . esc_html($message_body) . '</div>';
        if ($website) {
            $html .= '<div style="margin-top:20px;padding-top:14px;border-top:1px solid #eee;">';
            $html .= '<a href="' . esc_url($website) . '" style="color:#1a73e8;font-size:13px;">Visit our website</a>';
            $html .= '</div>';
        }
        $html .= '<div style="margin-top:14px;font-size:11px;color:#aaa;">This message was sent as a follow-up to your recent call with ' . esc_html($business_name) . '.</div>';
        $html .= '</div></div>';
        return $html;
    }

    // ======================================================================
    // DAILY / WEEKLY / MONTHLY / YEARLY REPORT GENERATION
    // ======================================================================

    /**
     * Generate daily reports for ALL users who had phone activity.
     * Called via WP cron at 11:55 PM daily.
     */
    /**
     * Get a user's timezone string (e.g. 'America/New_York').
     * Falls back to WordPress timezone → UTC.
     */
    private function get_user_timezone($user_id)
    {
        // 1. Explicitly stored timezone from browser auto-detection
        $tz = get_user_meta($user_id, 'vmo_user_timezone', true);
        if (!empty($tz) && @timezone_open($tz))
            return $tz;

        // 2. WordPress site timezone
        $wp_tz = wp_timezone_string();
        if (!empty($wp_tz) && @timezone_open($wp_tz))
            return $wp_tz;

        // 3. Fallback
        return 'UTC';
    }

    /**
     * Convert a DB timestamp (stored in WP site timezone) to ISO 8601 with offset.
     * The browser receives something like "2026-02-21T04:41:11+00:00" and
     * JavaScript's `new Date()` + `toLocaleString()` auto-converts to the
     * user's browser local time.
     *
     * @param string $db_datetime The datetime from the database (WP site timezone).
     * @return string ISO 8601 string with timezone offset, or original string on failure.
     */
    public static function to_iso8601($db_datetime)
    {
        if (empty($db_datetime))
            return '';
        try {
            $dt = new DateTime($db_datetime, wp_timezone());
            return $dt->format('c');
        } catch (Exception $e) {
            return $db_datetime;
        }
    }

    /**
     * Format a DB timestamp in a specific user's local timezone.
     * Used for emails and server-side rendered strings.
     *
     * @param string $db_datetime The datetime from the database (WP site timezone).
     * @param int    $user_id     The user whose timezone to use.
     * @param string $format      PHP date format string.
     * @return string Formatted date string in user's local timezone.
     */
    private function format_for_user($db_datetime, $user_id, $format = 'F j, Y g:i a')
    {
        if (empty($db_datetime))
            return '';
        try {
            $dt = new DateTime($db_datetime, wp_timezone());
            $user_tz = new DateTimeZone($this->get_user_timezone($user_id));
            $dt->setTimezone($user_tz);
            return $dt->format($format);
        } catch (Exception $e) {
            return date($format, strtotime($db_datetime));
        }
    }

    /**
     * Get the WP site timezone offset string (e.g. '+00:00', '-05:00') for CONVERT_TZ queries.
     * Since we store timestamps using current_time('mysql') which uses WP site timezone,
     * CONVERT_TZ should convert FROM this offset, not from '+00:00'.
     */
    private function wp_tz_offset_string()
    {
        try {
            $now = new DateTime('now', wp_timezone());
            return $now->format('P');
        } catch (Exception $e) {
            return '+00:00';
        }
    }

    /**
     * AJAX: Save the user's timezone detected from their browser.
     */
    public function ajax_save_user_timezone()
    {
        if (!$this->verify_ajax_request()) {
            wp_send_json_error(array('message' => 'Security check failed'), 403);
            return;
        }
        $user_id = get_current_user_id();
        if (!$user_id)
            wp_send_json_error(array('message' => 'Not logged in'));

        $tz = isset($_POST['timezone']) ? sanitize_text_field(wp_unslash($_POST['timezone'])) : '';
        if (empty($tz) || !@timezone_open($tz)) {
            wp_send_json_error(array('message' => 'Invalid timezone'));
        }

        update_user_meta($user_id, 'vmo_user_timezone', $tz);
        wp_send_json_success(array('timezone' => $tz));
    }

    /**
     * Hourly cron: check every user's local time and generate daily reports
     * for those whose local time is between 23:00-23:59 (end of their day).
     * This ensures each user gets their report at the end of THEIR day.
     */
    public function generate_daily_reports_for_all_users()
    {
        global $wpdb;
        $calls_table = $wpdb->prefix . 'vmo_phone_calls';
        $periodic_table = $wpdb->prefix . 'vmo_periodic_reports';

        // Get all users who have ever had calls (active phone users)
        // phpcs:ignore WordPress.DB.PreparedSQL.InterpolatedNotPrepared, WordPress.DB.DirectDatabaseQuery.DirectQuery, WordPress.DB.DirectDatabaseQuery.NoCaching
        $all_user_ids = $wpdb->get_col(
            "SELECT DISTINCT user_id FROM {$calls_table}"
        );

        if (empty($all_user_ids))
            return;

        $generated = 0;

        foreach ($all_user_ids as $uid) {
            $uid = (int) $uid;

            // Determine this user's local time right now
            $tz_string = $this->get_user_timezone($uid);
            try {
                $tz = new DateTimeZone($tz_string);
            } catch (Exception $e) {
                $tz = new DateTimeZone('UTC');
            }
            $now_local = new DateTime('now', $tz);
            $local_hour = (int) $now_local->format('G'); // 0-23

            // Only generate report when it's 11 PM (23:xx) in the user's timezone
            if ($local_hour !== 23)
                continue;

            $today_local = $now_local->format('Y-m-d');

            // Check if we already generated a daily report for this user today (prevent duplicates)
            if ($wpdb->get_var($wpdb->prepare("SHOW TABLES LIKE %s", $wpdb->esc_like($periodic_table))) === $periodic_table) {
                $existing = $wpdb->get_var($wpdb->prepare(
                    "SELECT id FROM {$periodic_table} WHERE user_id = %d AND report_period = 'daily' AND period_start = %s LIMIT 1",
                    $uid,
                    $today_local
                ));
                if ($existing)
                    continue; // Already generated today
            }

            // Check this user actually had calls today (in their local date)
            // created_at is stored in WP site timezone via current_time('mysql'),
            // so CONVERT_TZ must convert FROM the WP site offset, not from +00:00.
            $wp_offset = $this->wp_tz_offset_string();
            $has_calls = $wpdb->get_var($wpdb->prepare(
                "SELECT COUNT(*) FROM {$calls_table} WHERE user_id = %d AND DATE(CONVERT_TZ(created_at, %s, %s)) = %s",
                $uid,
                $wp_offset,
                $now_local->format('P'),
                $today_local
            ));
            if (!$has_calls)
                continue;

            // Generate the daily report using the user's local date
            $this->generate_daily_report($uid, $today_local);
            $generated++;

            // Weekly report: check if it's Sunday in user's timezone
            if ($now_local->format('w') == 0) {
                $week_start = (clone $now_local)->modify('last monday')->format('Y-m-d');
                $this->generate_periodic_report($uid, 'weekly', $week_start, $today_local);
            }

            // Monthly report: check if it's last day of month in user's timezone
            if ($now_local->format('j') == $now_local->format('t')) {
                $month_start = $now_local->format('Y-m-01');
                $this->generate_periodic_report($uid, 'monthly', $month_start, $today_local);
            }

            // Yearly report: Dec 31 in user's timezone
            if ($now_local->format('m-d') === '12-31') {
                $year_start = $now_local->format('Y-01-01');
                $this->generate_periodic_report($uid, 'yearly', $year_start, $today_local);
            }
        }

        if ($generated > 0) {
            error_log('VMO Reports: Hourly check generated daily reports for ' . $generated . ' users');
        }
    }

    /**
     * Generate a daily activity report for a specific user.
     *
     * @param int    $user_id User ID
     * @param string $date    Date in Y-m-d format (defaults to today)
     */
    public function generate_daily_report($user_id, $date = '')
    {
        if (empty($date))
            $date = current_time('Y-m-d');
        $user_id = (int) $user_id;

        global $wpdb;
        $calls_table = $wpdb->prefix . 'vmo_phone_calls';
        $reports_table = $wpdb->prefix . 'vmo_call_reports';
        $actions_table = $wpdb->prefix . 'vmo_action_items';
        $fulfillment_table = $wpdb->prefix . 'vmo_fulfillment_log';
        $periodic_table = $wpdb->prefix . 'vmo_periodic_reports';

        // Check if periodic reports table exists
        if ($wpdb->get_var($wpdb->prepare("SHOW TABLES LIKE %s", $wpdb->esc_like($periodic_table))) !== $periodic_table)
            return;

        // Gather daily data
        $calls = $wpdb->get_results($wpdb->prepare(
            "SELECT * FROM {$calls_table} WHERE user_id = %d AND DATE(created_at) = %s ORDER BY created_at ASC",
            $user_id,
            $date
        ));

        $call_reports = array();
        if ($wpdb->get_var($wpdb->prepare("SHOW TABLES LIKE %s", $wpdb->esc_like($reports_table))) === $reports_table) {
            $call_reports = $wpdb->get_results($wpdb->prepare(
                "SELECT * FROM {$reports_table} WHERE user_id = %d AND DATE(created_at) = %s ORDER BY created_at ASC",
                $user_id,
                $date
            ));
        }

        $action_items = array();
        if ($wpdb->get_var($wpdb->prepare("SHOW TABLES LIKE %s", $wpdb->esc_like($actions_table))) === $actions_table) {
            $action_items = $wpdb->get_results($wpdb->prepare(
                "SELECT * FROM {$actions_table} WHERE user_id = %d AND DATE(created_at) = %s",
                $user_id,
                $date
            ));
        }

        $fulfillments = array();
        if ($wpdb->get_var($wpdb->prepare("SHOW TABLES LIKE %s", $wpdb->esc_like($fulfillment_table))) === $fulfillment_table) {
            $fulfillments = $wpdb->get_results($wpdb->prepare(
                "SELECT * FROM {$fulfillment_table} WHERE user_id = %d AND DATE(created_at) = %s",
                $user_id,
                $date
            ));
        }

        // Calculate metrics
        $total_calls = count($calls);
        $total_duration = 0;
        $ai_handled = 0;
        foreach ($calls as $c) {
            $total_duration += (int) ($c->duration ?? 0);
            if ($c->ai_handled)
                $ai_handled++;
        }

        $leads = 0;
        $customers = 0;
        $support = 0;
        $intents = array();
        $sentiments = array('positive' => 0, 'neutral' => 0, 'negative' => 0, 'frustrated' => 0);
        $products = array();
        foreach ($call_reports as $cr) {
            if ($cr->crm_category === 'lead')
                $leads++;
            elseif ($cr->crm_category === 'customer')
                $customers++;
            elseif ($cr->crm_category === 'support')
                $support++;
            $sentiments[$cr->call_sentiment ?? 'neutral']++;
            if (!empty($cr->caller_intent))
                $intents[] = $cr->caller_intent;
            $prods = json_decode($cr->products_discussed ?? '[]', true);
            if (is_array($prods))
                $products = array_merge($products, $prods);
        }

        $actions_completed = 0;
        $actions_pending = 0;
        $actions_overdue = 0;
        foreach ($action_items as $ai_item) {
            if ($ai_item->status === 'completed')
                $actions_completed++;
            elseif ($ai_item->status === 'overdue')
                $actions_overdue++;
            else
                $actions_pending++;
        }

        $fulfillments_sent = count(array_filter($fulfillments, function ($f) {
            return $f->status === 'sent'; }));

        // Summary data
        $summary_data = array(
            'total_calls' => $total_calls,
            'total_duration_seconds' => $total_duration,
            'avg_duration_seconds' => $total_calls > 0 ? round($total_duration / $total_calls) : 0,
            'ai_handled_calls' => $ai_handled,
            'new_leads' => $leads,
            'new_customers' => $customers,
            'support_calls' => $support,
            'sentiments' => $sentiments,
            'top_intents' => array_slice(array_unique($intents), 0, 5),
            'products_discussed' => array_values(array_unique($products)),
            'action_items_total' => count($action_items),
            'action_items_completed' => $actions_completed,
            'action_items_pending' => $actions_pending,
            'action_items_overdue' => $actions_overdue,
            'fulfillments_sent' => $fulfillments_sent,
            'fulfillments_failed' => count($fulfillments) - $fulfillments_sent,
        );

        // Build lookup of recording URLs by call_sid
        $recording_map = array();
        foreach ($calls as $c) {
            if (!empty($c->recording_url)) {
                $recording_map[$c->call_sid] = $c->recording_url;
            }
        }

        // Detail data: per-call breakdown
        $detail_data = array();
        foreach ($call_reports as $cr) {
            $detail_data[] = array(
                'call_sid' => $cr->call_sid,
                'caller' => $cr->caller_name ?: $cr->caller_phone,
                'intent' => $cr->caller_intent,
                'category' => $cr->crm_category,
                'sentiment' => $cr->call_sentiment,
                'urgency' => $cr->urgency,
                'duration' => $cr->call_duration,
                'summary' => $cr->summary_text,
                'recording_url' => isset($recording_map[$cr->call_sid]) ? $recording_map[$cr->call_sid] : '',
                'actions_required' => json_decode($cr->actions_required ?? '[]', true),
                'fulfillment_status' => $cr->fulfillment_status,
            );
        }

        // Roadmap comparison (if daily_activities table exists)
        $roadmap_comparison = null;
        $daily_activities_table = $wpdb->prefix . 'vmo_daily_activities';
        if ($wpdb->get_var($wpdb->prepare("SHOW TABLES LIKE %s", $wpdb->esc_like($daily_activities_table))) === $daily_activities_table) {
            $expected = $wpdb->get_results($wpdb->prepare(
                "SELECT * FROM {$daily_activities_table} WHERE user_id = %d AND activity_date = %s",
                $user_id,
                $date
            ));
            if (!empty($expected)) {
                $expected_total = count($expected);
                $expected_done = count(array_filter($expected, function ($a) {
                    return $a->status === 'completed'; }));
                $roadmap_comparison = array(
                    'expected_activities' => $expected_total,
                    'completed_activities' => $expected_done,
                    'completion_rate' => $expected_total > 0 ? round(($expected_done / $expected_total) * 100) : 0,
                    'activities' => array_map(function ($a) {
                        return array(
                            'title' => $a->activity_title,
                            'status' => $a->status,
                            'priority' => $a->priority,
                        );
                    }, $expected),
                );
            }
        }

        // Generate AI executive insights
        $cover_page = $this->generate_report_cover_page($user_id, 'daily', $date, $date, $summary_data);

        // Insert or update periodic report
        $existing = $wpdb->get_var($wpdb->prepare(
            "SELECT id FROM {$periodic_table} WHERE user_id = %d AND report_period = 'daily' AND period_start = %s AND period_end = %s LIMIT 1",
            $user_id,
            $date,
            $date
        ));

        $report_row = array(
            'user_id' => $user_id,
            'report_period' => 'daily',
            'period_start' => $date,
            'period_end' => $date,
            'cover_page' => wp_json_encode($cover_page),
            'summary_data' => wp_json_encode($summary_data),
            'detail_data' => wp_json_encode($detail_data),
            'roadmap_comparison' => $roadmap_comparison ? wp_json_encode($roadmap_comparison) : null,
            'total_calls' => $total_calls,
            'total_leads' => $leads,
            'total_customers' => $customers,
            'total_action_items' => count($action_items),
            'action_items_completed' => $actions_completed,
            'fulfillments_sent' => $fulfillments_sent,
            'generated_at' => current_time('mysql'),
        );

        if ($existing) {
            // phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery, WordPress.DB.DirectDatabaseQuery.NoCaching
            $wpdb->update($periodic_table, $report_row, array('id' => $existing));
        } else {
            // phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery, WordPress.DB.DirectDatabaseQuery.NoCaching
            $wpdb->insert($periodic_table, $report_row);
        }

        // Email daily report to user
        $this->send_daily_report_email($user_id, $date, $summary_data, $detail_data, $cover_page, $roadmap_comparison);

        error_log('VMO Reports: Daily report generated for user ' . $user_id . ' on ' . $date);
    }

    /**
     * Generate a periodic report (weekly/monthly/quarterly/yearly).
     * Aggregates data from daily reports within the period.
     *
     * @param int    $user_id   User ID
     * @param string $period    weekly|monthly|quarterly|yearly|custom
     * @param string $start     Start date Y-m-d
     * @param string $end       End date Y-m-d
     */
    public function generate_periodic_report($user_id, $period, $start, $end = '')
    {
        if (empty($end))
            $end = current_time('Y-m-d');
        $user_id = (int) $user_id;

        global $wpdb;
        $periodic_table = $wpdb->prefix . 'vmo_periodic_reports';
        $calls_table = $wpdb->prefix . 'vmo_phone_calls';
        $reports_table = $wpdb->prefix . 'vmo_call_reports';
        $actions_table = $wpdb->prefix . 'vmo_action_items';
        $fulfillment_table = $wpdb->prefix . 'vmo_fulfillment_log';

        if ($wpdb->get_var($wpdb->prepare("SHOW TABLES LIKE %s", $wpdb->esc_like($periodic_table))) !== $periodic_table)
            return;

        // Aggregate calls
        $calls = $wpdb->get_results($wpdb->prepare(
            "SELECT * FROM {$calls_table} WHERE user_id = %d AND DATE(created_at) BETWEEN %s AND %s",
            $user_id,
            $start,
            $end
        ));

        $total_calls = count($calls);
        $total_duration = 0;
        $ai_handled = 0;
        $calls_by_day = array();
        foreach ($calls as $c) {
            $total_duration += (int) ($c->duration ?? 0);
            if ($c->ai_handled)
                $ai_handled++;
            $day = date('Y-m-d', strtotime($c->created_at));
            $calls_by_day[$day] = ($calls_by_day[$day] ?? 0) + 1;
        }

        // Aggregate reports
        $leads = 0;
        $customers = 0;
        $sentiments = array('positive' => 0, 'neutral' => 0, 'negative' => 0, 'frustrated' => 0);
        if ($wpdb->get_var($wpdb->prepare("SHOW TABLES LIKE %s", $wpdb->esc_like($reports_table))) === $reports_table) {
            $cr_rows = $wpdb->get_results($wpdb->prepare(
                "SELECT crm_category, call_sentiment FROM {$reports_table} WHERE user_id = %d AND DATE(created_at) BETWEEN %s AND %s",
                $user_id,
                $start,
                $end
            ));
            foreach ($cr_rows as $cr) {
                if ($cr->crm_category === 'lead')
                    $leads++;
                elseif ($cr->crm_category === 'customer')
                    $customers++;
                $sentiments[$cr->call_sentiment ?? 'neutral']++;
            }
        }

        // Action items
        $total_actions = 0;
        $completed_actions = 0;
        if ($wpdb->get_var($wpdb->prepare("SHOW TABLES LIKE %s", $wpdb->esc_like($actions_table))) === $actions_table) {
            $total_actions = (int) $wpdb->get_var($wpdb->prepare(
                "SELECT COUNT(*) FROM {$actions_table} WHERE user_id = %d AND DATE(created_at) BETWEEN %s AND %s",
                $user_id,
                $start,
                $end
            ));
            $completed_actions = (int) $wpdb->get_var($wpdb->prepare(
                "SELECT COUNT(*) FROM {$actions_table} WHERE user_id = %d AND DATE(created_at) BETWEEN %s AND %s AND status = 'completed'",
                $user_id,
                $start,
                $end
            ));
        }

        // Fulfillments
        $fulfillments_sent = 0;
        if ($wpdb->get_var($wpdb->prepare("SHOW TABLES LIKE %s", $wpdb->esc_like($fulfillment_table))) === $fulfillment_table) {
            $fulfillments_sent = (int) $wpdb->get_var($wpdb->prepare(
                "SELECT COUNT(*) FROM {$fulfillment_table} WHERE user_id = %d AND DATE(created_at) BETWEEN %s AND %s AND status = 'sent'",
                $user_id,
                $start,
                $end
            ));
        }

        $summary_data = array(
            'total_calls' => $total_calls,
            'total_duration_seconds' => $total_duration,
            'avg_duration_seconds' => $total_calls > 0 ? round($total_duration / $total_calls) : 0,
            'ai_handled_calls' => $ai_handled,
            'new_leads' => $leads,
            'new_customers' => $customers,
            'sentiments' => $sentiments,
            'calls_by_day' => $calls_by_day,
            'action_items_total' => $total_actions,
            'action_items_completed' => $completed_actions,
            'action_completion_rate' => $total_actions > 0 ? round(($completed_actions / $total_actions) * 100) : 0,
            'fulfillments_sent' => $fulfillments_sent,
            'busiest_day' => !empty($calls_by_day) ? array_search(max($calls_by_day), $calls_by_day) : null,
            'peak_calls' => !empty($calls_by_day) ? max($calls_by_day) : 0,
        );

        // Roadmap comparison for the period
        $roadmap_comparison = null;
        $daily_activities_table = $wpdb->prefix . 'vmo_daily_activities';
        if ($wpdb->get_var($wpdb->prepare("SHOW TABLES LIKE %s", $wpdb->esc_like($daily_activities_table))) === $daily_activities_table) {
            $exp_total = (int) $wpdb->get_var($wpdb->prepare(
                "SELECT COUNT(*) FROM {$daily_activities_table} WHERE user_id = %d AND activity_date BETWEEN %s AND %s",
                $user_id,
                $start,
                $end
            ));
            $exp_done = (int) $wpdb->get_var($wpdb->prepare(
                "SELECT COUNT(*) FROM {$daily_activities_table} WHERE user_id = %d AND activity_date BETWEEN %s AND %s AND status = 'completed'",
                $user_id,
                $start,
                $end
            ));
            if ($exp_total > 0) {
                $roadmap_comparison = array(
                    'expected_activities' => $exp_total,
                    'completed_activities' => $exp_done,
                    'completion_rate' => round(($exp_done / $exp_total) * 100),
                );
            }
        }

        $cover_page = $this->generate_report_cover_page($user_id, $period, $start, $end, $summary_data);

        // Insert or update
        $existing = $wpdb->get_var($wpdb->prepare(
            "SELECT id FROM {$periodic_table} WHERE user_id = %d AND report_period = %s AND period_start = %s AND period_end = %s LIMIT 1",
            $user_id,
            $period,
            $start,
            $end
        ));

        $row = array(
            'user_id' => $user_id,
            'report_period' => $period,
            'period_start' => $start,
            'period_end' => $end,
            'cover_page' => wp_json_encode($cover_page),
            'summary_data' => wp_json_encode($summary_data),
            'detail_data' => null,
            'roadmap_comparison' => $roadmap_comparison ? wp_json_encode($roadmap_comparison) : null,
            'total_calls' => $total_calls,
            'total_leads' => $leads,
            'total_customers' => $customers,
            'total_action_items' => $total_actions,
            'action_items_completed' => $completed_actions,
            'fulfillments_sent' => $fulfillments_sent,
            'generated_at' => current_time('mysql'),
        );

        if ($existing) {
            // phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery, WordPress.DB.DirectDatabaseQuery.NoCaching
            $wpdb->update($periodic_table, $row, array('id' => $existing));
        } else {
            // phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery, WordPress.DB.DirectDatabaseQuery.NoCaching
            $wpdb->insert($periodic_table, $row);
        }

        error_log("VMO Reports: {$period} report generated for user {$user_id} ({$start} to {$end})");
    }

    /**
     * Generate AI-powered executive cover page for a report.
     */
    private function generate_report_cover_page($user_id, $period, $start, $end, $summary_data)
    {
        $business_name = get_user_meta($user_id, 'business_name', true) ?: get_bloginfo('name');
        $api_key = get_option('vmo_openai_api_key');

        $cover = array(
            'business_name' => $business_name,
            'report_title' => ucfirst($period) . ' Activity Report',
            'period' => ($start === $end) ? $this->format_for_user($start . ' 00:00:00', $user_id, 'F j, Y') : $this->format_for_user($start . ' 00:00:00', $user_id, 'M j') . ' — ' . $this->format_for_user($end . ' 00:00:00', $user_id, 'M j, Y'),
            'generated_at' => $this->format_for_user(current_time('mysql'), $user_id, 'F j, Y g:i a'),
            'headline' => '',
            'executive_summary' => '',
            'key_metrics' => array(
                array('label' => 'Total Calls', 'value' => $summary_data['total_calls'] ?? 0),
                array('label' => 'New Leads', 'value' => $summary_data['new_leads'] ?? 0),
                array('label' => 'New Customers', 'value' => $summary_data['new_customers'] ?? 0),
                array('label' => 'AI Handled', 'value' => $summary_data['ai_handled_calls'] ?? 0),
                array('label' => 'Fulfillments Sent', 'value' => $summary_data['fulfillments_sent'] ?? 0),
                array('label' => 'Action Completion', 'value' => ($summary_data['action_completion_rate'] ?? ($summary_data['action_items_total'] > 0 ? round(($summary_data['action_items_completed'] / $summary_data['action_items_total']) * 100) : 0)) . '%'),
            ),
        );

        // Generate executive summary with GPT
        if (!empty($api_key) && ($summary_data['total_calls'] ?? 0) > 0) {
            $data_str = json_encode($summary_data);
            $prompt = "You are a business analyst generating an executive summary for a {$period} phone activity report for {$business_name}.

Report period: {$cover['period']}
Data: {$data_str}

Generate:
1. A compelling one-line headline (e.g., 'Strong Lead Generation Day with 12 New Inquiries')
2. A 2-3 sentence executive summary highlighting the most important findings, trends, and actionable insights.

Return as JSON: {\"headline\": \"...\", \"executive_summary\": \"...\"}";

            $report_model = function_exists('vmo_get_model') ? vmo_get_model('nano') : 'gpt-5.5-nano';
            $report_token_param = function_exists('vmo_get_token_param')
                ? vmo_get_token_param($report_model, 200)
                : array('max_completion_tokens' => 200);

            $response = wp_remote_post('https://api.openai.com/v1/chat/completions', array(
                'headers' => array('Authorization' => 'Bearer ' . $api_key, 'Content-Type' => 'application/json'),
                'body' => json_encode(array_merge(array(
                    'model' => $report_model,
                    'messages' => array(
                        array('role' => 'system', 'content' => 'You generate concise executive report summaries. Return only valid JSON.'),
                        array('role' => 'user', 'content' => $prompt),
                    ),
                    'temperature' => 0.4,
                    'response_format' => array('type' => 'json_object'),
                ), $report_token_param)),
                'timeout' => 20,
            ));

            if (!is_wp_error($response)) {
                $body = json_decode(wp_remote_retrieve_body($response), true);
                $insight = json_decode($body['choices'][0]['message']['content'] ?? '{}', true);
                if (is_array($insight)) {
                    $cover['headline'] = $insight['headline'] ?? '';
                    $cover['executive_summary'] = $insight['executive_summary'] ?? '';
                }
            }
        }

        // Normalize generated headline to avoid misleading labels (e.g. 38 calls marked low activity)
        $cover['headline'] = $this->normalize_report_headline($cover['headline'], $summary_data);
        return $cover;
    }

    /**
     * Normalize report headline based on numeric thresholds to avoid AI wording mismatch.
     */
    private function normalize_report_headline($headline, $summary_data)
    {
        $total_calls = isset($summary_data['total_calls']) ? (int) $summary_data['total_calls'] : 0;
        $default = $this->default_report_headline($summary_data);

        if (!$headline || !is_string($headline) || trim($headline) === '') {
            return $default;
        }

        $lowWords = '/\b(low|minimal|weak|sluggish|underwhelming)\b/i';
        $highWords = '/\b(strong|high|excellent|robust|record)\b/i';

        if ($total_calls >= 30 && preg_match($lowWords, $headline)) {
            return "Strong phone activity day with {$total_calls} calls";
        }

        if ($total_calls <= 5 && preg_match($highWords, $headline)) {
            return "Minimal phone activity day with {$total_calls} calls";
        }

        if ($total_calls === 0 && preg_match('/\b(active|busy)\b/i', $headline)) {
            return "No phone activity recorded";
        }

        return $headline;
    }

    /**
     * Build a safe default headline based on numeric activity metrics.
     */
    private function default_report_headline($summary_data)
    {
        $total_calls = isset($summary_data['total_calls']) ? (int) $summary_data['total_calls'] : 0;
        $calls_msg = "{$total_calls} call" . ($total_calls === 1 ? '' : 's');

        if ($total_calls === 0) {
            return 'No call activity detected for this period';
        }
        if ($total_calls <= 5) {
            return "Minimal phone activity day with {$calls_msg}";
        }
        if ($total_calls <= 20) {
            return "Light phone activity day with {$calls_msg}";
        }
        if ($total_calls <= 50) {
            return "Moderate phone activity day with {$calls_msg}";
        }
        return "Strong phone activity day with {$calls_msg}";
    }

    /**
     * Send a professional daily report email.
     */
    private function send_daily_report_email($user_id, $date, $summary_data, $detail_data, $cover_page, $roadmap_comparison = null)
    {
        $user = get_userdata($user_id);
        if (!$user)
            return;

        // Gate: check user preference
        $daily_pref = get_user_meta($user_id, 'vmo_email_daily_reports', true);
        if ($daily_pref === '0')
            return;

        $to = $user->user_email;
        $business_name = $cover_page['business_name'] ?? get_bloginfo('name');
        $date_display = $this->format_for_user($date . ' 12:00:00', $user_id, 'l, F j, Y');
        $dashboard_link = admin_url('admin.php?page=vmo-ai-dashboard&section=phone&tab=reports');

        $html = '<div style="max-width:660px;margin:0 auto;font-family:Arial,Helvetica,sans-serif;color:#333;">';

        // ── Cover Page ──
        $html .= '<div style="background:linear-gradient(135deg,#1a1a2e 0%,#16213e 100%);color:#fff;padding:28px 24px;border-radius:10px 10px 0 0;text-align:center;">';
        $html .= '<h1 style="margin:0 0 4px;font-size:22px;font-weight:700;">' . esc_html($business_name) . '</h1>';
        $html .= '<p style="margin:0 0 12px;font-size:13px;opacity:0.8;">Daily Activity Report</p>';
        $html .= '<p style="margin:0 0 6px;font-size:15px;font-weight:600;">' . esc_html($date_display) . '</p>';
        if (!empty($cover_page['headline'])) {
            $html .= '<p style="margin:12px 0 0;font-size:14px;font-style:italic;opacity:0.9;">"' . esc_html($cover_page['headline']) . '"</p>';
        }
        $html .= '</div>';

        $html .= '<div style="padding:22px;border:1px solid #e0e0e0;border-top:none;background:#fff;">';

        // ── Key Metrics Bar ──
        $metrics = $cover_page['key_metrics'] ?? array();
        if (!empty($metrics)) {
            $html .= '<div style="display:flex;flex-wrap:wrap;gap:8px;margin-bottom:20px;">';
            $colors = array('#1a73e8', '#4CAF50', '#FF9800', '#9C27B0', '#2196F3', '#795548');
            $i = 0;
            foreach ($metrics as $m) {
                $bg = $colors[$i % count($colors)];
                $html .= '<div style="flex:1;min-width:90px;text-align:center;padding:10px 6px;background:' . $bg . '15;border-radius:8px;border:1px solid ' . $bg . '30;">';
                $html .= '<div style="font-size:20px;font-weight:700;color:' . $bg . ';">' . esc_html($m['value']) . '</div>';
                $html .= '<div style="font-size:11px;color:#666;margin-top:2px;">' . esc_html($m['label']) . '</div>';
                $html .= '</div>';
                $i++;
            }
            $html .= '</div>';
        }

        // Executive Summary
        if (!empty($cover_page['executive_summary'])) {
            $html .= '<div style="margin-bottom:18px;padding:14px 16px;background:#f0f7ff;border-left:4px solid #1a73e8;border-radius:6px;">';
            $html .= '<strong style="font-size:14px;color:#1a73e8;display:block;margin-bottom:6px;">Executive Summary</strong>';
            $html .= '<div style="font-size:13px;color:#333;line-height:1.6;">' . esc_html($cover_page['executive_summary']) . '</div>';
            $html .= '</div>';
        }

        // ── Call Breakdown ──
        if (!empty($detail_data)) {
            $html .= '<h3 style="margin:18px 0 10px;color:#1a1a2e;font-size:15px;">📞 Call Details (' . count($detail_data) . ' calls)</h3>';
            $html .= '<p style="font-size:12px;color:#888;margin:0 0 10px;">Individual post-call reports with full details are emailed after each call. Below is an at-a-glance summary.</p>';
            foreach ($detail_data as $call) {
                $cat_icons = array('lead' => '🔵', 'customer' => '🟢', 'support' => '🟠', 'inquiry' => '⚪');
                $icon = $cat_icons[$call['category'] ?? ''] ?? '⚪';
                $dur = ($call['duration'] ?? 0) > 0 ? floor($call['duration'] / 60) . 'm' : '0s';
                $html .= '<div style="margin-bottom:10px;padding:10px 14px;background:#fafafa;border-radius:6px;border:1px solid #eee;">';
                $html .= '<div style="font-size:13px;font-weight:600;">' . $icon . ' ' . esc_html($call['caller'] ?? 'Unknown') . ' <span style="float:right;font-weight:400;color:#888;font-size:12px;">' . esc_html($dur) . '</span></div>';
                if (!empty($call['summary'])) {
                    $html .= '<div style="font-size:12px;color:#555;margin-top:4px;line-height:1.5;">' . esc_html($this->safe_substr((string) $call['summary'], 0, 200)) . '</div>';
                }
                // Recording & Playback links
                $call_sid_val = !empty($call['call_sid']) ? $call['call_sid'] : '';
                $rec_url = !empty($call['recording_url']) ? $call['recording_url'] : '';
                if ($call_sid_val || $rec_url) {
                    $html .= '<div style="margin-top:6px;font-size:12px;">';
                    if ($call_sid_val) {
                        $play_link = admin_url('admin.php?page=vmo-ai-dashboard&section=phone&play_call=' . urlencode($call_sid_val));
                        $html .= '<a href="' . esc_url($play_link) . '" style="color:#1a73e8;text-decoration:none;margin-right:12px;">🎧 Listen in Dashboard</a>';
                    }
                    if ($rec_url) {
                        $html .= '<a href="' . esc_url($rec_url) . '" style="color:#1a73e8;text-decoration:none;">⬇️ Download Recording</a>';
                    }
                    $html .= '</div>';
                }
                $html .= '</div>';
            }
        }

        // ── Roadmap Progress ──
        if ($roadmap_comparison) {
            $pct = $roadmap_comparison['completion_rate'] ?? 0;
            $bar_color = $pct >= 80 ? '#4CAF50' : ($pct >= 50 ? '#FF9800' : '#f44336');
            $html .= '<div style="margin:18px 0;padding:14px 16px;background:#f3e5f5;border-left:4px solid #9C27B0;border-radius:6px;">';
            $html .= '<strong style="font-size:14px;color:#6A1B9A;display:block;margin-bottom:8px;">🎯 Roadmap Progress</strong>';
            $html .= '<div style="font-size:13px;margin-bottom:6px;">' . ($roadmap_comparison['completed_activities'] ?? 0) . ' of ' . ($roadmap_comparison['expected_activities'] ?? 0) . ' expected activities completed</div>';
            $html .= '<div style="background:#e0e0e0;border-radius:10px;height:14px;overflow:hidden;">';
            $html .= '<div style="background:' . $bar_color . ';height:100%;width:' . min($pct, 100) . '%;border-radius:10px;"></div>';
            $html .= '</div>';
            $html .= '<div style="text-align:right;font-size:12px;color:#888;margin-top:2px;">' . $pct . '%</div>';
            $html .= '</div>';
        }

        // Action button
        $html .= '<div style="text-align:center;margin:20px 0;">';
        $html .= '<a href="' . esc_url($dashboard_link) . '" style="display:inline-block;background:#1a1a2e;color:#fff;padding:12px 24px;border-radius:6px;text-decoration:none;font-size:14px;font-weight:600;">📊 View Full Report in Dashboard</a>';
        $html .= '</div>';

        // Footer
        $html .= '</div>';
        $html .= '<div style="padding:14px 20px;background:#f5f5f5;border:1px solid #e0e0e0;border-top:none;border-radius:0 0 10px 10px;text-align:center;">';
        $html .= '<p style="font-size:11px;color:#aaa;margin:0;">Powered by VMO AI Phone System &bull; <a href="' . esc_url(admin_url('admin.php?page=vmo-ai-dashboard')) . '" style="color:#1a73e8;">Dashboard</a></p>';
        $html .= '</div></div>';

        $subject = "📊 Daily Report: " . $this->format_for_user($date . ' 12:00:00', $user_id, 'M j') . " — {$summary_data['total_calls']} calls, {$summary_data['new_leads']} leads | {$business_name}";
        wp_mail($to, $subject, $html, array('Content-Type: text/html; charset=UTF-8'));

        error_log('VMO Reports: Daily report email sent to ' . $to);
    }

    // ======================================================================
    // AJAX HANDLERS — Reports, Action Items, Fulfillment
    // ======================================================================

    /**
     * AJAX: Get call reports with optional filters.
     */
    public function ajax_get_call_reports()
    {
        // Prevent output corruption of JSON response
        ob_start();

        if (!$this->verify_ajax_request()) {
            ob_end_clean();
            wp_send_json_error(array('message' => 'Security check failed'), 403);
            return;
        }
        $user_id = get_current_user_id();

        global $wpdb;
        $table = $wpdb->prefix . 'vmo_call_reports';
        if ($wpdb->get_var($wpdb->prepare("SHOW TABLES LIKE %s", $wpdb->esc_like($table))) !== $table) {
            wp_send_json_success(array('reports' => array()));
        }

        $page = max(1, (int) wp_unslash($_POST['page'] ?? 1));
        $per_page = min(50, max(1, (int) wp_unslash($_POST['per_page'] ?? 20)));
        $offset = ($page - 1) * $per_page;
        $category = sanitize_text_field($_POST['category'] ?? '');
        $date_from = sanitize_text_field($_POST['date_from'] ?? '');
        $date_to = sanitize_text_field($_POST['date_to'] ?? '');

        $where = $wpdb->prepare("user_id = %d", $user_id);
        if ($category)
            $where .= $wpdb->prepare(" AND crm_category = %s", $category);
        if ($date_from)
            $where .= $wpdb->prepare(" AND DATE(created_at) >= %s", $date_from);
        if ($date_to)
            $where .= $wpdb->prepare(" AND DATE(created_at) <= %s", $date_to);

        // phpcs:ignore WordPress.DB.PreparedSQL.InterpolatedNotPrepared, WordPress.DB.DirectDatabaseQuery.DirectQuery, WordPress.DB.DirectDatabaseQuery.NoCaching
        $total = (int) $wpdb->get_var("SELECT COUNT(*) FROM {$table} WHERE {$where}");
        $reports = $wpdb->get_results("SELECT * FROM {$table} WHERE {$where} ORDER BY created_at DESC LIMIT {$per_page} OFFSET {$offset}");

        // Decode JSON fields and convert timestamps to ISO 8601
        foreach ($reports as &$r) {
            $r->actions_taken = json_decode($r->actions_taken ?? '[]', true);
            $r->actions_required = json_decode($r->actions_required ?? '[]', true);
            $r->actions_recommended = json_decode($r->actions_recommended ?? '[]', true);
            $r->promises_made = json_decode($r->promises_made ?? '[]', true);
            $r->products_discussed = json_decode($r->products_discussed ?? '[]', true);
            $r->fulfillment_log = json_decode($r->fulfillment_log ?? '[]', true);
            if (!empty($r->created_at))
                $r->created_at = self::to_iso8601($r->created_at);
        }

        ob_end_clean();
        wp_send_json_success(array(
            'reports' => $reports,
            'total' => $total,
            'page' => $page,
            'pages' => ceil($total / $per_page),
        ));
    }

    /**
     * AJAX: Get action items (with filtering).
     */
    public function ajax_get_action_items()
    {
        // Prevent output corruption of JSON response
        ob_start();

        if (!$this->verify_ajax_request()) {
            ob_end_clean();
            wp_send_json_error(array('message' => 'Security check failed'), 403);
            return;
        }
        $user_id = get_current_user_id();

        global $wpdb;
        $table = $wpdb->prefix . 'vmo_action_items';
        if ($wpdb->get_var($wpdb->prepare("SHOW TABLES LIKE %s", $wpdb->esc_like($table))) !== $table) {
            wp_send_json_success(array('items' => array()));
        }

        $status = sanitize_text_field($_POST['status'] ?? '');
        $where = $wpdb->prepare("user_id = %d", $user_id);
        if ($status)
            $where .= $wpdb->prepare(" AND status = %s", $status);

        // Auto-mark overdue items
        $wpdb->query($wpdb->prepare(
            "UPDATE {$table} SET status = 'overdue' WHERE user_id = %d AND status = 'pending' AND deadline < NOW()",
            $user_id
        ));

        // phpcs:ignore WordPress.DB.PreparedSQL.InterpolatedNotPrepared, WordPress.DB.DirectDatabaseQuery.DirectQuery, WordPress.DB.DirectDatabaseQuery.NoCaching
        $items = $wpdb->get_results("SELECT * FROM {$table} WHERE {$where} ORDER BY FIELD(status,'overdue','pending','in_progress','completed','cancelled'), deadline ASC LIMIT 100");

        // Convert timestamps to ISO 8601 for browser timezone handling
        foreach ($items as &$item) {
            if (!empty($item->created_at))
                $item->created_at = self::to_iso8601($item->created_at);
            if (!empty($item->deadline))
                $item->deadline = self::to_iso8601($item->deadline);
            if (!empty($item->completed_at))
                $item->completed_at = self::to_iso8601($item->completed_at);
        }

        ob_end_clean();
        wp_send_json_success(array('items' => $items));
    }

    /**
     * AJAX: Update an action item (mark complete, change status, add notes).
     */
    public function ajax_update_action_item()
    {
        if (!$this->verify_ajax_request()) {
            wp_send_json_error(array('message' => 'Security check failed'), 403);
            return;
        }
        $user_id = get_current_user_id();

        $item_id = (int) wp_unslash($_POST['item_id'] ?? 0);
        $new_status = sanitize_text_field($_POST['status'] ?? '');
        $notes = sanitize_textarea_field($_POST['notes'] ?? '');

        if (!$item_id || !in_array($new_status, array('pending', 'in_progress', 'completed', 'cancelled'), true)) {
            wp_send_json_error('Invalid parameters');
        }

        global $wpdb;
        $table = $wpdb->prefix . 'vmo_action_items';

        $update = array('status' => $new_status, 'updated_at' => current_time('mysql'));
        if ($new_status === 'completed') {
            $update['completed_at'] = current_time('mysql');
            $update['completed_by'] = $user_id;
        }
        if ($notes)
            $update['notes'] = $notes;

        // phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery, WordPress.DB.DirectDatabaseQuery.NoCaching
        $result = $wpdb->update($table, $update, array('id' => $item_id, 'user_id' => $user_id));

        wp_send_json_success(array('updated' => $result !== false));
    }

    /**
     * AJAX: Get periodic reports list.
     */
    public function ajax_get_periodic_reports()
    {
        // Prevent output corruption of JSON response
        ob_start();

        if (!$this->verify_ajax_request()) {
            ob_end_clean();
            wp_send_json_error(array('message' => 'Security check failed'), 403);
            return;
        }
        $user_id = get_current_user_id();

        global $wpdb;
        $table = $wpdb->prefix . 'vmo_periodic_reports';
        if ($wpdb->get_var($wpdb->prepare("SHOW TABLES LIKE %s", $wpdb->esc_like($table))) !== $table) {
            ob_end_clean();
            wp_send_json_success(array('reports' => array()));
            return;
        }

        $period = sanitize_text_field($_POST['period'] ?? '');
        $where = $wpdb->prepare("user_id = %d", $user_id);
        if ($period)
            $where .= $wpdb->prepare(" AND report_period = %s", $period);

        // phpcs:ignore WordPress.DB.PreparedSQL.InterpolatedNotPrepared, WordPress.DB.DirectDatabaseQuery.DirectQuery, WordPress.DB.DirectDatabaseQuery.NoCaching
        $reports = $wpdb->get_results("SELECT * FROM {$table} WHERE {$where} ORDER BY period_start DESC LIMIT 100");

        foreach ($reports as &$r) {
            // Decode JSON fields
            $r->cover_page = json_decode($r->cover_page ?? '{}', true);
            $r->summary_data = json_decode($r->summary_data ?? '{}', true);
            $r->roadmap_comparison = json_decode($r->roadmap_comparison ?? 'null', true);

            // Add frontend-friendly field aliases for proper UI mapping
            $r->period = $r->report_period;
            $r->start_date = !empty($r->period_start) ? substr($r->period_start, 0, 10) : '';
            $r->end_date = !empty($r->period_end) ? substr($r->period_end, 0, 10) : '';

            // Convert timestamps to ISO 8601
            if (!empty($r->generated_at))
                $r->generated_at = self::to_iso8601($r->generated_at);
            if (!empty($r->created_at))
                $r->created_at = self::to_iso8601($r->created_at);
        }

        ob_end_clean();
        wp_send_json_success(array('reports' => $reports));
    }

    /**
     * AJAX: Generate a custom date range report.
     */
    public function ajax_generate_custom_report()
    {
        // Prevent output corruption of JSON response
        ob_start();

        if (!$this->verify_ajax_request()) {
            ob_end_clean();
            wp_send_json_error(array('message' => 'Security check failed'), 403);
            return;
        }
        $user_id = get_current_user_id();

        $start = sanitize_text_field($_POST['date_from'] ?? '');
        $end = sanitize_text_field($_POST['date_to'] ?? '');

        if (!$start || !$end || strtotime($start) === false || strtotime($end) === false) {
            ob_end_clean();
            wp_send_json_error('Invalid date range');
            return;
        }

        $this->generate_periodic_report($user_id, 'custom', $start, $end);

        global $wpdb;
        $table = $wpdb->prefix . 'vmo_periodic_reports';
        $report = $wpdb->get_row($wpdb->prepare(
            "SELECT * FROM {$table} WHERE user_id = %d AND report_period = 'custom' AND period_start = %s AND period_end = %s ORDER BY id DESC LIMIT 1",
            $user_id,
            $start,
            $end
        ));

        if ($report) {
            $report->cover_page = json_decode($report->cover_page ?? '{}', true);
            $report->summary_data = json_decode($report->summary_data ?? '{}', true);
            $report->roadmap_comparison = json_decode($report->roadmap_comparison ?? 'null', true);
            // Add frontend-friendly field aliases
            $report->period = $report->report_period;
            $report->start_date = !empty($report->period_start) ? substr($report->period_start, 0, 10) : '';
            $report->end_date = !empty($report->period_end) ? substr($report->period_end, 0, 10) : '';
        }

        ob_end_clean();
        wp_send_json_success(array('report' => $report));
    }

    /**
     * AJAX: Get fulfillment log.
     */
    public function ajax_get_fulfillment_log()
    {
        // Prevent output corruption of JSON response
        ob_start();

        if (!$this->verify_ajax_request()) {
            ob_end_clean();
            wp_send_json_error(array('message' => 'Security check failed'), 403);
            return;
        }
        $user_id = get_current_user_id();

        global $wpdb;
        $table = $wpdb->prefix . 'vmo_fulfillment_log';
        if ($wpdb->get_var($wpdb->prepare("SHOW TABLES LIKE %s", $wpdb->esc_like($table))) !== $table) {
            ob_end_clean();
            wp_send_json_success(array('log' => array()));
            return;
        }

        $log = $wpdb->get_results($wpdb->prepare(
            "SELECT * FROM {$table} WHERE user_id = %d ORDER BY created_at DESC LIMIT 100",
            $user_id
        ));

        ob_end_clean();
        wp_send_json_success(array('log' => $log));
    }

    /**
     * Summarize existing transcript without a recording.
     * 
     * This is used when the AI receptionist collected a transcript via Gather+GPT
     * but no recording was made (recording toggle off). The transcript is already
     * stored in vmo_phone_calls.transcript — we just need to summarize it.
     * 
     * @param string $call_sid Twilio call SID
     */
    public function summarize_existing_transcript($call_sid)
    {
        global $wpdb;
        $table = $wpdb->prefix . 'vmo_phone_calls';
        $row = $wpdb->get_row($wpdb->prepare("SELECT user_id, transcript, summary FROM {$table} WHERE call_sid = %s", $call_sid));

        if (!$row || empty($row->transcript)) {
            error_log('VMO: No transcript found for call ' . $call_sid . ' — skipping summary');
            return;
        }

        if (!empty($row->summary)) {
            error_log('VMO: Transcript summary already exists for call ' . $call_sid . ' — skipping duplicate generation');
            $this->maybe_send_post_call_summary_email((string) $call_sid);
            return;
        }

        $api_key = get_option('vmo_openai_api_key');
        if (empty($api_key)) {
            error_log('VMO: OpenAI API key not configured — cannot summarize transcript');
            return;
        }

        $summary = $this->summarize_transcription($row->transcript, $api_key, (int) ($row->user_id ?? 0), (string) $call_sid);

        if (is_wp_error($summary)) {
            error_log('VMO: Failed to summarize transcript for ' . $call_sid . ': ' . $summary->get_error_message());
            return;
        }

        // phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery, WordPress.DB.DirectDatabaseQuery.NoCaching
        $wpdb->update(
            $table,
            array('summary' => $summary),
            array('call_sid' => $call_sid),
            array('%s'),
            array('%s')
        );

        // Process full intelligence pipeline (report, action items, CRM, fulfillment)
        $this->process_post_call_intelligence($call_sid, $summary, $row->transcript);

        $this->maybe_send_post_call_summary_email((string) $call_sid);

        error_log('VMO: Transcript summary + intelligence generated for CallSid: ' . $call_sid);
    }

    /**
     * Download call recording from Twilio
     * 
     * @param string $recording_url Twilio recording URL
     * @return string|WP_Error Audio data or error
     */
    private function download_recording($recording_url)
    {
        // Add .mp3 extension if not present
        if (strpos($recording_url, '.mp3') === false) {
            $recording_url .= '.mp3';
        }

        $headers = array();
        if (!empty($this->account_sid) && !empty($this->auth_token)) {
            $headers['Authorization'] = 'Basic ' . base64_encode($this->account_sid . ':' . $this->auth_token); // phpcs:ignore WordPress.PHP.DiscouragedPHPFunctions.obfuscation_base64_encode -- HTTP Basic auth header
        }

        $response = wp_remote_get($recording_url, array(
            'timeout' => 60,
            'headers' => $headers,
        ));

        if (is_wp_error($response)) {
            return $response;
        }

        $status_code = wp_remote_retrieve_response_code($response);
        if ($status_code !== 200) {
            return new WP_Error('download_failed', 'Failed to download recording: HTTP ' . $status_code);
        }

        return wp_remote_retrieve_body($response);
    }

    /**
     * Transcribe call recording using OpenAI Whisper
     * 
     * @param string $audio_data Raw audio data
     * @param string $api_key OpenAI API key
     * @return string|WP_Error Transcription text or error
     */
    private function transcribe_call_recording($audio_data, $api_key, $user_id = 0, $call_sid = '')
    {
        // ✅ TOKEN DEDUCTION v8.1.0: Charge for Whisper transcription
        // Uses VMO_TOKENS_TRANSCRIPTION_PER_MIN constant × estimated call duration
        // For simplicity, charge flat rate of 3 min avg × 75 tokens/min = 225 tokens
        $transcription_tokens = defined('VMO_TOKENS_TRANSCRIPTION_PER_MIN') ? (VMO_TOKENS_TRANSCRIPTION_PER_MIN * 3) : 225;
        $user_id = (int) $user_id;
        if ($user_id <= 0 && $call_sid !== '') {
            $user_id = $this->resolve_call_owner_user_id((string) $call_sid);
        }
        if ($user_id > 0 && function_exists('vmo_deduct_tokens')) {
            if (!vmo_deduct_tokens($user_id, $transcription_tokens, 'transcription', 'Call recording transcription')) {
                return new WP_Error('insufficient_tokens', 'Insufficient tokens for transcription');
            }
        } elseif ($user_id <= 0) {
            // ── ZERO-LEAK: fail CLOSED. If we cannot resolve the call owner we
            // must NOT call paid OpenAI Whisper uncharged — that would bill VMO's
            // account with no offsetting credit deduction.
            error_log('VMO Twilio: Aborting transcription (no token deduction possible) because call owner could not be resolved for ' . $call_sid);
            return new WP_Error('owner_unresolved', 'Call owner could not be resolved; transcription skipped to prevent uncharged API usage.');
        }

        // Save audio to temp file
        $temp_file = wp_tempnam('call-recording-');
        vmo_get_filesystem()->put_contents($temp_file, $audio_data, FS_CHMOD_FILE);

        // Prepare multipart form data
        $boundary = wp_generate_password(24, false);
        $payload = '';

        // Add file
        $payload .= '--' . $boundary . "\r\n";
        $payload .= 'Content-Disposition: form-data; name="file"; filename="recording.mp3"' . "\r\n";
        $payload .= 'Content-Type: audio/mpeg' . "\r\n\r\n";
        $payload .= $audio_data . "\r\n";

        // Add model
        $payload .= '--' . $boundary . "\r\n";
        $payload .= 'Content-Disposition: form-data; name="model"' . "\r\n\r\n";
        $payload .= 'whisper-1' . "\r\n";

        $payload .= '--' . $boundary . '--';

        // Call OpenAI Whisper API
        $response = wp_remote_post('https://api.openai.com/v1/audio/transcriptions', array(
            'headers' => array(
                'Authorization' => 'Bearer ' . $api_key,
                'Content-Type' => 'multipart/form-data; boundary=' . $boundary
            ),
            'body' => $payload,
            'timeout' => 60
        ));

        // Clean up temp file
        @unlink($temp_file);

        if (is_wp_error($response)) {
            return $response;
        }

        $body = json_decode(wp_remote_retrieve_body($response), true);

        if (isset($body['error'])) {
            return new WP_Error('transcription_failed', $body['error']['message']);
        }

        return isset($body['text']) ? $body['text'] : '';
    }

    /**
     * Generate summary of transcription using GPT-5.5
     * 
     * @param string $transcription Call transcription text
     * @param string $api_key OpenAI API key
     * @return string|WP_Error Summary or error
     */
    private function summarize_transcription($transcription, $api_key, $user_id = 0, $call_sid = '')
    {
        // ✅ TOKEN DEDUCTION v8.1.0: Charge for call summary AI generation
        // Uses VMO_TOKENS_CALL_SUMMARY constant (10x markup on $0.03 real cost)
        $summary_tokens = defined('VMO_TOKENS_CALL_SUMMARY') ? VMO_TOKENS_CALL_SUMMARY : 450;
        $user_id = (int) $user_id;
        if ($user_id <= 0 && $call_sid !== '') {
            $user_id = $this->resolve_call_owner_user_id((string) $call_sid);
        }
        if ($user_id > 0 && function_exists('vmo_deduct_tokens')) {
            if (!vmo_deduct_tokens($user_id, $summary_tokens, 'call_summary', 'Call transcription summary')) {
                return new WP_Error('insufficient_tokens', 'Insufficient tokens for call summary');
            }
        } elseif ($user_id <= 0) {
            // ── ZERO-LEAK: fail CLOSED. Do not call paid OpenAI summarization
            // when the call owner is unknown (no credit could be deducted).
            error_log('VMO Twilio: Aborting call summary (no token deduction possible) because call owner could not be resolved for ' . $call_sid);
            return new WP_Error('owner_unresolved', 'Call owner could not be resolved; summary skipped to prevent uncharged API usage.');
        }

        $prompt = 'You are analyzing a business phone call. Return your analysis as a JSON object with NO markdown formatting — pure JSON only.

Required JSON structure:
{
  "caller_name": "Name if mentioned, or UNKNOWN",
  "caller_email": "Email if mentioned, or null",
  "caller_intent": "Brief description of what the caller wanted",
  "call_type": "inbound_inquiry | inbound_support | inbound_order | inbound_booking | outbound_sales | outbound_followup | general",
    "crm_category": "lead | support | inquiry | vendor",
  "sentiment": "positive | neutral | negative | frustrated",
  "urgency": "low | medium | high | critical",
  "summary": "2-3 sentence plain text summary of the call",
  "products_discussed": ["product/service names discussed"],
  "actions_taken": [
    {"action": "what the AI did during the call", "details": "specifics"}
  ],
  "actions_required": [
    {"action": "what the business owner needs to do", "deadline_hours": 24, "priority": "high", "category": "follow_up | send_info | callback | order | booking | support"}
  ],
  "actions_recommended": [
    {"action": "optional improvement suggestion", "reason": "why"}
  ],
  "promises_made": [
    {"promise": "what was promised to send/do", "type": "sms | email | callback | info", "content_needed": "description of info to include"}
  ],
  "sales_signals": {
    "interest_level": "cold | warm | hot | closed",
    "objections": ["list of objections raised"],
    "buying_indicators": ["positive buying signals noted"]
  },
    "financial_events": [
        {
            "event_type": "opportunity | payment_commitment | confirmed_payment | invoice_needed | payment_link_needed | refund | expense",
            "amount": 0,
            "currency": "USD",
            "payment_status": "pending | needs_confirmation | invoice_needed | payment_link_needed | completed",
            "payment_method": "cash | zelle | venmo | cash_app | paypal | bank_transfer | check | card | stripe | payment_link | unknown",
            "product_service": "service/product discussed",
            "customer_name": "payer name if known",
            "category": "sales | deposit | refund | operating_expense | opportunity",
            "confidence": 0.0,
            "recommended_action": "review | create_invoice | send_payment_link | confirm_payment"
        }
    ],
  "key_info_collected": {
    "name": "if provided",
    "phone": "if provided",
    "email": "if provided",
    "company": "if provided",
    "address": "if provided",
    "order_details": "if any order was taken",
    "booking_details": "if any reservation was made"
  }
}

IMPORTANT RULES:
- If the AI promised to send information (prices, menu, details, brochure, etc.) via SMS or email, include it in "promises_made" with the content description.
- Do not set crm_category to "customer" from call content. A caller becomes a customer only after verified payment, completed order record, paid invoice, or manual owner confirmation.
- If a purchase/order was discussed or requested during the call, keep crm_category as "lead" and set interest_level to "hot" or "closed" as appropriate.
- If money, deposits, invoices, payment links, quotes, bookings, purchases, refunds, or expenses are discussed, fill financial_event with the safest classification. Do not mark uncertain payment talk as completed revenue.
- If the caller says they already paid outside VMO, set payment_status to "needs_confirmation" unless the business owner explicitly confirmed it.
- If there is no financial signal, return an empty financial_events array.
- If caller was just inquiring, set crm_category to "lead".
- Be specific in actions_required — include real deadlines (in hours from now).
- actions_taken should reflect what the AI actually handled during the call.

Call Transcription:
' . $transcription . '

Return ONLY the JSON object, no other text.';

        // ✅ FIX: Use centralized model registry — nano for call analysis
        $_tw_model = function_exists('vmo_get_model') ? vmo_get_model('nano') : (defined('VMO_OPENAI_MODEL') ? VMO_OPENAI_MODEL : 'gpt-5.5-nano');
        $payload = array_merge(array(
            'model' => $_tw_model,
            'messages' => array(
                array('role' => 'system', 'content' => 'You are a business call analyst. Return ONLY valid JSON. No markdown code fences, no extra text.'),
                array('role' => 'user', 'content' => $prompt)
            ),
            'temperature' => 0.2,
            'response_format' => array('type' => 'json_object')
        ), function_exists('vmo_get_token_param') ? vmo_get_token_param($_tw_model, 1200) : array('max_tokens' => 1200));

        $args = array(
            'headers' => array(
                'Authorization' => 'Bearer ' . $api_key,
                'Content-Type' => 'application/json'
            ),
            'body' => json_encode($payload),
            'timeout' => 60
        );

        $response = wp_remote_post('https://api.openai.com/v1/chat/completions', $args);

        // Fallback Logic: Retry with fallback model if primary fails
        if (is_wp_error($response) || wp_remote_retrieve_response_code($response) >= 400) {
            error_log('VMO: Call summary generation failed. Retrying with fallback model.');
            $payload['model'] = function_exists('vmo_get_model') ? vmo_get_model('fallback') : 'gpt-5.5-nano';
            $args['body'] = json_encode($payload);
            $response = wp_remote_post('https://api.openai.com/v1/chat/completions', $args);
        }

        if (is_wp_error($response)) {
            return $response;
        }

        $body = json_decode(wp_remote_retrieve_body($response), true);

        if (isset($body['error'])) {
            return new WP_Error('summary_failed', $body['error']['message']);
        }

        return isset($body['choices'][0]['message']['content'])
            ? $body['choices'][0]['message']['content']
            : '';
    }

    /**
     * Handle dial status callback from Twilio
     * 
     * Called after <Dial> attempt (user answered or didn't answer)
     */
    public function handle_dial_status($request)
    {
        $params = $request->get_params();

        // ── Granular outbound-dial telemetry (Phase 3) ──
        // Twilio invokes <Dial action="..."> with DialCallStatus / DialCallSid +
        // ErrorCode / ErrorMessage when the leg fails (e.g. caller-ID-not-verified,
        // 21210, 21214, 13224, 13225). This single line gives us the exact
        // failure reason without needing to dig through print_r dumps.
        error_log(sprintf(
            '[VMO Outbound DialStatus] parent=%s dial_sid=%s status=%s error_code=%s error_message=%s from=%s to=%s source=%s',
            isset($params['CallSid']) ? sanitize_text_field($params['CallSid']) : '',
            isset($params['DialCallSid']) ? sanitize_text_field($params['DialCallSid']) : '',
            isset($params['DialCallStatus']) ? sanitize_text_field($params['DialCallStatus']) : '',
            isset($params['ErrorCode']) ? sanitize_text_field((string) $params['ErrorCode']) : '',
            isset($params['ErrorMessage']) ? sanitize_text_field((string) $params['ErrorMessage']) : '',
            isset($params['From']) ? sanitize_text_field($params['From']) : '',
            isset($params['To']) ? sanitize_text_field($params['To']) : '',
            isset($params['source']) ? sanitize_text_field($params['source']) : ''
        ));

        error_log('VMO Twilio Dial Status: ' . print_r($params, true));

        $dial_call_status = isset($params['DialCallStatus']) ? sanitize_text_field($params['DialCallStatus']) : '';
        $dial_source = isset($params['source']) ? sanitize_text_field($params['source']) : '';
        // Twilio sends PascalCase parameter names (CallSid, not call_sid).
        // Also check lowercase as fallback for gateway-injected query params.
        $call_sid = '';
        if (isset($params['CallSid'])) {
            $call_sid = sanitize_text_field($params['CallSid']);
        } elseif (isset($params['call_sid'])) {
            $call_sid = sanitize_text_field($params['call_sid']);
        }

        // Twilio expects TwiML from a <Dial action="..."> callback.
        // Returning JSON here can cause Twilio to treat it as an error and re-hit the main webhook,
        // which presents as a looping greeting or endless hold music.
        $user_id = 0;
        $call_direction = '';
        if (!empty($call_sid)) {
            global $wpdb;
            $table = $wpdb->prefix . 'vmo_phone_calls';
            if ($wpdb->get_var("SHOW TABLES LIKE '{$table}'") === $table) {
                $call_row = $wpdb->get_row($wpdb->prepare(
                    "SELECT user_id, direction FROM {$table} WHERE call_sid = %s ORDER BY id DESC LIMIT 1",
                    $call_sid
                ), ARRAY_A);
                if (is_array($call_row)) {
                    $user_id = (int) ($call_row['user_id'] ?? 0);
                    $call_direction = sanitize_text_field((string) ($call_row['direction'] ?? ''));
                }

                // Best-effort: if the user answered, mark as user-handled.
                if ($user_id > 0 && $dial_call_status === 'completed') {
                    // phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery, WordPress.DB.DirectDatabaseQuery.NoCaching
                    $wpdb->update(
                        $table,
                        array('ai_handled' => 0),
                        array('call_sid' => $call_sid),
                        array('%d'),
                        array('%s')
                    );
                }
            }
        }
        // Fallback: read uid from gateway query params if DB lookup didn't find it
        if ($user_id <= 0 && isset($params['uid'])) {
            $user_id = intval($params['uid']);
        }

        $is_browser_outbound = ($dial_source === 'browser_outbound' || $call_direction === 'outbound');

        // If the user answered and completed, end the Twilio leg.
        // Inbound route-to-owner failures fall back to AI; manual outbound failures do not.
        $ai_greeting = ($user_id > 0) ? (string) get_user_meta($user_id, 'vmo_ai_greeting', true) : '';
        if ($ai_greeting === '') {
            $ai_greeting = 'Thank you for calling. How can I help you today?';
        }

        $recording_enabled = $this->is_recording_enabled($user_id);

        // Emit TwiML
        while (ob_get_level()) {
            @ob_end_clean();
        }
        header('Content-Type: text/xml; charset=UTF-8');
        echo '<?xml version="1.0" encoding="UTF-8"?>';
        echo '<Response>';

        if ($dial_call_status === 'completed') {
            echo '<Hangup/>';
        } elseif ($is_browser_outbound) {
            // Differentiate Twilio's <Dial> failure reasons so the dialer
            // user hears something actionable instead of a single
            // catch-all "could not be completed" line on every failure.
            // DialCallStatus values: completed, no-answer, busy, failed, canceled.
            // Twilio also forwards an ErrorCode/ErrorMessage on hard failures
            // (e.g. 13224 invalid number, 21210 caller-id not verified,
            // 13225 geo-permission denied) — surface those in logs so the
            // operator can act on the real cause.
            $dial_error_code = isset($params['ErrorCode']) ? sanitize_text_field((string) $params['ErrorCode']) : '';
            $dial_error_message = isset($params['ErrorMessage']) ? sanitize_text_field((string) $params['ErrorMessage']) : '';

            switch ($dial_call_status) {
                case 'no-answer':
                    $spoken_message = 'There was no answer. The call ended without being picked up.';
                    $persisted_status = 'no-answer';
                    break;
                case 'busy':
                    $spoken_message = 'The line you called was busy. Please try again later.';
                    $persisted_status = 'busy';
                    break;
                case 'canceled':
                    $spoken_message = 'The call was canceled before it could connect.';
                    $persisted_status = 'canceled';
                    break;
                case 'failed':
                default:
                    if ($dial_error_code !== '') {
                        $spoken_message = 'The call could not be completed. Please verify the number and try again, or contact support if this keeps happening.';
                    } else {
                        $spoken_message = 'The call could not be completed. Please try again in a moment.';
                    }
                    $persisted_status = 'failed';
                    break;
            }

            // Persist the real outcome to the call DB so the admin call
            // history / diagnostics page can show what actually happened
            // (previously every failure looked like a 6-second "completed"
            // outbound row because only the parent leg status was stored).
            if ($call_sid !== '' && isset($wpdb) && isset($table) && !empty($table)) {
                $update_data = array(
                    'status' => $persisted_status,
                );
                if ($dial_error_code !== '' || $dial_error_message !== '') {
                    $update_data['summary'] = wp_json_encode(array(
                        'dial_call_status' => $dial_call_status,
                        'dial_error_code' => $dial_error_code,
                        'dial_error_message' => $dial_error_message,
                    ));
                }
                // phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery, WordPress.DB.DirectDatabaseQuery.NoCaching
                $wpdb->update(
                    $table,
                    $update_data,
                    array('call_sid' => $call_sid),
                    array_fill(0, count($update_data), '%s'),
                    array('%s')
                );
            }

            error_log(sprintf(
                '[VMO Outbound DialStatus] outcome=%s sid=%s err_code=%s err_msg=%s',
                $persisted_status,
                $call_sid,
                $dial_error_code,
                $dial_error_message
            ));

            echo '<Say voice="' . $this->tts_voice() . '">' . esc_xml($spoken_message) . '</Say>';
            echo '<Hangup/>';
        } else {
            if ($recording_enabled) {
                echo '<Say voice="' . $this->tts_voice() . '">This call may be recorded for quality and training purposes.</Say>';
                echo '<Pause length="1"/>';
            }

            // AI Receptionist via Gather + GPT
            $ai_converse_url = $this->phone_gateway_url('ai_converse', array('uid' => $user_id));
            if ($this->is_whisper_language()) {
                $whisper_converse_url = $this->phone_gateway_url('ai_converse_whisper', array('uid' => $user_id));
                // phpcs:ignore WordPress.Security.EscapeOutput.OutputNotEscaped -- TwiML XML response
                echo $this->tts_play($ai_greeting, $call_sid, $user_id); // phpcs:ignore WordPress.Security.EscapeOutput.OutputNotEscaped -- TwiML XML output
                echo '<Record action="' . esc_xml($whisper_converse_url) . '" maxLength="30" timeout="10" playBeep="false" trim="trim-silence" />';
            } else {
                echo '<Gather input="speech" action="' . esc_xml($ai_converse_url) . '" speechTimeout="2" timeout="10" enhanced="true" language="' . $this->gather_lang() . '">';
                // phpcs:ignore WordPress.Security.EscapeOutput.OutputNotEscaped -- TwiML XML response
                echo $this->tts_play($ai_greeting, $call_sid, $user_id); // phpcs:ignore WordPress.Security.EscapeOutput.OutputNotEscaped -- TwiML XML output
                echo '</Gather>';
            }
            // Re-prompt before voicemail
            if ($this->is_whisper_language()) {
                echo '<Say voice="' . $this->tts_voice() . '">Are you still there?</Say>';
                echo '<Record action="' . esc_xml($whisper_converse_url) . '" maxLength="30" timeout="20" playBeep="false" trim="trim-silence" />';
            } else {
                echo '<Gather input="speech" action="' . esc_xml($ai_converse_url) . '" speechTimeout="2" timeout="20" enhanced="true" language="' . $this->gather_lang() . '">';
                echo '<Say voice="' . $this->tts_voice() . '">Are you still there? I\'m here to help.</Say>';
                echo '</Gather>';
            }
            // Voicemail fallback — only after both Gathers time out
            echo '<Say voice="' . $this->tts_voice() . '">Let me take a message. Please speak after the beep.</Say>';
            echo '<Record maxLength="120" timeout="15" transcribe="true" transcribeCallback="' . esc_xml($this->twilio_public_url('transcription')) . '" />';
            echo '<Say voice="' . $this->tts_voice() . '">Thank you for calling. Goodbye.</Say>';
        }

        echo '</Response>';
        exit;
    }

    // ═══════════════════════════════════════════════════════════════════════════
    // OPENAI REALTIME SIP INTEGRATION v9.1.0
    // Connects inbound phone calls to OpenAI Realtime API via SIP for
    // sub-second AI voice responses, replacing the legacy Gather+GPT+TTS
    // pipeline. Setup: configure vmo_openai_project_id + webhook secret in
    // Settings, then point Twilio Elastic SIP Trunk or <Dial><Sip> at
    // sip:$PROJECT_ID@sip.api.openai.com;transport=tls
    // ═══════════════════════════════════════════════════════════════════════════

    /**
     * Resolve the configured OpenAI Realtime project identifier.
     */
    private function get_openai_realtime_project_id(): string
    {
        $candidates = array(
            (string) get_option('vmo_openai_project_id', ''),
            defined('VMO_OPENAI_PROJECT_ID') ? (string) VMO_OPENAI_PROJECT_ID : '',
            getenv('VMO_OPENAI_PROJECT_ID') ?: '',
            getenv('OPENAI_PROJECT_ID') ?: '',
        );

        foreach ($candidates as $candidate) {
            $candidate = trim((string) $candidate);
            if ($candidate !== '') {
                return $candidate;
            }
        }

        return '';
    }

    private function is_valid_openai_realtime_project_id(string $project_id): bool
    {
        $project_id = trim($project_id);
        return $project_id !== '' && preg_match('/^proj_[A-Za-z0-9_-]+$/', $project_id) === 1;
    }

    /**
     * Resolve the configured OpenAI Realtime webhook secret.
     */
    private function get_openai_realtime_webhook_secret(): string
    {
        $candidates = array(
            (string) get_option('vmo_openai_webhook_secret', ''),
            defined('VMO_OPENAI_WEBHOOK_SECRET') ? (string) VMO_OPENAI_WEBHOOK_SECRET : '',
            getenv('VMO_OPENAI_WEBHOOK_SECRET') ?: '',
            getenv('OPENAI_WEBHOOK_SECRET') ?: '',
        );

        foreach ($candidates as $candidate) {
            $candidate = trim((string) $candidate);
            if ($candidate !== '') {
                return $candidate;
            }
        }

        return '';
    }

    /**
     * OPTION C DIAGNOSTIC: Append a breadcrumb to a per-call trace transient.
     * The trace is read back by the Listen-button diagnostic to show exactly
     * which code paths the voice webhook executed for this CallSid.
     */
    private function trace_voice_call(string $call_sid, string $stage): void
    {
        if ($call_sid === '')
            return;
        $key = 'vmo_voice_trace_' . sanitize_key($call_sid);
        $trace = get_transient($key);
        if (!is_array($trace))
            $trace = array();
        $trace[] = sprintf('[%.3f] %s', microtime(true), $stage);
        // Cap at 50 entries to bound size
        if (count($trace) > 50)
            $trace = array_slice($trace, -50);
        set_transient($key, $trace, 3600);
    }

    private function record_premium_bridge_status(int $user_id, string $call_sid, string $message, bool $is_error = false): void
    {
        if ($user_id <= 0) {
            return;
        }

        $clean_message = preg_replace('/\s+/', ' ', wp_strip_all_tags((string) $message));
        $payload = array(
            'call_sid' => sanitize_text_field($call_sid),
            'message' => substr((string) $clean_message, 0, 320),
            'time' => time(),
        );
        set_transient($is_error ? ('vmo_premium_bridge_last_error_' . $user_id) : ('vmo_premium_bridge_last_stream_' . $user_id), $payload, 2 * HOUR_IN_SECONDS);
    }

    /**
     * Legacy OpenAI SIP is no longer an active phone route.
     *
     * Standard mode stays on the Gather/GPT/TTS pipeline. Premium mode is
     * selected from ringer settings and uses the WebSocket bridge.
     */
    private function is_realtime_enabled(int $user_id): bool
    {
        return false;
    }

    /**
     * Get the OpenAI Realtime model based on user's voice quality tier.
     * Standard = realtime-mini (cheaper), Premium = realtime-1.5 (higher quality).
     */
    private function get_realtime_model_for_user(int $user_id): string
    {
        $tier = get_user_meta($user_id, 'vmo_voice_quality_tier', true);
        if ($tier === 'premium') {
            return vmo_get_model('realtime');
        }
        return vmo_get_model('realtime_fallback');
    }

    /**
     * Verify OpenAI webhook signature (Svix format).
     *
     * @param string $body    Raw request body.
     * @param array  $headers Request headers (WP_REST_Request format: lowercase keys, array values).
     * @return bool True if signature is valid.
     */
    private function verify_openai_webhook(string $body, array $headers): bool
    {
        $secret = $this->get_openai_realtime_webhook_secret();
        if (empty($secret))
            return false;

        // Normalize: WP_REST_Request->get_headers() returns lowercase keys with array values
        $get_header = function (string $name) use ($headers): string {
            $key = str_replace('-', '_', strtolower($name));
            if (isset($headers[$key])) {
                return is_array($headers[$key]) ? $headers[$key][0] : $headers[$key];
            }
            $key2 = strtolower($name);
            if (isset($headers[$key2])) {
                return is_array($headers[$key2]) ? $headers[$key2][0] : $headers[$key2];
            }
            return '';
        };

        $msg_id = $get_header('webhook-id');
        $timestamp = $get_header('webhook-timestamp');
        $signature = $get_header('webhook-signature');

        if (empty($msg_id) || empty($timestamp) || empty($signature))
            return false;

        // Reject if timestamp is more than 5 minutes old
        if (abs(time() - intval($timestamp)) > 300)
            return false;

        // Strip whsec_ prefix and base64-decode the secret
        $secret_raw = str_replace('whsec_', '', $secret);
        $secret_bytes = base64_decode($secret_raw);
        if ($secret_bytes === false)
            return false;

        // Compute expected signature: HMAC-SHA256(secret, msg_id.timestamp.body)
        $to_sign = $msg_id . '.' . $timestamp . '.' . $body;
        $expected = base64_encode(hash_hmac('sha256', $to_sign, $secret_bytes, true));

        // Signature header can contain multiple space-separated signatures
        $provided_sigs = explode(' ', $signature);
        foreach ($provided_sigs as $sig) {
            $parts = explode(',', $sig, 2);
            if (count($parts) !== 2 || $parts[0] !== 'v1')
                continue;
            if (hash_equals($expected, $parts[1]))
                return true;
        }

        return false;
    }

    /**
     * Accept an OpenAI Realtime SIP call.
     *
     * @param string $call_id      The call_id from realtime.call.incoming webhook.
     * @param string $model        Realtime model ID (e.g. gpt-realtime-1.5).
     * @param string $voice        Voice ID (e.g. ash, coral, sage).
     * @param string $instructions System prompt for the AI receptionist.
     * @return bool True on success.
     */
    private function openai_realtime_accept(string $call_id, string $model, string $voice, string $instructions, array $tools = array()): bool
    {
        $api_key = get_option('vmo_openai_api_key', '');
        if (empty($api_key)) {
            set_transient('vmo_openai_accept_lastfail', array('time' => time(), 'reason' => 'no_api_key', 'call_id' => $call_id), 3600);
            return false;
        }
        $project_id = $this->get_openai_realtime_project_id();
        $client_request_id = 'vmo-rt-accept-' . sanitize_key($call_id);

        $accept_body = array(
            'type' => 'realtime',
            'model' => $model,
            'voice' => $voice,
            'instructions' => $instructions,
        );
        if (!empty($tools)) {
            $accept_body['tools'] = $tools;
            $accept_body['tool_choice'] = 'auto';
        }

        $response = wp_remote_post(
            'https://api.openai.com/v1/realtime/calls/' . urlencode($call_id) . '/accept',
            array(
                'timeout' => 10,
                'headers' => array_filter(array(
                    'Authorization' => 'Bearer ' . $api_key,
                    'Content-Type' => 'application/json',
                    'OpenAI-Project' => $this->is_valid_openai_realtime_project_id($project_id) ? $project_id : '',
                    'X-Client-Request-Id' => $client_request_id,
                )),
                'body' => wp_json_encode($accept_body),
            )
        );

        if (is_wp_error($response)) {
            error_log('VMO Realtime: Accept call failed: ' . $response->get_error_message());
            set_transient('vmo_openai_accept_lastfail', array(
                'time' => time(),
                'reason' => 'wp_error',
                'message' => $response->get_error_message(),
                'call_id' => $call_id,
                'project_id' => $project_id,
                'client_request_id' => $client_request_id,
                'request_body' => substr(wp_json_encode($accept_body), 0, 500),
            ), 3600);
            return false;
        }

        $code = wp_remote_retrieve_response_code($response);
        if ($code !== 200) {
            $resp_body = wp_remote_retrieve_body($response);
            $openai_request_id = (string) wp_remote_retrieve_header($response, 'x-request-id');
            error_log('VMO Realtime: Accept call returned HTTP ' . $code . ': ' . $resp_body);
            set_transient('vmo_openai_accept_lastfail', array(
                'time' => time(),
                'reason' => 'http_' . $code,
                'response_body' => substr($resp_body, 0, 1000),
                'call_id' => $call_id,
                'project_id' => $project_id,
                'client_request_id' => $client_request_id,
                'openai_request_id' => $openai_request_id,
                'request_body' => substr(wp_json_encode($accept_body), 0, 500),
            ), 3600);
            return false;
        }

        return true;
    }

    /**
     * Reject an OpenAI Realtime SIP call.
     *
     * @param string $call_id     The call_id from realtime.call.incoming webhook.
     * @param int    $status_code SIP status code (default 603 Decline).
     */
    private function openai_realtime_reject(string $call_id, int $status_code = 603): void
    {
        $api_key = get_option('vmo_openai_api_key', '');
        if (empty($api_key))
            return;
        $project_id = $this->get_openai_realtime_project_id();

        wp_remote_post(
            'https://api.openai.com/v1/realtime/calls/' . urlencode($call_id) . '/reject',
            array(
                'timeout' => 5,
                'headers' => array_filter(array(
                    'Authorization' => 'Bearer ' . $api_key,
                    'Content-Type' => 'application/json',
                    'OpenAI-Project' => $this->is_valid_openai_realtime_project_id($project_id) ? $project_id : '',
                    'X-Client-Request-Id' => 'vmo-rt-reject-' . sanitize_key($call_id),
                )),
                'body' => wp_json_encode(array('status_code' => $status_code)),
            )
        );
    }

    /* =====================================================================
     * PLATFORM-WIDE TWILIO ELASTIC SIP TRUNK ↔ OPENAI REALTIME
     * =====================================================================
     * Multi-tenant SaaS architecture: ONE Twilio SIP trunk for the entire
     * VMO platform. Every user-provisioned Twilio number is auto-assigned
     * to this trunk. Trunk's Origination URL points at OpenAI's SIP gateway
     * for the platform's project. Per-user customization happens in the
     * realtime webhook by mapping the SIP To header → VMO user.
     *
     * One-time setup is fully automated via ensure_realtime_sip_trunk():
     *   - Creates the trunk via Twilio Trunking API if missing
     *   - Adds OpenAI SIP origination URL if missing
     *   - Stores the trunk SID in the vmo_twilio_sip_trunk_sid option
     * ===================================================================== */

    private const TRUNKING_API_BASE = 'https://trunking.twilio.com/v1';
    private const REALTIME_TRUNK_FRIENDLY_NAME = 'VMO Realtime Platform Trunk';
    private const REALTIME_ORIGINATION_FRIENDLY_NAME = 'OpenAI Realtime SIP';

    /**
     * Make a request to the Twilio Trunking API (separate base URL from main API).
     * @return array|WP_Error
     */
    private function trunking_request(string $method, string $endpoint, array $args = array(), int $timeout = 20)
    {
        if ($this->simulation_mode) {
            return new WP_Error('simulation_mode', 'In simulation mode');
        }
        $url = self::TRUNKING_API_BASE . $endpoint;
        $request_args = array(
            'headers' => array(
                'Authorization' => 'Basic ' . base64_encode($this->account_sid . ':' . $this->auth_token), // phpcs:ignore
            ),
            'timeout' => $timeout,
        );
        if ($method === 'GET') {
            if (!empty($args))
                $url = add_query_arg($args, $url);
            $resp = wp_remote_get($url, $request_args);
        } elseif ($method === 'DELETE') {
            $request_args['method'] = 'DELETE';
            $resp = wp_remote_request($url, $request_args);
        } else {
            $request_args['body'] = $args;
            $resp = wp_remote_post($url, $request_args);
        }
        if (is_wp_error($resp))
            return $resp;
        $code = wp_remote_retrieve_response_code($resp);
        $body = wp_remote_retrieve_body($resp);
        $data = json_decode($body, true);
        if ($code >= 400) {
            return new WP_Error(
                'twilio_trunking_error',
                isset($data['message']) ? $data['message'] : ('HTTP ' . $code . ': ' . substr($body, 0, 300)),
                array('status' => $code, 'response' => $data)
            );
        }
        return is_array($data) ? $data : array();
    }

    /**
     * Build the OpenAI Realtime SIP origination URL for the platform's project.
     */
    private function realtime_openai_sip_url(): string
    {
        $project_id = $this->get_openai_realtime_project_id();
        if (empty($project_id) || !$this->is_valid_openai_realtime_project_id($project_id))
            return '';
        return 'sip:' . $project_id . '@sip.api.openai.com;transport=tls';
    }

    /**
     * Idempotent: ensure the platform-wide SIP trunk exists, has OpenAI as
     * its origination URL, and that we have the trunk SID stored. Safe to
     * call as often as needed — only mutates Twilio when something is missing.
     *
     * @return array { trunk_sid, origination_url_sid, created, origination_added, openai_url, error? }
     */
    public function ensure_realtime_sip_trunk(): array
    {
        $result = array(
            'trunk_sid' => '',
            'origination_url_sid' => '',
            'created' => false,
            'origination_added' => false,
            'openai_url' => '',
            'error' => '',
        );

        if (get_option('vmo_enable_legacy_realtime_sip', '0') !== '1') {
            $result['error'] = 'Legacy OpenAI SIP routing is disabled; Premium calls use the WebSocket bridge.';
            return $result;
        }

        $openai_sip = $this->realtime_openai_sip_url();
        if (empty($openai_sip)) {
            $result['error'] = $this->is_valid_openai_realtime_project_id($this->get_openai_realtime_project_id())
                ? 'Missing OpenAI Realtime Project ID — set it in VMO Settings first.'
                : 'OpenAI Realtime Project ID is invalid. Use the exact proj_... value from OpenAI Project > General.';
            return $result;
        }
        $result['openai_url'] = $openai_sip;

        if (empty($this->account_sid) || empty($this->auth_token)) {
            $result['error'] = 'Missing Twilio credentials.';
            return $result;
        }

        $disaster_recovery_url = $this->phone_gateway_url('voice');

        // 1. Get or create the trunk
        $trunk_sid = (string) get_option('vmo_twilio_sip_trunk_sid', '');
        $trunk_data = array();

        // Verify cached SID still exists at Twilio
        if (!empty($trunk_sid)) {
            $check = $this->trunking_request('GET', '/Trunks/' . $trunk_sid);
            if (is_wp_error($check)) {
                error_log('VMO Trunk: cached SID ' . $trunk_sid . ' not found at Twilio, will recreate');
                $trunk_sid = '';
                delete_option('vmo_twilio_sip_trunk_sid');
            } else {
                $trunk_data = $check;
            }
        }

        if (empty($trunk_sid)) {
            $create = $this->trunking_request('POST', '/Trunks', array(
                'FriendlyName' => self::REALTIME_TRUNK_FRIENDLY_NAME,
                'DisasterRecoveryUrl' => $disaster_recovery_url,
                'DisasterRecoveryMethod' => 'POST',
            ));
            if (is_wp_error($create)) {
                $result['error'] = 'Could not create trunk: ' . $create->get_error_message();
                return $result;
            }
            $trunk_sid = (string) ($create['sid'] ?? '');
            $trunk_data = is_array($create) ? $create : array();
            if (empty($trunk_sid)) {
                $result['error'] = 'Trunk creation returned no SID.';
                return $result;
            }
            update_option('vmo_twilio_sip_trunk_sid', $trunk_sid, false);
            $result['created'] = true;
        }
        $result['trunk_sid'] = $trunk_sid;

        $existing_dr_url = (string) ($trunk_data['disaster_recovery_url'] ?? '');
        $existing_dr_method = strtoupper((string) ($trunk_data['disaster_recovery_method'] ?? ''));
        if ($existing_dr_url !== $disaster_recovery_url || $existing_dr_method !== 'POST') {
            $dr_update = $this->trunking_request('POST', '/Trunks/' . $trunk_sid, array(
                'DisasterRecoveryUrl' => $disaster_recovery_url,
                'DisasterRecoveryMethod' => 'POST',
            ));
            if (is_wp_error($dr_update)) {
                error_log('VMO Trunk: failed to update disaster recovery URL: ' . $dr_update->get_error_message());
            }
        }

        // 2. Ensure the OpenAI origination URL is present on the trunk
        $urls = $this->trunking_request('GET', '/Trunks/' . $trunk_sid . '/OriginationUrls');
        $existing_url_sid = '';
        $existing_sip_url = '';
        if (!is_wp_error($urls) && !empty($urls['origination_urls']) && is_array($urls['origination_urls'])) {
            foreach ($urls['origination_urls'] as $u) {
                $sip_url = (string) ($u['sip_url'] ?? '');
                // Match by host — different project IDs are still our platform's URLs
                if (stripos($sip_url, '@sip.api.openai.com') !== false) {
                    $existing_url_sid = (string) ($u['sid'] ?? '');
                    $existing_sip_url = $sip_url;
                    break;
                }
            }
        }

        // If URL exists but project ID changed, update it; if missing, create it.
        if (empty($existing_url_sid)) {
            $add = $this->trunking_request('POST', '/Trunks/' . $trunk_sid . '/OriginationUrls', array(
                'FriendlyName' => self::REALTIME_ORIGINATION_FRIENDLY_NAME,
                'SipUrl' => $openai_sip,
                'Priority' => 10,
                'Weight' => 10,
                'Enabled' => 'true',
            ));
            if (is_wp_error($add)) {
                $result['error'] = 'Could not add OpenAI origination URL: ' . $add->get_error_message();
                return $result;
            }
            $result['origination_url_sid'] = (string) ($add['sid'] ?? '');
            $result['origination_added'] = true;
        } else {
            $result['origination_url_sid'] = $existing_url_sid;
            // If project ID changed since trunk was set up, update the URL in place
            if ($existing_sip_url !== $openai_sip) {
                $upd = $this->trunking_request(
                    'POST',
                    '/Trunks/' . $trunk_sid . '/OriginationUrls/' . $existing_url_sid,
                    array('SipUrl' => $openai_sip, 'Enabled' => 'true')
                );
                if (!is_wp_error($upd)) {
                    $result['origination_added'] = true; // signals "updated"
                }
            }
        }

        return $result;
    }

    /**
     * Assign a single Twilio phone number to the platform realtime trunk.
     * Idempotent: returns success if the number is already on the trunk.
     *
     * @param string $phone_number_sid Twilio IncomingPhoneNumber SID (PNxxxx)
     * @return true|WP_Error
     */
    public function assign_number_to_realtime_trunk(string $phone_number_sid)
    {
        if (get_option('vmo_enable_legacy_realtime_sip', '0') !== '1') {
            return new WP_Error('legacy_sip_disabled', 'Legacy OpenAI SIP routing is disabled; number remains on the Voice URL webhook path for the WebSocket bridge.');
        }

        if (empty($phone_number_sid)) {
            return new WP_Error('missing_sid', 'Phone number SID required');
        }
        $trunk_sid = (string) get_option('vmo_twilio_sip_trunk_sid', '');
        if (empty($trunk_sid)) {
            // Auto-bootstrap the trunk on first use so SaaS users get realtime
            // automatically without admin intervention.
            $boot = $this->ensure_realtime_sip_trunk();
            if (!empty($boot['error']) || empty($boot['trunk_sid'])) {
                return new WP_Error('no_trunk', $boot['error'] ?: 'Failed to auto-create trunk');
            }
            $trunk_sid = $boot['trunk_sid'];
        }

        $resp = $this->trunking_request(
            'POST',
            '/Trunks/' . $trunk_sid . '/PhoneNumbers',
            array('PhoneNumberSid' => $phone_number_sid)
        );
        if (is_wp_error($resp)) {
            // Twilio returns an error if number is already on this trunk — treat as success
            $err_msg = $resp->get_error_message();
            if (stripos($err_msg, 'already') !== false || stripos($err_msg, 'duplicate') !== false) {
                $resp = true;
            } else {
                return $resp;
            }
        }

        // Critical: also set trunk_sid on the IncomingPhoneNumber itself.
        // Being listed on the trunk resource is not sufficient if the live
        // number still routes through its legacy VoiceUrl.
        $number_update = $this->twilio_request('POST', "/IncomingPhoneNumbers/{$phone_number_sid}.json", array(
            'TrunkSid' => $trunk_sid,
        ));
        if (is_wp_error($number_update)) {
            return new WP_Error(
                'trunk_number_update_failed',
                'Number was associated with the realtime trunk, but Twilio did not accept TrunkSid on the phone number: ' . $number_update->get_error_message()
            );
        }

        return true;
    }

    /**
     * EMERGENCY ROLLBACK: remove every Twilio phone number from the platform
     * realtime trunk so they revert to using their VoiceUrl webhook (legacy
     * pipeline). Use this if OpenAI's SIP gateway is rejecting INVITEs and
     * you need to restore basic call service immediately while diagnosing.
     *
     * Each number's VoiceUrl is preserved on Twilio (we never cleared it),
     * so removing from the trunk is sufficient to restore webhook routing.
     *
     * @return array { total, removed, failed, errors[] }
     */
    public function remove_all_numbers_from_realtime_trunk(): array
    {
        $stats = array('total' => 0, 'removed' => 0, 'failed' => 0, 'errors' => array());
        $trunk_sid = (string) get_option('vmo_twilio_sip_trunk_sid', '');
        if (empty($trunk_sid)) {
            $stats['errors'][] = 'No trunk configured.';
            return $stats;
        }

        $list = $this->trunking_request('GET', '/Trunks/' . $trunk_sid . '/PhoneNumbers');
        if (is_wp_error($list)) {
            $stats['errors'][] = 'Could not list trunk numbers: ' . $list->get_error_message();
            return $stats;
        }
        $numbers = isset($list['phone_numbers']) && is_array($list['phone_numbers']) ? $list['phone_numbers'] : array();
        $stats['total'] = count($numbers);

        foreach ($numbers as $pn) {
            $assoc_sid = (string) ($pn['sid'] ?? '');     // trunk-association SID
            $pn_label = (string) ($pn['phone_number'] ?? $assoc_sid);
            if (empty($assoc_sid)) {
                $stats['failed']++;
                $stats['errors'][] = $pn_label . ': missing association SID';
                continue;
            }
            $del = $this->trunking_request('DELETE', '/Trunks/' . $trunk_sid . '/PhoneNumbers/' . $assoc_sid);
            if (is_wp_error($del)) {
                $stats['failed']++;
                $stats['errors'][] = $pn_label . ': ' . $del->get_error_message();
            } else {
                $stats['removed']++;
            }
        }
        return $stats;
    }

    public function backfill_all_numbers_to_realtime_trunk(): array
    {
        global $wpdb;
        $stats = array('total' => 0, 'success' => 0, 'failed' => 0, 'skipped' => 0, 'errors' => array());

        $trunk_sid = (string) get_option('vmo_twilio_sip_trunk_sid', '');
        if (empty($trunk_sid)) {
            $boot = $this->ensure_realtime_sip_trunk();
            if (!empty($boot['error'])) {
                $stats['errors'][] = $boot['error'];
                return $stats;
            }
            $trunk_sid = $boot['trunk_sid'];
        }

        // Source 1: every row in vmo_phone_numbers regardless of status — we'd
        // rather try to assign a "released" or "pending" number than skip a
        // legitimate active line because of a stale status column.
        $rows = $wpdb->get_results(
            "SELECT id, user_id, phone_number, twilio_sid, status
             FROM {$wpdb->prefix}vmo_phone_numbers"
        );
        $by_number = array();
        foreach ($rows as $r) {
            if (!empty($r->phone_number)) {
                $by_number[$r->phone_number] = $r;
            }
        }

        // Source 2: every user with a vmo_phone_number user_meta — covers numbers
        // that exist for the user but never made it into the phone_numbers table.
        $meta_rows = $wpdb->get_results(
            "SELECT user_id, meta_value AS phone_number
             FROM {$wpdb->usermeta}
             WHERE meta_key = 'vmo_phone_number' AND meta_value <> ''"
        );
        foreach ($meta_rows as $mr) {
            if (empty($by_number[$mr->phone_number])) {
                $by_number[$mr->phone_number] = (object) array(
                    'id' => 0,
                    'user_id' => intval($mr->user_id),
                    'phone_number' => $mr->phone_number,
                    'twilio_sid' => (string) get_user_meta(intval($mr->user_id), 'vmo_phone_sid', true),
                    'status' => 'meta_only',
                );
            }
        }

        // Source 3: every IncomingPhoneNumber owned by the Twilio account — the
        // ultimate safety net so no live number is left behind.
        $tw_list = $this->twilio_request('GET', '/IncomingPhoneNumbers.json', array('PageSize' => 100));
        if (!is_wp_error($tw_list) && !empty($tw_list['incoming_phone_numbers']) && is_array($tw_list['incoming_phone_numbers'])) {
            foreach ($tw_list['incoming_phone_numbers'] as $pn) {
                $num = (string) ($pn['phone_number'] ?? '');
                $sid = (string) ($pn['sid'] ?? '');
                if ($num === '' || $sid === '')
                    continue;
                if (empty($by_number[$num])) {
                    $by_number[$num] = (object) array(
                        'id' => 0,
                        'user_id' => 0,
                        'phone_number' => $num,
                        'twilio_sid' => $sid,
                        'status' => 'twilio_only',
                    );
                } elseif (empty($by_number[$num]->twilio_sid)) {
                    // Heal: fill in the SID from Twilio
                    $by_number[$num]->twilio_sid = $sid;
                    if (!empty($by_number[$num]->id)) {
                        $wpdb->update(
                            $wpdb->prefix . 'vmo_phone_numbers',
                            array('twilio_sid' => $sid),
                            array('id' => $by_number[$num]->id)
                        );
                    }
                }
            }
        }

        $stats['total'] = count($by_number);

        foreach ($by_number as $row) {
            $sid = (string) ($row->twilio_sid ?? '');

            // Heal: if still missing twilio_sid, look it up by number
            if (empty($sid) && !empty($row->phone_number)) {
                $lookup = $this->twilio_request(
                    'GET',
                    '/IncomingPhoneNumbers.json',
                    array('PhoneNumber' => $row->phone_number)
                );
                if (!is_wp_error($lookup) && !empty($lookup['incoming_phone_numbers'][0]['sid'])) {
                    $sid = (string) $lookup['incoming_phone_numbers'][0]['sid'];
                    if (!empty($row->id)) {
                        $wpdb->update(
                            $wpdb->prefix . 'vmo_phone_numbers',
                            array('twilio_sid' => $sid),
                            array('id' => $row->id)
                        );
                    }
                }
            }

            if (empty($sid)) {
                $stats['skipped']++;
                $stats['errors'][] = $row->phone_number . ': no Twilio SID found';
                continue;
            }

            $r = $this->assign_number_to_realtime_trunk($sid);
            if ($r === true) {
                $stats['success']++;
            } else {
                $stats['failed']++;
                $stats['errors'][] = $row->phone_number . ' (' . $sid . '): ' . (is_wp_error($r) ? $r->get_error_message() : 'unknown');
            }
        }
        return $stats;
    }

    /**
     * Handle incoming OpenAI Realtime SIP webhook (realtime.call.incoming).
     *
     * Called by OpenAI when Twilio creates the AI SIP participant for the
     * conference-first inbound phone flow. Identifies the user, builds the
     * receptionist prompt, and accepts the call with the Realtime model.
     */
    public function handle_openai_realtime_webhook($request)
    {
        $body = $request->get_body();
        $headers = $request->get_headers();

        // OPTION C DIAGNOSTIC: capture every webhook hit so we can prove OpenAI is calling us
        set_transient('vmo_openai_webhook_lasthit', array(
            'time' => time(),
            'time_str' => date('Y-m-d H:i:s'),
            'body_len' => strlen($body),
            'body_first300' => substr($body, 0, 300),
            'has_sig' => isset($headers['webhook_signature']) || isset($headers['svix_signature']) || isset($headers['openai_signature']),
        ), 3600);

        // Verify OpenAI webhook signature
        if (!$this->verify_openai_webhook($body, $headers)) {
            error_log('VMO Realtime: Invalid webhook signature');
            set_transient('vmo_openai_webhook_lastfail', array(
                'time' => time(),
                'reason' => 'signature_failed',
                'body_first300' => substr($body, 0, 300),
                'header_keys' => array_keys($headers),
            ), 3600);
            return new WP_REST_Response(array('error' => 'Invalid signature'), 400);
        }

        $event = json_decode($body, true);
        set_transient('vmo_openai_webhook_lastverified', array(
            'time' => time(),
            'time_str' => date('Y-m-d H:i:s'),
            'type' => is_array($event) ? (string) ($event['type'] ?? '') : '',
            'call_id' => is_array($event) ? (string) ($event['data']['call_id'] ?? '') : '',
            'body_first300' => substr($body, 0, 300),
        ), DAY_IN_SECONDS);
        if (!is_array($event) || empty($event['type'])) {
            return new WP_REST_Response(array('error' => 'Invalid event'), 400);
        }

        if ($event['type'] !== 'realtime.call.incoming') {
            // OPTION C: handle tool-call events delivered via webhook for SIP-accepted calls.
            // OpenAI's exact event type may vary (e.g. realtime.call.tool_call,
            // response.function_call_arguments.done, etc.) — sniff the payload generically.
            $maybe_handled = $this->maybe_handle_realtime_tool_event($event);
            if ($maybe_handled) {
                return new WP_REST_Response(array('status' => 'tool_handled'), 200);
            }
            return new WP_REST_Response(array('status' => 'ignored'), 200);
        }

        $call_id = $event['data']['call_id'] ?? '';
        $sip_headers = $event['data']['sip_headers'] ?? array();

        if (empty($call_id)) {
            return new WP_REST_Response(array('error' => 'Missing call_id'), 400);
        }

        error_log('VMO Realtime: Incoming call ' . $call_id . ' headers: ' . wp_json_encode($sip_headers));

        // ── Identify the user via SIP headers ──
        // Primary: pre-stored call context keyed by the Twilio SIP leg CallSid.
        // Fallback: To header contains the dialed Twilio number → look up VMO user.
        // Last resort: caller's From number (if they happen to be a known VMO user).
        $user_id = 0;
        $twilio_sid = '';
        $from_number = '';
        $to_number = '';
        $call_id_hdr = '';
        $parent_call_sid = '';

        foreach ($sip_headers as $header) {
            $name = $header['name'] ?? '';
            $value = $header['value'] ?? '';

            if (strcasecmp($name, 'X-Vmo-Uid') === 0) {
                $user_id = intval($value);
            } elseif (strcasecmp($name, 'X-Vmo-Sid') === 0) {
                $twilio_sid = sanitize_text_field($value);
            } elseif (strcasecmp($name, 'From') === 0) {
                if (preg_match('/sip:([^@;>?]+)@/', $value, $m)) {
                    $from_number = $m[1];
                } elseif (preg_match('/\+?\d{10,15}/', $value, $m)) {
                    $from_number = $m[0];
                }
            } elseif (strcasecmp($name, 'To') === 0) {
                if (preg_match('/sip:([^@;>?]+)@/', $value, $m)) {
                    $to_number = $m[1];
                } elseif (preg_match('/\+?\d{10,15}/', $value, $m)) {
                    $to_number = $m[0];
                }
            } elseif (strcasecmp($name, 'Call-ID') === 0) {
                // Twilio puts its CallSid in Call-ID's leftmost token before any '@'
                $cid = trim($value);
                if (preg_match('/^([A-Za-z0-9_-]+)/', $cid, $m)) {
                    $call_id_hdr = $m[1];
                }
            }
        }

        // Fallback 1: look up transient via Twilio CallSid (from X-Vmo-Sid or Call-ID)
        $realtime_ctx = array();
        $lookup_sid = $twilio_sid !== '' ? $twilio_sid : $call_id_hdr;
        if (!empty($lookup_sid)) {
            $realtime_ctx = $this->get_realtime_call_context($lookup_sid);
            if (!empty($realtime_ctx['parent_call_sid'])) {
                $parent_call_sid = sanitize_text_field((string) $realtime_ctx['parent_call_sid']);
            }
            if ($user_id <= 0 && !empty($realtime_ctx['user_id'])) {
                $user_id = intval($realtime_ctx['user_id']);
            }
        }
        if ($parent_call_sid !== '') {
            $twilio_sid = $parent_call_sid;
        } elseif ($twilio_sid === '' && !empty($lookup_sid)) {
            $twilio_sid = $lookup_sid;
        }

        // Fallback 2: identify VMO user by the DIALED number (To header) — this is the
        // primary reliable identifier when X-Vmo-Uid is unavailable, because the To
        // header contains the Twilio phone number that the VMO user owns.
        if ($user_id <= 0 && !empty($to_number)) {
            $user_id = (int) $this->get_user_id_from_phone($to_number);
        }

        // Fallback 3: caller's From number (only useful if caller happens to be a VMO user)
        if ($user_id <= 0 && !empty($from_number)) {
            $user_id = (int) $this->get_user_id_from_phone($from_number);
        }

        if ($user_id <= 0) {
            error_log('VMO Realtime: Cannot identify user for call ' . $call_id . ' (to=' . $to_number . ' from=' . $from_number . '), rejecting');
            $this->openai_realtime_reject($call_id, 486);
            return new WP_REST_Response(array('status' => 'rejected', 'reason' => 'unknown_user'), 200);
        }

        // ── Build session configuration ──
        $model = $this->get_realtime_model_for_user($user_id);
        $voice = strtolower((string) (get_user_meta($user_id, 'vmo_selected_reception_voice', true) ?: 'ash'));
        $call_language = isset($realtime_ctx['call_language']) && is_array($realtime_ctx['call_language'])
            ? $this->normalize_call_language_context($realtime_ctx['call_language'])
            : ($twilio_sid !== '' ? $this->get_call_language_context($twilio_sid) : $this->infer_initial_call_language(array(), $from_number));
        $instructions = $this->build_receptionist_prompt($user_id, $call_language);
        $initial_instruction = trim((string) ($realtime_ctx['initial_instruction'] ?? ''));
        if ($initial_instruction !== '') {
            $instructions .= "\n\nCall Context: " . preg_replace('/\s+/', ' ', wp_strip_all_tags($initial_instruction));
        }

        $phone_opening = (isset($realtime_ctx['phone_opening']) && is_array($realtime_ctx['phone_opening']))
            ? $realtime_ctx['phone_opening']
            : $this->get_phone_opening_script($user_id);
        $initial_greeting = trim((string) ($phone_opening['initial_greeting'] ?? ''));
        if ($initial_greeting === '') {
            $initial_greeting = trim((string) ($phone_opening['ai_greeting'] ?? ''));
        }
        $initial_greeting = preg_replace('/\s+/', ' ', wp_strip_all_tags($initial_greeting));

        // Append the greeting instruction so Realtime speaks first
        if ($initial_greeting !== '') {
            $instructions .= "\n\nIMPORTANT: Begin the conversation by saying: \"" . $initial_greeting . "\"";
        } else {
            $instructions .= "\n\nIMPORTANT: Begin the conversation by warmly greeting the caller and asking how you can help.";
        }

        // ── OPTION C: Build the transfer_to_extension tool when extensions exist ──
        $tools = array();
        $extension_map = (isset($realtime_ctx['extension_map']) && is_array($realtime_ctx['extension_map']))
            ? $realtime_ctx['extension_map']
            : array();
        if (!empty($extension_map)) {
            $allowed_digits = array_map('strval', array_keys($extension_map));
            $tools[] = array(
                'type' => 'function',
                'name' => 'transfer_to_extension',
                'description' => 'Notify and ring one of the configured business extensions while keeping the AI receptionist on the line with the caller. Call this immediately as soon as the caller asks for a department, person, or presses a digit corresponding to an extension. After the tool is called, tell the caller that the department has been notified and may join at any time, then continue helping them while they wait.',
                'parameters' => array(
                    'type' => 'object',
                    'additionalProperties' => false,
                    'properties' => array(
                        'digit' => array(
                            'type' => 'string',
                            'enum' => $allowed_digits,
                            'description' => 'The digit (1-9) of the extension to transfer to. Must be one of the listed extensions.',
                        ),
                        'spoken_confirmation' => array(
                            'type' => 'string',
                            'description' => 'Brief one-sentence confirmation to speak to the caller before transferring (e.g. "Connecting you to Sales now, please hold.").',
                        ),
                    ),
                    'required' => array('digit'),
                ),
            );
            // Persist call_sid <-> openai call_id mapping so the tool-call event can find it
            if (!empty($twilio_sid)) {
                set_transient(
                    'vmo_realtime_callid_' . sanitize_key($call_id),
                    array('twilio_sid' => $twilio_sid, 'user_id' => $user_id),
                    7200
                );
            }
        }

        error_log('VMO Realtime: Accepting call ' . $call_id . ' user=' . $user_id . ' model=' . $model . ' voice=' . $voice . ' tools=' . count($tools));
        if (!empty($twilio_sid)) {
            $this->trace_voice_call($twilio_sid, 'openai_webhook_received call_id=' . $call_id . ' user=' . $user_id . ' model=' . $model);
        }

        // ── Accept the call ──
        $accepted = $this->openai_realtime_accept($call_id, $model, $voice, $instructions, $tools);

        if (!$accepted) {
            error_log('VMO Realtime: Failed to accept call ' . $call_id . ', rejecting');
            if (!empty($twilio_sid)) {
                $this->trace_voice_call($twilio_sid, 'openai_accept_FAILED call_id=' . $call_id);
            }
            $this->openai_realtime_reject($call_id, 503);
            return new WP_REST_Response(array('status' => 'error', 'reason' => 'accept_failed'), 200);
        }
        if (!empty($twilio_sid)) {
            $this->trace_voice_call($twilio_sid, 'openai_accept_OK call_id=' . $call_id);
            $this->bootstrap_realtime_inbound_call($twilio_sid, $user_id, $from_number, $to_number);
            $this->trace_voice_call($twilio_sid, 'openai_runtime_bootstrap_OK call_id=' . $call_id);
        }

        // Store call mapping for billing correlation
        set_transient('vmo_realtime_call_' . sanitize_key($call_id), array(
            'user_id' => $user_id,
            'twilio_sid' => $twilio_sid,
            'model' => $model,
            'started_at' => time(),
        ), 7200);

        error_log('VMO Realtime: Call ' . $call_id . ' accepted for user ' . $user_id);

        return new WP_REST_Response(array('status' => 'accepted'), 200);
    }

    /**
     * OPTION C: Inspect a non-incoming Realtime webhook event and, if it is a
     * tool-call event for transfer_to_extension, execute the transfer.
     *
     * Returns true if the event was recognized and handled.
     */
    private function maybe_handle_realtime_tool_event(array $event): bool
    {
        $type = (string) ($event['type'] ?? '');
        if ($type === '')
            return false;

        // Locate the call_id (top-level data, or nested in data.call)
        $call_id = '';
        if (!empty($event['data']['call_id'])) {
            $call_id = (string) $event['data']['call_id'];
        } elseif (!empty($event['data']['call']['id'])) {
            $call_id = (string) $event['data']['call']['id'];
        }
        if ($call_id === '')
            return false;

        // Sniff for a function-call payload anywhere in the event
        $fn_name = '';
        $fn_args_json = '';
        $candidates = array(
            $event['data']['name'] ?? null,
            $event['data']['function'] ?? null,
            $event['data']['function_call']['name'] ?? null,
            $event['data']['item']['name'] ?? null,
            $event['data']['tool_call']['name'] ?? null,
        );
        foreach ($candidates as $c) {
            if (is_string($c) && $c !== '') {
                $fn_name = $c;
                break;
            }
        }
        $arg_candidates = array(
            $event['data']['arguments'] ?? null,
            $event['data']['function_call']['arguments'] ?? null,
            $event['data']['item']['arguments'] ?? null,
            $event['data']['tool_call']['arguments'] ?? null,
        );
        foreach ($arg_candidates as $c) {
            if (is_string($c) && $c !== '') {
                $fn_args_json = $c;
                break;
            }
            if (is_array($c)) {
                $fn_args_json = wp_json_encode($c);
                break;
            }
        }

        if ($fn_name !== 'transfer_to_extension') {
            // Not our tool — silently ignore unknown event types
            error_log('VMO Realtime: Non-incoming event ignored type=' . $type . ' fn=' . $fn_name);
            return false;
        }

        $args = json_decode($fn_args_json, true);
        if (!is_array($args))
            $args = array();
        $digit = isset($args['digit']) ? (string) $args['digit'] : '';
        if ($digit === '') {
            error_log('VMO Realtime: transfer_to_extension called with no digit, call_id=' . $call_id);
            return true;
        }

        // Resolve call_id → twilio_sid → extension_map
        $mapping = get_transient('vmo_realtime_callid_' . sanitize_key($call_id));
        if (!is_array($mapping) || empty($mapping['twilio_sid'])) {
            error_log('VMO Realtime: No twilio_sid mapping for call_id=' . $call_id);
            return true;
        }
        $twilio_sid = (string) $mapping['twilio_sid'];
        $user_id = (int) ($mapping['user_id'] ?? 0);

        $ctx = $this->get_realtime_call_context($twilio_sid);
        $ext_map = (isset($ctx['extension_map']) && is_array($ctx['extension_map'])) ? $ctx['extension_map'] : array();
        if (!isset($ext_map[$digit])) {
            error_log('VMO Realtime: digit=' . $digit . ' not in extension_map for sid=' . $twilio_sid);
            return true;
        }
        $ext_label = (string) $ext_map[$digit]['label'];

        $result = $this->ring_configured_extension_for_live_call($twilio_sid, $user_id, $digit, 'openai_realtime_tool');
        if (is_wp_error($result)) {
            error_log('VMO Realtime: Extension notify failed sid=' . $twilio_sid . ' digit=' . $digit . ' err=' . $result->get_error_message());
        } else {
            error_log('VMO Realtime: Extension notified sid=' . $twilio_sid . ' digit=' . $digit . ' label=' . $ext_label);
        }
        return true;
    }

    /**
     * OPTION C: TwiML emitter that bridges the live caller to an extension's phone number.
     * Called by Twilio after we redirect the call via REST.
     *
     * Expects query: ?sid=<original_call_sid>&digit=<ext_digit>&uid=<user_id>
     */
    public function handle_realtime_transfer_twiml($request)
    {
        $params = $request->get_params();
        $sid = isset($params['sid']) ? sanitize_text_field($params['sid']) : '';
        $digit = isset($params['digit']) ? sanitize_text_field($params['digit']) : '';

        $ctx = !empty($sid) ? $this->get_realtime_call_context($sid) : array();
        $ext_map = (isset($ctx['extension_map']) && is_array($ctx['extension_map'])) ? $ctx['extension_map'] : array();

        while (ob_get_level())
            @ob_end_clean();
        header('Content-Type: text/xml; charset=UTF-8');
        echo '<?xml version="1.0" encoding="UTF-8"?>';
        echo '<Response>';
        if ($digit !== '' && isset($ext_map[$digit]) && !empty($ext_map[$digit]['phone'])) {
            $ext_phone = (string) $ext_map[$digit]['phone'];
            $ext_label = (string) $ext_map[$digit]['label'];
            echo '<Say voice="alice">' . esc_xml('Connecting you to ' . $ext_label . ' now. Please hold.') . '</Say>';
            echo '<Dial timeout="30" answerOnBridge="true">' . esc_xml($ext_phone) . '</Dial>';
        } else {
            echo '<Say voice="alice">We were unable to complete that transfer. Goodbye.</Say>';
            echo '<Hangup/>';
        }
        echo '</Response>';
        exit;
    }

    public function handle_realtime_participant_status($request)
    {
        $params = $request->get_params();

        $parent_call_sid = isset($params['parent_sid']) ? sanitize_text_field($params['parent_sid']) : '';
        $participant_call_sid = isset($params['CallSid']) ? sanitize_text_field($params['CallSid']) : '';
        $user_id = isset($params['uid']) ? intval($params['uid']) : 0;
        $call_status = strtolower(trim((string) ($params['CallStatus'] ?? $params['Status'] ?? '')));
        $call_duration = intval($params['CallDuration'] ?? 0);
        $sip_response_code = sanitize_text_field((string) ($params['SipResponseCode'] ?? ''));
        $error_code = sanitize_text_field((string) ($params['ErrorCode'] ?? ''));

        if ($parent_call_sid === '' && $participant_call_sid !== '') {
            $parent_call_sid = $this->find_parent_for_realtime_ai_leg($participant_call_sid);
        }
        if ($parent_call_sid === '') {
            return array('ok' => true, 'reason' => 'missing_parent');
        }

        $tracked = $this->get_tracked_realtime_ai_leg($parent_call_sid);
        if ($user_id <= 0) {
            $user_id = intval($tracked['user_id'] ?? 0);
        }

        if ($participant_call_sid !== '') {
            $this->track_realtime_ai_leg($parent_call_sid, array(
                'call_sid' => $participant_call_sid,
                'status' => $call_status,
                'last_callback_at' => time(),
            ), 1800);
        }

        $this->trace_voice_call($parent_call_sid, 'realtime_participant_status participant=' . $participant_call_sid . ' status=' . ($call_status !== '' ? $call_status : '(none)') . ' duration=' . $call_duration . ' sip_code=' . ($sip_response_code !== '' ? $sip_response_code : '(none)') . ' error_code=' . ($error_code !== '' ? $error_code : '(none)'));

        if (in_array($call_status, array('in-progress', 'answered'), true)) {
            $this->track_realtime_ai_leg($parent_call_sid, array(
                'answered' => true,
                'answered_at' => time(),
                'status' => $call_status,
            ), 1800);
            return array('ok' => true, 'state' => 'answered');
        }

        if (in_array($call_status, array('queued', 'initiated', 'ringing'), true)) {
            return array('ok' => true, 'state' => $call_status);
        }

        $manual_disconnect = (bool) get_transient($this->realtime_ai_disconnect_intent_key($parent_call_sid));
        $answered = !empty($tracked['answered']);

        if ($manual_disconnect) {
            $this->trace_voice_call($parent_call_sid, 'realtime_participant_manual_disconnect participant=' . $participant_call_sid);
            delete_transient('vmo_realtime_active_' . sanitize_key($parent_call_sid));
            $this->clear_realtime_call_context($parent_call_sid);
            $this->clear_tracked_realtime_ai_leg($parent_call_sid);
            return array('ok' => true, 'state' => 'manual_disconnect');
        }

        if (!$answered) {
            $failure_hint = ($sip_response_code === '400')
                ? 'OpenAI SIP explicitly rejected the INVITE before the AI could join the live conference. Most likely causes: (1) the configured Realtime Project ID is not the exact proj_... ID from OpenAI Project > General, (2) the webhook is not registered on that same project, (3) that project is not enabled for Realtime SIP, or (4) OpenAI rejected this project/provider combination.'
                : 'Common causes: (1) OpenAI project not enrolled in Realtime SIP, (2) webhook URL not registered with OpenAI for this project, (3) project_id wrong, or (4) OpenAI service issue.';
            if ($user_id > 0) {
                set_transient('vmo_realtime_last_error_' . $user_id, array(
                    'call_sid' => $parent_call_sid,
                    'message' => 'OpenAI Realtime SIP failed before the AI joined the live conference. CallStatus=' . ($call_status !== '' ? $call_status : '(none)') . ' SipResponseCode=' . ($sip_response_code !== '' ? $sip_response_code : '(none)') . ' ErrorCode=' . ($error_code !== '' ? $error_code : '(none)') . '. ' . $failure_hint,
                    'file' => __FILE__ . ':' . __LINE__,
                    'time' => time(),
                    'sip_response_code' => $sip_response_code,
                    'participant_call_sid' => $participant_call_sid,
                    'call_status' => $call_status,
                    'error_code' => $error_code,
                    'all_params' => $params,
                ), 3600);
            }

            $realtime_ctx = $this->get_realtime_call_context($parent_call_sid);
            $fallback_twiml = $this->wrap_twiml_response($this->build_realtime_legacy_fallback_twiml($user_id, $parent_call_sid, $realtime_ctx));
            $redirect_result = $this->twilio_request('POST', "/Calls/{$parent_call_sid}.json", array(
                'Twiml' => $fallback_twiml,
            ));
            if (is_wp_error($redirect_result)) {
                error_log('[VMO Realtime] Failed to redirect caller ' . $parent_call_sid . ' to legacy fallback after participant failure: ' . $redirect_result->get_error_message());
            } else {
                $this->trace_voice_call($parent_call_sid, 'realtime_participant_failure_redirected_to_legacy');
            }

            delete_transient('vmo_realtime_active_' . sanitize_key($parent_call_sid));
            $this->clear_realtime_call_context($parent_call_sid);
            $this->clear_tracked_realtime_ai_leg($parent_call_sid);
            return array('ok' => true, 'state' => 'fallback_redirected');
        }

        if ($call_duration > 0 && $user_id > 0) {
            $this->deduct_realtime_tokens($user_id, $call_duration, $parent_call_sid);
        }

        $hangup_result = $this->twilio_request('POST', "/Calls/{$parent_call_sid}.json", array(
            'Twiml' => '<Response><Hangup/></Response>',
        ));
        if (is_wp_error($hangup_result)) {
            error_log('[VMO Realtime] Failed to hang up caller ' . $parent_call_sid . ' after AI completion: ' . $hangup_result->get_error_message());
        }

        delete_transient('vmo_realtime_active_' . sanitize_key($parent_call_sid));
        $this->clear_realtime_call_context($parent_call_sid);
        $this->clear_tracked_realtime_ai_leg($parent_call_sid);
        return array('ok' => true, 'state' => 'completed');
    }

    /**
     * Handle the <Dial action="..."> callback after a Realtime SIP call ends.
     *
     * On success (completed): deduct tokens and hang up.
     * On failure (busy/no-answer/failed): fall back to legacy Gather+GPT+TTS pipeline.
     */
    public function handle_realtime_dial_status($request)
    {
        $params = $request->get_params();

        error_log('VMO Realtime Dial Status: ' . print_r($params, true));

        $dial_call_status = isset($params['DialCallStatus']) ? sanitize_text_field($params['DialCallStatus']) : '';
        $dial_call_duration = isset($params['DialCallDuration']) ? intval($params['DialCallDuration']) : 0;
        $call_sid = isset($params['CallSid']) ? sanitize_text_field($params['CallSid']) : '';
        $user_id = isset($params['uid']) ? intval($params['uid']) : 0;
        $realtime_ctx = $this->get_realtime_call_context($call_sid);

        // OPTION C DIAGNOSTIC: capture SIP failure details into trace + persistent transient
        $dial_sip_response_code = isset($params['DialSipResponseCode']) ? sanitize_text_field($params['DialSipResponseCode']) : '';
        $sip_response_code = $dial_sip_response_code !== ''
            ? $dial_sip_response_code
            : (isset($params['SipResponseCode']) ? sanitize_text_field($params['SipResponseCode']) : '');
        $dial_sip_call_id = isset($params['DialSipCallId']) ? sanitize_text_field($params['DialSipCallId']) : '';
        $dial_call_sid = isset($params['DialCallSid']) ? sanitize_text_field($params['DialCallSid']) : '';
        $dial_bridged = isset($params['DialBridged']) ? sanitize_text_field($params['DialBridged']) : '';
        $this->trace_voice_call($call_sid, 'realtime_dial_status status=' . $dial_call_status . ' duration=' . $dial_call_duration . ' sip_code=' . $sip_response_code . ' sip_call_id=' . $dial_sip_call_id . ' dial_sid=' . $dial_call_sid . ' bridged=' . $dial_bridged);
        if ($dial_call_status !== 'completed' && $user_id > 0) {
            $sip_failure_hint = ($sip_response_code === '400')
                ? 'OpenAI SIP explicitly rejected the INVITE with 400 before any realtime.call.incoming webhook fired. Most likely causes: (1) the configured Realtime Project ID is not the exact proj_... ID from OpenAI Project > General, (2) the webhook is not registered on that same project, (3) that project is not enabled for Realtime SIP, or (4) OpenAI rejected this project/provider combination.'
                : 'Common causes: (1) OpenAI project not enrolled in Realtime SIP beta, (2) webhook URL not registered with OpenAI for this project, (3) project_id wrong, (4) OpenAI service issue.';
            set_transient('vmo_realtime_last_error_' . $user_id, array(
                'call_sid' => $call_sid,
                'message' => 'SIP <Dial> to OpenAI Realtime FAILED. DialCallStatus=' . $dial_call_status . ' SipResponseCode=' . ($sip_response_code !== '' ? $sip_response_code : '(none)') . ' DialBridged=' . ($dial_bridged !== '' ? $dial_bridged : '(none)') . '. ' . $sip_failure_hint . ' Check Twilio Console call log for SIP error details and OpenAI dashboard for the project\'s SIP/Realtime webhook configuration.',
                'file' => __FILE__ . ':' . __LINE__,
                'time' => time(),
                'sip_response_code' => $sip_response_code,
                'dial_sip_call_id' => $dial_sip_call_id,
                'dial_call_status' => $dial_call_status,
                'dial_bridged' => $dial_bridged,
                'all_params' => $params,
            ), 3600);
        }

        // Clear Realtime active marker — SIP session has ended
        if (!empty($call_sid)) {
            delete_transient('vmo_realtime_active_' . sanitize_key($call_sid));
            $this->clear_realtime_call_context($call_sid);
        }

        while (ob_get_level())
            @ob_end_clean();
        header('Content-Type: text/xml; charset=UTF-8');
        echo '<?xml version="1.0" encoding="UTF-8"?>';

        if ($dial_call_status === 'completed') {
            if ($dial_call_duration > 0 && $user_id > 0) {
                $this->deduct_realtime_tokens($user_id, $dial_call_duration, $call_sid);
            }
            echo '<Response><Hangup/></Response>';
        } else {
            error_log('VMO Realtime: SIP dial failed (status=' . $dial_call_status . '), falling back to legacy pipeline for call ' . $call_sid);
            echo $this->wrap_twiml_response($this->build_realtime_legacy_fallback_twiml($user_id, $call_sid, $realtime_ctx));
        }
        exit;
    }

    /**
     * Deduct tokens for a Realtime SIP call based on duration and model tier.
     *
     * Realtime model pricing (per minute of audio, at VMO_TOKEN_RATE $0.000667/token):
     *   Standard (gpt-realtime-1.5):  ~$0.040/min ≈ 60 tokens/min
     *   Premium  (gpt-realtime-1.5):   ~$0.086/min ≈ 129 tokens/min
     *   + Twilio telephony:            ~$0.022/min ≈ 33 tokens/min
     */
    private function deduct_realtime_tokens(int $user_id, int $duration_seconds, string $call_sid): void
    {
        $duration_seconds = $this->normalize_billable_call_duration($call_sid, $duration_seconds);
        $minutes = (int) ceil($duration_seconds / 60);
        if ($minutes <= 0)
            return;

        $tier = get_user_meta($user_id, 'vmo_voice_quality_tier', true) ?: 'standard';

        // AI model tokens per minute
        $ai_rate = ($tier === 'premium') ? 129 : 60;
        // Twilio telephony tokens per minute (inbound rate)
        $telco_rate = 33;
        $total_tokens = $minutes * ($ai_rate + $telco_rate);

        $label = ($tier === 'premium') ? 'Premium' : 'Standard';
        $description = "AI Voice Call ({$label} Realtime): {$minutes} min";

        $current = (int) get_user_meta($user_id, 'vmo_token_balance', true);
        $new_balance = max(0, $current - $total_tokens);
        update_user_meta($user_id, 'vmo_token_balance', $new_balance);

        $this->log_token_usage($user_id, 'realtime_call', $total_tokens, $description);

        if (function_exists('vmo_log_billing_event')) {
            $cost_usd = $total_tokens * 0.000667;
            vmo_log_billing_event($user_id, 'realtime_call', $total_tokens, $description, 'openai_realtime', $cost_usd, $call_sid);
        }

        error_log("VMO Realtime: Deducted {$total_tokens} tokens for user {$user_id} ({$minutes}min, {$label})");
    }

    /**
     * Load the current call runtime context from the DB.
     *
     * @param string $call_sid Twilio call SID.
     * @return array<string,mixed>
     */
    private function get_call_runtime_context($call_sid)
    {
        global $wpdb;

        $context = array(
            'record' => null,
            'active_conference_name' => '',
            'ai_handled' => null,
            'transcript' => '',
            'status' => '',
            'created_at' => '',
        );

        $call_sid = sanitize_text_field((string) $call_sid);
        if ($call_sid === '') {
            return $context;
        }

        $table = $wpdb->prefix . 'vmo_phone_calls';
        if ($wpdb->get_var("SHOW TABLES LIKE '{$table}'") !== $table) {
            return $context;
        }

        // phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery, WordPress.DB.DirectDatabaseQuery.NoCaching
        $record = $wpdb->get_row($wpdb->prepare(
            "SELECT * FROM {$table} WHERE call_sid = %s LIMIT 1",
            $call_sid
        ));

        if (!$record) {
            return $context;
        }

        $context['record'] = $record;
        $context['ai_handled'] = isset($record->ai_handled) ? (bool) intval($record->ai_handled) : null;
        $context['transcript'] = (string) ($record->transcript ?? '');
        $context['status'] = (string) ($record->status ?? '');
        $context['created_at'] = (string) ($record->created_at ?? '');

        $topic = (string) ($record->topic ?? '');
        if ($topic !== '' && preg_match('/^conference:([^\s]+)/', $topic, $matches)) {
            $context['active_conference_name'] = sanitize_text_field($matches[1]);
        }

        return $context;
    }

    /**
     * Persist whether a live inbound call should alert the owner-facing UI.
     *
     * @param string $call_sid Twilio call SID.
     * @param bool $is_ready Whether browser/external owner alerts may fire.
     */
    private function set_owner_alert_state($call_sid, $is_ready): void
    {
        $safe_call_sid = sanitize_key((string) $call_sid);
        if ($safe_call_sid === '') {
            return;
        }

        set_transient('vmo_owner_alert_' . $safe_call_sid, $is_ready ? 'ready' : 'hold', 900);
    }

    /**
     * Check whether a live inbound call should be surfaced to owner controls.
     * Missing state defaults to ready so existing in-flight calls keep legacy
     * behavior.
     *
     * @param string $call_sid Twilio call SID.
     * @return bool
     */
    private function is_owner_alert_ready($call_sid): bool
    {
        $safe_call_sid = sanitize_key((string) $call_sid);
        if ($safe_call_sid === '') {
            return true;
        }

        return get_transient('vmo_owner_alert_' . $safe_call_sid) !== 'hold';
    }

    /**
     * Handle external phone ring TwiML for simultaneous ring.
     *
     * When an inbound call arrives and the user has an external phone but no
     * extensions, this endpoint is called to provide TwiML to the external
     * phone. It announces the caller and gives DTMF options:
     *   1 = Listen (join conference muted)
     *   2 = Take over (join conference unmuted, stops AI)
     *   * = Decline / hang up
     */
    public function handle_ext_ring($request)
    {
        while (ob_get_level())
            ob_end_clean();

        $params = $request->get_params();
        $user_id = isset($params['uid']) ? intval($params['uid']) : 0;
        $call_sid = isset($params['call_sid']) ? sanitize_text_field($params['call_sid']) : '';
        $caller = isset($params['caller']) ? sanitize_text_field($params['caller']) : 'unknown';
        $digits = isset($params['Digits']) ? sanitize_text_field($params['Digits']) : '';
        $call_context = $this->get_call_runtime_context($call_sid);
        $active_conference_name = (string) ($call_context['active_conference_name'] ?? '');
        $is_realtime = (bool) get_transient('vmo_realtime_active_' . sanitize_key($call_sid));

        error_log("VMO Ext Ring [v2-sync]: uid={$user_id}, call_sid={$call_sid}, caller={$caller}, digits={$digits}");

        // Track whether we need post-response bridge work
        $post_response_action = '';

        // If digits have been pressed, handle the action
        if ($digits !== '') {
            try {
                $conference_name = ($active_conference_name !== '') ? $active_conference_name : $this->call_conference_name($call_sid);

                if ($digits === '1') {
                    error_log("VMO Ext Ring: User chose LISTEN for call {$call_sid}");

                    $listen_conf = $active_conference_name;
                    $listen_start_on_enter = 'false';

                    // Realtime SIP cannot be reliably bridged mid-call via a re-INVITE 
                    // without OpenAI dropping the trunk. Therefore we MUST fall back
                    // to transcript monitoring (ext_monitor) for ALL listen attempts.
                    // We leave listen_conf alone if it is empty.

                    if ($listen_conf === '') {
                        $monitor_url = $this->phone_gateway_url('ext_monitor', array(
                            'uid' => $user_id,
                            'call_sid' => $call_sid,
                            'last_len' => 0,
                            'loops' => 0,
                        ));

                        header('Content-Type: text/xml; charset=UTF-8');
                        echo '<?xml version="1.0" encoding="UTF-8"?>';
                        echo '<Response>';
                        echo '<Say voice="' . $this->tts_voice() . '">The A I is currently helping the caller. I will read transcript updates to you while the A I continues the conversation. Press 2 at any time to take over. Press star to disconnect.</Say>';
                        echo '<Redirect>' . esc_xml($monitor_url) . '</Redirect>';
                        echo '</Response>';
                        exit;
                    }

                    $rec_enabled = $this->is_recording_enabled($user_id);
                    $rec_cb_url = $this->twilio_public_url('recording');

                    // Join this phone/browser leg MUTED into the same conference.
                    // hangupOnStar lets the user press * to leave and get the
                    // action menu (take over / resume AI / rejoin).
                    $listen_action_url = $this->phone_gateway_url('ext_listen_action', array(
                        'uid' => $user_id,
                        'call_sid' => $call_sid,
                    ));

                    // Build TwiML in buffer so we can flush before bridge work
                    ob_start();
                    header('Content-Type: text/xml; charset=UTF-8');
                    echo '<?xml version="1.0" encoding="UTF-8"?>';
                    echo '<Response>';
                    echo '<Say voice="' . $this->tts_voice() . '">You are now listening to the call live. Press star to leave and choose your next action.</Say>';
                    echo '<Dial hangupOnStar="true" action="' . esc_xml($listen_action_url) . '">';
                    echo '<Conference startConferenceOnEnter="' . esc_xml($listen_start_on_enter) . '" muted="true" endConferenceOnExit="true" beep="false" waitUrl=""';
                    if ($rec_enabled) {
                        echo ' record="record-from-start" recordingStatusCallback="' . esc_xml($rec_cb_url) . '" recordingStatusCallbackEvent="completed"';
                    }
                    echo '>' . esc_xml($listen_conf) . '</Conference>';
                    echo '</Dial>';
                    echo '</Response>';

                    if ($post_response_action === 'live_supervision') {
                        $this->flush_response_and_continue();
                        $bridge_start = microtime(true);
                        $result = $this->ensure_live_supervision_conference($call_sid);
                        $bridge_ms = round((microtime(true) - $bridge_start) * 1000);
                        if (is_wp_error($result)) {
                            error_log('[VMO Ext Ring] Post-response live supervision failed for ' . $call_sid . ' in ' . $bridge_ms . 'ms: ' . $result->get_error_message());
                        } else {
                            error_log('[VMO Ext Ring] Post-response live supervision completed for ' . $call_sid . ' in ' . $bridge_ms . 'ms');
                        }
                    }
                    exit;

                } elseif ($digits === '2') {
                    // TAKE OVER MODE: Move inbound call to conference, join external phone UNMUTED
                    error_log("VMO Ext Ring: User chose TAKE OVER for call {$call_sid}");
                    $rec_cb_url = $this->twilio_public_url('recording');
                    $rec_enabled = $this->is_recording_enabled($user_id);

                    // Mark as user-handled BEFORE TwiML output
                    $this->mark_call_conference_state($call_sid, $conference_name, '(ext-takeover)');

                    // Build TwiML in buffer so we can flush before bridge work
                    ob_start();
                    header('Content-Type: text/xml; charset=UTF-8');
                    echo '<?xml version="1.0" encoding="UTF-8"?>';
                    echo '<Response>';
                    echo '<Say voice="' . $this->tts_voice() . '">You are now connected to the caller.</Say>';
                    echo '<Dial>';
                    echo '<Conference startConferenceOnEnter="true" endConferenceOnExit="true" beep="false" waitUrl=""';
                    if ($rec_enabled) {
                        echo ' record="record-from-start" recordingStatusCallback="' . esc_xml($rec_cb_url) . '" recordingStatusCallbackEvent="completed"';
                    }
                    echo '>' . esc_xml($conference_name) . '</Conference>';
                    echo '</Dial>';
                    echo '</Response>';

                    // Flush response to Twilio, then do bridge work synchronously
                    $this->flush_response_and_continue();
                    $bridge_start = microtime(true);
                    $result = $this->prepare_takeover_conference($call_sid, $user_id);
                    $bridge_ms = round((microtime(true) - $bridge_start) * 1000);
                    if (is_wp_error($result)) {
                        error_log('[VMO Ext Ring] Post-response takeover bridge failed for ' . $call_sid . ' in ' . $bridge_ms . 'ms: ' . $result->get_error_message());
                    } else {
                        error_log('[VMO Ext Ring] Post-response takeover bridge completed for ' . $call_sid . ' in ' . $bridge_ms . 'ms');
                    }
                    exit;

                } elseif ($digits === '9') {
                    // OPT OUT of instructions on future calls
                    if ($user_id > 0) {
                        update_user_meta($user_id, 'vmo_ext_ring_skip_instructions', '1');
                    }
                    error_log("VMO Ext Ring: User opted out of instructions for uid={$user_id}");
                    $action_url = $this->phone_gateway_url('ext_ring', array(
                        'uid' => $user_id,
                        'call_sid' => $call_sid,
                        'caller' => $caller,
                    ));
                    header('Content-Type: text/xml; charset=UTF-8');
                    echo '<?xml version="1.0" encoding="UTF-8"?>';
                    echo '<Response>';
                    echo '<Say voice="' . $this->tts_voice() . '">Got it. Instructions will be skipped on future calls.</Say>';
                    echo '<Gather input="dtmf" numDigits="1" timeout="30" action="' . esc_xml($action_url) . '">';
                    echo '<Say voice="' . $this->tts_voice() . '">Press 1 to listen. Press 2 to take over.</Say>';
                    echo '</Gather>';
                    echo '<Say voice="' . $this->tts_voice() . '">No response. Disconnecting.</Say>';
                    echo '<Hangup/>';
                    echo '</Response>';
                    exit;

                } elseif ($digits === '*') {
                    // Decline
                    header('Content-Type: text/xml; charset=UTF-8');
                    echo '<?xml version="1.0" encoding="UTF-8"?>';
                    echo '<Response>';
                    echo '<Say voice="' . $this->tts_voice() . '">Disconnected. The AI will continue handling the call.</Say>';
                    echo '<Hangup/>';
                    echo '</Response>';
                    exit;
                } else {
                    // Invalid digit — repeat the menu
                    $action_url = $this->phone_gateway_url('ext_ring', array(
                        'uid' => $user_id,
                        'call_sid' => $call_sid,
                        'caller' => $caller,
                    ));
                    header('Content-Type: text/xml; charset=UTF-8');
                    echo '<?xml version="1.0" encoding="UTF-8"?>';
                    echo '<Response>';
                    echo '<Gather input="dtmf" numDigits="1" timeout="30" action="' . esc_xml($action_url) . '">';
                    echo '<Say voice="' . $this->tts_voice() . '">Invalid selection. Press 1 to listen. Press 2 to take over. Press star to disconnect.</Say>';
                    echo '</Gather>';
                    echo '<Say voice="' . $this->tts_voice() . '">No response. Disconnecting.</Say>';
                    echo '<Hangup/>';
                    echo '</Response>';
                    exit;
                }
            } catch (\Throwable $ext_err) {
                // ── INNER CATCH: log the EXACT error and still output valid TwiML ──
                error_log('[VMO Ext Ring] INNER CATCH: digit=' . $digits . ' call_sid=' . $call_sid . ' error=' . $ext_err->getMessage() . ' in ' . $ext_err->getFile() . ':' . $ext_err->getLine());
                error_log('[VMO Ext Ring] INNER CATCH trace: ' . $ext_err->getTraceAsString());
                while (ob_get_level())
                    ob_end_clean();
                header('Content-Type: text/xml; charset=UTF-8');
                echo '<?xml version="1.0" encoding="UTF-8"?>';
                echo '<Response>';
                echo '<Say voice="' . esc_attr($this->tts_voice()) . '">An error occurred while processing your selection. Please try again.</Say>';
                $retry_url = $this->phone_gateway_url('ext_ring', array(
                    'uid' => $user_id,
                    'call_sid' => $call_sid,
                    'caller' => $caller,
                ));
                echo '<Redirect>' . htmlspecialchars($retry_url, ENT_QUOTES | ENT_XML1, 'UTF-8') . '</Redirect>';
                echo '</Response>';
                exit;
            }
        } else {
            // First ring — show DTMF menu
            $action_url = $this->phone_gateway_url('ext_ring', array(
                'uid' => $user_id,
                'call_sid' => $call_sid,
                'caller' => $caller,
            ));

            // Format caller number for speech
            $caller_spoken = preg_replace('/(\d)/', '$1 ', $caller);

            header('Content-Type: text/xml; charset=UTF-8');
            echo '<?xml version="1.0" encoding="UTF-8"?>';
            echo '<Response>';
            echo '<Gather input="dtmf" numDigits="1" timeout="45" action="' . esc_xml($action_url) . '">';
            echo '<Say voice="' . $this->tts_voice() . '">Incoming business call from ' . esc_xml($caller_spoken) . '. The A I receptionist is already helping the caller.</Say>';
            echo '<Say voice="' . $this->tts_voice() . '">Press 1 to listen. Press 2 to take over. Press star to disconnect.</Say>';
            echo '<Pause length="15"/>';
            echo '<Say voice="' . $this->tts_voice() . '">Press 1 to listen. Press 2 to take over. Press star to disconnect.</Say>';
            echo '<Pause length="15"/>';
            echo '</Gather>';
            echo '<Say voice="' . $this->tts_voice() . '">No response received. The AI will continue handling the call. Goodbye.</Say>';
            echo '<Hangup/>';
            echo '</Response>';
            exit;
        }
    }

    /**
     * Handle actions after the user leaves the listen-mode conference.
     *
     * Called when the user presses * (hangupOnStar) while listening in a
     * muted conference. Presents a menu:
     *   1 = Resume listening (rejoin conference muted)
     *   2 = Take over (rejoin conference unmuted)
     *   3 = Disconnect and resume AI on the inbound call
     *
     * Also handles Gather timeout and the auto=resume redirect to
     * restart the AI conversation on the inbound call.
     */
    public function handle_ext_listen_action($request)
    {
        while (ob_get_level())
            ob_end_clean();

        $params = $request->get_params();
        $user_id = isset($params['uid']) ? intval($params['uid']) : 0;
        $call_sid = isset($params['call_sid']) ? sanitize_text_field($params['call_sid']) : '';
        $digits = isset($params['Digits']) ? sanitize_text_field($params['Digits']) : '';
        $auto = isset($params['auto']) ? sanitize_text_field($params['auto']) : '';

        error_log("VMO Ext Listen Action [v2]: uid={$user_id}, call_sid={$call_sid}, digits={$digits}, auto={$auto}");

        try {
            $call_context = $this->get_call_runtime_context($call_sid);
            $active_conference_name = (string) ($call_context['active_conference_name'] ?? '');
            $listen_conf = ($active_conference_name !== '') ? $active_conference_name : $this->call_conference_name($call_sid);
            $realtime_active = (bool) get_transient('vmo_realtime_active_' . sanitize_key($call_sid));

            $rec_enabled = $this->is_recording_enabled($user_id);
            $rec_cb_url = $this->twilio_public_url('recording');

            // ── DIGIT 2: Take over (rejoin conference UNMUTED) ──────────
            // Must be checked FIRST so we can buffer the complete TwiML for flush
            if ($digits === '2') {
                error_log("VMO Ext Listen Action: User taking over call {$call_sid} from listen mode");

                $needs_bridge = $realtime_active;
                $this->mark_call_conference_state($call_sid, $listen_conf, '(ext-takeover)');

                ob_start();
                header('Content-Type: text/xml; charset=UTF-8');
                echo '<?xml version="1.0" encoding="UTF-8"?>';
                echo '<Response>';
                echo '<Say voice="' . $this->tts_voice() . '">You are now connected to the caller.</Say>';
                echo '<Dial>';
                echo '<Conference startConferenceOnEnter="true" endConferenceOnExit="true" beep="false" waitUrl=""';
                if ($rec_enabled) {
                    echo ' record="record-from-start" recordingStatusCallback="' . esc_xml($rec_cb_url) . '" recordingStatusCallbackEvent="completed"';
                }
                echo '>' . esc_xml($listen_conf) . '</Conference>';
                echo '</Dial>';
                echo '</Response>';

                if ($needs_bridge) {
                    $this->flush_response_and_continue();
                    $bridge_start = microtime(true);
                    $result = $this->prepare_takeover_conference($call_sid, $user_id);
                    $bridge_ms = round((microtime(true) - $bridge_start) * 1000);
                    if (is_wp_error($result)) {
                        error_log('[VMO Ext Listen Action] Post-response takeover bridge failed for ' . $call_sid . ' in ' . $bridge_ms . 'ms: ' . $result->get_error_message());
                    } else {
                        error_log('[VMO Ext Listen Action] Post-response takeover bridge completed for ' . $call_sid . ' in ' . $bridge_ms . 'ms');
                    }
                }
                exit;
            }

            // All other digit paths: output XML directly (no flush needed)
            header('Content-Type: text/xml; charset=UTF-8');
            echo '<?xml version="1.0" encoding="UTF-8"?>';
            echo '<Response>';

            // ── AUTO-RESUME: Gather timed out without input ─────────────
            if ($auto === 'resume') {
                error_log("VMO Ext Listen Action: Menu timeout — auto-resuming AI for call {$call_sid}");
                if ($realtime_active && $active_conference_name !== '') {
                    echo '<Say voice="' . $this->tts_voice() . '">Disconnected. The A I will continue helping the caller.</Say>';
                    echo '<Hangup/>';
                    echo '</Response>';
                    exit;
                }
                $this->_resume_ai_on_call($call_sid, $user_id);
                echo '<Say voice="' . $this->tts_voice() . '">No response. The A I will resume handling the call. Goodbye.</Say>';
                echo '<Hangup/>';
                echo '</Response>';
                exit;
            }

            // ── DIGIT 1: Rejoin the conference MUTED (resume listening) ─
            if ($digits === '1') {
                error_log("VMO Ext Listen Action: User rejoining listen for call {$call_sid}");
                $listen_action_url = $this->phone_gateway_url('ext_listen_action', array(
                    'uid' => $user_id,
                    'call_sid' => $call_sid,
                ));
                echo '<Say voice="' . $this->tts_voice() . '">Resuming listen mode. Press star to leave.</Say>';
                echo '<Dial hangupOnStar="true" action="' . esc_xml($listen_action_url) . '">';
                echo '<Conference startConferenceOnEnter="true" muted="true" endConferenceOnExit="true" beep="false" waitUrl=""';
                if ($rec_enabled) {
                    echo ' record="record-from-start" recordingStatusCallback="' . esc_xml($rec_cb_url) . '" recordingStatusCallbackEvent="completed"';
                }
                echo '>' . esc_xml($listen_conf) . '</Conference>';
                echo '</Dial>';
                echo '</Response>';
                exit;
            }

            // ── DIGIT 3 or *: Disconnect and resume AI ──────────────────
            if ($digits === '3' || $digits === '*') {
                error_log("VMO Ext Listen Action: Resuming AI for call {$call_sid}");
                if ($realtime_active && $active_conference_name !== '') {
                    echo '<Say voice="' . $this->tts_voice() . '">Disconnected. The A I will continue helping the caller.</Say>';
                    echo '<Hangup/>';
                    echo '</Response>';
                    exit;
                }
                $this->_resume_ai_on_call($call_sid, $user_id);
                echo '<Say voice="' . $this->tts_voice() . '">Disconnected. The A I will resume handling the call.</Say>';
                echo '<Hangup/>';
                echo '</Response>';
                exit;
            }

            // ── NO DIGITS: First arrival after pressing * — show menu ───
            $action_url = $this->phone_gateway_url('ext_listen_action', array(
                'uid' => $user_id,
                'call_sid' => $call_sid,
            ));
            $auto_resume_url = $this->phone_gateway_url('ext_listen_action', array(
                'uid' => $user_id,
                'call_sid' => $call_sid,
                'auto' => 'resume',
            ));
            echo '<Gather input="dtmf" numDigits="1" timeout="10" action="' . esc_xml($action_url) . '">';
            echo '<Say voice="' . $this->tts_voice() . '">Press 1 to keep listening. Press 2 to take over. Press 3 to disconnect and let the A I resume.</Say>';
            echo '</Gather>';
            // Gather timeout — auto-resume AI via Redirect so PHP can make
            // the REST API call to redirect the inbound call.
            echo '<Redirect>' . esc_xml($auto_resume_url) . '</Redirect>';

            echo '</Response>';
            exit;
        } catch (\Throwable $ext_err) {
            error_log('[VMO Ext Listen Action] INNER CATCH: digits=' . $digits . ' call_sid=' . $call_sid . ' error=' . $ext_err->getMessage() . ' in ' . $ext_err->getFile() . ':' . $ext_err->getLine());
            error_log('[VMO Ext Listen Action] INNER CATCH trace: ' . $ext_err->getTraceAsString());
            while (ob_get_level())
                ob_end_clean();
            header('Content-Type: text/xml; charset=UTF-8');
            echo '<?xml version="1.0" encoding="UTF-8"?>';
            echo '<Response>';
            echo '<Say voice="' . esc_attr($this->tts_voice()) . '">An error occurred. Please try again.</Say>';
            $retry_url = $this->phone_gateway_url('ext_listen_action', array(
                'uid' => $user_id,
                'call_sid' => $call_sid,
            ));
            echo '<Redirect>' . htmlspecialchars($retry_url, ENT_QUOTES | ENT_XML1, 'UTF-8') . '</Redirect>';
            echo '</Response>';
            exit;
        }
    }

    /**
     * Resume AI conversation on an inbound call.
     *
     * Redirects the call out of whatever it's currently doing (conference,
     * hold, etc.) and back into the AI <Gather> speech loop.
     */
    private function _resume_ai_on_call($call_sid, $user_id)
    {
        $ai_converse_url = $this->phone_gateway_url('ai_converse', array('uid' => $user_id));
        try {
            if ($this->is_whisper_language()) {
                $whisper_url = $this->phone_gateway_url('ai_converse_whisper', array('uid' => $user_id));
                $this->twilio_request('POST', "/Calls/{$call_sid}.json", array(
                    'Twiml' => '<Response><Say voice="' . $this->tts_voice() . '">Thank you for holding. How can I help you?</Say>'
                        . '<Record action="' . esc_attr($whisper_url) . '" maxLength="30" timeout="10" playBeep="false" trim="trim-silence" /></Response>',
                ));
            } else {
                $this->twilio_request('POST', "/Calls/{$call_sid}.json", array(
                    'Twiml' => '<Response><Gather input="speech" action="' . esc_attr($ai_converse_url) . '" speechTimeout="2" timeout="10" enhanced="true" language="' . $this->gather_lang() . '">'
                        . '<Say voice="' . $this->tts_voice() . '">Thank you for holding. How can I help you?</Say>'
                        . '</Gather></Response>',
                ));
            }
        } catch (Throwable $e) {
            error_log("VMO: Failed to resume AI on call {$call_sid}: " . $e->getMessage());
        }
    }

    /**
     * External phone monitoring loop for listen mode.
     *
     * Instead of interrupting the AI conversation (which would silence the caller),
     * this keeps the external phone on a Gather loop that periodically reads
     * new transcript entries via TTS. The user can press 2 at any time to
     * redirect the inbound call into a conference and take over.
     */
    public function handle_ext_monitor($request)
    {
        while (ob_get_level())
            ob_end_clean();

        $params = $request->get_params();
        $user_id = isset($params['uid']) ? intval($params['uid']) : 0;
        $call_sid = isset($params['call_sid']) ? sanitize_text_field($params['call_sid']) : '';
        $last_len = isset($params['last_len']) ? intval($params['last_len']) : 0;
        $loops = isset($params['loops']) ? intval($params['loops']) : 0;
        $digits = isset($params['Digits']) ? sanitize_text_field($params['Digits']) : '';

        error_log("VMO Ext Monitor [v2]: uid={$user_id}, call_sid={$call_sid}, loop={$loops}, digits={$digits}");

        try {

            // DIGIT 2: Take over the call — handle FIRST so we can buffer complete TwiML
            if ($digits === '2') {
                $conference_name = $this->call_conference_name($call_sid);
                error_log("VMO Ext Monitor: User taking over call {$call_sid}");

                $rec_enabled = $this->is_recording_enabled($user_id);
                $rec_cb_url = $this->twilio_public_url('recording');

                // Mark as user-handled BEFORE sending TwiML
                $this->mark_call_conference_state($call_sid, $conference_name, '(ext-takeover)');

                // Buffer complete TwiML for proper flush
                ob_start();
                header('Content-Type: text/xml; charset=UTF-8');
                echo '<?xml version="1.0" encoding="UTF-8"?>';
                echo '<Response>';
                echo '<Say voice="' . $this->tts_voice() . '">You are now connected to the caller.</Say>';
                echo '<Dial>';
                echo '<Conference startConferenceOnEnter="true" endConferenceOnExit="false" beep="false" waitUrl=""';
                if ($rec_enabled) {
                    echo ' record="record-from-start" recordingStatusCallback="' . esc_xml($rec_cb_url) . '" recordingStatusCallbackEvent="completed"';
                }
                echo '>' . esc_xml($conference_name) . '</Conference>';
                echo '</Dial>';
                echo '</Response>';
                $this->flush_response_and_continue();

                // Now synchronously set up the takeover conference
                $this->prepare_takeover_conference($call_sid, $user_id);
                exit;
            }

            // All other paths: output XML directly (no flush needed)
            header('Content-Type: text/xml; charset=UTF-8');
            echo '<?xml version="1.0" encoding="UTF-8"?>';
            echo '<Response>';

            // DIGIT *: Disconnect
            if ($digits === '*') {
                echo '<Say voice="' . $this->tts_voice() . '">Disconnected. The A I will continue handling the call.</Say>';
                echo '<Hangup/>';
                echo '</Response>';
                exit;
            }

            // Safety: max 60 loops (~10 min) — prevent infinite monitoring
            if ($loops > 60) {
                echo '<Say voice="' . $this->tts_voice() . '">Monitoring session has timed out. Goodbye.</Say>';
                echo '<Hangup/>';
                echo '</Response>';
                exit;
            }

            // Check if the inbound call is still active
            $call_still_active = true;
            try {
                $call_info = $this->twilio_request('GET', "/Calls/{$call_sid}.json", array());
                if (is_array($call_info) && isset($call_info['status'])) {
                    if (in_array($call_info['status'], array('completed', 'canceled', 'failed', 'busy', 'no-answer'), true)) {
                        $call_still_active = false;
                    }
                }
            } catch (Throwable $e) {
                // If we can't check, assume still active
            }

            if (!$call_still_active) {
                echo '<Say voice="' . $this->tts_voice() . '">The call has ended. Goodbye.</Say>';
                echo '<Hangup/>';
                echo '</Response>';
                exit;
            }

            // Read latest transcript from DB
            global $wpdb;
            $transcript = (string) $wpdb->get_var($wpdb->prepare(
                "SELECT transcript FROM {$wpdb->prefix}vmo_phone_calls WHERE call_sid = %s LIMIT 1",
                $call_sid
            ));

            // Determine if there's new content since last check
            $current_len = strlen($transcript);
            $new_text = '';
            if ($current_len > $last_len) {
                $new_text = trim(substr($transcript, $last_len));
                $last_len = $current_len;
            }

            // Build the next loop URL
            $next_url = $this->phone_gateway_url('ext_monitor', array(
                'uid' => $user_id,
                'call_sid' => $call_sid,
                'last_len' => $last_len,
                'loops' => $loops + 1,
            ));

            // Gather with timeout — user can press 2 at any point
            echo '<Gather input="dtmf" numDigits="1" timeout="10" action="' . esc_xml($next_url) . '">';
            if (!empty($new_text)) {
                // Read new transcript content (limit to 500 chars to avoid excessive TTS)
                $spoken = substr($new_text, 0, 500);
                echo '<Say voice="' . $this->tts_voice() . '">Update: ' . esc_xml($spoken) . '</Say>';
            } else {
                // Every 3rd loop without new content, remind the user of controls
                if ($loops % 3 === 0) {
                    echo '<Say voice="' . $this->tts_voice() . '">The A I is handling the call. Press 2 to take over. Press star to disconnect.</Say>';
                }
            }
            echo '<Pause length="8"/>';
            echo '</Gather>';

            // If Gather times out (no digit pressed), loop back for next check
            echo '<Redirect>' . esc_xml($next_url) . '</Redirect>';

            echo '</Response>';
            exit;
        } catch (\Throwable $ext_err) {
            error_log('[VMO Ext Monitor] INNER CATCH: digits=' . $digits . ' call_sid=' . $call_sid . ' error=' . $ext_err->getMessage() . ' in ' . $ext_err->getFile() . ':' . $ext_err->getLine());
            error_log('[VMO Ext Monitor] INNER CATCH trace: ' . $ext_err->getTraceAsString());
            while (ob_get_level())
                ob_end_clean();
            header('Content-Type: text/xml; charset=UTF-8');
            echo '<?xml version="1.0" encoding="UTF-8"?>';
            echo '<Response>';
            echo '<Say voice="' . esc_attr($this->tts_voice()) . '">An error occurred. Please try again.</Say>';
            echo '<Hangup/>';
            echo '</Response>';
            exit;
        }
    }

    /**
     * Handle transcription callback from Twilio
     * 
     * Called when voicemail transcription completes
     */
    public function handle_transcription_callback($request)
    {
        $params = $request->get_params();

        error_log('VMO Twilio Transcription: ' . print_r($params, true));

        $transcription_text = isset($params['TranscriptionText']) ? sanitize_textarea_field($params['TranscriptionText']) : '';
        $call_sid = isset($params['CallSid']) ? sanitize_text_field($params['CallSid']) : '';

        if (!empty($call_sid) && !empty($transcription_text)) {
            global $wpdb;
            $table = $wpdb->prefix . 'vmo_phone_calls';

            // phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery, WordPress.DB.DirectDatabaseQuery.NoCaching
            $wpdb->update(
                $table,
                array('transcript' => $transcription_text),
                array('call_sid' => $call_sid),
                array('%s'),
                array('%s')
            );

            // Generate AI summary from transcription
            $user_id = $wpdb->get_var($wpdb->prepare(
                "SELECT user_id FROM {$table} WHERE call_sid = %s",
                $call_sid
            ));

            if ($user_id) {
                // Send voicemail notification email
                $recording_url_for_vm = isset($params['RecordingUrl']) ? esc_url_raw($params['RecordingUrl']) : '';
                $caller_for_vm = $wpdb->get_var($wpdb->prepare(
                    "SELECT caller_number FROM {$table} WHERE call_sid = %s",
                    $call_sid
                ));
                $this->send_call_alert((int) $user_id, 'voicemail', array(
                    'caller' => $caller_for_vm ?: 'Unknown',
                    'transcription' => $transcription_text,
                    'recording_url' => $recording_url_for_vm,
                ));

                $call_id = $wpdb->get_var($wpdb->prepare(
                    "SELECT id FROM {$table} WHERE call_sid = %s",
                    $call_sid
                ));

                if ($call_id) {
                    // Schedule summary generation
                    wp_schedule_single_event(time() + 10, 'vmo_generate_call_summary', array($call_sid, $recording_url_for_vm));
                }
            }
        }

        return new WP_REST_Response(array('success' => true), 200);
    }

    /**
     * Perform call action (join, listen, whisper, barge)
     *
     * Now supports browser-based joining via the Twilio Client SDK.
     * When the dashboard has a live browser device, the frontend sends
     * via=browser so we skip dialing an external phone and just return
     * the conference name for the browser to connect to directly.
     * 
     * @param string $action_type Action type (join, listen, whisper, barge)
     * @param string $call_sid Twilio call SID
     * @param string $conference_sid Twilio conference SID (optional)
     * @param int $user_id WordPress user ID
     * @param string $via 'browser' or 'phone' — how the user joins
     * @param string $child_sid Optional child leg SID from the UI layer
     * @return array|WP_Error Result or error
     */
    public function call_action($action_type, $call_sid, $conference_sid, $user_id, $via = 'auto', $child_sid = '')
    {
        try {
            $external_phone = get_user_meta($user_id, 'vmo_external_phone', true);
            $vmo_phone = $this->get_user_phone($user_id);
            $use_browser = ($via === 'browser');
            $call_sid = $this->resolve_control_target_call_sid((string) $call_sid, (string) $child_sid, (int) $user_id);
            if ($call_sid === '') {
                return new WP_Error('missing_call', 'Could not resolve the live call to control');
            }
            $is_realtime = (bool) get_transient('vmo_realtime_active_' . sanitize_key($call_sid));
            $bridge_context = $this->get_bridge_active_context($call_sid);
            $is_bridge_realtime = !empty($bridge_context);
            $call_context = $this->get_call_runtime_context($call_sid);
            $stored_conference_name = (string) ($call_context['active_conference_name'] ?? '');

            // Whenever the user clicks Take Over or Listen from the browser, we IMMEDIATELY
            // cancel any outbound ringing to the external phone or browser child calls to
            // stop the "persistent ringing" issue without disconnecting the parent call.
            $this->cancel_child_ring_calls($call_sid, $child_sid);
            $this->clear_user_ring_signal($user_id, $call_sid);

            switch ($action_type) {
                case 'end':
                case 'hangup':
                    return $this->terminate_call_everywhere($call_sid, 'manual', array(
                        'user_id' => $user_id,
                        'child_sid' => $child_sid,
                        'status' => 'completed',
                    ));

                case 'join':
                case 'barge':
                    $conference_name = !empty($conference_sid) ? $conference_sid : ($stored_conference_name !== '' ? $stored_conference_name : $this->call_conference_name($call_sid));
                    $call_already_in_conference = ($stored_conference_name !== '' && $conference_name === $stored_conference_name);

                    if ($is_realtime && $call_already_in_conference) {
                        $this->disconnect_realtime_ai_leg($call_sid);
                        error_log('[VMO call_action] Realtime AI disconnected from supervision conference for takeover on call ' . $call_sid);
                    }

                    if (!$call_already_in_conference) {
                        // Redirect the active Twilio call into the conference
                        // (interrupts any Gather/AI conversation in progress)
                        $redirect_result = $this->twilio_request('POST', "/Calls/{$call_sid}.json", array(
                            'Twiml' => '<Response><Say voice="' . $this->tts_voice() . '">Please hold while we connect you.</Say><Dial><Conference startConferenceOnEnter="true" endConferenceOnExit="false" beep="false" waitUrl="">' . $conference_name . '</Conference></Dial></Response>'
                        ));
                        if (is_wp_error($redirect_result)) {
                            error_log('[VMO call_action] Join redirect failed for ' . $call_sid . ': ' . $redirect_result->get_error_message());
                            return new WP_Error('redirect_failed', 'Could not redirect call to conference: ' . $redirect_result->get_error_message());
                        }
                        if ($is_realtime) {
                            $this->disconnect_realtime_ai_leg($call_sid);
                            error_log('[VMO call_action] Breaking Realtime SIP for takeover on call ' . $call_sid);
                        }
                        if ($is_bridge_realtime) {
                            $this->clear_bridge_active_context($call_sid);
                            $this->clear_realtime_call_context($call_sid);
                            error_log('[VMO call_action] Breaking Premium bridge stream for takeover on call ' . $call_sid);
                        }
                        sleep(1);
                    } else {
                        error_log('[VMO call_action] Call ' . $call_sid . ' already in conference ' . $conference_name . ' — skipping redirect');
                    }

                    // Update DB with real conference name for tracking
                    $this->mark_call_conference_state($call_sid, $conference_name);

                    $result = array(
                        'call_sid' => $call_sid,
                        'message' => 'Conference ready — connect via browser',
                        'conference_name' => $conference_name,
                    );

                    // If caller explicitly wants phone or browser is unavailable
                    if (!$use_browser && $external_phone) {
                        $user_call = $this->twilio_request('POST', '/Calls.json', array(
                            'To' => $external_phone,
                            'From' => $vmo_phone,
                            'Twiml' => '<Response><Dial><Conference startConferenceOnEnter="true" endConferenceOnExit="false" beep="false" waitUrl="">' . $conference_name . '</Conference></Dial></Response>'
                        ));
                        if (!is_wp_error($user_call) && isset($user_call['sid'])) {
                            $result['user_call_sid'] = $user_call['sid'];
                            $result['message'] = 'Joined call via external phone';
                        }
                    } elseif (!$use_browser && empty($external_phone)) {
                        // No browser, no phone — still return conference so browser can try
                        $result['message'] = 'Conference ready — use your browser to connect (no external phone configured)';
                    }

                    return $result;

                case 'listen':
                case 'whisper':
                    if ($use_browser) {
                        if (!$this->is_premium_voice_tier($user_id)) {
                            global $wpdb;
                            // phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery, WordPress.DB.DirectDatabaseQuery.NoCaching
                            $wpdb->update(
                                $wpdb->prefix . 'vmo_phone_calls',
                                array('topic' => 'monitor (standard-transcript)'),
                                array('call_sid' => $call_sid),
                                array('%s'),
                                array('%s')
                            );

                            $result = array(
                                'call_sid' => $call_sid,
                                'message' => 'Transcript monitoring is active. Premium enables live audio Listen.',
                                'mode' => 'transcript',
                            );
                            if (!empty($call_context['transcript'])) {
                                $result['transcript'] = $call_context['transcript'];
                            }

                            return $result;
                        }

                        if ($is_bridge_realtime) {
                            $listen_url = $this->build_bridge_listen_url($user_id, $call_sid);
                            if (is_wp_error($listen_url)) {
                                return array(
                                    'call_sid' => $call_sid,
                                    'message' => 'Premium live listen is not configured yet: ' . $listen_url->get_error_message(),
                                    'mode' => 'transcript',
                                    'is_realtime' => true,
                                    'bridge' => true,
                                    'reason' => 'bridge_listen_unavailable',
                                );
                            }

                            return array(
                                'call_sid' => $call_sid,
                                'message' => 'Listening to Premium realtime audio — you are muted',
                                'mode' => 'bridge_ws',
                                'listen_url' => $listen_url,
                                'is_realtime' => true,
                                'bridge' => true,
                                'voice' => (string) ($bridge_context['voice'] ?? ''),
                            );
                        }

                        if ($stored_conference_name !== '') {
                            return array(
                                'call_sid' => $call_sid,
                                'message' => 'Joining live call audio — you will be muted',
                                'mode' => 'conference',
                                'conference_name' => $stored_conference_name,
                            );
                        }

                        // ── PLUGIN UI LISTEN: No conference exists yet ──
                        if ($is_realtime) {
                            $live_conf = $this->ensure_live_supervision_conference($call_sid);
                            if (is_wp_error($live_conf)) {
                                error_log('[VMO call_action] Realtime live listen bridge retry queued for ' . $call_sid . ': ' . $live_conf->get_error_message());
                                $fallback_conference = $this->call_conference_name($call_sid);
                                if ($fallback_conference !== '') {
                                    $this->queue_live_supervision_bridge($call_sid);
                                    return array(
                                        'call_sid' => $call_sid,
                                        'message' => 'Starting Live Audio bridge... you will be muted while the call connects.',
                                        'mode' => 'conference',
                                        'conference_name' => '',
                                        'is_realtime' => true,
                                        'bridge_pending' => true,
                                    );
                                }
                            } else {
                                return array(
                                    'call_sid' => $call_sid,
                                    'message' => 'Joining live call audio — you will be muted',
                                    'mode' => 'conference',
                                    'conference_name' => (string) ($live_conf['conference_name'] ?? ''),
                                    'is_realtime' => true,
                                );
                            }
                        }

                        // ── STANDARD / NON-BRIDGE AI FALLBACK ──
                        // This path is reached when the Premium WebSocket bridge
                        // is not active for this call, so Listen cannot attach
                        // to the live bridge fanout and must show transcript mode.
                        //
                        // Per spec (VMO PHONE SYSTEM §12 Listen Mode): "the AI Receptionist
                        // continues as the active speaker, the caller should not be disrupted".
                        // The legacy single-leg Gather/GPT/Say architecture cannot satisfy
                        // both conditions — redirecting the leg into a conference for live
                        // audio would silence the AI. The spec-honoring behavior is to fall
                        // back to live transcript monitoring; live audio Listen
                        // requires an active Premium WebSocket bridge session.
                        global $wpdb;
                        // phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery, WordPress.DB.DirectDatabaseQuery.NoCaching
                        $wpdb->update(
                            $wpdb->prefix . 'vmo_phone_calls',
                            array('topic' => 'monitor (transcript-fallback)'),
                            array('call_sid' => $call_sid),
                            array('%s'),
                            array('%s')
                        );
                        $result = array(
                            'call_sid' => $call_sid,
                            'message' => 'Live audio listen is unavailable for this call because the Premium WebSocket bridge is not active for this call. Live transcript monitoring is on — click "Take Over" to join the call directly.',
                            'mode' => 'transcript',
                            'is_realtime' => false,
                            'reason' => 'premium_bridge_inactive',
                        );
                        // ── DIAGNOSTIC: explain WHY the Premium bridge wasn't active for this call ──
                        // Surfaced in the response so the dashboard can show it without
                        // requiring access to the WP error log.
                        $diag = array();
                        $diag['bridge_active_context_present'] = !empty($bridge_context);
                        $diag['premium_bridge_enabled_for_user'] = $this->is_premium_bridge_enabled($user_id);
                        $diag['voice_quality_tier'] = $this->get_voice_quality_tier($user_id);
                        $diag['transient_present'] = (bool) get_transient('vmo_realtime_active_' . sanitize_key($call_sid));
                        $diag['realtime_enabled_for_user'] = $this->is_realtime_enabled($user_id);
                        $diag['has_project_id'] = !empty($this->get_openai_realtime_project_id());
                        $diag['has_webhook_secret'] = !empty($this->get_openai_realtime_webhook_secret());
                        $diag['has_openai_api_key'] = !empty(get_option('vmo_openai_api_key', ''));
                        $diag['user_opted_out'] = (get_user_meta($user_id, 'vmo_realtime_disabled', true) === '1');
                        $diag['user_extensions_count'] = count((array) get_user_meta($user_id, 'vmo_phone_extensions', true) ?: array());
                        // Look up the call in the phone_calls table to see what topic was set
                        global $wpdb_diag;
                        $wpdb_diag = $GLOBALS['wpdb'];
                        $row = $wpdb_diag->get_row($wpdb_diag->prepare(
                            "SELECT topic, status, created_at FROM {$wpdb_diag->prefix}vmo_phone_calls WHERE call_sid = %s LIMIT 1",
                            $call_sid
                        ), ARRAY_A);
                        $diag['phone_calls_row'] = $row ?: 'NOT_FOUND';
                        // OPTION C DIAGNOSTIC: surface the most-recent realtime failure recorded by the voice webhook
                        $diag['last_realtime_error'] = get_transient('vmo_realtime_last_error_' . $user_id) ?: null;
                        // OPTION C DIAGNOSTIC: surface the per-call execution trace so we can see exactly which code path ran
                        $diag['voice_trace'] = get_transient('vmo_voice_trace_' . sanitize_key($call_sid)) ?: 'NO_TRACE_FOUND_voice_webhook_did_not_run_for_this_call_sid';
                        // OPTION C DIAGNOSTIC: surface the most-recent OpenAI webhook activity to prove OpenAI is/isn't calling us
                        $diag['openai_webhook_lasthit'] = get_transient('vmo_openai_webhook_lasthit') ?: 'NEVER_HIT — OpenAI has not called your webhook in the last hour. Verify webhook URL + project enrollment in OpenAI dashboard.';
                        $diag['openai_webhook_lastfail'] = get_transient('vmo_openai_webhook_lastfail') ?: null;
                        $diag['openai_accept_lastfail'] = get_transient('vmo_openai_accept_lastfail') ?: null;
                        if (!$diag['realtime_enabled_for_user']) {
                            $diag['hint'] = 'Realtime is NOT enabled for this user — config check failed. See has_project_id / has_webhook_secret / has_openai_api_key flags.';
                        } elseif (!empty($diag['last_realtime_error']['message'])) {
                            $diag['hint'] = 'Realtime block FAILED for the most recent call. Reason: ' . $diag['last_realtime_error']['message'] . ' (' . ($diag['last_realtime_error']['file'] ?? 'unknown') . '). Fix this and the next call will engage Realtime.';
                        } else {
                            $diag['hint'] = 'Realtime is configured but the active-call transient was not set for this CallSid. Likely cause: the voice webhook on your Twilio number is NOT pointing at this site (or Twilio cached an old webhook URL). Verify the Twilio number\'s Voice URL matches: ' . admin_url('admin-ajax.php') . '?action=vmo_website_chat&vmo_gw=phone&kind=voice&k=...';
                        }
                        $result['diagnostic'] = $diag;
                        if (!empty($call_context['transcript'])) {
                            $result['transcript'] = $call_context['transcript'];
                        }
                        return $result;
                    }

                    // ── EXTERNAL PHONE LISTEN ──
                    // The user pressed 1 on their external phone to listen.
                    if ($is_realtime) {

                        $result = array(
                            'call_sid' => $call_sid,
                            'message' => 'Realtime AI is handling this call. Click "Take Over" to join the conversation (this will disconnect the AI).',
                            'mode' => 'realtime_monitor',
                            'is_realtime' => true,
                        );

                        if (!empty($call_context['transcript'])) {
                            $result['transcript'] = $call_context['transcript'];
                        }
                        if (!empty($call_context['created_at'])) {
                            $result['started_at'] = $call_context['created_at'];
                        }

                        $recording_on = $this->is_recording_enabled($user_id);
                        $result['recording_enabled'] = $recording_on;

                        return $result;
                    }

                    if ($use_browser) {
                        global $wpdb;
                        // phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery, WordPress.DB.DirectDatabaseQuery.NoCaching
                        $wpdb->update(
                            $wpdb->prefix . 'vmo_phone_calls',
                            array('topic' => 'monitor (browser-listen)'),
                            array('call_sid' => $call_sid),
                            array('%s'),
                            array('%s')
                        );

                        $result = array(
                            'call_sid' => $call_sid,
                            'message' => 'Live audio listen requires the realtime voice bridge. Transcript monitoring will stay active until you take over.',
                            'mode' => 'transcript',
                        );
                        if (!empty($call_context['transcript'])) {
                            $result['transcript'] = $call_context['transcript'];
                        }

                        return $result;
                    }

                    // Non-browser path: transcript monitoring (keeps AI active)
                    global $wpdb;
                    // Update DB to note monitoring is active
                    // phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery, WordPress.DB.DirectDatabaseQuery.NoCaching
                    $wpdb->update(
                        $wpdb->prefix . 'vmo_phone_calls',
                        array('topic' => 'monitor (dashboard-listen)'),
                        array('call_sid' => $call_sid),
                        array('%s'),
                        array('%s')
                    );

                    $result = array(
                        'call_sid' => $call_sid,
                        'message' => 'Transcript monitoring is active while the AI receptionist continues the conversation.',
                        'mode' => 'transcript',
                    );

                    // Include any transcript the AI already gathered
                    if (!empty($call_context['transcript'])) {
                        $result['transcript'] = $call_context['transcript'];
                    }

                    return $result;

                case 'resume_ai':
                    // Resume the AI Gather/ai_converse loop on the caller's leg.
                    // Used after Listen ends OR after a manual takeover handoff.
                    // Uses inline TwiML that re-enters the ai_converse loop directly,
                    // skipping the inbound voice webhook's IVR/ring-owner logic so the
                    // caller doesn't re-hear the greeting menu or trigger a new ring.
                    $ai_converse_url = $this->phone_gateway_url('ai_converse', array('uid' => $user_id));
                    $resume_greeting = 'I\'m still here. How can I help you?';
                    $resume_twiml = '<Response>';
                    if ($this->is_whisper_language()) {
                        $whisper_url = $this->phone_gateway_url('ai_converse_whisper', array('uid' => $user_id));
                        $resume_twiml .= '<Say voice="' . $this->tts_voice() . '">' . esc_xml($resume_greeting) . '</Say>';
                        $resume_twiml .= '<Record action="' . esc_xml($whisper_url) . '" maxLength="30" timeout="10" playBeep="false" trim="trim-silence" />';
                    } else {
                        $resume_twiml .= '<Gather input="speech" action="' . esc_xml($ai_converse_url) . '" speechTimeout="2" timeout="15" enhanced="true" language="' . $this->gather_lang() . '">';
                        $resume_twiml .= '<Say voice="' . $this->tts_voice() . '">' . esc_xml($resume_greeting) . '</Say>';
                        $resume_twiml .= '</Gather>';
                        $resume_twiml .= '<Gather input="speech" action="' . esc_xml($ai_converse_url) . '" speechTimeout="2" timeout="30" enhanced="true" language="' . $this->gather_lang() . '">';
                        $resume_twiml .= '<Say voice="' . $this->tts_voice() . '">Are you still there? I can help with anything you need.</Say>';
                        $resume_twiml .= '</Gather>';
                    }
                    $resume_twiml .= '<Say voice="' . $this->tts_voice() . '">Let me take a message. Please speak after the beep.</Say>';
                    $resume_twiml .= '<Record maxLength="120" timeout="15" transcribe="true" transcribeCallback="' . esc_xml($this->twilio_public_url('transcription')) . '" />';
                    $resume_twiml .= '<Say voice="' . $this->tts_voice() . '">Thank you for calling. Goodbye.</Say>';
                    $resume_twiml .= '</Response>';

                    $resume_result = $this->twilio_request('POST', "/Calls/{$call_sid}.json", array(
                        'Twiml' => $resume_twiml,
                    ));
                    if (is_wp_error($resume_result)) {
                        error_log('[VMO resume_ai] redirect failed for ' . $call_sid . ': ' . $resume_result->get_error_message());
                        return new WP_Error('resume_failed', 'Could not resume the AI conversation: ' . $resume_result->get_error_message());
                    }

                    // Clear listen-paused + conference state
                    delete_transient('vmo_listen_ai_paused_' . sanitize_key($call_sid));
                    $this->mark_call_conference_state($call_sid, '', '(ai-resumed)');

                    // Update DB to mark as AI-handled again
                    global $wpdb;
                    // phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery, WordPress.DB.DirectDatabaseQuery.NoCaching
                    $wpdb->update(
                        $wpdb->prefix . 'vmo_phone_calls',
                        array('ai_handled' => 1),
                        array('call_sid' => $call_sid),
                        array('%d'),
                        array('%s')
                    );

                    error_log('[VMO resume_ai] AI resumed on call ' . $call_sid);
                    return array(
                        'call_sid' => $call_sid,
                        'message' => 'AI receptionist resumed',
                    );

                case 'hold':
                    // Put the caller on hold by redirecting them to hold music TwiML.
                    $hold_twiml = '<Response>'
                        . '<Say voice="' . $this->tts_voice() . '">Please hold. Someone will be with you shortly.</Say>'
                        . '<Play loop="10">http://com.twilio.sounds.music.s3.amazonaws.com/ClockworkWaltz.mp3</Play>'
                        . '</Response>';
                    $hold_result = $this->twilio_request('POST', "/Calls/{$call_sid}.json", array(
                        'Twiml' => $hold_twiml,
                    ));
                    if (is_wp_error($hold_result)) {
                        return new WP_Error('hold_failed', 'Could not place caller on hold: ' . $hold_result->get_error_message());
                    }
                    return array(
                        'call_sid' => $call_sid,
                        'message' => 'Caller placed on hold',
                    );

                case 'unhold':
                    // Resume the caller from hold by redirecting back into the conference.
                    $conference_name = !empty($conference_sid) ? $conference_sid : ($stored_conference_name !== '' ? $stored_conference_name : $this->call_conference_name($call_sid));
                    $resume_twiml = '<Response>'
                        . '<Say voice="' . $this->tts_voice() . '">Thank you for holding.</Say>'
                        . '<Dial><Conference startConferenceOnEnter="true" endConferenceOnExit="false" beep="false" waitUrl="">' . $conference_name . '</Conference></Dial>'
                        . '</Response>';
                    $resume_result = $this->twilio_request('POST', "/Calls/{$call_sid}.json", array(
                        'Twiml' => $resume_twiml,
                    ));
                    if (is_wp_error($resume_result)) {
                        return new WP_Error('unhold_failed', 'Could not resume caller from hold: ' . $resume_result->get_error_message());
                    }
                    $this->mark_call_conference_state($call_sid, $conference_name);
                    return array(
                        'call_sid' => $call_sid,
                        'message' => 'Caller resumed from hold',
                        'conference_name' => $conference_name,
                    );

                default:
                    return new WP_Error('invalid_action', 'Invalid action type');
            }
        } catch (Throwable $e) {
            error_log('VMO Twilio call_action error: ' . $e->getMessage() . ' in ' . $e->getFile() . ':' . $e->getLine());
            error_log('VMO Twilio call_action trace: ' . $e->getTraceAsString());
            return new WP_Error('twilio_error', $e->getMessage());
        }
    }

    /**
     * End/hangup a call
     * 
     * @param string $call_sid Twilio call SID
     * @return array|WP_Error Result or error
     */
    public function end_call($call_sid, $extra = array())
    {
        try {
            $extra = is_array($extra) ? $extra : array();
            $reason = !empty($extra['reason']) ? sanitize_key((string) $extra['reason']) : 'manual';
            $extra['status'] = !empty($extra['status']) ? sanitize_key((string) $extra['status']) : 'completed';
            return $this->terminate_call_everywhere((string) $call_sid, $reason, $extra);

        } catch (Throwable $e) {
            error_log('VMO Twilio end_call error: ' . $e->getMessage());
            return new WP_Error('twilio_error', $e->getMessage());
        }
    }

    /**
     * Get the live AI transcript for a call (polled by the dashboard).
     *
     * @param string $call_sid
     * @param int    $user_id
     * @return array
     */
    public function get_call_transcript($call_sid, $user_id)
    {
        global $wpdb;
        $table = $wpdb->prefix . 'vmo_phone_calls';
        $row = $wpdb->get_row($wpdb->prepare(
            "SELECT transcript, ai_handled, status, caller_number, direction, created_at FROM {$table} WHERE call_sid = %s AND user_id = %d LIMIT 1",
            $call_sid,
            $user_id
        ));

        if (!$row) {
            return array('transcript' => '', 'ai_handled' => false, 'status' => 'unknown');
        }

        return array(
            'transcript' => (string) ($row->transcript ?: ''),
            'ai_handled' => (bool) intval($row->ai_handled),
            'status' => (string) ($row->status ?: ''),
            'caller_number' => (string) ($row->caller_number ?: ''),
            'direction' => (string) ($row->direction ?: ''),
            'created_at' => (string) ($row->created_at ?: ''),
        );
    }

    /**
     * Link call to CRM contact
     * 
     * @param int $call_id Phone call database ID
     * @param string $caller_number Caller phone number
     * @param int $user_id WordPress user ID
     * @return int|WP_Error Contact ID or error
     */
    /**
     * Extract caller name from AI summary and update CRM contact.
     * AI summaries enrich lead context but do not create customer status.
     */
    private function update_contact_name_from_summary($call_sid, $summary)
    {
        if (empty($summary))
            return;

        // Extract CALLER_NAME from the first line
        $caller_name = '';
        if (preg_match('/CALLER_NAME:\s*(.+)/i', $summary, $m)) {
            $name = trim($m[1]);
            if ($name !== '' && strtoupper($name) !== 'UNKNOWN' && stripos($name, 'not mentioned') === false) {
                $caller_name = sanitize_text_field($name);
            }
        }

        if (empty($caller_name))
            return;

        // Look up the call record to get the caller_number and user_id
        global $wpdb;
        $table = $wpdb->prefix . 'vmo_phone_calls';
        $call = $wpdb->get_row($wpdb->prepare(
            "SELECT user_id, caller_number FROM {$table} WHERE call_sid = %s LIMIT 1",
            $call_sid
        ));
        if (!$call || empty($call->caller_number))
            return;

        $user_id = (int) $call->user_id;
        $phone_digits = preg_replace('/[^0-9]/', '', $call->caller_number);
        if ($phone_digits === '')
            return;

        // Find the CRM contact by phone
        $contacts_table = $wpdb->prefix . 'vmo_contacts';
        if ($wpdb->get_var($wpdb->prepare("SHOW TABLES LIKE %s", $wpdb->esc_like($contacts_table))) !== $contacts_table)
            return;

        $contact = $wpdb->get_row($wpdb->prepare(
            "SELECT * FROM {$contacts_table} WHERE user_id = %d AND REPLACE(REPLACE(REPLACE(phone, '+', ''), '-', ''), ' ', '') LIKE %s LIMIT 1",
            $user_id,
            '%' . $wpdb->esc_like($phone_digits) . '%'
        ));

        if (!$contact)
            return;

        // Resolve name with fallback chain
        $contact_full_name = $contact->full_name ?? '';
        if (empty($contact_full_name)) {
            $contact_full_name = trim(($contact->first_name ?? '') . ' ' . ($contact->last_name ?? ''));
        }

        // Determine category from summary content
        $category = $contact->primary_category ?? '';
        $lower_summary = strtolower($summary);
        if (preg_match('/\b(purchased|bought|order placed|payment received|closed deal|closed)\b/', $lower_summary)) {
            $category = ($contact->primary_category ?? '') === 'customer' ? 'customer' : 'lead';
        } elseif (preg_match('/\b(inquiry|inquir|interested|considering|lead|prospect|warm|hot)\b/', $lower_summary)) {
            $category = 'lead';
        }

        // Update contact name (only if it's still the default "Caller XXXX")
        $update_data = array('updated_at' => current_time('mysql'));
        if (strpos($contact_full_name, 'Caller ') === 0 || empty($contact_full_name)) {
            $update_data['full_name'] = $caller_name;
        }
        if ($category !== ($contact->primary_category ?? '')) {
            $update_data['primary_category'] = $category;
        }

        if (count($update_data) > 1) { // more than just updated_at
            // phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery, WordPress.DB.DirectDatabaseQuery.NoCaching
            $wpdb->update($contacts_table, $update_data, array('id' => $contact->id));
            error_log('VMO: Updated CRM contact #' . $contact->id . ' name="' . ($update_data['full_name'] ?? $contact->full_name) . '" category="' . ($update_data['primary_category'] ?? $category) . '"');
        }
    }

    private function record_cfo_financial_event_from_call($user_id, $call_sid, $call, array $report_data, $transcript = '')
    {
        if (!function_exists('vmo_cfo_record_financial_event')) {
            return;
        }

        $financial_events = array();
        if (isset($report_data['financial_events']) && is_array($report_data['financial_events'])) {
            foreach ($report_data['financial_events'] as $event_candidate) {
                if (is_array($event_candidate)) {
                    $financial_events[] = $event_candidate;
                }
            }
        } elseif (isset($report_data['financial_event']) && is_array($report_data['financial_event'])) {
            $financial_events[] = $report_data['financial_event'];
        }

        $haystack = strtolower((string) ($report_data['summary'] ?? '') . ' ' . (string) $transcript);
        if (
            empty($financial_events) || count(array_filter($financial_events, function ($event_candidate) {
                return is_array($event_candidate) && (($event_candidate['event_type'] ?? 'none') !== 'none');
            })) === 0
        ) {
            if (!preg_match('/\b(pay|paid|payment|deposit|invoice|quote|book|purchase|order|refund|zelle|venmo|paypal|cash app|bank transfer)\b/', $haystack)) {
                return;
            }
            $amount = 0;
            if (preg_match('/(?:\$|usd\s*)?([0-9][0-9,]*(?:\.[0-9]{1,2})?)/i', (string) $transcript . ' ' . (string) ($report_data['summary'] ?? ''), $amount_match)) {
                $amount = (float) str_replace(',', '', $amount_match[1]);
            }
            if ($amount <= 0) {
                return;
            }
            $financial_events = array(
                array(
                    'event_type' => preg_match('/invoice/', $haystack) ? 'invoice_needed' : (preg_match('/payment link/', $haystack) ? 'payment_link_needed' : 'opportunity'),
                    'amount' => $amount,
                    'currency' => 'USD',
                    'payment_status' => preg_match('/paid|payment received/', $haystack) ? 'needs_confirmation' : 'pending',
                    'payment_method' => 'unknown',
                    'product_service' => is_array($report_data['products_discussed'] ?? null) ? implode(', ', array_slice($report_data['products_discussed'], 0, 3)) : '',
                    'category' => 'opportunity',
                    'confidence' => 0.55,
                    'recommended_action' => 'review',
                )
            );
        }

        foreach ($financial_events as $event_index => $financial_event) {
            if (!is_array($financial_event)) {
                continue;
            }

            $event_type = sanitize_key($financial_event['event_type'] ?? 'none');
            if ($event_type === 'none') {
                continue;
            }

            $amount = (float) ($financial_event['amount'] ?? 0);
            if ($amount <= 0 && !in_array($event_type, array('invoice_needed', 'payment_link_needed', 'opportunity'), true)) {
                continue;
            }

            $payment_status = sanitize_key($financial_event['payment_status'] ?? 'pending');
            $event_status = 'needs_review';
            if ($event_type === 'invoice_needed') {
                $event_status = 'invoice_needed';
            } elseif ($event_type === 'payment_link_needed') {
                $event_status = 'payment_link_needed';
            }

            $caller_name = sanitize_text_field($financial_event['customer_name'] ?? $report_data['caller_name'] ?? '');
            if (strtoupper($caller_name) === 'UNKNOWN') {
                $caller_name = '';
            }

            vmo_cfo_record_financial_event((int) $user_id, array(
                'event_type' => $event_type,
                'source' => 'phone_call',
                'source_reference' => (string) $call_sid . '_' . (int) $event_index,
                'direction' => in_array($event_type, array('refund', 'expense'), true) ? 'expense' : ($event_type === 'opportunity' ? 'opportunity' : 'income'),
                'amount' => $amount,
                'currency' => strtoupper(sanitize_text_field($financial_event['currency'] ?? 'USD')),
                'payment_status' => $payment_status,
                'event_status' => $event_status,
                'confidence' => (float) ($financial_event['confidence'] ?? 0.6),
                'customer_name' => $caller_name,
                'customer_email' => sanitize_email($report_data['caller_email'] ?? ''),
                'customer_phone' => sanitize_text_field($call->caller_number ?? ''),
                'payment_method' => sanitize_key($financial_event['payment_method'] ?? 'unknown'),
                'category' => sanitize_key($financial_event['category'] ?? 'sales'),
                'product_service' => sanitize_text_field($financial_event['product_service'] ?? (is_array($report_data['products_discussed'] ?? null) ? implode(', ', array_slice($report_data['products_discussed'], 0, 3)) : 'Phone call financial event')),
                'transaction_date' => current_time('mysql'),
                'contact_id' => sanitize_text_field($call->contact_id ?? ''),
                'recommended_action' => sanitize_key($financial_event['recommended_action'] ?? 'review'),
                'notes' => sanitize_textarea_field($report_data['summary'] ?? ''),
                'metadata' => array(
                    'call_sid' => (string) $call_sid,
                    'call_summary_financial_event' => $financial_event,
                    'source_prompt' => 'post_call_intelligence',
                ),
            ));
        }
    }

    public function link_call_to_contact($call_id, $caller_number, $user_id)
    {
        global $wpdb;
        $calls_table = $wpdb->prefix . 'vmo_phone_calls';

        $contact = function_exists('vmo_crm_upsert_contact') ? vmo_crm_upsert_contact((int) $user_id, array(
            'phone' => $caller_number,
            'full_name' => 'Caller ' . substr((string) $caller_number, -4),
        ), array(
            'source' => 'phone_call',
            'primary_category' => 'lead',
            'lifecycle_stage' => 'new',
            'tags' => array('phone_call', 'inbound_call'),
        )) : array();

        if (empty($contact['id'])) {
            return new WP_Error('crm_contact_failed', 'Unable to link call to CRM contact.');
        }

        $contact_id = (int) $contact['id'];
        $contact_uuid = (string) $contact['contact_id'];

        // Link call to contact
        // phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery, WordPress.DB.DirectDatabaseQuery.NoCaching
        $wpdb->update(
            $calls_table,
            array('contact_id' => $contact_id),
            array('id' => $call_id),
            array('%d'),
            array('%d')
        );

        if (function_exists('vmo_crm_log_interaction')) {
            vmo_crm_log_interaction((int) $user_id, $contact_uuid, array(
                'interaction_type' => 'phone_call',
                'subject' => 'Inbound Phone Call',
                'summary' => 'Phone call from ' . $caller_number,
                'direction' => 'inbound',
                'full_details' => json_encode(array(
                    'call_id' => $call_id,
                    'caller_number' => $caller_number,
                    'phone_calls_row_id' => $call_id,
                )),
                'outcome' => 'pending',
            ));
        }

        return $contact_id;
    }

    /**
     * Build a rich system prompt for the AI receptionist.
     *
     * Pulls from EVERY available data source:
     *  1. user_meta (business_name, industry_type, services, hours, address, etc.)
     *  2. vmo_business_profiles table (product_catalog, customer_personas, business_summary)
     *  3. vmo_consultations table (client_data Q&A, generated_content)
     *  4. vmo_business_profile user meta JSON blob (consultation snapshot)
     *
     * The result is a concise but comprehensive context block so the AI can
     * answer real questions about the business (pricing, products, location, etc.).
     */
    private function build_receptionist_prompt(int $user_id, array $call_language = array()): string
    {
        $language_policy = $this->receptionist_language_policy($call_language);
        // ── 1. Core meta ──
        $business_name = ($user_id > 0) ? (string) get_user_meta($user_id, 'business_name', true) : '';
        if ($business_name === '')
            $business_name = 'our business';
        $industry = ($user_id > 0) ? (string) get_user_meta($user_id, 'industry_type', true) : '';
        $services = ($user_id > 0) ? (string) get_user_meta($user_id, 'business_services', true) : '';
        $hours = ($user_id > 0) ? (string) get_user_meta($user_id, 'business_hours', true) : '';
        $address = ($user_id > 0) ? (string) get_user_meta($user_id, 'business_address', true) : '';
        $description = ($user_id > 0) ? (string) get_user_meta($user_id, 'business_description', true) : '';
        $website = ($user_id > 0) ? (string) get_user_meta($user_id, 'website_url', true) : '';
        // Auto-detect domain from the site where this plugin is installed
        if ($website === '') {
            $website = home_url('/');
        }
        $email = ($user_id > 0) ? (string) get_user_meta($user_id, 'business_email', true) : '';
        $phone = ($user_id > 0) ? (string) $this->get_user_phone($user_id) : '';
        $owner_name = ($user_id > 0) ? (string) get_user_meta($user_id, 'first_name', true) : '';
        if ($owner_name === '' && $user_id > 0) {
            $udata = get_userdata($user_id);
            if ($udata)
                $owner_name = $udata->display_name;
        }

        // ── 1b. Cached master business brief ──
        $brief_ctx = $this->get_master_business_brief_context($user_id);

        // ── 2. Business profile table (persistent cross-session knowledge) ──
        $profile_ctx = '';
        if ($user_id > 0) {
            global $wpdb;
            $profile_table = $wpdb->prefix . 'vmo_business_profiles';
            if ($wpdb->get_var("SHOW TABLES LIKE '{$profile_table}'") === $profile_table) {
                $profile = $wpdb->get_row($wpdb->prepare(
                    "SELECT business_name, industry, business_summary, product_catalog, customer_personas, current_goals FROM {$profile_table} WHERE user_id = %d LIMIT 1",
                    $user_id
                ));
                if ($profile) {
                    if (!empty($profile->business_name) && $business_name === 'our business')
                        $business_name = $profile->business_name;
                    if (!empty($profile->industry) && $industry === '')
                        $industry = $profile->industry;

                    // Product catalog
                    $catalog = json_decode($profile->product_catalog ?? '', true);
                    if (is_array($catalog) && !empty($catalog)) {
                        $items = array();
                        foreach (array_slice($catalog, 0, 20) as $item) {
                            $name = is_string($item) ? $item : ($item['name'] ?? ($item['title'] ?? ''));
                            $price = $item['price'] ?? ($item['pricing'] ?? '');
                            $desc = $item['description'] ?? '';
                            $line = $name;
                            if ($price !== '')
                                $line .= " ($price)";
                            if ($desc !== '' && strlen($desc) < 100)
                                $line .= " - $desc";
                            if ($line !== '')
                                $items[] = $line;
                        }
                        if (!empty($items)) {
                            $profile_ctx .= "\nProducts/Services Catalog:\n" . implode("\n", $items) . "\n";
                        }
                    }

                    // Customer personas
                    $personas = json_decode($profile->customer_personas ?? '', true);
                    if (is_array($personas) && !empty($personas)) {
                        $plines = array();
                        foreach (array_slice($personas, 0, 5) as $p) {
                            $pn = is_string($p) ? $p : ($p['name'] ?? ($p['persona'] ?? json_encode($p)));
                            $plines[] = $pn;
                        }
                        $profile_ctx .= "\nTarget Customers: " . implode(', ', $plines) . "\n";
                    }

                    // Business summary
                    $summary = json_decode($profile->business_summary ?? '', true);
                    if (is_array($summary)) {
                        $sumtext = $summary['executive_summary'] ?? ($summary['overview'] ?? '');
                        if (is_string($sumtext) && $sumtext !== '') {
                            $profile_ctx .= "\nBusiness Summary: " . $this->safe_substr($sumtext, 0, 500) . "\n";
                        }
                    } elseif (is_string($profile->business_summary) && $profile->business_summary !== '') {
                        $profile_ctx .= "\nBusiness Summary: " . $this->safe_substr($profile->business_summary, 0, 500) . "\n";
                    }

                    // Current goals
                    $goals = json_decode($profile->current_goals ?? '', true);
                    if (is_array($goals) && !empty($goals)) {
                        $glines = array();
                        foreach (array_slice($goals, 0, 5) as $g) {
                            $glines[] = is_string($g) ? $g : ($g['goal'] ?? json_encode($g));
                        }
                        $profile_ctx .= "\nCurrent Goals: " . implode('; ', $glines) . "\n";
                    }
                }
            }
        }

        // ── 3. Latest consultation client_data (Q&A answers from wizard) ──
        $consult_ctx = '';
        if ($user_id > 0) {
            global $wpdb;
            $consult_table = $wpdb->prefix . 'vmo_consultations';
            if ($wpdb->get_var("SHOW TABLES LIKE '{$consult_table}'") === $consult_table) {
                $consult = $wpdb->get_row($wpdb->prepare(
                    "SELECT client_data, business_summary FROM {$consult_table} WHERE user_id = %d ORDER BY updated_at DESC LIMIT 1",
                    $user_id
                ));
                if ($consult) {
                    // client_data: Q&A responses
                    $cdata = json_decode($consult->client_data ?? '', true);
                    if (is_array($cdata) && !empty($cdata)) {
                        $pairs = array();
                        $useful_keys = array(
                            'business_description',
                            'main_products_services',
                            'revenue_model',
                            'target_customers',
                            'unique_selling_points',
                            'pricing_model',
                            'mission_statement',
                            'competitive_advantage',
                            'location',
                            'founding_story',
                            'team_size',
                            'annual_revenue',
                        );
                        // First add known useful keys
                        foreach ($useful_keys as $k) {
                            if (isset($cdata[$k]) && is_string($cdata[$k]) && $cdata[$k] !== '') {
                                $label = ucwords(str_replace('_', ' ', $k));
                                $pairs[] = "{$label}: {$cdata[$k]}";
                            }
                        }
                        // Then add any others we missed (up to 10 more)
                        $added = 0;
                        foreach ($cdata as $k => $v) {
                            if (in_array($k, $useful_keys, true))
                                continue;
                            if (!is_string($v) || $v === '' || strlen($v) > 300)
                                continue;
                            $label = ucwords(str_replace('_', ' ', $k));
                            $pairs[] = "{$label}: {$v}";
                            if (++$added >= 10)
                                break;
                        }
                        if (!empty($pairs)) {
                            $consult_ctx .= "\nConsultation Insights:\n" . implode("\n", $pairs) . "\n";
                        }
                    }

                    // business_summary from consultation
                    if (empty($profile_ctx) || strpos($profile_ctx, 'Business Summary') === false) {
                        $bsum = json_decode($consult->business_summary ?? '', true);
                        $btext = '';
                        if (is_array($bsum)) {
                            $btext = $bsum['executive_summary'] ?? ($bsum['overview'] ?? '');
                        } elseif (is_string($consult->business_summary) && $consult->business_summary !== '') {
                            $btext = $consult->business_summary;
                        }
                        if (is_string($btext) && $btext !== '') {
                            $consult_ctx .= "\nBusiness Overview: " . $this->safe_substr($btext, 0, 400) . "\n";
                        }
                    }
                }
            }
        }

        // ── 4. User meta business profile JSON blob ──
        $meta_profile_ctx = '';
        if ($user_id > 0) {
            $profile_blob = get_user_meta($user_id, 'vmo_business_profile', true);
            if (is_string($profile_blob) && $profile_blob !== '') {
                $pdata = json_decode($profile_blob, true);
                if (is_array($pdata)) {
                    // Extract useful fields we haven't already captured
                    foreach (array('pricing', 'faq', 'unique_features', 'testimonials') as $pk) {
                        if (isset($pdata[$pk]) && is_string($pdata[$pk]) && $pdata[$pk] !== '') {
                            $label = ucwords(str_replace('_', ' ', $pk));
                            $meta_profile_ctx .= "{$label}: {$pdata[$pk]}\n";
                        }
                    }
                }
            }
        }

        // ── 5. VMO Store Products (live catalog with prices) ──
        $store_ctx = '';
        if ($user_id > 0) {
            global $wpdb;
            $store_table = $wpdb->prefix . 'vmo_store_products';
            if ($wpdb->get_var("SHOW TABLES LIKE '{$store_table}'") === $store_table) {
                $store_products = $wpdb->get_results($wpdb->prepare(
                    "SELECT name, description, price_cents, currency, product_type, stock_tracking, stock_qty, recurring_interval FROM {$store_table} WHERE user_id = %d AND status = 'active' ORDER BY name ASC LIMIT 30",
                    $user_id
                ));
                if (!empty($store_products)) {
                    $store_lines = array();
                    foreach ($store_products as $sp) {
                        $price = number_format($sp->price_cents / 100, 2);
                        $cur = strtoupper($sp->currency ?: 'USD');
                        $line = "{$sp->name} — \${$price} {$cur}";
                        if ($sp->product_type === 'subscription' && $sp->recurring_interval) {
                            $line .= " /{$sp->recurring_interval}";
                        }
                        if ($sp->product_type === 'service')
                            $line .= ' (service)';
                        if ($sp->stock_tracking && $sp->stock_qty <= 0)
                            $line .= ' [OUT OF STOCK]';
                        if (!empty($sp->description) && strlen($sp->description) < 120) {
                            $line .= ' — ' . wp_strip_all_tags($sp->description);
                        }
                        $store_lines[] = $line;
                    }
                    $store_ctx = "\nStore Products (available for purchase/order):\n" . implode("\n", $store_lines) . "\n";
                }
            }
        }

        // ── 6. Upcoming Bookings / Availability ──
        $booking_ctx = '';
        if ($user_id > 0) {
            global $wpdb;
            $booking_table = $wpdb->prefix . 'vmo_bookings';
            if ($wpdb->get_var("SHOW TABLES LIKE '{$booking_table}'") === $booking_table) {
                // Get today's date for availability context
                $today = current_time('Y-m-d');
                $upcoming = $wpdb->get_results($wpdb->prepare(
                    "SELECT service, booking_date, booking_time, status FROM {$booking_table} WHERE user_id = %d AND booking_date >= %s AND status IN ('confirmed','pending') ORDER BY booking_date ASC, booking_time ASC LIMIT 15",
                    $user_id,
                    $today
                ));
                if (!empty($upcoming)) {
                    $bk_lines = array();
                    foreach ($upcoming as $bk) {
                        $bk_lines[] = "{$bk->booking_date} {$bk->booking_time} — {$bk->service} ({$bk->status})";
                    }
                    $booking_ctx = "\nUpcoming Bookings (already scheduled):\n" . implode("\n", $bk_lines) . "\n";
                    $booking_ctx .= "Note: When a caller wants to book, avoid these existing time slots.\n";
                }
            }
        }

        // ── 7. Website pages content (FAQ, About, Services pages) ──
        $website_ctx = '';
        if ($user_id > 0) {
            $pages_map = get_user_meta($user_id, 'vmo_pages_map', true);
            if (is_string($pages_map) && $pages_map !== '') {
                $pages = json_decode($pages_map, true);
            } elseif (is_array($pages_map)) {
                $pages = $pages_map;
            } else {
                $pages = null;
            }

            if (is_array($pages)) {
                $faq_pages = array();
                $other_pages = array();
                foreach ($pages as $slug => $page_data) {
                    $title = is_array($page_data) ? ($page_data['title'] ?? $slug) : $slug;
                    $content = $this->extract_builder_page_plain_text($page_data);
                    if ($content === '') {
                        continue;
                    }

                    $is_faq_page = $this->is_faq_like_page((string) $slug, (string) $title);
                    $page_limit = $is_faq_page ? 1800 : 800;
                    $entry = "{$title}: " . $this->safe_substr($content, 0, $page_limit);

                    if ($is_faq_page) {
                        $faq_pages[] = $entry;
                    } else {
                        $other_pages[] = $entry;
                    }
                }

                if (!empty($faq_pages)) {
                    $website_ctx .= "\nWebsite FAQ / Help Content (priority source):\n" . implode("\n\n", array_slice($faq_pages, 0, 2)) . "\n";
                }
                if (!empty($other_pages)) {
                    $website_ctx .= "\nWebsite Content:\n" . implode("\n\n", array_slice($other_pages, 0, 4)) . "\n";
                }
            }
        }

        // ── 8. Live website scan — fetch user's actual website for real-time info ──
        // Auto-detect domain: fall back to the site where this plugin is installed
        $scan_url = $website;
        if ($scan_url === '') {
            $scan_url = home_url('/');
        }
        $is_vmo_brand = function_exists('vmo_platform_faq_should_ground_current_site')
            ? vmo_platform_faq_should_ground_current_site((string) $business_name, (string) $website)
            : $this->is_vmo_brand_context($business_name, $website);
        $brand_pricing_ctx = $is_vmo_brand ? $this->get_vmo_plan_pricing_context() : '';
        $live_site_ctx = '';
        $scan_base_url = $this->normalize_site_scan_base_url($scan_url);
        if ($user_id > 0 && $scan_base_url !== '') {
            $cache_key = 'vmo_site_scan_' . md5($scan_base_url) . '_' . $user_id;
            $cached = get_transient($cache_key);
            if ($cached !== false) {
                $live_site_ctx = $cached;
            } else {
                $base_url = $scan_base_url;

                // Scrape homepage + common subpages for comprehensive business context
                $pages_to_scan = array(
                    '' => 'Homepage',
                    '/faq' => 'FAQ',
                    '/faqs' => 'FAQs',
                    '/frequently-asked-questions' => 'Frequently Asked Questions',
                    '/help' => 'Help',
                    '/support' => 'Support',
                    '/index.html' => 'Homepage / Pricing',
                    '/index.php' => 'Homepage',
                    '/about' => 'About',
                    '/about-us' => 'About Us',
                    '/services' => 'Services',
                    '/products' => 'Products',
                    '/pricing' => 'Pricing',
                    '/pricing-plans' => 'Pricing & Plans',
                    '/plans' => 'Plans',
                    '/menu' => 'Menu',
                    '/contact' => 'Contact',
                );
                $all_text = '';
                $pages_scraped = 0;
                $max_total_chars = 6000; // budget across all pages
                $seen_text_hashes = array();
                foreach ($pages_to_scan as $path => $label) {
                    if (strlen($all_text) >= $max_total_chars)
                        break;
                    $page_url = $base_url . $path;
                    $resp = wp_remote_get($page_url, array(
                        'timeout' => 4,
                        'sslverify' => true,
                        'user-agent' => 'VMO-AI-Receptionist/1.0',
                        'redirection' => 2,
                    ));
                    if (is_wp_error($resp))
                        continue;
                    $code = wp_remote_retrieve_response_code($resp);
                    if ($code !== 200)
                        continue;
                    $html = wp_remote_retrieve_body($resp);
                    if (!$html)
                        continue;
                    // Strip non-content sections
                    $html = preg_replace('/<script\b[^>]*>.*?<\/script>/is', '', $html);
                    $html = preg_replace('/<style\b[^>]*>.*?<\/style>/is', '', $html);
                    $html = preg_replace('/<nav\b[^>]*>.*?<\/nav>/is', '', $html);
                    $html = preg_replace('/<footer\b[^>]*>.*?<\/footer>/is', '', $html);
                    $html = preg_replace('/<header\b[^>]*>.*?<\/header>/is', '', $html);
                    $html = preg_replace('/<!--.*?-->/s', '', $html);
                    $text = wp_strip_all_tags($html);
                    $text = preg_replace('/\s+/', ' ', $text);
                    $text = trim($text);
                    if (strlen($text) < 50)
                        continue; // skip near-empty pages
                    $text_hash = md5($this->safe_substr($text, 0, 1200));
                    if (isset($seen_text_hashes[$text_hash]))
                        continue;
                    $seen_text_hashes[$text_hash] = true;
                    $budget_left = $max_total_chars - strlen($all_text);
                    $is_faq_priority = $this->is_faq_like_page($path, $label);
                    $page_limit = ($path === '') ? 2200 : ($is_faq_priority ? 1700 : 1000);
                    $page_text = $this->safe_substr($text, 0, min($page_limit, $budget_left));
                    $all_text .= "\n[{$label}] {$page_text}\n";
                    $pages_scraped++;
                }
                if ($pages_scraped > 0 && strlen($all_text) > 100) {
                    $domain_display = parse_url($base_url, PHP_URL_HOST) ?: $base_url;
                    $live_site_ctx = "\nLive Website Content (from {$domain_display}, {$pages_scraped} pages scanned):\n{$all_text}\n";
                    // Cache for 6 hours to avoid hitting the site on every call
                    set_transient($cache_key, $live_site_ctx, 6 * HOUR_IN_SECONDS);
                }
            }
        }

        // ── Assemble the prompt ──
        $prompt = "You are the professional phone receptionist for {$business_name}";
        $prompt .= ($industry !== '') ? ", a {$industry} business.\n" : ".\n";
        if ($owner_name !== '')
            $prompt .= "The business owner is {$owner_name}.\n";
        $prompt .= <<<RULES

YOUR IDENTITY:
You are a friendly, knowledgeable STAFF MEMBER (customer service / support representative) of {BUSINESS_NAME}. You speak on behalf of the business in first person plural ("we offer", "our team", "we'd love to help"). You are a human employee — warm, personable, and professional.

CRITICAL DISTINCTION — PRODUCT vs SERVICE COMPANY:
- If the business sells SOFTWARE, a PLUGIN, an APP, or a TECHNOLOGY PRODUCT: Describe what the PRODUCT does for the customer. If the product/platform automates a task, it is correct to say "yes, our platform can do that" or "our plugin can generate that for you." You are customer support staff, NOT the product itself. Never say "you can install us" — say "you can install our plugin" or "you can install {BUSINESS_NAME}." When describing capabilities, say "our software does X" or "{BUSINESS_NAME} enables you to X."
- If the business is a SERVICE company (restaurant, law firm, salon, etc.): "We" refers to the team naturally: "We serve fresh pasta daily", "We handle family law cases."

FACT GROUNDING — WEBSITE AND BUSINESS TRUTH:
- Treat the BUSINESS KNOWLEDGE sections below as authoritative for this call, especially VMO FAQ SOURCE OF TRUTH, Website FAQ / Help Content, Website Content, Live Website Content, Master Business Brief, Store Products, and VMO Product Capabilities when present.
- If those sections state that a product, service, feature, plan, price, or capability exists, answer as if it exists. Do not contradict it, deny it, or say it is unavailable because of generic assumptions about the industry.
- If the caller asks whether the business can do something and the website/business context says yes, answer yes first, then briefly explain how. For VMO specifically, if asked whether we can build/generate a website from scratch, answer yes.
- If a detail is missing, collect the caller's contact information and offer follow-up. Missing detail means unknown specifics; it does not mean the business cannot do the thing.

ABSOLUTE PROHIBITIONS:
- NEVER speak AS IF you ARE the software/product itself. You are a PERSON on the TEAM.
- NEVER tell a caller that pricing, plans, or features are "not publicly available", "not displayed", or "not on the website" if the business website content below contains that information. If the Live Website Content section mentions prices, plans, or tiers, share them accurately and confidently.
- NEVER refer to "VMO" or "Virtual Media Office" as a separate entity or third-party platform. If the business IS VMO/Virtual Media Office, you are its support team — talk about "our plugin", "our plans", "our platform."
- NEVER list your own AI receptionist capabilities (answering calls, taking messages, capturing leads) as if they are services you are offering to the caller. Those are your internal duties, not products.
- NEVER use markdown formatting: no asterisks, no bullet points, no hashtags. You are speaking on a phone call.
- NEVER say "asterisk" or describe formatting characters.
- NEVER disclaim or deny that the business can do something. If you are unsure about a specific capability, say "Absolutely, let me get your information so we can discuss the details with you" or "Great question — let me take down your contact info and have our team follow up with all the specifics."

YOUR ROLE:
You are the primary front-line representative for this business. The business owner relies on you to handle callers completely so they rarely need to intervene. You must sound natural, warm, and human — like a knowledgeable employee who genuinely cares. Speak conversationally, use contractions ("I'd be happy to", "we've got"), and vary your sentence structure. NEVER sound robotic or scripted.

TONE & WARMTH:
- Always greet callers warmly and enthusiastically, as if you are genuinely delighted they called. Example: "Hi there! Thank you so much for calling — how can I help you today?"
- Be cordial, friendly, and personable throughout the entire conversation. Use phrases like "Absolutely!", "Of course!", "I'd love to help with that!", "Great question!"
- Show empathy and understanding. If a caller has a concern or complaint, acknowledge their feelings first: "I completely understand, and I'm sorry you're dealing with that."
- Use the caller's name warmly once they provide it: "That's wonderful, [Name]! Let me get that taken care of for you."
- End every call on a positive, caring note: "Thank you so much for calling! We really appreciate your business. Have a wonderful day!"
- Never be curt, dismissive, or matter-of-fact. Always add a touch of genuine human warmth.

CRITICAL RULES:
1. HANDLE IT YOURSELF — Do NOT default to "let me have someone call you back" or "I'll pass that along." Answer questions, take orders, make reservations, provide pricing, and solve problems directly.
2. Only offer a callback when you genuinely cannot provide a specific detail (like a custom quote) and need the team to follow up.
3. ALWAYS finish your complete thought before you stop speaking — never cut yourself off mid-sentence. Speak naturally and conversationally: usually a few sentences, but take all the space you genuinely need to fully answer the question or explain every detail the caller needs. Give the complete information rather than a teaser, then invite the next question.
4. Be specific — give real prices, real hours, real product names when available. If details are missing from your knowledge, confidently say "Let me take your information and have our specialist reach out with those details."
5. LANGUAGE: ' . $language_policy . '
6. NEVER reveal you are an AI unless the caller directly and explicitly asks "are you a real person?" or similar.
7. If the caller says goodbye, respond with a brief, warm farewell.
8. Be patient and never rush the caller. If they pause or seem uncertain, gently encourage them: "Take your time — I'm right here to help!"

CAPABILITIES — What You Do On This Call:
- Answer any question about the business (hours, location, services, pricing, products, policies)
- Take orders: Collect product name, quantity, customer name, phone number, and any special instructions. Confirm the order details back.
- Make reservations/appointments: Collect service type, preferred date/time, customer name, and phone number.
- Provide detailed product/service information including pricing
- Collect customer information for follow-up (name, phone, email, nature of inquiry)
- Handle complaints with empathy and offer solutions

When taking an ORDER or RESERVATION:
"Let me get that set up for you. I'll need your name, phone number, and what you're looking for."
Then confirm: "So that's [details]. Is that correct?"
End with: "Perfect, you're all set!"

RULES;

        // Inject the business name into the prompt rules
        $prompt = str_replace('{BUSINESS_NAME}', $business_name, $prompt);

        // Core details block
        $details = '';
        if ($description !== '')
            $details .= "About: {$description}\n";
        if ($services !== '')
            $details .= "Services: {$services}\n";
        if ($hours !== '')
            $details .= "Hours: {$hours}\n";
        if ($address !== '')
            $details .= "Address: {$address}\n";
        if ($phone !== '')
            $details .= "Phone: {$phone}\n";
        if ($email !== '')
            $details .= "Email: {$email}\n";
        if ($website !== '')
            $details .= "Website: {$website}\n";

        if ($details !== '' || $brief_ctx !== '' || $profile_ctx !== '' || $consult_ctx !== '' || $meta_profile_ctx !== '' || $store_ctx !== '' || $booking_ctx !== '' || $website_ctx !== '' || $live_site_ctx !== '') {
            $prompt .= "\n=== BUSINESS KNOWLEDGE ===\n";
            $prompt .= $details;
            $prompt .= $brand_pricing_ctx;
            $prompt .= $website_ctx;
            $prompt .= $live_site_ctx;
            $prompt .= $brief_ctx;
            $prompt .= $profile_ctx;
            $prompt .= $consult_ctx;
            $prompt .= $meta_profile_ctx;
            $prompt .= $store_ctx;
            $prompt .= $booking_ctx;
        }

        // GPT-5.x supports large context; keep website-visible facts inside the phone prompt.
        if (strlen($prompt) > 18000) {
            $prompt = $this->safe_substr($prompt, 0, 17950) . "\n[...truncated for brevity]";
        }

        return $prompt;
    }

    /**
     * Send email alert for call events
     */
    private function phone_email_headers(string $business_name, string $reply_to = ''): array
    {
        $headers = array(
            'Content-Type: text/html; charset=UTF-8',
        );

        // Prefer the site's real sender address so SMTP / DMARC rules do not
        // silently drop phone-system emails that claim to be from a fabricated
        // no-reply@host address.
        $from_email = sanitize_email((string) get_option('admin_email', ''));
        if (!is_email($from_email)) {
            $site_host = wp_parse_url(home_url(), PHP_URL_HOST);
            if (!is_string($site_host) || $site_host === '') {
                $site_host = 'example.invalid';
            }
            if (strpos($site_host, '.') === false) {
                $site_host .= '.local';
            }
            $fallback = 'wordpress@' . preg_replace('/[^A-Za-z0-9.\-]/', '', $site_host);
            $from_email = sanitize_email((string) apply_filters('wp_mail_from', $fallback));
        }

        if (is_email($from_email)) {
            $safe_business_name = wp_specialchars_decode($business_name, ENT_QUOTES);
            $safe_business_name = preg_replace('/[:<>@]/', '', $safe_business_name);
            $headers[] = 'From: "' . esc_attr($safe_business_name) . '" <' . $from_email . '>';
        }

        if ($reply_to !== '' && is_email($reply_to) && strtolower($reply_to) !== strtolower((string) $from_email)) {
            $headers[] = 'Reply-To: ' . sanitize_email($reply_to);
        }

        return $headers;
    }

    private function is_phone_email_enabled(int $user_id, string $meta_key): bool
    {
        $pref = get_user_meta($user_id, $meta_key, true);

        // If they affirmatively turned it off, respect that
        if ($pref === '0') {
            return false;
        }

        // Default to true
        return true;
    }

    private function resolve_phone_email_recipient(int $user_id): string
    {
        $user = get_userdata($user_id);
        if ($user) {
            $candidate = sanitize_email((string) $user->user_email);
            if (is_email($candidate)) {
                return $candidate;
            }
        }

        foreach (array('business_email', 'vmo_business_email') as $email_key) {
            $candidate = sanitize_email((string) get_user_meta($user_id, $email_key, true));
            if (is_email($candidate)) {
                return $candidate;
            }
        }

        $admin_email = sanitize_email((string) get_option('admin_email', ''));
        return is_email($admin_email) ? $admin_email : '';
    }

    private function resolve_phone_email_reply_to(int $user_id, string $to = ''): string
    {
        foreach (array('business_email', 'vmo_business_email') as $email_key) {
            $candidate = sanitize_email((string) get_user_meta($user_id, $email_key, true));
            if (is_email($candidate) && strtolower($candidate) !== strtolower($to)) {
                return $candidate;
            }
        }

        return '';
    }

    private function send_call_alert($user_id, $type, $data)
    {
        $user = get_userdata($user_id);
        if (!$user)
            return null;

        // Gate: if user disabled incoming call email alerts, skip 'incoming' type
        if ($type === 'incoming') {
            if (!$this->is_phone_email_enabled((int) $user_id, 'vmo_email_incoming_alerts'))
                return null;
        }

        // Gate: if user disabled post-call summary emails, skip 'completed'/'missed'/'voicemail' types
        if (in_array($type, array('completed', 'missed', 'voicemail'), true)) {
            if (!$this->is_phone_email_enabled((int) $user_id, 'vmo_email_call_summaries'))
                return null;
        }

        $to = $this->resolve_phone_email_recipient((int) $user_id);
        if (!is_email($to)) {
            error_log('[VMO Email] ✗ Call alert email skipped: no valid recipient for user ' . $user_id . ' type=' . $type);
            return false;
        }

        $caller = isset($data['caller']) ? $data['caller'] : 'Unknown';
        $time = current_time('F j, Y g:i a');
        $business_name = get_user_meta($user_id, 'business_name', true) ?: get_bloginfo('name');
        $reply_to = $this->resolve_phone_email_reply_to((int) $user_id, $to);

        $subject = '';
        $message = '';

        // Common HTML header for styled emails
        $html_head = '<div style="max-width:600px;margin:0 auto;font-family:Arial,Helvetica,sans-serif;color:#333;">';
        $html_head .= '<div style="background:#1a1a2e;color:#fff;padding:16px 20px;border-radius:8px 8px 0 0;"><strong style="font-size:16px;">' . esc_html($business_name) . ' — Phone System</strong></div>';
        $html_head .= '<div style="padding:20px;border:1px solid #e0e0e0;border-top:none;border-radius:0 0 8px 8px;">';
        $html_foot = '</div></div>';

        switch ($type) {
            case 'incoming':
                $subject = "Incoming Call: $caller";
                $message = $html_head;
                $message .= "<h3 style='color:#1a1a2e;margin-top:0;'>Incoming Call</h3>";
                $message .= "<table style='border-collapse:collapse;width:100%;'>";
                $message .= "<tr><td style='padding:6px 10px;font-weight:600;width:110px;'>Caller</td><td style='padding:6px 10px;'>$caller</td></tr>";
                $message .= "<tr><td style='padding:6px 10px;font-weight:600;'>Time</td><td style='padding:6px 10px;'>$time</td></tr>";
                $message .= "</table>";
                $message .= "<p style='color:#666;margin-top:14px;'>The AI receptionist is handling this call. A summary will be emailed when the call ends.</p>";
                $message .= $html_foot;
                break;

            case 'completed':
                $status = isset($data['status']) ? $data['status'] : 'completed';
                $duration = isset($data['duration']) ? intval($data['duration']) : 0;
                $recording_url = isset($data['recording_url']) ? $data['recording_url'] : '';
                $dur_display = ($duration > 0) ? (floor($duration / 60) . 'm ' . ($duration % 60) . 's') : '0s';

                // Fetch transcript and summary from DB if available
                $transcript_text = '';
                $summary_text = '';
                if (isset($data['call_sid']) && !empty($data['call_sid'])) {
                    global $wpdb;
                    $row = $wpdb->get_row($wpdb->prepare(
                        "SELECT transcript, summary FROM {$wpdb->prefix}vmo_phone_calls WHERE call_sid = %s",
                        $data['call_sid']
                    ));
                    if ($row) {
                        $transcript_text = $row->transcript ?: '';
                        $summary_text = $row->summary ?: '';
                    }
                }

                $subject = "Call Summary: $caller ($status) - $dur_display";
                $message = $html_head;
                $message .= "<h3 style='color:#1a1a2e;margin-top:0;'>Call Completed</h3>";
                $message .= "<table style='border-collapse:collapse;width:100%;'>";
                $message .= "<tr><td style='padding:6px 10px;font-weight:600;width:110px;'>Caller</td><td style='padding:6px 10px;'>$caller</td></tr>";
                $message .= "<tr><td style='padding:6px 10px;font-weight:600;'>Status</td><td style='padding:6px 10px;'>$status</td></tr>";
                $message .= "<tr><td style='padding:6px 10px;font-weight:600;'>Duration</td><td style='padding:6px 10px;'>$dur_display</td></tr>";
                $message .= "<tr><td style='padding:6px 10px;font-weight:600;'>Time</td><td style='padding:6px 10px;'>$time</td></tr>";
                $message .= "</table>";

                if ($recording_url) {
                    $message .= "<div style='margin-top:14px;'><a href='" . esc_url($recording_url) . "' style='display:inline-block;background:#1a73e8;color:#fff;padding:8px 16px;border-radius:5px;text-decoration:none;font-size:13px;'>🎧 Listen to Recording</a></div>";
                }

                if (!empty($summary_text)) {
                    $message .= "<div style='margin-top:16px;padding:12px 14px;background:#f0f7ff;border-left:3px solid #1a73e8;border-radius:4px;'>";
                    $message .= "<strong style='font-size:13px;color:#1a73e8;'>AI Summary</strong>";
                    $message .= "<p style='margin:6px 0 0;font-size:13px;color:#333;white-space:pre-line;'>" . esc_html($summary_text) . "</p>";
                    $message .= "</div>";
                }

                if (!empty($transcript_text)) {
                    $truncated = (strlen($transcript_text) > 2000) ? $this->safe_substr($transcript_text, 0, 2000) . '...' : $transcript_text;
                    $message .= "<div style='margin-top:14px;padding:12px 14px;background:#f9f9f9;border:1px solid #e0e0e0;border-radius:4px;'>";
                    $message .= "<strong style='font-size:13px;color:#555;'>Conversation Transcript</strong>";
                    $message .= "<pre style='margin:6px 0 0;font-size:12px;color:#444;white-space:pre-wrap;font-family:monospace;'>" . esc_html($truncated) . "</pre>";
                    $message .= "</div>";
                }

                $message .= $html_foot;
                break;

            case 'missed':
                $subject = "Missed Call: $caller";
                $message = $html_head;
                $message .= "<h3 style='color:#e53935;margin-top:0;'>Missed Call</h3>";
                $message .= "<table style='border-collapse:collapse;width:100%;'>";
                $message .= "<tr><td style='padding:6px 10px;font-weight:600;width:110px;'>Caller</td><td style='padding:6px 10px;'>$caller</td></tr>";
                $message .= "<tr><td style='padding:6px 10px;font-weight:600;'>Time</td><td style='padding:6px 10px;'>$time</td></tr>";
                $message .= "</table>";
                $message .= "<p style='color:#e53935;margin-top:14px;'>This caller disconnected before the AI could respond. Consider calling them back.</p>";
                $message .= "<div style='margin-top:10px;'><a href='tel:" . esc_attr($caller) . "' style='display:inline-block;background:#4CAF50;color:#fff;padding:8px 16px;border-radius:5px;text-decoration:none;font-size:13px;'>📞 Call Back</a></div>";
                $message .= $html_foot;
                break;

            case 'voicemail':
                $subject = "New Voicemail: $caller";
                $message = $html_head;
                $message .= "<h3 style='color:#ff9800;margin-top:0;'>New Voicemail</h3>";
                $message .= "<table style='border-collapse:collapse;width:100%;'>";
                $message .= "<tr><td style='padding:6px 10px;font-weight:600;width:110px;'>Caller</td><td style='padding:6px 10px;'>$caller</td></tr>";
                $message .= "<tr><td style='padding:6px 10px;font-weight:600;'>Time</td><td style='padding:6px 10px;'>$time</td></tr>";
                $message .= "</table>";
                if (!empty($data['transcription'])) {
                    $message .= "<div style='margin-top:14px;padding:12px 14px;background:#fff8e1;border-left:3px solid #ff9800;border-radius:4px;'>";
                    $message .= "<strong style='font-size:13px;color:#e65100;'>Voicemail Transcription</strong>";
                    $message .= "<p style='margin:6px 0 0;font-size:13px;color:#333;'>" . esc_html($data['transcription']) . "</p>";
                    $message .= "</div>";
                }
                if (!empty($data['recording_url'])) {
                    $message .= "<div style='margin-top:10px;'><a href='" . esc_url($data['recording_url']) . "' style='display:inline-block;background:#ff9800;color:#fff;padding:8px 16px;border-radius:5px;text-decoration:none;font-size:13px;'>🎧 Listen to Voicemail</a></div>";
                }
                $message .= $html_foot;
                break;
        }

        if (empty($subject))
            return null;

        $headers = $this->phone_email_headers($business_name, $reply_to);
        $mail_sent = wp_mail($to, $subject, $message, $headers);
        if ($mail_sent) {
            error_log('[VMO Email] ✓ Call alert email sent: type=' . $type . ' to=' . $to . ' caller=' . $caller);
        } else {
            error_log('[VMO Email] ✗ Call alert email FAILED: type=' . $type . ' to=' . $to . ' caller=' . $caller . ' — check wp_mail / SMTP config');
        }
        return $mail_sent;
    }

    /**
     * Send a comprehensive post-call summary email AFTER the AI summary has been generated.
     *
     * Scheduled via wp_schedule_single_event ~90s after call completion, ensuring:
     *  - AI-generated summary is available
     *  - Recording has been downloaded locally
     *  - Transcript is fully built
     *
     * Includes a link to the plugin dashboard for recording playback rather than
     * a raw file URL, keeping users inside the plugin experience.
     *
     * @param string $call_sid Twilio Call SID
     */
    public function send_post_call_summary_email($call_sid)
    {
        global $wpdb;
        $table = $wpdb->prefix . 'vmo_phone_calls';
        $call = $wpdb->get_row($wpdb->prepare(
            "SELECT * FROM {$table} WHERE call_sid = %s LIMIT 1",
            $call_sid
        ));

        if (!$call) {
            error_log('VMO: send_post_call_summary_email — call not found: ' . $call_sid);
            return;
        }

        $email_key = $this->call_report_email_key((string) $call_sid);
        $retry_key = $this->call_report_retry_key((string) $call_sid);
        if ($email_key !== '' && get_transient($email_key)) {
            error_log('[VMO Email] Post-call report already sent for ' . $call_sid . ' — skipping duplicate');
            return;
        }

        $user_id = (int) $call->user_id;
        $user = get_userdata($user_id);
        if (!$user) {
            error_log('[VMO Email] Post-call report skipped: invalid user for ' . $call_sid . ' user_id=' . $user_id);
            return false;
        }

        // Gate: if user disabled post-call summary emails, skip
        if (!$this->is_phone_email_enabled($user_id, 'vmo_email_call_summaries')) {
            error_log('[VMO Email] Post-call report skipped by preference for ' . $call_sid . ' user_id=' . $user_id);
            return null;
        }

        $to = $this->resolve_phone_email_recipient($user_id);
        if (!is_email($to)) {
            error_log('[VMO Email] Post-call report skipped: no valid recipient for ' . $call_sid . ' user_id=' . $user_id);
            return false;
        }
        $business_name = get_user_meta($user_id, 'business_name', true) ?: get_bloginfo('name');
        $reply_to = $this->resolve_phone_email_reply_to($user_id, $to);
        $caller = !empty($call->caller_number) ? $call->caller_number : 'Unknown';
        $duration = (int) ($call->duration ?? 0);
        $dur_display = ($duration > 0) ? (floor($duration / 60) . 'm ' . ($duration % 60) . 's') : '0s';
        $call_time = !empty($call->created_at) ? $this->format_for_user($call->created_at, $user_id) : $this->format_for_user(current_time('mysql'), $user_id);
        $summary_text = !empty($call->summary) ? $call->summary : '';
        $transcript_text = !empty($call->transcript) ? $call->transcript : '';
        $recording_url = !empty($call->recording_url) ? $call->recording_url : '';

        $status = strtolower((string) ($call->status ?? ''));
        $needs_summary = ($status === 'completed' && $duration > 5);
        if ($needs_summary && trim($summary_text) === '') {
            $attempt = $retry_key !== '' ? ((int) get_transient($retry_key) + 1) : 1;
            if ($attempt <= 6) {
                error_log('[VMO Email] Post-call report not ready for ' . $call_sid . ' — summary missing, retry ' . $attempt);
                $this->schedule_post_call_summary_retry((string) $call_sid, $attempt);
                return false;
            }

            error_log('[VMO Email] Post-call report sending without AI summary after max retries for ' . $call_sid);
        }

        // Load structured call report if available
        $reports_table = $wpdb->prefix . 'vmo_call_reports';
        $report = null;
        if ($wpdb->get_var($wpdb->prepare("SHOW TABLES LIKE %s", $wpdb->esc_like($reports_table))) === $reports_table) {
            $report = $wpdb->get_row($wpdb->prepare(
                "SELECT * FROM {$reports_table} WHERE call_sid = %s ORDER BY id DESC LIMIT 1",
                $call_sid
            ));
        }

        // Load action items
        $actions_table = $wpdb->prefix . 'vmo_action_items';
        $action_items = array();
        if ($wpdb->get_var($wpdb->prepare("SHOW TABLES LIKE %s", $wpdb->esc_like($actions_table))) === $actions_table) {
            $action_items = $wpdb->get_results($wpdb->prepare(
                "SELECT * FROM {$actions_table} WHERE call_sid = %s ORDER BY priority DESC, deadline ASC",
                $call_sid
            ));
        }

        // Load fulfillment log
        $fulfillment_table = $wpdb->prefix . 'vmo_fulfillment_log';
        $fulfillments = array();
        if ($wpdb->get_var($wpdb->prepare("SHOW TABLES LIKE %s", $wpdb->esc_like($fulfillment_table))) === $fulfillment_table) {
            $fulfillments = $wpdb->get_results($wpdb->prepare(
                "SELECT * FROM {$fulfillment_table} WHERE call_sid = %s ORDER BY created_at ASC",
                $call_sid
            ));
        }

        // Build dashboard playback link
        $dashboard_link = admin_url('admin.php?page=vmo-ai-dashboard&section=phone&play_call=' . urlencode($call_sid));

        // Parse structured summary if JSON
        $parsed = json_decode($summary_text, true);
        $display_summary = '';
        if (is_array($parsed) && isset($parsed['summary'])) {
            $display_summary = $parsed['summary'];
        } elseif ($report && !empty($report->summary_text)) {
            $display_summary = (string) $report->summary_text;
        } else {
            $display_summary = $summary_text;
        }
        if ($display_summary === '' && $transcript_text !== '') {
            $display_summary = $this->safe_substr(trim($transcript_text), 0, 400);
        }

        // ── HTML Email ──
        $html = '<div style="max-width:660px;margin:0 auto;font-family:Arial,Helvetica,sans-serif;color:#333;">';

        // Header with branding
        $html .= '<div style="background:#1a1a2e;color:#fff;padding:18px 22px;border-radius:10px 10px 0 0;">';
        $html .= '<strong style="font-size:17px;">' . esc_html($business_name) . ' — Post-Call Report</strong>';
        $html .= '</div>';
        $html .= '<div style="padding:22px;border:1px solid #e0e0e0;border-top:none;border-radius:0 0 10px 10px;background:#fff;">';

        // ── Call Details Section ──
        $html .= '<h3 style="color:#1a1a2e;margin-top:0;margin-bottom:14px;">📞 Call Completed</h3>';
        $html .= '<table style="border-collapse:collapse;width:100%;margin-bottom:16px;">';
        $caller_display = $caller;
        if ($report && !empty($report->caller_name)) {
            $caller_display = esc_html($report->caller_name) . ' (' . esc_html($caller) . ')';
        }
        $html .= '<tr style="border-bottom:1px solid #f0f0f0;"><td style="padding:8px 12px;font-weight:700;width:130px;color:#555;">Caller</td><td style="padding:8px 12px;">' . $caller_display . '</td></tr>';
        $html .= '<tr style="border-bottom:1px solid #f0f0f0;"><td style="padding:8px 12px;font-weight:700;color:#555;">Duration</td><td style="padding:8px 12px;">' . esc_html($dur_display) . '</td></tr>';
        $html .= '<tr style="border-bottom:1px solid #f0f0f0;"><td style="padding:8px 12px;font-weight:700;color:#555;">Time</td><td style="padding:8px 12px;">' . esc_html($call_time) . '</td></tr>';
        if ($report) {
            $sentiment_emoji = array('positive' => '😊', 'neutral' => '😐', 'negative' => '😟', 'frustrated' => '😤');
            $urgency_colors = array('low' => '#4CAF50', 'medium' => '#FF9800', 'high' => '#f44336', 'critical' => '#9C27B0');
            $cat_labels = array('lead' => '🔵 Lead', 'customer' => '🟢 Customer', 'support' => '🟠 Support', 'inquiry' => '⚪ Inquiry', 'vendor' => '🟤 Vendor', 'spam' => '🔴 Spam');

            $html .= '<tr style="border-bottom:1px solid #f0f0f0;"><td style="padding:8px 12px;font-weight:700;color:#555;">CRM Category</td><td style="padding:8px 12px;">' . ($cat_labels[$report->crm_category] ?? esc_html($report->crm_category)) . '</td></tr>';
            $html .= '<tr style="border-bottom:1px solid #f0f0f0;"><td style="padding:8px 12px;font-weight:700;color:#555;">Sentiment</td><td style="padding:8px 12px;">' . ($sentiment_emoji[$report->call_sentiment] ?? '') . ' ' . ucfirst(esc_html($report->call_sentiment)) . '</td></tr>';
            $urg_color = $urgency_colors[$report->urgency] ?? '#999';
            $html .= '<tr style="border-bottom:1px solid #f0f0f0;"><td style="padding:8px 12px;font-weight:700;color:#555;">Urgency</td><td style="padding:8px 12px;"><span style="background:' . $urg_color . ';color:#fff;padding:2px 8px;border-radius:10px;font-size:12px;">' . strtoupper(esc_html($report->urgency)) . '</span></td></tr>';
            if (!empty($report->caller_intent)) {
                $html .= '<tr style="border-bottom:1px solid #f0f0f0;"><td style="padding:8px 12px;font-weight:700;color:#555;">Caller Intent</td><td style="padding:8px 12px;">' . esc_html($report->caller_intent) . '</td></tr>';
            }
        }
        $html .= '</table>';

        // ── AI Summary ──
        if (!empty($display_summary)) {
            $html .= '<div style="margin-bottom:18px;padding:14px 16px;background:#f0f7ff;border-left:4px solid #1a73e8;border-radius:6px;">';
            $html .= '<strong style="font-size:14px;color:#1a73e8;display:block;margin-bottom:8px;">🤖 AI Call Summary</strong>';
            $html .= '<div style="font-size:13px;color:#333;line-height:1.6;">' . esc_html($display_summary) . '</div>';
            $html .= '</div>';
        }

        // ── Actions Taken by AI ──
        if ($report && !empty($report->actions_taken)) {
            $taken = json_decode($report->actions_taken, true);
            if (is_array($taken) && !empty($taken)) {
                $html .= '<div style="margin-bottom:18px;padding:14px 16px;background:#e8f5e9;border-left:4px solid #4CAF50;border-radius:6px;">';
                $html .= '<strong style="font-size:14px;color:#2E7D32;display:block;margin-bottom:8px;">✅ Actions Handled by AI</strong>';
                $html .= '<ul style="margin:0;padding-left:18px;font-size:13px;line-height:1.8;">';
                foreach ($taken as $t) {
                    $action_text = is_array($t) ? ($t['action'] ?? json_encode($t)) : $t;
                    $html .= '<li>' . esc_html($action_text) . '</li>';
                }
                $html .= '</ul></div>';
            }
        }

        // ── Action Items / Checklist ──
        if (!empty($action_items)) {
            $html .= '<div style="margin-bottom:18px;padding:14px 16px;background:#fff3e0;border-left:4px solid #FF9800;border-radius:6px;">';
            $html .= '<strong style="font-size:14px;color:#E65100;display:block;margin-bottom:8px;">📋 Action Items — Your Follow-Up Checklist</strong>';
            $html .= '<table style="width:100%;border-collapse:collapse;font-size:13px;">';
            $html .= '<tr style="background:#fff8e1;"><th style="text-align:left;padding:6px 10px;border-bottom:1px solid #ffe0b2;">Task</th><th style="text-align:center;padding:6px 10px;border-bottom:1px solid #ffe0b2;width:70px;">Priority</th><th style="text-align:center;padding:6px 10px;border-bottom:1px solid #ffe0b2;width:120px;">Deadline</th></tr>';
            $priority_badges = array(
                'critical' => '<span style="background:#9C27B0;color:#fff;padding:1px 6px;border-radius:8px;font-size:11px;">CRITICAL</span>',
                'high' => '<span style="background:#f44336;color:#fff;padding:1px 6px;border-radius:8px;font-size:11px;">HIGH</span>',
                'medium' => '<span style="background:#FF9800;color:#fff;padding:1px 6px;border-radius:8px;font-size:11px;">MEDIUM</span>',
                'low' => '<span style="background:#4CAF50;color:#fff;padding:1px 6px;border-radius:8px;font-size:11px;">LOW</span>',
            );
            foreach ($action_items as $ai_item) {
                $dl = !empty($ai_item->deadline) ? $this->format_for_user($ai_item->deadline, $user_id, 'M j, g:i a') : '—';
                $badge = $priority_badges[$ai_item->priority] ?? $priority_badges['medium'];
                $html .= '<tr style="border-bottom:1px solid #f5f5f5;">';
                $html .= '<td style="padding:8px 10px;">☐ ' . esc_html($ai_item->title) . '</td>';
                $html .= '<td style="text-align:center;padding:8px 10px;">' . $badge . '</td>';
                $html .= '<td style="text-align:center;padding:8px 10px;font-size:12px;color:#666;">' . esc_html($dl) . '</td>';
                $html .= '</tr>';
            }
            $html .= '</table></div>';
        }

        // ── Fulfillment Status ──
        if (!empty($fulfillments)) {
            $html .= '<div style="margin-bottom:18px;padding:14px 16px;background:#e3f2fd;border-left:4px solid #2196F3;border-radius:6px;">';
            $html .= '<strong style="font-size:14px;color:#1565C0;display:block;margin-bottom:8px;">📤 AI Fulfillment Actions</strong>';
            foreach ($fulfillments as $f) {
                $icon = ($f->status === 'sent') ? '✅' : '❌';
                $via = strtoupper($f->action_type);
                $to_display = !empty($f->recipient_phone) ? $f->recipient_phone : $f->recipient_email;
                $html .= '<div style="font-size:13px;margin-bottom:6px;">' . $icon . ' <strong>' . $via . '</strong> sent to ' . esc_html($to_display) . '</div>';
                if ($f->status === 'failed' && !empty($f->error_message)) {
                    $html .= '<div style="font-size:12px;color:#c62828;margin-left:20px;">Error: ' . esc_html($f->error_message) . '</div>';
                }
            }
            $html .= '</div>';
        }

        // ── Action Buttons ──
        $html .= '<div style="margin-bottom:18px;display:flex;gap:10px;flex-wrap:wrap;">';
        // Direct MP3 playback button (primary)
        if (!empty($recording_url)) {
            $html .= '<a href="' . esc_url($recording_url) . '" style="display:inline-block;background:#1a1a2e;color:#fff;padding:10px 20px;border-radius:6px;text-decoration:none;font-size:13px;font-weight:600;">🎧 Listen to Recording</a>';
        }
        // Dashboard playback button
        $html .= '<a href="' . esc_url($dashboard_link) . '" style="display:inline-block;background:#6c5ce7;color:#fff;padding:10px 20px;border-radius:6px;text-decoration:none;font-size:13px;font-weight:600;">📂 Open in Dashboard</a>';
        if ($caller !== 'Unknown') {
            $html .= '<a href="tel:' . esc_attr($caller) . '" style="display:inline-block;background:#4CAF50;color:#fff;padding:10px 20px;border-radius:6px;text-decoration:none;font-size:13px;font-weight:600;">📞 Call Back</a>';
        }
        $reports_link = admin_url('admin.php?page=vmo-ai-dashboard&section=phone&tab=reports');
        $html .= '<a href="' . esc_url($reports_link) . '" style="display:inline-block;background:#1a73e8;color:#fff;padding:10px 20px;border-radius:6px;text-decoration:none;font-size:13px;font-weight:600;">📊 View Reports</a>';
        $html .= '</div>';

        // ── Transcript ──
        if (!empty($transcript_text)) {
            $truncated = (strlen($transcript_text) > 2500) ? $this->safe_substr($transcript_text, 0, 2500) . "\n\n... [Full transcript in dashboard]" : $transcript_text;
            $html .= '<div style="padding:14px 16px;background:#f9f9f9;border:1px solid #e8e8e8;border-radius:6px;">';
            $html .= '<strong style="font-size:13px;color:#555;display:block;margin-bottom:8px;">📝 Conversation Transcript</strong>';
            $html .= '<pre style="margin:0;font-size:12px;color:#444;white-space:pre-wrap;font-family:\'Courier New\',monospace;line-height:1.5;">' . esc_html($truncated) . '</pre>';
            $html .= '</div>';
        }

        // Footer
        $html .= '<div style="margin-top:20px;padding-top:14px;border-top:1px solid #e8e8e8;text-align:center;">';
        $html .= '<p style="font-size:11px;color:#aaa;margin:0;">Powered by VMO AI Phone System &bull; <a href="' . esc_url(admin_url('admin.php?page=vmo-ai-dashboard')) . '" style="color:#1a73e8;">Open Dashboard</a></p>';
        $html .= '</div>';

        $html .= '</div></div>';

        // Subject line with caller name if available
        $subject_caller = $caller;
        if ($report && !empty($report->caller_name)) {
            $subject_caller = $report->caller_name;
        }
        $subject = "Call Report: {$subject_caller} - {$dur_display} | {$business_name}";
        $headers = $this->phone_email_headers($business_name, $reply_to);
        $mail_sent = wp_mail($to, $subject, $html, $headers);

        if ($mail_sent) {
            if ($email_key !== '') {
                set_transient($email_key, 1, WEEK_IN_SECONDS);
            }
            if ($retry_key !== '') {
                delete_transient($retry_key);
            }
            error_log('[VMO Email] ✓ Post-call report email sent for ' . $call_sid . ' to ' . $to);
        } else {
            error_log('[VMO Email] ✗ Post-call report email FAILED for ' . $call_sid . ' to ' . $to . ' — check wp_mail / SMTP config');
        }

        return $mail_sent;
    }

    /**
     * Save call recording locally
     */
    private function save_call_recording($call_sid, $recording_url)
    {
        if (empty($recording_url))
            return false;

        // Create directory
        $upload_dir = wp_upload_dir();
        $vmo_dir = $upload_dir['basedir'] . '/vmo_calls';
        if (!file_exists($vmo_dir)) {
            wp_mkdir_p($vmo_dir);
        }

        // Twilio RecordingUrl typically requires auth and needs a format extension (e.g. .mp3)
        $download_url = $recording_url;
        if (!preg_match('/\.(mp3|wav)(\?|$)/i', $download_url)) {
            $download_url .= '.mp3';
        }

        $headers = array();
        if (!empty($this->account_sid) && !empty($this->auth_token)) {
            $headers['Authorization'] = 'Basic ' . base64_encode($this->account_sid . ':' . $this->auth_token); // phpcs:ignore WordPress.PHP.DiscouragedPHPFunctions.obfuscation_base64_encode -- HTTP Basic auth header
        }

        // Download file
        $response = wp_remote_get($download_url, array(
            'timeout' => 45,
            'headers' => $headers,
        ));
        if (is_wp_error($response)) {
            error_log('[VMO save_call_recording] Download WP error for ' . $call_sid . ': ' . $response->get_error_message());
            return false;
        }

        $http_code = wp_remote_retrieve_response_code($response);
        $body = wp_remote_retrieve_body($response);

        // Validate: must be HTTP 200 with a body that looks like actual audio (> 1KB).
        // Twilio error pages and 4xx/5xx responses are typically small HTML/JSON.
        if ($http_code !== 200 || strlen($body) < 1024) {
            error_log('[VMO save_call_recording] Download failed for ' . $call_sid . ': HTTP ' . $http_code . ', body_size=' . strlen($body));
            return false;
        }

        $filename = $call_sid . '.mp3';
        $file_path = $vmo_dir . '/' . $filename;
        $file_url = $upload_dir['baseurl'] . '/vmo_calls/' . $filename;

        vmo_get_filesystem()->put_contents($file_path, $body, FS_CHMOD_FILE);
        error_log('[VMO save_call_recording] Saved ' . $call_sid . '.mp3 (' . strlen($body) . ' bytes)');

        // Update DB (store playable URL in recording_url; also populate recording_local_path if that column exists)
        global $wpdb;
        $table = $wpdb->prefix . 'vmo_phone_calls';
        if ($wpdb->get_var("SHOW TABLES LIKE '{$table}'") === $table) {
            $update = array('recording_url' => $file_url);
            try {
                $cols = $wpdb->get_col("SHOW COLUMNS FROM {$table}", 0);
                if (is_array($cols) && in_array('recording_local_path', $cols, true)) {
                    $update['recording_local_path'] = $file_url;
                }
            } catch (Throwable $e) {
                // ignore
            }

            // phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery, WordPress.DB.DirectDatabaseQuery.NoCaching
            $updated = $wpdb->update(
                $table,
                $update,
                array('call_sid' => $call_sid)
            );
            error_log('[VMO save_call_recording] DB update for ' . $call_sid . ': rows_affected=' . var_export($updated, true));
        }

        return $file_url;
    }

    // =====================================================
    // POWER DIALER METHODS
    // AI-powered high-volume outbound calling campaigns
    // =====================================================

    /**
     * Get Power Dialer balance (uses main token balance for now)
     */
    public function power_dialer_get_balance($request)
    {
        $user_id = get_current_user_id();

        // Get token balance from user meta
        $balance = (int) get_user_meta($user_id, 'vmo_token_balance', true);

        // Also check for dedicated dialer balance (future feature)
        $dialer_balance = (int) get_user_meta($user_id, 'vmo_power_dialer_balance', true);

        // For now, use main balance if no dedicated balance
        $effective_balance = $dialer_balance > 0 ? $dialer_balance : $balance;

        return array(
            'balance' => $effective_balance,
            'main_balance' => $balance,
            'dialer_balance' => $dialer_balance
        );
    }

    /**
     * Power Dialer Campaigns - GET list or POST new campaign
     */
    public function power_dialer_campaigns($request)
    {
        global $wpdb;
        $user_id = get_current_user_id();

        // Check for active plan before allowing campaign operations
        $plan_check = $this->user_has_active_plan($user_id);
        if ($plan_check !== true) {
            return new WP_Error('no_active_plan', $plan_check, array('status' => 403));
        }

        $table = $wpdb->prefix . 'vmo_power_dialer_campaigns';

        // Ensure table exists
        $this->ensure_power_dialer_tables();

        if ($request->get_method() === 'POST') {
            // Create new campaign
            $params = $request->get_json_params();

            // phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery, WordPress.DB.DirectDatabaseQuery.NoCaching
            $campaign_id = $wpdb->insert($table, array(
                'user_id' => $user_id,
                'name' => sanitize_text_field($params['name']),
                'type' => sanitize_text_field($params['type']),
                'callback_extension' => intval($params['callback_extension']),
                'opening_script' => sanitize_textarea_field($params['opening']),
                'goal' => sanitize_textarea_field($params['goal']),
                'talking_points' => sanitize_textarea_field($params['points']),
                'schedule_data' => json_encode($params['schedule']),
                'settings_data' => json_encode($params['settings']),
                'contacts_json' => json_encode($params['contacts']),
                'contacts_total' => count($params['contacts']),
                'status' => ($params['schedule']['mode'] === 'now') ? 'active' : 'scheduled',
                'created_at' => current_time('mysql')
            ));

            if ($wpdb->insert_id) {
                // If starting immediately, schedule the first batch
                if ($params['schedule']['mode'] === 'now') {
                    $this->schedule_power_dialer_batch($wpdb->insert_id);
                }

                return array('success' => true, 'campaign_id' => $wpdb->insert_id);
            }

            return array('success' => false, 'message' => 'Failed to create campaign');
        }

        // GET - List campaigns
        $campaigns = $wpdb->get_results($wpdb->prepare(
            "SELECT * FROM $table WHERE user_id = %d ORDER BY created_at DESC",
            $user_id
        ), ARRAY_A);

        // Calculate stats
        $stats = array(
            'active' => 0,
            'calls_today' => 0,
            'connected' => 0,
            'conversion_rate' => 0
        );

        foreach ($campaigns as &$c) {
            $c['created'] = human_time_diff(strtotime($c['created_at'])) . ' ago';
            if ($c['status'] === 'active')
                $stats['active']++;
        }

        // Get today's call stats
        $calls_table = $wpdb->prefix . 'vmo_power_dialer_calls';
        $today = date('Y-m-d');
        $today_stats = $wpdb->get_row($wpdb->prepare(
            "SELECT 
                COUNT(*) as total,
                SUM(CASE WHEN status = 'connected' THEN 1 ELSE 0 END) as connected,
                SUM(CASE WHEN outcome = 'converted' THEN 1 ELSE 0 END) as conversions
            FROM $calls_table 
            WHERE user_id = %d AND DATE(created_at) = %s",
            $user_id,
            $today
        ));

        if ($today_stats) {
            $stats['calls_today'] = (int) $today_stats->total;
            $stats['connected'] = (int) $today_stats->connected;
            if ($stats['connected'] > 0) {
                $stats['conversion_rate'] = round(($today_stats->conversions / $stats['connected']) * 100);
            }
        }

        return array('campaigns' => $campaigns, 'stats' => $stats);
    }

    /**
     * Power Dialer Campaign Actions (pause/resume)
     */
    public function power_dialer_campaign_action($request)
    {
        global $wpdb;
        $user_id = get_current_user_id();
        $campaign_id = $request->get_param('id');
        $action = $request->get_param('action');
        $table = $wpdb->prefix . 'vmo_power_dialer_campaigns';

        // Verify ownership
        $campaign = $wpdb->get_row($wpdb->prepare(
            "SELECT * FROM $table WHERE id = %d AND user_id = %d",
            $campaign_id,
            $user_id
        ));

        if (!$campaign) {
            return new WP_Error('not_found', 'Campaign not found');
        }

        $new_status = ($action === 'pause') ? 'paused' : 'active';

        // phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery, WordPress.DB.DirectDatabaseQuery.NoCaching
        $wpdb->update(
            $table,
            array('status' => $new_status),
            array('id' => $campaign_id)
        );

        if ($new_status === 'active') {
            $this->schedule_power_dialer_batch($campaign_id);
        }

        return array('success' => true, 'status' => $new_status);
    }

    /**
     * Power Dialer Analytics
     */
    public function power_dialer_analytics($request)
    {
        global $wpdb;
        $user_id = get_current_user_id();
        $campaign_id = $request->get_param('campaign');
        $calls_table = $wpdb->prefix . 'vmo_power_dialer_calls';

        $where = $wpdb->prepare("user_id = %d", $user_id);
        if ($campaign_id && $campaign_id !== 'all') {
            $where .= $wpdb->prepare(" AND campaign_id = %d", $campaign_id);
        }

        // Get aggregate stats
        // phpcs:ignore WordPress.DB.PreparedSQL.InterpolatedNotPrepared, WordPress.DB.DirectDatabaseQuery.DirectQuery, WordPress.DB.DirectDatabaseQuery.NoCaching
        $stats = $wpdb->get_row(
            "SELECT 
                COUNT(*) as total,
                SUM(CASE WHEN status = 'connected' THEN 1 ELSE 0 END) as connected,
                SUM(CASE WHEN status = 'voicemail' THEN 1 ELSE 0 END) as voicemail,
                SUM(CASE WHEN status = 'noanswer' THEN 1 ELSE 0 END) as noanswer,
                SUM(CASE WHEN outcome = 'converted' THEN 1 ELSE 0 END) as conversions,
                AVG(duration) as avg_duration
            FROM $calls_table 
            WHERE $where"
        );

        // Get recent calls
        // phpcs:ignore WordPress.DB.PreparedSQL.InterpolatedNotPrepared, WordPress.DB.DirectDatabaseQuery.DirectQuery, WordPress.DB.DirectDatabaseQuery.NoCaching
        $calls = $wpdb->get_results(
            "SELECT c.*, COALESCE(co.full_name, CONCAT(COALESCE(co.first_name,''),' ',COALESCE(co.last_name,'')), '') as contact_name 
            FROM $calls_table c
            LEFT JOIN {$wpdb->prefix}vmo_contacts co ON c.contact_id = co.id
            WHERE $where
            ORDER BY c.created_at DESC
            LIMIT 50",
            ARRAY_A
        );

        foreach ($calls as &$call) {
            $call['datetime'] = self::to_iso8601($call['created_at']);
            $call['created_at'] = self::to_iso8601($call['created_at']);
            $call['duration'] = gmdate('i:s', (int) $call['duration']);
        }

        return array(
            'total' => (int) $stats->total,
            'connected' => (int) $stats->connected,
            'voicemail' => (int) $stats->voicemail,
            'noanswer' => (int) $stats->noanswer,
            'conversions' => (int) $stats->conversions,
            'avg_duration' => gmdate('i:s', (int) $stats->avg_duration),
            'calls' => $calls
        );
    }

    /**
     * Save Do Not Call list
     */
    public function power_dialer_save_dnc($request)
    {
        $user_id = get_current_user_id();
        $params = $request->get_json_params();
        $numbers = sanitize_textarea_field($params['numbers']);

        update_user_meta($user_id, 'vmo_power_dialer_dnc', $numbers);

        return array('success' => true, 'message' => 'DNC list saved');
    }

    /**
     * Outbound call callback handler
     */
    public function power_dialer_outbound_callback($request)
    {
        global $wpdb;
        $params = $request->get_params();

        $call_sid = sanitize_text_field($params['CallSid'] ?? '');
        $call_status = sanitize_text_field($params['CallStatus'] ?? '');
        $duration = intval($params['CallDuration'] ?? 0);
        $answered_by = sanitize_text_field($params['AnsweredBy'] ?? '');

        $calls_table = $wpdb->prefix . 'vmo_power_dialer_calls';

        // Map status
        $status = 'noanswer';
        if ($call_status === 'completed') {
            $status = ($answered_by === 'machine_start' || $answered_by === 'machine_end') ? 'voicemail' : 'connected';
        }

        // Update call record
        // phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery, WordPress.DB.DirectDatabaseQuery.NoCaching
        $wpdb->update(
            $calls_table,
            array(
                'status' => $status,
                'duration' => $duration,
                'call_status' => $call_status,
                'answered_by' => $answered_by,
                'updated_at' => current_time('mysql')
            ),
            array('call_sid' => $call_sid)
        );

        // Deduct tokens via Token Economics (Power Dialer = outbound)
        $call = $wpdb->get_row($wpdb->prepare(
            "SELECT * FROM $calls_table WHERE call_sid = %s",
            $call_sid
        ));

        if ($call && $duration > 0) {
            // Retrieve accumulated AI/TTS usage tracked during the call
            $safe_sid = preg_replace('/[^a-zA-Z0-9_]/', '', $call_sid);
            $ai_turns = (int) get_transient('vmo_ai_turns_' . $safe_sid);
            $tts_chars = (int) get_transient('vmo_tts_chars_' . $safe_sid);

            if (class_exists('VMO_Twilio_Token_Economics')) {
                VMO_Twilio_Token_Economics::get_instance()->deduct_call_tokens(
                    $call->user_id,
                    $duration,
                    'outbound',
                    false, // Power dialer calls typically don't have separate recordings
                    $ai_turns,
                    $tts_chars,
                    $call_sid
                );
            } else {
                // Fallback: outbound rate
                $minutes = ceil($duration / 60);
                $tokens = $minutes * 53;
                $current_balance = (int) get_user_meta($call->user_id, 'vmo_token_balance', true);
                update_user_meta($call->user_id, 'vmo_token_balance', max(0, $current_balance - $tokens));
                $this->log_token_usage($call->user_id, 'power_dialer_call', $tokens, "Power Dialer: {$minutes} min talk time");
                if (function_exists('vmo_log_billing_event')) {
                    vmo_log_billing_event($call->user_id, 'power_dialer_call', $tokens, "Power Dialer: {$minutes} min talk time", 'twilio', 0.0, $call_sid);
                }
            }

            // Clean up accumulated transients
            delete_transient('vmo_ai_turns_' . $safe_sid);
            delete_transient('vmo_tts_chars_' . $safe_sid);
        }

        // Return TwiML
        header('Content-Type: text/xml');
        echo '<?xml version="1.0" encoding="UTF-8"?>';
        echo '<Response></Response>';
        exit;
    }

    /**
     * Generate TwiML for Power Dialer outbound calls
     * This connects to the Twilio AI Agent with campaign-specific context
     */
    public function power_dialer_twiml($request)
    {
        global $wpdb;

        $send_twiml = function (string $body): void {
            // Ensure absolutely no extra output contaminates TwiML.
            while (ob_get_level() > 0) {
                @ob_end_clean();
            }
            header('Content-Type: text/xml; charset=UTF-8');
            echo '<?xml version="1.0" encoding="UTF-8"?>';
            // phpcs:ignore WordPress.Security.EscapeOutput.OutputNotEscaped -- raw binary response
            echo $body; // phpcs:ignore WordPress.Security.EscapeOutput.OutputNotEscaped -- TwiML XML output
            exit;
        };

        try {
            $params = $request->get_params();

            // Check for special actions (user takeover, conference)
            // IMPORTANT: `action` is reserved by admin-ajax.php, so Twilio hits this endpoint
            // using `twiml_action` to avoid overriding the admin-ajax routing.
            $action = sanitize_text_field($params['twiml_action'] ?? ($params['action'] ?? ''));

            // Handle user takeover - dial user into the call
            if ($action === 'user_takeover') {
                $user_phone = sanitize_text_field($params['user_phone'] ?? '');
                if (empty($user_phone)) {
                    $send_twiml('<Response><Say>Unable to connect you at this time.</Say></Response>');
                }

                $send_twiml(
                    '<Response>' .
                    '<Say voice="' . $this->tts_voice() . '">Connecting you with our specialist now.</Say>' .
                    '<Dial timeout="30"><Number>' . esc_xml($user_phone) . '</Number></Dial>' .
                    '<Say voice="' . $this->tts_voice() . '">The specialist is unavailable. Thank you for your time.</Say>' .
                    '</Response>'
                );
            }

            // Handle conference mode - put caller in conference
            if ($action === 'conference') {
                $conference_name = sanitize_text_field($params['conference'] ?? 'pd_default');

                // Determine which user owns this conference so we can respect recording preferences.
                $user_id = 0;
                if (!empty($params['user_id'])) {
                    $user_id = (int) $params['user_id'];
                }
                if ($user_id <= 0 && preg_match('/^vmo_call_(\d+)_/i', $conference_name, $m)) {
                    $user_id = (int) $m[1];
                }
                if ($user_id <= 0 && !empty($params['CallSid'])) {
                    try {
                        global $wpdb;
                        $phone_calls_table = $wpdb->prefix . 'vmo_phone_calls';
                        if ($wpdb->get_var("SHOW TABLES LIKE '{$phone_calls_table}'") === $phone_calls_table) {
                            $found = $wpdb->get_var($wpdb->prepare(
                                "SELECT user_id FROM {$phone_calls_table} WHERE call_sid = %s LIMIT 1",
                                sanitize_text_field((string) $params['CallSid'])
                            ));
                            if ($found) {
                                $user_id = (int) $found;
                            }
                        }
                    } catch (Throwable $e) {
                        // ignore
                    }
                }

                $recording_enabled = $this->is_recording_enabled($user_id);
                $dial_attrs = '';
                if ($recording_enabled) {
                    $dial_attrs = ' record="record-from-answer-dual" recordingStatusCallback="' . esc_xml($this->twilio_public_url('recording')) . '"';
                }

                $send_twiml(
                    '<Response>' .
                    '<Dial' . $dial_attrs . '><Conference startConferenceOnEnter="true" endConferenceOnExit="false" beep="false" waitUrl="">' .
                    esc_xml($conference_name) .
                    '</Conference></Dial>' .
                    '</Response>'
                );
            }

            $campaign_id = intval($params['campaign_id'] ?? 0);
            $contact_phone = sanitize_text_field($params['contact_phone'] ?? '');
            $contact_name = sanitize_text_field($params['contact_name'] ?? 'there');
            $answered_by = sanitize_text_field($params['AnsweredBy'] ?? '');

            if ($campaign_id <= 0) {
                $send_twiml('<Response><Say>We are unable to complete this call right now.</Say></Response>');
            }

            // Get campaign details (guard against missing tables / missing campaign)
            $table = $wpdb->prefix . 'vmo_power_dialer_campaigns';
            if ($wpdb->get_var("SHOW TABLES LIKE '{$table}'") !== $table) {
                error_log('VMO Power Dialer TwiML: campaigns table missing');
                $send_twiml('<Response><Say>We are currently experiencing technical difficulties. Please try again later.</Say></Response>');
            }

            $campaign = $wpdb->get_row($wpdb->prepare(
                "SELECT * FROM $table WHERE id = %d",
                $campaign_id
            ));

            if (!$campaign) {
                error_log('VMO Power Dialer TwiML: campaign not found id=' . $campaign_id);
                $send_twiml('<Response><Say>We are currently experiencing technical difficulties. Please try again later.</Say></Response>');
            }

            $twiml = '<Response>';

            // If voicemail detected, leave a message
            if ($answered_by === 'machine_start' || $answered_by === 'machine_end') {
                $settings = json_decode($campaign->settings_data ?? '{}', true);
                if (!empty($settings['voicemail'])) {
                    $voicemail_msg = $this->generate_voicemail_message($campaign, $contact_name);
                    $twiml .= '<Say voice="' . $this->tts_voice() . '">' . esc_xml($voicemail_msg) . '</Say>';
                }
                $twiml .= '</Response>';
                $send_twiml($twiml);
            }

            // Recording announcement (legal compliance)
            $twiml .= '<Say voice="' . $this->tts_voice() . '">This call may be recorded for quality and training purposes.</Say>';
            $twiml .= '<Pause length="1"/>';

            // Get user's business info for AI context
            $user_id = intval($campaign->user_id ?? 0);
            $business_name = ($user_id > 0) ? (get_user_meta($user_id, 'business_name', true) ?: 'Our Company') : 'Our Company';
            $business_type = ($user_id > 0) ? (get_user_meta($user_id, 'business_type', true) ?: 'business') : 'business';
            $ai_agent_name = ($user_id > 0) ? (get_user_meta($user_id, 'vmo_ai_agent_name', true) ?: 'Alex') : 'Alex';
            $user_phone = ($user_id > 0) ? $this->get_user_phone($user_id) : '';

            // Get campaign settings
            $settings = json_decode($campaign->settings_data ?? '{}', true);
            $ai_mode = $settings['ai_mode'] ?? 'full_autonomous'; // full_autonomous, user_available, user_first

            // Build AI prompt with campaign context
            $opening_script = $campaign->opening_script ?? '';
            $goal = $campaign->goal ?? '';
            $talking_points = $campaign->talking_points ?? '';

            // Replace placeholders
            $opening_script = str_replace(
                array('[AI Name]', '[Business Name]', '[Contact Name]'),
                array($ai_agent_name, $business_name, $contact_name),
                $opening_script
            );

            // Build elite sales agent system prompt
            $system_prompt = "You are {$ai_agent_name}, the WORLD'S GREATEST SALES AGENT calling on behalf of {$business_name}. You embody the combined expertise of the world's top salespeople - Grant Cardone, Jordan Belfort, Zig Ziglar, Brian Tracy, and Jeb Blount.

=== YOUR ELITE SALES DNA ===
You are not just making calls - you are creating OPPORTUNITIES. You have:
- Unshakeable confidence that comes from knowing your product helps people
- The ability to handle ANY objection with grace and skill
- A sixth sense for reading people and adapting your approach instantly
- Mastery of persuasion psychology without being manipulative
- The persistence of a champion who never gives up on the first no

=== CAMPAIGN MISSION ===
Campaign Type: {$campaign->type}
PRIMARY GOAL: {$goal}

KEY VALUE PROPOSITIONS:
{$talking_points}

OPENING (Start with this, then flow naturally):
{$opening_script}

=== ELITE SALES TECHNIQUES TO DEPLOY ===

1. THE OPENING (First 10 Seconds Are Everything)
   - Sound confident, warm, and genuinely interested
   - Use their name to create instant rapport
   - Pattern interrupt: Do not sound like every other salesperson

2. HANDLE RESISTANCE LIKE A PRO
   - When they say I am busy: I totally get it - I will be brief. Just 30 seconds - if what I share does not interest you, I will let you go immediately. Fair?
   - When they say Not interested: I appreciate your honesty! Just curious - is it the timing, or do you already have a solution for the problem?
   - When they say Send me an email: Absolutely! So I send you the right info - quick question: what would be most valuable to you?
   - When they say We already use X: Great choice! How is that working for you? Most of our clients switched from X because...

3. CREATE URGENCY (Ethically)
   - Reference limited availability, special offers, or timing advantages
   - Help them see the cost of inaction
   - Make the next step feel easy and low-risk

4. CLOSE WITH CONFIDENCE
   - Assume the sale: So let us get you set up...
   - Either/or close: Would morning or afternoon work better?
   - Summary close: So you get benefit 1, benefit 2, and benefit 3 - should we move forward?

5. IF THEY SAY NO
   - Thank them genuinely
   - Plant a seed for future: When would be a better time to revisit this?
   - Ask for referrals: Do you know anyone who might benefit?

=== CRITICAL RULES ===
1. NEVER be rude, pushy, or desperate - elite salespeople do not need to be
2. If they firmly ask to be removed from the list, confirm and end graciously
3. Mirror their energy - if they are rushed, be concise; if they are chatty, build rapport
4. Always provide VALUE in every interaction - even if they do not buy today
5. Record mental notes about their objections for follow-up
6. If they want to speak with a human representative, say you can transfer them

=== USER TAKEOVER MODE ===
AI Mode: {$ai_mode}
If user_available: The business owner may join this call at any time. If they do, smoothly introduce them.
If they request to speak with someone else, say: Absolutely! Let me connect you with our specialist right now.

Remember: Every no is just a not yet. Your job is to find the path to yes or plant seeds for the future. You are {$ai_agent_name}, and you are EXCEPTIONAL at what you do.";

            if (function_exists('vmo_platform_faq_should_ground_current_site') && function_exists('vmo_get_platform_faq_grounding_text') && vmo_platform_faq_should_ground_current_site((string) $business_name, (string) $talking_points . ' ' . (string) $goal)) {
                $system_prompt .= "\n\n" . vmo_get_platform_faq_grounding_text('power_dialer', 8000);
            }

            // Use Twilio's AI assistant with campaign context
            $assistant_url = ($user_id > 0) ? $this->get_or_create_power_dialer_assistant($user_id, $system_prompt) : null;

            // Build action URL for user takeover requests
            $takeover_url = $this->twilio_public_url('power_dialer_twiml', array(
                'campaign_id' => $campaign_id,
                'user_phone' => urlencode($user_phone),
                'twiml_action' => 'user_takeover'
            ));

            if ($assistant_url) {
                $twiml .= '<Connect><Assistant ';
                $twiml .= 'assistantUrl="' . esc_xml($assistant_url) . '" ';
                if ($ai_mode === 'user_available') {
                    $twiml .= 'action="' . esc_xml($takeover_url) . '" ';
                }
                $twiml .= '/></Connect>';
            } else {
                // Production-ready AI fallback: Use Gather + GPT conversation loop
                // (mirrors the inbound AI receptionist but with sales-focused prompts)

                // Store the sales system prompt in a transient keyed by CallSid so
                // the pd_ai_converse handler can retrieve it
                $call_sid_raw = sanitize_text_field($params['CallSid'] ?? '');
                if (!empty($call_sid_raw)) {
                    $prompt_key = 'vmo_pd_prompt_' . preg_replace('/[^a-zA-Z0-9_]/', '', $call_sid_raw);
                    set_transient($prompt_key, $system_prompt, 1800); // 30 min TTL
                }

                // AI conversation URL for speech-based dialogue
                $ai_converse_url = $this->phone_gateway_url('pd_ai_converse', array(
                    'campaign_id' => $campaign_id,
                    'uid' => $user_id,
                    'user_phone' => urlencode($user_phone),
                ));

                // Opening: Say the script, then enter speech Gather loop
                $twiml .= '<Gather input="speech" action="' . esc_xml($ai_converse_url) . '" speechTimeout="2" enhanced="true" language="' . $this->gather_lang() . '">';
                $twiml .= '<Say voice="' . $this->tts_voice() . '">' . esc_xml($opening_script ?: "Hello, this is {$ai_agent_name} calling from {$business_name}. How are you today?") . '</Say>';
                $twiml .= '</Gather>';
                // If no speech detected (timeout), try once more
                $twiml .= '<Say voice="' . $this->tts_voice() . '">I hope I haven\'t caught you at a bad time. I\'ll let you go. Have a great day!</Say>';
            }

            $twiml .= '</Response>';
            $send_twiml($twiml);
        } catch (Throwable $e) {
            error_log('VMO Power Dialer TwiML Critical Error: ' . $e->getMessage());
            // Always return valid TwiML on failure.
            while (ob_get_level() > 0) {
                @ob_end_clean();
            }
            header('Content-Type: text/xml; charset=UTF-8');
            echo '<?xml version="1.0" encoding="UTF-8"?>';
            echo '<Response><Say>We are currently experiencing technical difficulties. Please try again later.</Say></Response>';
            exit;
        }
    }

    /**
     * Generate a voicemail message for Power Dialer
     */
    private function generate_voicemail_message($campaign, $contact_name)
    {
        $user_id = $campaign->user_id;
        $business_name = get_user_meta($user_id, 'business_name', true) ?: 'Our Company';
        $ai_agent_name = get_user_meta($user_id, 'vmo_ai_agent_name', true) ?: 'Alex';
        $phone = $this->get_user_phone($user_id);

        return "Hi {$contact_name}, this is {$ai_agent_name} from {$business_name}. " .
            "I was calling regarding {$campaign->goal}. " .
            "Please give us a call back at {$phone}. " .
            "Thank you and have a great day!";
    }

    /**
     * Get or create Power Dialer AI assistant (Returns URL of REALTIME MANAGER STREAM)
     * Upgraded: Directs call to VMO Realtime Manager via Websocket Stream
     */
    private function get_or_create_power_dialer_assistant($user_id, $system_prompt)
    {
        $assistant_id = get_user_meta($user_id, 'vmo_twilio_assistant_id', true);

        if ($assistant_id) {
            // If they have a specific Twilio AI Assistant ID configured, use it (Advanced)
            return 'https://intelligence.twilio.com/v1/Assistants/' . $assistant_id;
        }

        return null; // Fallback to Polly TTS + Standard Gather
    }

    /**
     * Schedule a batch of Power Dialer calls
     */
    private function schedule_power_dialer_batch($campaign_id)
    {
        global $wpdb;
        $table = $wpdb->prefix . 'vmo_power_dialer_campaigns';

        $campaign = $wpdb->get_row($wpdb->prepare(
            "SELECT * FROM $table WHERE id = %d",
            $campaign_id
        ));

        if (!$campaign || $campaign->status !== 'active')
            return;

        $contacts = json_decode($campaign->contacts_json, true);
        $schedule = json_decode($campaign->schedule_data, true);
        $settings = json_decode($campaign->settings_data, true);

        // Get already called contacts
        $calls_table = $wpdb->prefix . 'vmo_power_dialer_calls';
        $called = $wpdb->get_col($wpdb->prepare(
            "SELECT phone FROM $calls_table WHERE campaign_id = %d",
            $campaign_id
        ));

        // Filter to uncalled contacts
        $uncalled = array_filter($contacts, function ($c) use ($called) {
            return !in_array($c['phone'], $called);
        });

        if (empty($uncalled)) {
            // Campaign complete
            // phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery, WordPress.DB.DirectDatabaseQuery.NoCaching
            $wpdb->update($table, array('status' => 'completed'), array('id' => $campaign_id));
            return;
        }

        // Get rate limit
        $rate_limit = intval($schedule['rate_limit'] ?? 10);
        $batch = array_slice(array_values($uncalled), 0, $rate_limit);

        // Check calling hours
        $now = current_time('H:i');
        $start_hour = $schedule['hours_start'] ?? '09:00';
        $end_hour = $schedule['hours_end'] ?? '17:00';

        if ($now < $start_hour || $now > $end_hour) {
            // Outside calling hours, schedule for next day
            wp_schedule_single_event(
                strtotime('tomorrow ' . $start_hour),
                'vmo_power_dialer_batch',
                array($campaign_id)
            );
            return;
        }

        // Make calls
        foreach ($batch as $contact) {
            $this->make_power_dialer_call($campaign, $contact, $settings);
        }

        // Schedule next batch in 1 hour
        wp_schedule_single_event(
            time() + 3600,
            'vmo_power_dialer_batch',
            array($campaign_id)
        );
    }

    /**
     * Make a single Power Dialer call
     */
    private function make_power_dialer_call($campaign, $contact, $settings)
    {
        // Remove client check, we use native

        // Verify user still has active plan before making call
        $plan_check = $this->user_has_active_plan($campaign->user_id);
        if ($plan_check !== true) {
            error_log('VMO Power Dialer: Skipping call - user ' . $campaign->user_id . ' has no active plan');
            return false;
        }

        // Check token balance before call
        $token_balance = (int) get_user_meta($campaign->user_id, 'vmo_token_balance', true);
        $min_tokens_per_call = 1575; // 3-min call estimate
        if ($token_balance < $min_tokens_per_call) {
            error_log('VMO Power Dialer: Skipping call - user ' . $campaign->user_id . ' has insufficient tokens');
            return false;
        }

        global $wpdb;
        $calls_table = $wpdb->prefix . 'vmo_power_dialer_calls';

        // Get user's phone number
        $from_number = $this->get_user_phone($campaign->user_id);
        if (!$from_number)
            return false;

        // Check DNC list
        $dnc = get_user_meta($campaign->user_id, 'vmo_power_dialer_dnc', true);
        $dnc_list = array_map('trim', explode("\n", $dnc));
        if (in_array($contact['phone'], $dnc_list))
            return false;

        // Build TwiML URL with campaign context
        $twiml_url = add_query_arg(array(
            'campaign_id' => $campaign->id,
            'contact_phone' => urlencode($contact['phone']),
            'contact_name' => urlencode($contact['name'] ?? ''),
            'type' => 'outbound'
        ), $this->twilio_public_url('power_dialer_twiml'));

        $status_callback = $this->twilio_public_url('pd_outbound_callback');

        try {
            $call_args = array(
                'To' => $contact['phone'],
                'From' => $from_number,
                'Url' => $twiml_url,
                'StatusCallback' => $status_callback,
                'StatusCallbackMethod' => 'POST',
                'StatusCallbackEvent' => 'completed', // API expects string or array param matching multiple keys? SDK handles array. API usually repeated keys. Let's just stick to 'completed' for now.
                'MachineDetection' => 'Enable',
                'Timeout' => 30
            );

            if (!empty($settings['record'])) {
                $call_args['Record'] = 'true';
                $call_args['RecordingStatusCallback'] = $this->twilio_public_url('recording');
            }

            $call = $this->twilio_request('POST', '/Calls.json', $call_args);

            if (is_wp_error($call))
                throw new Exception($call->get_error_message());

            // Log call in database
            // phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery, WordPress.DB.DirectDatabaseQuery.NoCaching
            $wpdb->insert($calls_table, array(
                'campaign_id' => $campaign->id,
                'user_id' => $campaign->user_id,
                'call_sid' => $call['sid'],
                'phone' => $contact['phone'],
                'contact_name' => $contact['name'] ?? '',
                'status' => 'initiated',
                'created_at' => current_time('mysql')
            ));

            return true;

        } catch (Exception $e) {
            error_log('VMO Power Dialer call error: ' . $e->getMessage());
            return false;
        }
    }

    /**
     * Ensure Power Dialer database tables exist
     */
    private function ensure_power_dialer_tables()
    {
        global $wpdb;
        $charset_collate = $wpdb->get_charset_collate();

        // Campaigns table
        $campaigns_table = $wpdb->prefix . 'vmo_power_dialer_campaigns';
        if ($wpdb->get_var("SHOW TABLES LIKE '$campaigns_table'") !== $campaigns_table) {
            $sql = "CREATE TABLE $campaigns_table (
                id bigint(20) UNSIGNED NOT NULL AUTO_INCREMENT,
                user_id bigint(20) UNSIGNED NOT NULL,
                name varchar(255) NOT NULL,
                type varchar(50) DEFAULT 'sales',
                callback_extension int DEFAULT 0,
                opening_script text,
                goal text,
                talking_points text,
                schedule_data text,
                settings_data text,
                contacts_json longtext,
                contacts_total int DEFAULT 0,
                calls_made int DEFAULT 0,
                status varchar(20) DEFAULT 'draft',
                created_at datetime DEFAULT CURRENT_TIMESTAMP,
                updated_at datetime DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP,
                PRIMARY KEY (id),
                KEY user_id (user_id),
                KEY status (status)
            ) $charset_collate;";

            require_once(ABSPATH . 'wp-admin/includes/upgrade.php');
            dbDelta($sql);
        }

        // Calls table
        $calls_table = $wpdb->prefix . 'vmo_power_dialer_calls';
        if ($wpdb->get_var("SHOW TABLES LIKE '$calls_table'") !== $calls_table) {
            $sql = "CREATE TABLE $calls_table (
                id bigint(20) UNSIGNED NOT NULL AUTO_INCREMENT,
                campaign_id bigint(20) UNSIGNED NOT NULL,
                user_id bigint(20) UNSIGNED NOT NULL,
                contact_id bigint(20) UNSIGNED DEFAULT NULL,
                call_sid varchar(50),
                phone varchar(20),
                contact_name varchar(255),
                status varchar(20) DEFAULT 'initiated',
                call_status varchar(50),
                answered_by varchar(50),
                duration int DEFAULT 0,
                outcome varchar(50),
                recording_url varchar(500),
                summary text,
                created_at datetime DEFAULT CURRENT_TIMESTAMP,
                updated_at datetime DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP,
                PRIMARY KEY (id),
                KEY campaign_id (campaign_id),
                KEY user_id (user_id),
                KEY call_sid (call_sid),
                KEY status (status)
            ) $charset_collate;";

            require_once(ABSPATH . 'wp-admin/includes/upgrade.php');
            dbDelta($sql);
        }
    }

    /**
     * Log token usage
     */
    private function log_token_usage($user_id, $type, $tokens, $description = '')
    {
        global $wpdb;
        $table = $wpdb->prefix . 'vmo_token_usage';

        if (function_exists('vmo_cfo_log_token_usage')) {
            vmo_cfo_log_token_usage($user_id, $type, $type, $tokens, [
                'system_key' => 'phone',
                'description' => $description,
                'usage_type' => $type,
            ]);
            return;
        }

        // Insert if table exists
        if ($wpdb->get_var("SHOW TABLES LIKE '$table'") === $table) {
            // phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery, WordPress.DB.DirectDatabaseQuery.NoCaching
            $wpdb->insert($table, array(
                'user_id' => $user_id,
                'usage_type' => $type,
                'tokens_used' => $tokens,
                'description' => $description,
                'created_at' => current_time('mysql')
            ));
        }
    }

    /**
     * Get live Power Dialer calls (Direct util for AJAX)
     */
    public function get_live_calls($user_id)
    {
        global $wpdb;

        $calls = array();
        $user_id = (int) $user_id;
        if ($user_id <= 0) {
            return array('calls' => array());
        }

        // Suppress wpdb HTML error output — this method is called via AJAX
        // and any HTML in the response poisons the JSON result.
        $prev_suppress = $wpdb->suppress_errors(true);
        $prev_show = $wpdb->show_errors;
        $wpdb->show_errors = false;

        $biz_number = (string) ($this->get_user_phone($user_id) ?: '');

        // 1) Phone system calls (inbound/outbound) from vmo_phone_calls
        //    Only return calls that are genuinely live — exclude ALL terminal statuses
        //    and limit the time window to 15 minutes (no real call lasts 90 min
        //    and stale records with un-updated status cause phantom ringing).
        $phone_calls_table = $wpdb->prefix . 'vmo_phone_calls';
        if ($wpdb->get_var("SHOW TABLES LIKE '{$phone_calls_table}'") === $phone_calls_table) {
            $rows = $wpdb->get_results($wpdb->prepare(
                "SELECT call_sid, caller_number, direction, status, created_at 
                 FROM {$phone_calls_table}
                 WHERE user_id = %d
                 AND (status IS NULL OR status NOT IN ('completed','busy','failed','canceled','cancelled','no-answer','expired'))
                 AND created_at > DATE_SUB(NOW(), INTERVAL 15 MINUTE)
                 ORDER BY created_at DESC
                 LIMIT 20",
                $user_id
            ), ARRAY_A);

            foreach (($rows ?: array()) as $r) {
                $sid = (string) ($r['call_sid'] ?? '');
                if ($sid === '') {
                    continue;
                }

                // Staleness check: calls stuck in 'initiated'/'ringing' or with empty
                // status for >2 minutes are definitely stale (no real call rings that
                // long). Mark them completed in DB so they stop polluting results.
                // Also clean up 'in-progress' calls older than 10 minutes where the
                // status callback may have failed to fire.
                $call_status = strtolower(trim((string) ($r['status'] ?? '')));
                $created = strtotime((string) ($r['created_at'] ?? ''));
                $age_seconds = $created ? (time() - $created) : 9999;
                $is_stale_ringing = in_array($call_status, array('initiated', 'ringing', 'queued', ''), true) && $age_seconds > 120;
                $is_stale_progress = ($call_status === 'in-progress') && $age_seconds > 600;
                if ($is_stale_ringing || $is_stale_progress) {
                    $this->terminate_call_everywhere($sid, 'live_poll_stale', array(
                        'user_id' => $user_id,
                        'status' => 'completed',
                    ));
                    error_log('[VMO get_live_calls] Auto-completed stale call ' . $sid . ' (status=' . $call_status . ', age=' . $age_seconds . 's)');
                    continue; // Skip this stale call
                }

                $direction = (string) ($r['direction'] ?? 'inbound');
                $owner_alert_ready = $this->is_owner_alert_ready($sid);
                $caller = (string) ($r['caller_number'] ?? '');
                $from = ($direction === 'outbound') ? $biz_number : $caller;
                $to = ($direction === 'outbound') ? $caller : $biz_number;
                $is_rt = (bool) get_transient('vmo_realtime_active_' . sanitize_key($sid));
                $calls[] = array(
                    'source' => 'phone',
                    'call_sid' => $sid,
                    'from_number' => $from,
                    'to_number' => $to,
                    'direction' => $direction,
                    'status' => (string) ($r['status'] ?? ''),
                    'created_at' => (string) ($r['created_at'] ?? ''),
                    'conference_sid' => '',
                    'conference_name' => 'vmo_call_' . $sid,
                    'is_realtime' => $is_rt,
                    'owner_alert_ready' => $owner_alert_ready,
                );
            }
        }

        if ($biz_number !== '') {
            $existing_sids = array();
            foreach ($calls as $existing_call) {
                $existing_sid = (string) ($existing_call['call_sid'] ?? '');
                if ($existing_sid !== '') {
                    $existing_sids[$existing_sid] = true;
                }
            }

            foreach ($this->get_live_call_signals($biz_number) as $signal) {
                $sid = (string) ($signal['call_sid'] ?? '');
                if ($sid === '' || isset($existing_sids[$sid])) {
                    continue;
                }

                $owner_alert_ready = $this->is_owner_alert_ready($sid);

                $calls[] = array(
                    'source' => 'signal',
                    'call_sid' => $sid,
                    'from_number' => (string) ($signal['from_number'] ?? ''),
                    'to_number' => (string) ($signal['to_number'] ?? $biz_number),
                    'direction' => 'inbound',
                    'status' => (string) ($signal['status'] ?? 'in-progress'),
                    'created_at' => (string) ($signal['created_at'] ?? current_time('mysql')),
                    'conference_sid' => '',
                    'conference_name' => 'vmo_call_' . $sid,
                    'is_realtime' => (bool) get_transient('vmo_realtime_active_' . sanitize_key($sid)),
                    'owner_alert_ready' => $owner_alert_ready,
                );
                $existing_sids[$sid] = true;
            }
        }

        // 2) Power dialer / direct outbound calls stored in vmo_power_dialer_calls
        $pd_calls_table = $wpdb->prefix . 'vmo_power_dialer_calls';
        if ($wpdb->get_var("SHOW TABLES LIKE '{$pd_calls_table}'") === $pd_calls_table) {
            $pd_rows = $wpdb->get_results($wpdb->prepare(
                "SELECT c.*, cam.name as campaign_name
                 FROM {$pd_calls_table} c
                 LEFT JOIN {$wpdb->prefix}vmo_power_dialer_campaigns cam ON c.campaign_id = cam.id
                 WHERE c.user_id = %d
                 AND c.status IN ('initiated', 'ringing', 'in-progress')
                 AND c.created_at > DATE_SUB(NOW(), INTERVAL 30 MINUTE)
                 ORDER BY c.created_at DESC",
                $user_id
            ), ARRAY_A);

            foreach (($pd_rows ?: array()) as $call) {
                $sid = (string) ($call['call_sid'] ?? '');
                if ($sid === '') {
                    continue;
                }

                $to = (string) ($call['phone'] ?? '');
                $calls[] = array(
                    'source' => 'power_dialer',
                    'call_sid' => $sid,
                    'from_number' => $biz_number,
                    'to_number' => $to,
                    'direction' => 'outbound',
                    'status' => (string) ($call['status'] ?? ''),
                    'created_at' => (string) ($call['created_at'] ?? ''),
                    'campaign_name' => (string) ($call['campaign_name'] ?? ''),
                    'conference_sid' => '',
                    'conference_name' => 'pd_' . $sid,
                );
            }
        }

        // Restore wpdb error settings
        $wpdb->suppress_errors($prev_suppress);
        $wpdb->show_errors = $prev_show;

        // ── RING SIGNAL: Check user-ID-keyed transient for guaranteed ring ──
        // This bypasses complex phone-number normalization and provides a
        // direct "ring now" signal from the voice webhook to the dashboard.
        $ring_now = null;
        $ring_signal = get_transient($this->user_ring_signal_key($user_id));
        if (is_array($ring_signal) && !empty($ring_signal['call_sid'])) {
            $rs_sid = (string) $ring_signal['call_sid'];
            $rs_status = strtolower(trim((string) ($ring_signal['status'] ?? '')));
            $owner_alert_ready = $this->ring_signal_owner_alert_ready($ring_signal, $rs_sid);
            $signal_created = strtotime((string) ($ring_signal['created_at'] ?? ''));
            $signal_age = $signal_created ? (time() - $signal_created) : 9999;
            $clear_ring_signal = $this->live_call_status_is_terminal($rs_status) || $signal_age > 180;
            $clear_owner_alert = $this->live_call_status_is_terminal($rs_status);

            if (!$clear_ring_signal && $wpdb->get_var("SHOW TABLES LIKE '{$phone_calls_table}'") === $phone_calls_table) {
                $ring_row = $wpdb->get_row($wpdb->prepare(
                    "SELECT status FROM {$phone_calls_table} WHERE call_sid = %s LIMIT 1",
                    $rs_sid
                ), ARRAY_A);
                if (is_array($ring_row)) {
                    $db_ring_status = strtolower(trim((string) ($ring_row['status'] ?? '')));
                    if ($this->live_call_status_is_terminal($db_ring_status)) {
                        $clear_ring_signal = true;
                        $clear_owner_alert = true;
                    }
                }
            }

            if ($clear_ring_signal) {
                $this->clear_user_ring_signal($user_id, $rs_sid);
                if ($clear_owner_alert) {
                    $this->clear_owner_alert_state($rs_sid);
                }
            } else {
                // Only signal if the call isn't already in the list AND isn't terminal
                $already_listed = false;
                foreach ($calls as $c) {
                    if (($c['call_sid'] ?? '') === $rs_sid) {
                        $already_listed = true;
                        break;
                    }
                }
                if (!$already_listed && !in_array($rs_status, array('completed', 'busy', 'failed', 'canceled', 'no-answer'), true)) {
                    $calls[] = array(
                        'source' => 'ring_signal',
                        'call_sid' => $rs_sid,
                        'from_number' => (string) ($ring_signal['from_number'] ?? ''),
                        'to_number' => (string) ($ring_signal['to_number'] ?? ''),
                        'direction' => 'inbound',
                        'status' => ($rs_status !== '' ? $rs_status : 'ringing'),
                        'created_at' => (string) ($ring_signal['created_at'] ?? current_time('mysql')),
                        'conference_sid' => '',
                        'conference_name' => 'vmo_call_' . $rs_sid,
                        'is_realtime' => (bool) get_transient('vmo_realtime_active_' . sanitize_key($rs_sid)),
                        'owner_alert_ready' => $owner_alert_ready,
                    );
                }

                // Do not trigger immediate ringing if owner alert gate dictates silence (IVR/extensions active)
                if ($owner_alert_ready) {
                    $ring_now = array(
                        'call_sid' => $rs_sid,
                        'from' => (string) ($ring_signal['from_number'] ?? 'Unknown'),
                    );
                }
            }
        }

        if (!$ring_now) {
            foreach ($calls as $candidate) {
                $candidate_sid = (string) ($candidate['call_sid'] ?? '');
                $candidate_status = strtolower(trim((string) ($candidate['status'] ?? '')));
                $candidate_direction = strtolower(trim((string) ($candidate['direction'] ?? '')));
                $candidate_alert_ready = !empty($candidate['owner_alert_ready']);

                if ($candidate_sid === '' || $candidate_direction !== 'inbound') {
                    continue;
                }

                if ($this->live_call_status_is_terminal($candidate_status)) {
                    continue;
                }

                if (!$candidate_alert_ready) {
                    continue;
                }

                $ring_now = array(
                    'call_sid' => $candidate_sid,
                    'from' => (string) ($candidate['from_number'] ?? 'Unknown'),
                );
                break;
            }
        }

        $result = array('calls' => $calls);
        if ($ring_now) {
            $result['ring_now'] = $ring_now;
        }
        return $result;
    }

    /**
     * Get live Power Dialer calls for user (for monitoring/takeover)
     */
    public function power_dialer_live_calls($request)
    {
        if (!$this->client) {
            return array('calls' => [], 'error' => 'Twilio not configured');
        }

        $user_id = get_current_user_id();
        global $wpdb;
        $calls_table = $wpdb->prefix . 'vmo_power_dialer_calls';

        // Get recent calls that are likely still in progress (last 30 min, not completed)
        $live_calls = $wpdb->get_results($wpdb->prepare(
            "SELECT c.*, cam.name as campaign_name 
            FROM $calls_table c
            LEFT JOIN {$wpdb->prefix}vmo_power_dialer_campaigns cam ON c.campaign_id = cam.id
            WHERE c.user_id = %d 
            AND c.status IN ('initiated', 'ringing', 'in-progress')
            AND c.created_at > DATE_SUB(NOW(), INTERVAL 30 MINUTE)
            ORDER BY c.created_at DESC",
            $user_id
        ));

        // Enrich with Twilio call status
        foreach ($live_calls as &$call) {
            try {
                $twilio_call = $this->twilio_request('GET', "/Calls/{$call['call_sid']}.json");
                $call['twilio_status'] = isset($twilio_call['status']) ? $twilio_call['status'] : 'unknown';
                $call['duration_live'] = isset($twilio_call['duration']) ? $twilio_call['duration'] : 0;
            } catch (Exception $e) {
                $call['twilio_status'] = 'unknown';
            }
        }

        return array('calls' => $live_calls);
    }

    /**
     * User takeover - add user to an active Power Dialer call
     */
    public function power_dialer_user_takeover($request)
    {
        // No client check

        $user_id = get_current_user_id();
        $params = $request->get_json_params();
        $call_sid = sanitize_text_field($params['call_sid'] ?? '');
        $action = sanitize_text_field($params['action'] ?? 'join'); // join, takeover, whisper

        if (empty($call_sid)) {
            return new WP_Error('missing_call_sid', 'Call SID is required');
        }

        global $wpdb;
        $calls_table = $wpdb->prefix . 'vmo_power_dialer_calls';

        // Verify ownership
        $call = $wpdb->get_row($wpdb->prepare(
            "SELECT * FROM $calls_table WHERE call_sid = %s AND user_id = %d",
            $call_sid,
            $user_id
        ));

        if (!$call) {
            return new WP_Error('call_not_found', 'Call not found or access denied');
        }

        try {
            if ($action === 'browser_join' || $action === 'browser_listen') {
                $conference_name = 'pd_' . $call_sid;
                $twiml_url = add_query_arg(array(
                    'twiml_action' => 'conference',
                    'conference' => $conference_name
                ), $this->twilio_public_url('power_dialer_twiml'));

                $this->twilio_request('POST', "/Calls/{$call_sid}.json", array(
                    'Url' => $twiml_url,
                    'Method' => 'POST'
                ));

                return array(
                    'success' => true,
                    'conference_name' => $conference_name,
                    'message' => ($action === 'browser_listen')
                        ? 'Live Power Dialer audio is ready. Your browser will join muted.'
                        : 'Live Power Dialer audio is ready. Your browser can take over the call now.'
                );
            }

            // Get user's phone number (The number to call the user on - typically external/personal)
            $user_personal_phone = get_user_meta($user_id, 'vmo_external_phone', true);
            if (!$user_personal_phone) {
                // Fallback
                $user_personal_phone = get_user_meta($user_id, 'billing_phone', true);
            }

            if (!$user_personal_phone) {
                return new WP_Error('no_phone', 'No external phone configured to connect you to. Check your profile settings.');
            }

            // The 'From' number for the call to the user should be their VMO business number
            $vmo_number = $this->get_user_phone($user_id);

            if ($action === 'takeover') {
                // Create a TwiML that bridges user into the call
                $twiml_url = add_query_arg(array(
                    'twiml_action' => 'user_takeover',
                    'user_phone' => urlencode($user_personal_phone)
                ), $this->twilio_public_url('power_dialer_twiml'));

                // Redirect the call to new TwiML that dials the user
                $this->twilio_request('POST', "/Calls/{$call_sid}.json", array(
                    'Url' => $twiml_url,
                    'Method' => 'POST'
                ));

                return array(
                    'success' => true,
                    'message' => 'You are being connected to the call. Your phone will ring shortly.'
                );

            } elseif ($action === 'join') {
                // Add user as a participant (conference style)
                // First, update call to use a conference
                $conference_name = 'pd_' . $call_sid;

                $twiml_url = add_query_arg(array(
                    'twiml_action' => 'conference',
                    'conference' => $conference_name
                ), $this->twilio_public_url('power_dialer_twiml'));

                // Update existing call to join conference
                $this->twilio_request('POST', "/Calls/{$call_sid}.json", array(
                    'Url' => $twiml_url,
                    'Method' => 'POST'
                ));

                // Call user and put them in same conference
                $this->twilio_request('POST', '/Calls.json', array(
                    'To' => $user_personal_phone,
                    'From' => $vmo_number,
                    'Url' => $twiml_url,
                    'StatusCallback' => $this->twilio_public_url('pd_outbound_callback')
                ));

                return array(
                    'success' => true,
                    'message' => 'Joining call. Both parties will hear each other.'
                );

            } elseif ($action === 'whisper') {
                // Whisper mode - user can hear but not be heard (coaching mode)
                // This would require Twilio's <Stream> or conference with mute
                return array(
                    'success' => false,
                    'message' => 'Whisper mode coming soon'
                );
            }

        } catch (TwilioException $e) {
            error_log('VMO Power Dialer takeover error: ' . $e->getMessage());
            return new WP_Error('takeover_failed', 'Failed to join call: ' . $e->getMessage());
        }

        return array('success' => false, 'message' => 'Unknown action');
    }
}

// Initialize
VMO_Twilio_Manager::get_instance();

/**
 * Register daily cron event for phone rentals
 */
function vmo_register_phone_rental_cron()
{
    if (!wp_next_scheduled('vmo_daily_phone_rental_check')) {
        wp_schedule_event(time(), 'daily', 'vmo_daily_phone_rental_check');
    }
}
add_action('init', 'vmo_register_phone_rental_cron');

/**
 * Handle recurring phone rental charges ($2.50/mo) and dormancy
 */
function vmo_process_phone_rentals()
{
    $args = array(
        'meta_key' => 'vmo_twilio_phone_number',
        'meta_compare' => 'EXISTS',
        'number' => -1,
        'fields' => 'ID'
    );

    $user_query = new WP_User_Query($args);
    $users = $user_query->get_results();

    if (empty($users))
        return;

    // Ensure REST client is available for deletions
    if (!class_exists('VMO_Twilio_REST')) {
        require_once __DIR__ . '/class-vmo-twilio-rest.php';
    }

    foreach ($users as $user_id) {
        $next_due = get_user_meta($user_id, 'vmo_phone_next_rent_due', true);

        // Initial grace period for new/migrated users
        if (empty($next_due)) {
            update_user_meta($user_id, 'vmo_phone_next_rent_due', strtotime('+30 days'));
            continue;
        }

        if (time() >= $next_due) {
            $rent_cost = 1875; // $2.50

            if (function_exists('vmo_deduct_tokens')) {
                $deducted = vmo_deduct_tokens($user_id, $rent_cost, 'phone_rental', 'Monthly Phone Number Rental ($2.50)');

                if ($deducted) {
                    // SUCCESS
                    update_user_meta($user_id, 'vmo_phone_next_rent_due', strtotime('+30 days'));
                    delete_user_meta($user_id, 'vmo_phone_rent_fail_start'); // Clear any failure flags
                    error_log("VMO Phone Rental: SUCCESS user #{$user_id}.");
                } else {
                    // FAILURE
                    $fail_start = get_user_meta($user_id, 'vmo_phone_rent_fail_start', true);
                    if (!$fail_start) {
                        $fail_start = time();
                        update_user_meta($user_id, 'vmo_phone_rent_fail_start', $fail_start);

                        // Send WARNING Email (Day 0 of failure)
                        vmo_send_phone_rent_email($user_id, 'warning');
                        error_log("VMO Phone Rental: FAILURE user #{$user_id}. Warning sent.");
                    }

                    // Check if grace period exceeded (25 Days - Extended Logic)
                    // Logic: VMO has likely already paid for the month. We keep the number alive
                    // for the duration of the prepaid period (approx 28 days) but spam warnings.
                    $days_overdue = (time() - $fail_start) / DAY_IN_SECONDS;

                    // Notifications (Bombardment Phase)
                    if ($days_overdue >= 0 && $days_overdue < 25) {
                        $severity = 'warning';
                        if ($days_overdue >= 7)
                            $severity = 'urgent';
                        if ($days_overdue >= 20)
                            $severity = 'critical';

                        // Only send once per day to avoid being flagged as spam by email providers
                        $last_sent = get_user_meta($user_id, 'vmo_phone_rent_warning_sent_date', true);
                        if ($last_sent != date('Y-m-d')) {
                            vmo_send_phone_rent_email($user_id, $severity, 25 - floor($days_overdue));
                            update_user_meta($user_id, 'vmo_phone_rent_warning_sent_date', date('Y-m-d'));
                            error_log("VMO Phone Rental: Sent {$severity} warning to user #{$user_id}. Days left: " . (25 - floor($days_overdue)));
                        }
                    }

                    // TERMINATION (Day 25)
                    // We kill it before day 28/30 to ensure we don't get hit with next month's bill
                    if ($days_overdue >= 25) {
                        // TERMINATION PROCEDURE
                        $phone_sid = get_user_meta($user_id, 'vmo_twilio_phone_sid', true);
                        if ($phone_sid) {
                            $rest = new VMO_Twilio_REST();
                            $deleted = $rest->release_number($phone_sid);

                            if (!is_wp_error($deleted)) {
                                error_log("VMO Phone Rental: TERMINATED number for user #{$user_id} due to non-payment (25 days overdue).");

                                // Clean User Meta - PHONE NUMBER ONLY
                                // We keep 'vmo_business_phone' typically so the site doesn't break, 
                                // but we remove the billing identity.
                                delete_user_meta($user_id, 'vmo_twilio_phone_number');
                                delete_user_meta($user_id, 'vmo_twilio_phone_sid');
                                delete_user_meta($user_id, 'vmo_phone_next_rent_due');
                                delete_user_meta($user_id, 'vmo_phone_rent_fail_start');
                                delete_user_meta($user_id, 'vmo_phone_rent_warning_sent_date');

                                // Send Termination Email
                                vmo_send_phone_rent_email($user_id, 'terminated', 0);
                            } else {
                                error_log("VMO Phone Rental: Failed to release number for user #{$user_id}. Twilio Error: " . $deleted->get_error_message());
                            }
                        }
                    }
                }
            }
        }
    }
}
add_action('vmo_daily_phone_rental_check', 'vmo_process_phone_rentals');

function vmo_send_phone_rent_email($user_id, $type, $days_left = 0)
{
    $user = get_userdata($user_id);
    if (!$user)
        return;

    $headers = array('Content-Type: text/html; charset=UTF-8');

    if ($type === 'warning') {
        $subject = 'Action Required: Your VMO Phone Number Payment Failed';
        $message = "
        <p>Hi {$user->display_name},</p>
        <p>We were unable to process the monthly rental fee (1,875 Tokens) for your business phone number.</p>
        <p><strong>To prevent disconnection, please top up your token balance.</strong></p>
        <p>You have approx <strong>{$days_left} days</strong> before the number is released.</p>
        <p><a href='" . home_url('/dashboard/billing') . "'>Top Up Now</a></p>
        ";
    } elseif ($type === 'urgent') {
        $subject = 'URGENT: Your Phone Number will be disconnected soon';
        $message = "
        <p>Hi {$user->display_name},</p>
        <p>Your business phone number rental payment is overdue.</p>
        <p><strong>This is your 2nd Notice.</strong></p>
        <p>If payment is not successful within <strong>{$days_left} days</strong>, your phone number will be permanently deleted.</p>
        <p><a href='" . home_url('/dashboard/billing') . "'>Top Up Now</a></p>
        ";
    } elseif ($type === 'critical') {
        $subject = "FINAL NOTICE: Phone Number Deletion in {$days_left} Days";
        $message = "
        <p>Hi {$user->display_name},</p>
        <p><strong style='color:red;'>CRITICAL ALERT: Your business phone number is about to be deleted.</strong></p>
        <p>We have attempted to charge your rental fee for 20 days without success.</p>
        <p><strong>Number deletion is scheduled for: " . date('M jS', strtotime("+{$days_left} days")) . ".</strong></p>
        <p>Once deleted, this number cannot be recovered. All your call history will be saved, but the number itself will be lost.</p>
        <p><a href='" . home_url('/dashboard/billing') . "'>SAVE MY NUMBER NOW</a></p>
        ";
    } elseif ($type === 'terminated') {
        $subject = 'Service Notice: VMO Phone Number Disconnected';
        $message = "
        <p>Hi {$user->display_name},</p>
        <p>This is a notification that your VMO Business Phone Number has been disconnected due to extended non-payment.</p>
        <p>The number has been released back to the telecom provider.</p>
        <p><strong>Your Data is Safe:</strong> All your past call logs, contacts, and recordings remain accessible in your dashboard.</p>
        <p>If you wish to resume phone service, you may provision a new number at any time from your dashboard settings.</p>
        ";
    }

    wp_mail($user->user_email, $subject, $message, $headers);
}
