<?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;
}

// 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'];
    }

    /**
     * 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 : '';
    }

    /**
     * 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.4 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);
    }

    /**
     * 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);
        return array(
            'kind' => 'chat',
            'tier' => $use_strategic ? 'strategic' : 'fast',
            'model' => $use_strategic ? 'gpt-5.4' : 'gpt-5.3-chat-latest',
            'fallback_model' => $use_strategic ? 'gpt-5.3-chat-latest' : 'gpt-5.4',
        );
    }

    /**
     * Execute a phone-turn chat completion against the selected GPT route.
     */
    private function run_phone_chat_completion(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 . ')');

        $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 = trim((string) ($response_json['choices'][0]['message']['content'] ?? ''));

        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,
            );
        }

        $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.3 by default and GPT-5.4 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', '');
        if (empty($api_key) || trim($speech) === '') {
            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.3-chat-latest');
        $messages = array_merge(
            array(array('role' => 'system', 'content' => $system_prompt)),
            $history
        );

        $max_tokens = ($tier === 'strategic') ? 320 : 220;
        $timeout = ($tier === 'strategic') ? 10 : 8;
        $result = $this->run_phone_chat_completion($api_key, $messages, $model, $max_tokens, $timeout, $call_sid, $tier);

        $fallback_model = (string) ($route['fallback_model'] ?? '');
        if ($result['content'] === '' && $fallback_model !== '' && $fallback_model !== $model && !empty($result['retryable'])) {
            $fallback_tier = ($tier === 'strategic') ? 'fast' : 'strategic';
            $fallback_tokens = ($fallback_tier === 'strategic') ? 320 : 220;
            $fallback_timeout = ($fallback_tier === 'strategic') ? 10 : 8;
            $fallback_result = $this->run_phone_chat_completion($api_key, $messages, $fallback_model, $fallback_tokens, $fallback_timeout, $call_sid, $fallback_tier);
            if ($fallback_result['content'] !== '') {
                return $fallback_result;
            }
        }

        return $result;
    }

    /**
     * Strip formatting so spoken responses stay natural.
     */
    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);
        return trim((string) preg_replace('/\s{2,}/', ' ', $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 = 4.0): string {
        $api_key = get_option('vmo_openai_api_key', '');
        if (empty($api_key) || empty(trim($text))) {
            return '';
        }

        $timeout_seconds = min(4.0, 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 ----
        // Timeout reduced from 15s to 4s — the initial voice webhook must
        // return TwiML within ~15s total, and other operations (DB, Twilio API
        // calls for recording/ring) consume 3-8s.  A slow TTS response was
        // pushing total time past the Twilio deadline, causing "application
        // error" for inbound calls.  If TTS doesn't respond in 4s, the
        // graceful Polly <Say> fallback kicks in (zero latency).
        $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 Polly → <Say voice="...">text</Say>.
     *
     * 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 '';
        }

        $fallback = '<Say voice="' . $this->tts_voice() . '">' . esc_xml($text) . '</Say>';

        $tts_timeout = 4.0;
        if ($request_started_at !== null) {
            $remaining_budget = 13.5 - (microtime(true) - $request_started_at);
            if ($remaining_budget <= 1.5) {
                return $fallback;
            }
            $tts_timeout = min($tts_timeout, $remaining_budget - 0.5);
        }

        $audio_url = $this->openai_tts_url($text, $call_sid, $user_id, $tts_timeout);
        if (!empty($audio_url)) {
            return '<Play>' . esc_xml($audio_url) . '</Play>';
        }
        // Graceful fallback — Polly via Twilio <Say>
        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);

        // 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', '');
            if ($last_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
    }

    /**
     * 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] From=' . $from_identity . ' To=' . $to . ' conference=' . $conference_name . ' mode=' . $mode);

        // 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);

        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 muted="true" 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', '');
            }
            error_log('[VMO Browser Call] caller_id=' . ($caller_id ?: '(none)') . ' dialing To=' . $to);
            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);
                        }
                    }
                }

                // 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));
                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 caller ID found for user ' . $user_id);
                echo '<Say voice="' . $this->tts_voice() . '">No business phone number is configured for outbound calls. Please set up your phone number in the dashboard settings.</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);
    }

    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() {
        $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() {
        $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() {
        $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() {
        $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() {
        $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() {
        $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() {
        $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() {
        $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) {
        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' => 30
        );

        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'),
        ));
        
        // === 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;

                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));
                        }
                    }
                }

                $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)
                    );
                }
            } 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->twilio_public_url('voice'),
                    '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
            // We charge an Activation Fee (e.g., 2500 Tokens ~ $3.00)
            $activation_cost = defined('VMO_TOKENS_PHONE_ACTIVATION') ? VMO_TOKENS_PHONE_ACTIVATION : 2500;
            
            // 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->twilio_public_url('voice'),
                    'SmsUrl' => $this->twilio_public_url('sms_webhook'),
                    'FriendlyName' => $friendly_id
                ));
            } else {
                // Step 2: Purchase phone number (Standard Path)
                $buy_args = array(
                    'PhoneNumber' => $selected_number,
                    'FriendlyName' => $friendly_id,
                    'VoiceUrl' => $this->twilio_public_url('voice'),
                    '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');
            
            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());
            // 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());
            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->twilio_public_url('voice'),
                    'SmsUrl' => $this->twilio_public_url('sms_webhook'),
                    'FriendlyName' => $friendly_id
                ));
            } else {
                $phone_number_data = $this->twilio_request('POST', '/IncomingPhoneNumbers.json', array(
                    'PhoneNumber' => $selected_number,
                    'FriendlyName' => $friendly_id,
                    'VoiceUrl' => $this->twilio_public_url('voice'),
                    '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;
        
        $type_str = $business_type ? ", a {$business_type} business" : '';
        $instructions = "You are the professional phone receptionist for {$business_name}{$type_str}.
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.

LANGUAGE: Detect the caller's language INSTANTLY and respond in it. Fluent in all global languages.

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 their detected language).
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.
- Keep responses concise (1-3 sentences). This is a phone call.
- 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
        $website_url = get_user_meta($user_id, 'website_url', true);
        if (empty($website_url)) {
            $website_url = home_url('/');
        }

        $context = array(
            '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),
            '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' => get_userdata($user_id)->user_email
        );

        // 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();

        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';
            
            if (empty($call_sid)) {
                 // Return 400 Bad Request
                 status_header(400);
                 return 'Missing CallSid';
            }
            
            // Validate User ID Lookup
            $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: If unmatched number, route to Admin (User 1 or current logged in?)
                 // Webhook is stateless, so we cannot check logged in.
                 // We will assume ID 1 is the Super Admin.
                 $admin_id = 1;
                 $user_id = $admin_id;
                 error_log($log_prefix . "Routing unmatched call to Admin ID: {$user_id}");
            }

        
        // 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) {
                    $this->link_call_to_contact($call_id, $from, $user_id);
                    
                    // Send Incoming Call Alert
                    $this->send_call_alert($user_id, 'incoming', array('caller' => $from));
                }
            }
        }
        
        // 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>';
        
        // 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']) : '';
        // Detect whether this is a Gather re-entry (Twilio posting back after
        // digit input) vs a truly fresh incoming call. On a fresh call Twilio
        // does NOT include the Digits parameter; after a <Gather> completes it
        // always includes Digits (possibly empty string on timeout).
        $is_gather_reentry = isset($params['Digits']);
        
        $did_gather = false;
        $has_extensions = !empty($extensions);
        $has_external_only = (!$has_extensions && !empty($external_phone));

        // 1. Handle Extension Input (if digits received)
        if ($incoming_digits !== '' && isset($extensions[$incoming_digits])) {
             // Will be routed in priority logic below
        }

        // 2. Output IVR Gather (if extensions exist and this is a truly fresh call)
        // Build a dynamic IVR menu from extension labels.
        // IMPORTANT: No 'action' attribute on <Gather>. This way:
        //   - If the caller presses a digit → Twilio POSTs to the current
        //     document URL (the phone number's Voice URL) with Digits=<digit>,
        //     and the extension routing logic above handles it.
        //   - If the caller stays on the line (timeout, no digits) → Twilio
        //     falls through to the NEXT TwiML verb (<Dial><Client>) and the
        //     browser rings. With an explicit action URL, Twilio would POST
        //     to it with Digits='' causing an infinite IVR loop.
        if (!$is_gather_reentry && $has_extensions && !isset($power_dialer_campaign)) {
             $did_gather = true;
             echo '<Gather input="dtmf" numDigits="1" timeout="8">';
             if ($recording_enabled) {
                echo '<Say voice="' . $this->tts_voice() . '">This call may be recorded for quality and training purposes.</Say>';
                echo '<Pause length="1"/>';
             }
             // Build IVR menu from labels
             $ivr_parts = array();
             foreach ($extensions as $digit => $phone) {
                 $label = isset($extension_labels[$digit]) ? $extension_labels[$digit] : 'extension ' . $digit;
                 $ivr_parts[] = 'For ' . $label . ', press ' . $digit . '.';
             }
             $ivr_parts[] = 'To speak with an assistant, please remain on the line.';
             
             // Use greeting as intro, then IVR menu
             $ivr_text = esc_xml($ai_greeting) . ' ' . implode(' ', $ivr_parts);
             echo '<Say voice="' . $this->tts_voice() . '">' . $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';
                }
                $this->twilio_request('POST', '/Calls.json', $ext_call_params);
                error_log("VMO: Extension ring (listen/takeover) initiated to {$ext_selected_phone} ({$ext_label}) for call {$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';
                }
            }
        }
        
        // PRIORITY 3: Removed — previously did a sequential <Dial> to external phone
        // which blocked for 25s before AI could respond. Now replaced by simultaneous
        // ring (when external phone set + no extensions). With extensions configured,
        // IVR handles routing and AI is the direct fallback after timeout.
        
        // 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="' . $this->tts_voice() . '">This call may be recorded for quality and training purposes.</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
        // Always ring the external phone simultaneously while AI handles,
        // regardless of whether extensions are configured. Extensions are
        // for IVR routing (caller presses a digit); the external phone
        // ring notifies the business owner of EVERY incoming call so they
        // can listen in or take over via the DTMF menu on their phone.
        // Skip on Gather re-entry (digit press) — the external phone was
        // already rung on the initial request.
        // =====================================================
        $has_external_phone = !empty($external_phone);
        if ($has_external_phone && !$is_gather_reentry && !isset($power_dialer_campaign) && empty($route_to_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',
                );
                // 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';
                }
                $this->twilio_request('POST', '/Calls.json', $ext_call_params);
                error_log("VMO: Simultaneous ring initiated to external phone {$external_phone} for call {$call_sid} record=" . ($recording_enabled ? 'true' : 'false'));
            } catch (Throwable $e) {
                error_log("VMO: Failed to ring 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 browser user gets the listen/takeover DTMF menu via the
        // ext_ring endpoint, just like external phone users.
        // =====================================================
        $ai_converse_url = $this->phone_gateway_url('ai_converse', array('uid' => $user_id));
        $is_fresh_call = !$is_gather_reentry && empty($params['skip_ring']) && empty($route_to_phone) && !isset($power_dialer_campaign);
        error_log('[VMO Voice] Browser ring check: is_fresh_call=' . var_export($is_fresh_call, 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)
            . ' 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 ($is_fresh_call && $user_id > 0) {
            // Ring the browser client via REST API (non-blocking).
            // When the browser answers, they get the listen/takeover DTMF menu.
            $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,
                );
                $this->twilio_request('POST', '/Calls.json', $browser_call_params);
                error_log('[VMO Voice] ✓ Browser ring (REST API) initiated to client:' . $client_identity . ' for call ' . $call_sid);
            } catch (Throwable $e) {
                error_log('[VMO Voice] ✗ Browser ring (REST API) FAILED: ' . $e->getMessage());
            }
        } else {
            error_log('[VMO Voice] ✗ Browser ring SKIPPED — is_fresh_call=' . var_export($is_fresh_call, true) . ' user_id=' . $user_id);
        }
        // ✅ RECORDING ANNOUNCEMENT - plays AFTER the ring phase so callers
        // hear ringing first (simulates a real phone connection attempt).
        if ($recording_enabled && !$did_gather) {
            echo '<Say voice="' . $this->tts_voice() . '">This call may be recorded for quality and training purposes.</Say>';
        }
        // ── 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 = $ext_selected ? 60 : 10;
        
        // Build the initial greeting based on context
        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?';
        }
        
        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="' . $gather_timeout . '" playBeep="false" trim="trim-silence" />';
        } else {
            echo '<Gather input="speech" action="' . esc_xml($ai_converse_url) . '" speechTimeout="2" timeout="' . $gather_timeout . '" enhanced="true" language="' . $this->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 = $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?';
        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="30" playBeep="false" trim="trim-silence" />';
        } else {
            echo '<Gather input="speech" action="' . esc_xml($ai_converse_url) . '" speechTimeout="2" timeout="30" enhanced="true" language="' . $this->gather_lang() . '">';
            echo '<Say voice="' . $this->tts_voice() . '">' . esc_xml($reprompt_msg) . '</Say>';
            echo '</Gather>';
        }
        // Voicemail fallback — only reached after BOTH Gathers time out
        echo '<Say voice="' . $this->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="' . $this->tts_voice() . '">Thank you for calling. Goodbye.</Say>';
        
        echo '</Response>';
        exit; // Ensure no trailing output

        } catch (Throwable $e) {
            // Fatal Error Handling within XML
            if (ob_get_length()) ob_clean();
            header('Content-Type: text/xml');
            error_log('VMO Twilio Webhook Critical Error: ' . $e->getMessage());
            echo '<?xml version="1.0" encoding="UTF-8"?>';
            echo '<Response><Say>We are currently experiencing technical difficulties. Please try again later.</Say></Response>';
            exit;
        }
    }

    /**
     * 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;

        error_log('[VMO AI Converse] CallSid=' . $call_sid . ' uid=' . $user_id . ' Speech="' . $speech . '" Confidence=' . $confidence);

        // --- 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);

        $ai_generation = $this->request_phone_ai_response($user_id, $system_prompt, $history, $speech, $call_sid);
        $ai_response = (string) ($ai_generation['content'] ?? '');

        if ($ai_response === '') {
            $ai_response = "I'm sorry, I didn't quite catch that. Could you repeat what you said, or would you like to 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;
            }
        }

        // Also end after a long conversation (prevent runaway loops)
        $turn_count = count(array_filter($history, function($m) { return $m['role'] === 'user'; }));
        if ($turn_count >= 15) {
            $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="8" 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="' . $this->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="' . $this->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="' . $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() . '">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="' . $this->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;
    }

    /**
     * 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;

        error_log('[VMO Whisper Converse] CallSid=' . $call_sid . ' uid=' . $user_id . ' RecordingSid=' . $recording_sid . ' Duration=' . $recording_duration . 's');

        // --- 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)) {
            $twilio_sid   = get_option('vmo_twilio_sid', '');
            $twilio_token = get_option('vmo_twilio_auth_token', '');
            if (!empty($twilio_sid) && !empty($twilio_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);

        $ai_generation = $this->request_phone_ai_response($user_id, $system_prompt, $history, $speech, $call_sid);
        $ai_response = (string) ($ai_generation['content'] ?? '');

        if ($ai_response === '') {
            $ai_response = "I'm sorry, I didn't quite catch that. Could you repeat what you said, or would you like to 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;
            }
        }

        $turn_count = count(array_filter($history, function($m) { return $m['role'] === 'user'; }));
        if ($turn_count >= 15) {
            $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="10" playBeep="false" trim="trim-silence" />';
            // Re-prompt before voicemail — keep the call alive
            echo '<Say voice="' . $this->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="' . $this->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="' . $this->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;
    }

    /**
     * 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
            );

            // Architecture C: gpt-4o-mini — faster + cheaper for sales AI.
            $model = 'gpt-4o-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;
        $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';
        
        $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) {
            // 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);
            }
        }

        // ── 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.
        $parent_sid = isset($params['parent_sid']) ? sanitize_text_field($params['parent_sid']) : '';
        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'));
                }
            }
        }

        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));
            
        if ($call_record && $call_status === 'completed' && $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,
                    $call_duration,
                    $call_direction,
                    $has_recording,
                    $ai_turns,
                    $tts_chars
                );
            } else {
                // Fallback: Old-style deduction (directional rates instead of flat 300)
                $minutes = ceil($call_duration / 60);
                $per_min = ($call_direction === 'outbound') ? 53 : 33;
                $total_tokens = $minutes * $per_min;
                if ($has_recording) {
                    $rec_minutes = ceil($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})");
            }

            // 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($call_sid, $recording_url);
            
            // Schedule summary generation (async to not block webhook)
            wp_schedule_single_event(time() + 60, 'vmo_generate_call_summary', array($call_sid, $recording_url));
        }

        // 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", $call_sid
            ));
            if ($existing && !empty($existing->transcript) && empty($existing->summary)) {
                // Generate summary directly from transcript (no Whisper needed)
                wp_schedule_single_event(time() + 30, 'vmo_summarize_call_transcript', array($call_sid));
            }
        }
            
        // ── Send Call Summary Email ──
        // For COMPLETED calls: defer the email so the AI summary & recording
        // are available. The vmo_send_call_summary_email cron action fires after
        // the summary has been generated (90s delay gives summarize time to finish).
        // For MISSED / short calls: send immediately since there's no summary to wait for.
        $alert_user_id = isset($call_record->user_id) ? (int) $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",
                    $call_sid
                ));
                if (!empty($stored_caller)) $caller_number = $stored_caller;
            }

            if ($call_status === 'completed' && $call_duration > 5) {
                // Schedule the full summary email AFTER summary generation completes
                // (summary is scheduled at +30s or +60s, so we add 90s to be safe)
                wp_schedule_single_event(time() + 90, 'vmo_send_call_summary_email', array($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' => $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) {
            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) {
        // In development/debug mode, skip verification for testing
        if (defined('WP_DEBUG') && WP_DEBUG) {
            return true;
        }

        // 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->twilio_public_url('voice');

        // 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');
        if ($current_voice_url === $expected_voice_url) {
            // 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;
        }

        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 keys 'vmo_phone_number' or 'phone' having this number
        $meta_users = $wpdb->get_col($wpdb->prepare(
            "SELECT user_id FROM {$wpdb->usermeta} WHERE (meta_key = 'vmo_phone_number' OR meta_key = 'phone') AND meta_value LIKE %s LIMIT 1",
            '%' . $cleaned
        ));

        if (!empty($meta_users)) {
             return intval($meta_users[0]);
        }

        return false;
    }

    private function normalize_phone_digits(string $phone): string {
        return preg_replace('/\D+/', '', $phone);
    }

    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;
    }

    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 {
        global $wpdb;

        if ($user_id <= 0) {
            return '';
        }

        $contacts_table = $wpdb->prefix . 'vmo_contacts';
        if ($wpdb->get_var($wpdb->prepare('SHOW TABLES LIKE %s', $wpdb->esc_like($contacts_table))) !== $contacts_table) {
            return '';
        }

        $normalized_digits = $this->normalize_phone_digits($phone_number);
        if ($normalized_digits === '') {
            return '';
        }

        $contact = $wpdb->get_row($wpdb->prepare(
            "SELECT id, contact_id FROM {$contacts_table}
             WHERE user_id = %d
             AND REPLACE(REPLACE(REPLACE(phone, '+', ''), '-', ''), ' ', '') LIKE %s
             LIMIT 1",
            $user_id,
            '%' . $wpdb->esc_like($normalized_digits) . '%'
        ));

        $now = current_time('mysql');
        if ($contact && !empty($contact->contact_id)) {
            $wpdb->update(
                $contacts_table,
                array(
                    'last_contact' => $now,
                    'updated_at' => $now,
                ),
                array('id' => (int) $contact->id),
                array('%s', '%s'),
                array('%d')
            );

            return (string) $contact->contact_id;
        }

        try {
            $contact_uuid = 'vmo_' . bin2hex(random_bytes(12));
        } catch (Exception $e) {
            $contact_uuid = 'vmo_' . wp_generate_password(24, false, false);
        }

        $display_phone = $this->normalize_phone_for_twilio($phone_number);
        if ($display_phone === '') {
            $display_phone = $phone_number;
        }

        $inserted = $wpdb->insert(
            $contacts_table,
            array(
                'user_id' => $user_id,
                'contact_id' => $contact_uuid,
                'source' => 'other',
                'primary_category' => 'lead',
                'lifecycle_stage' => 'new',
                'full_name' => 'SMS Contact ' . substr($normalized_digits, -4),
                'phone' => $display_phone,
                'last_contact' => $now,
                'created_at' => $now,
                'updated_at' => $now,
            ),
            array('%d', '%s', '%s', '%s', '%s', '%s', '%s', '%s', '%s', '%s')
        );

        return $inserted ? $contact_uuid : '';
    }

    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() {
        if (!$this->verify_ajax_request()) {
            wp_send_json_error(array('message' => 'Security check failed'), 403);
            return;
        }
        
        $user_id = get_current_user_id();
        $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_data) {
            wp_send_json_error(array('message' => 'No phone number found'));
        }
        
        // --- SELF-HEALING: Verify AI Agent Exists ---
        // If users have a phone but no AI Agent ID (due to previous provisioning failure),
        // we attempt to fix it right here, silently.
        $existing_agent = get_user_meta($user_id, 'twilio_ai_agent_sid', true);
        if (empty($existing_agent) && !empty($phone_number)) { 
             $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')));

        $total_calls = 0;
        $answered_calls = 0;
        $missed_calls = 0;
        $sum_duration = 0;
        $count_duration = 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);
            }
        }

        if ($missed_calls <= 0 && $total_calls > 0) {
            $missed_calls = max(0, $total_calls - $answered_calls);
        }

        $avg = ($count_duration > 0) ? (int) round($sum_duration / max(1, $count_duration)) : 0;

        // Derive top_intent from call summaries (if available)
        $top_intent = '—';
        $new_customers_count = 0;
        $return_customers_count = 0;

        $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
            $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 JSON_UNQUOTE(JSON_EXTRACT(ai_summary, '$.category'))
                 ORDER BY COUNT(*) DESC LIMIT 1",
                $user_id, $since
            ));
            if (!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
            ));
        }

        wp_send_json_success(array(
            'total_calls' => $total_calls,
            'answered_calls' => $answered_calls,
            'missed_calls' => $missed_calls,
            'average_duration' => $avg,
            'top_intent' => $top_intent,
            'new_customers' => $new_customers_count,
            'return_customers' => $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);
        
        // 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);
        
        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);
        
        if (is_wp_error($summary)) {
            error_log('VMO Twilio: Failed to generate summary: ' . $summary->get_error_message());
            return;
        }
        
        // Save to database
        global $wpdb;
        $table = $wpdb->prefix . 'vmo_phone_calls';
        
        // 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);
        
        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()),
                'key_info_collected' => array(),
            );
        }

        // ── 1. Create structured call report ──
        $reports_table = $wpdb->prefix . 'vmo_call_reports';
        if ($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(mb_substr($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);
        } else {
            $report_id = 0;
        }

        // ── 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) {
            $actions_required = $report_data['actions_required'] ?? array();
            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(mb_substr($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);
        }

        // ── 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)) {
            $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;
        $phone_digits = preg_replace('/[^0-9]/', '', $call->caller_number);
        if ($phone_digits === '') return;

        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;

        $update_data = array('updated_at' => current_time('mysql'));

        // Update name if still default
        $caller_name = sanitize_text_field($report_data['caller_name'] ?? '');
        if ($caller_name !== '' && strtoupper($caller_name) !== 'UNKNOWN') {
            $current_name = $contact->full_name ?? '';
            if (empty($current_name) || strpos($current_name, 'Caller ') === 0) {
                $update_data['full_name'] = $caller_name;
                // Split into first/last
                $parts = explode(' ', $caller_name, 2);
                $update_data['first_name'] = $parts[0];
                if (isset($parts[1])) $update_data['last_name'] = $parts[1];
            }
        }

        // Update email if discovered
        $email = sanitize_email($report_data['caller_email'] ?? ($report_data['key_info_collected']['email'] ?? ''));
        if ($email !== '' && is_email($email) && empty($contact->email)) {
            $update_data['email'] = $email;
        }

        // Update company if discovered
        $company = sanitize_text_field($report_data['key_info_collected']['company'] ?? '');
        if ($company !== '' && empty($contact->company)) {
            $update_data['company'] = $company;
        }

        // CRM category from structured analysis
        $crm_cat = $report_data['crm_category'] ?? '';
        $valid_cats = array('lead', 'customer', 'support', 'inquiry', 'vendor');
        if (in_array($crm_cat, $valid_cats, true)) {
            // Only upgrade category (lead → customer), never downgrade
            $current_cat = $contact->primary_category ?? 'lead';
            $rank = array('inquiry' => 1, 'lead' => 2, 'support' => 2, 'vendor' => 3, 'customer' => 4);
            if (($rank[$crm_cat] ?? 0) >= ($rank[$current_cat] ?? 0)) {
                $update_data['primary_category'] = $crm_cat;
            }
        }

        // Update lifecycle stage based on signals
        $interest = $report_data['sales_signals']['interest_level'] ?? '';
        if ($interest === 'hot' && ($contact->lifecycle_stage ?? '') !== 'customer') {
            $update_data['lifecycle_stage'] = 'active_relationship';
            $update_data['lead_score'] = max((int)($contact->lead_score ?? 0), 80);
        } elseif ($interest === 'warm') {
            if (in_array($contact->lifecycle_stage ?? '', array('new', 'engaged'), true)) {
                $update_data['lifecycle_stage'] = 'nurturing';
                $update_data['lead_score'] = max((int)($contact->lead_score ?? 0), 50);
            }
        } elseif ($interest === 'closed') {
            $update_data['primary_category'] = 'customer';
            $update_data['lifecycle_stage'] = 'customer';
            $update_data['lead_score'] = 100;
        }

        $update_data['last_contact'] = current_time('mysql');

        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: Enhanced CRM update for contact #' . $contact->id . ' — category=' . ($update_data['primary_category'] ?? $contact->primary_category));
        }
    }

    /**
     * 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[] = mb_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: ' . mb_substr($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 '';

        $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.";

        $payload = array(
            'model' => 'gpt-4o-mini',
            'messages' => array(
                array('role' => 'system', 'content' => "You compose professional business follow-up messages. Use ONLY real data provided. Be specific with prices, hours, addresses."),
                array('role' => 'user', 'content' => $prompt)
            ),
            'temperature' => 0.3,
            'max_tokens' => ($channel === 'sms') ? 150 : 500,
        );

        $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\": \"...\"}";

            $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(
                    'model' => 'gpt-4o-mini',
                    'messages' => array(
                        array('role' => 'system', 'content' => 'You generate concise executive report summaries. Return only valid JSON.'),
                        array('role' => 'user', 'content' => $prompt),
                    ),
                    'temperature' => 0.4,
                    'max_tokens' => 200,
                    'response_format' => array('type' => 'json_object'),
                )),
                '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'] ?? '';
                }
            }
        }

        return $cover;
    }

    /**
     * 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(mb_substr($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() {
        if (!$this->verify_ajax_request()) { 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);
        }

        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() {
        if (!$this->verify_ajax_request()) { 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);
        }

        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() {
        if (!$this->verify_ajax_request()) { 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) {
            wp_send_json_success(array('reports' => array()));
        }

        $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 *, NULL AS detail_data FROM {$table} WHERE {$where} ORDER BY period_start DESC LIMIT 100");

        foreach ($reports as &$r) {
            $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);
            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);
        }

        wp_send_json_success(array('reports' => $reports));
    }

    /**
     * AJAX: Generate a custom date range report.
     */
    public function ajax_generate_custom_report() {
        if (!$this->verify_ajax_request()) { 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) {
            wp_send_json_error('Invalid date range');
        }

        $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);
        }

        wp_send_json_success(array('report' => $report));
    }

    /**
     * AJAX: Get fulfillment log.
     */
    public function ajax_get_fulfillment_log() {
        if (!$this->verify_ajax_request()) { 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) {
            wp_send_json_success(array('log' => array()));
        }

        $log = $wpdb->get_results($wpdb->prepare(
            "SELECT * FROM {$table} WHERE user_id = %d ORDER BY created_at DESC LIMIT 100",
            $user_id
        ));

        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 transcript 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;
        }
        
        $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);
        
        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);
        
        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';
        }
        
        // Add auth credentials to URL
        $auth_url = str_replace('https://', 'https://' . $this->account_sid . ':' . $this->auth_token . '@', $recording_url);
        
        $response = wp_remote_get($auth_url, array(
            'timeout' => 60,
            'sslverify' => false
        ));
        
        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) {
        // ✅ 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 = get_current_user_id();
        if (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');
            }
        }
        
        // 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-4
     * 
     * @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) {
        // ✅ 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 = get_current_user_id();
        if (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');
            }
        }
        
        $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 | customer | 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"]
  },
  "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.
- If a purchase/order was discussed or placed, set crm_category to "customer".
- 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: GPT-5.2 requires max_completion_tokens, not max_tokens
        $_tw_model = defined('VMO_OPENAI_MODEL') ? VMO_OPENAI_MODEL : 'gpt-5.2-2025-12-11';
        $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 gpt-4o if primary fails
        if (is_wp_error($response) || wp_remote_retrieve_response_code($response) >= 400) {
             error_log('VMO: Call summary generation failed. Retrying with gpt-4o.');
             $payload['model'] = 'gpt-4o';
             // ✅ FIX: Swap max_completion_tokens back to max_tokens for gpt-4o
             if (isset($payload['max_completion_tokens'])) {
                 $payload['max_tokens'] = $payload['max_completion_tokens'];
                 unset($payload['max_completion_tokens']);
             }
             $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();
        
        error_log('VMO Twilio Dial Status: ' . print_r($params, true));
        
        $dial_call_status = isset($params['DialCallStatus']) ? sanitize_text_field($params['DialCallStatus']) : '';
        // 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;
        if (!empty($call_sid)) {
            global $wpdb;
            $table = $wpdb->prefix . 'vmo_phone_calls';
            if ($wpdb->get_var("SHOW TABLES LIKE '{$table}'") === $table) {
                $user_id = (int) $wpdb->get_var($wpdb->prepare(
                    "SELECT user_id FROM {$table} WHERE call_sid = %s ORDER BY id DESC LIMIT 1",
                    $call_sid
                ));

                // 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']);
        }
        
        // If the user answered and completed, we can end the Twilio leg.
        // Otherwise, we should fall back to the AI attendant.
        $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/>';
        } 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;
    }

    /**
     * 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']) : '';
        
        error_log("VMO Ext Ring: uid={$user_id}, call_sid={$call_sid}, caller={$caller}, digits={$digits}");
        
        header('Content-Type: text/xml; charset=UTF-8');
        echo '<?xml version="1.0" encoding="UTF-8"?>';
        echo '<Response>';
        
        // If digits have been pressed, handle the action
        if ($digits !== '') {
            $conference_name = 'vmo_inbound_' . $call_sid;
            
            if ($digits === '1') {
                // LISTEN MODE: Keep the AI active on the caller's leg while
                // the answerer monitors via periodic transcript TTS updates.
                // 
                // ARCHITECTURE FIX: Previously redirected the inbound call into
                // a conference, which killed the AI <Gather> loop and left the
                // caller in silence. Now we use the ext_monitor endpoint which
                // periodically reads new transcript content via TTS while the
                // AI continues handling the caller. Press 2 at any point to
                // take over (redirect caller into conference). Press * to leave.
                error_log("VMO Ext Ring: User chose LISTEN (monitor mode) for call {$call_sid}");
                
                // Mark as user-listening (AI still active)
                global $wpdb;
                // phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery, WordPress.DB.DirectDatabaseQuery.NoCaching
                $wpdb->update(
                    $wpdb->prefix . 'vmo_phone_calls',
                    array('topic' => 'monitor (ext-listen)'),
                    array('call_sid' => $call_sid),
                    array('%s'),
                    array('%s')
                );
                
                // Get current transcript length so monitor starts from here
                $transcript = (string) $wpdb->get_var($wpdb->prepare(
                    "SELECT transcript FROM {$wpdb->prefix}vmo_phone_calls WHERE call_sid = %s LIMIT 1",
                    $call_sid
                ));
                $last_len = strlen($transcript);
                
                // Redirect this external phone leg to the monitor loop
                $monitor_url = $this->phone_gateway_url('ext_monitor', array(
                    'uid'      => $user_id,
                    'call_sid' => $call_sid,
                    'last_len' => $last_len,
                    'loops'    => 0,
                ));
                
                echo '<Say voice="' . $this->tts_voice() . '">You are now monitoring the call. The A I receptionist is speaking with the caller. Press 2 to take over at any time. Press star to disconnect.</Say>';
                echo '<Redirect>' . esc_xml($monitor_url) . '</Redirect>';
                
            } 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);
                $conf_rec_attr = $rec_enabled ? ' record="record-from-start" recordingStatusCallback="' . esc_attr($rec_cb_url) . '" recordingStatusCallbackEvent="completed"' : '';
                
                // Redirect the inbound call into the conference
                try {
                    $this->twilio_request('POST', "/Calls/{$call_sid}.json", array(
                        'Twiml' => '<Response><Dial><Conference startConferenceOnEnter="true" endConferenceOnExit="true" beep="false" waitUrl=""' . $conf_rec_attr . '>' . $conference_name . '</Conference></Dial></Response>'
                    ));
                } catch (Throwable $e) {
                    error_log("VMO Ext Ring: Failed to redirect inbound call: " . $e->getMessage());
                }
                
                // Mark as user-handled
                global $wpdb;
                // phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery, WordPress.DB.DirectDatabaseQuery.NoCaching
                $wpdb->update(
                    $wpdb->prefix . 'vmo_phone_calls',
                    array('ai_handled' => 0, 'topic' => 'conference:' . $conference_name . ' (ext-takeover)'),
                    array('call_sid' => $call_sid),
                    array('%d', '%s'),
                    array('%s')
                );
                
                // Join this external phone call UNMUTED into the conference
                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>';
                
            } 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,
                ));
                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/>';
                
            } elseif ($digits === '*') {
                // Decline
                echo '<Say voice="' . $this->tts_voice() . '">Disconnected. The AI will continue handling the call.</Say>';
                echo '<Hangup/>';
            } else {
                // Invalid digit — repeat the menu
                $action_url = $this->phone_gateway_url('ext_ring', array(
                    'uid'      => $user_id,
                    'call_sid' => $call_sid,
                    'caller'   => $caller,
                ));
                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/>';
            }
        } 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);
            
            echo '<Gather input="dtmf" numDigits="1" timeout="45" action="' . esc_xml($action_url) . '">';
            echo '<Say voice="' . $this->tts_voice() . '">Incoming call from ' . esc_xml($caller_spoken) . '.</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 the action when a user presses * during conference listen mode.
     *
     * When the external phone user is listening to a call (muted in a
     * conference) and presses *, the hangupOnStar Dial attribute triggers
     * this action URL. We rejoin them to the same conference UNMUTED so
     * they can speak with the caller (take over).
     */
    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']) : '';
        $listen_conf = 'vmo_listen_' . $call_sid;

        error_log("VMO Ext Listen Action: User taking over call {$call_sid} from listen mode");

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

        // Mark as user-handled (takeover from listen)
        global $wpdb;
        // phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery, WordPress.DB.DirectDatabaseQuery.NoCaching
        $wpdb->update(
            $wpdb->prefix . 'vmo_phone_calls',
            array('ai_handled' => 0, 'topic' => 'conference:' . $listen_conf . ' (ext-takeover)'),
            array('call_sid' => $call_sid),
            array('%d', '%s'),
            array('%s')
        );

        // Rejoin the SAME conference UNMUTED so the user can now speak
        $rec_enabled = $this->is_recording_enabled($user_id);
        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($this->twilio_public_url('recording')) . '" recordingStatusCallbackEvent="completed"';
        }
        echo '>' . esc_xml($listen_conf) . '</Conference>';
        echo '</Dial>';

        echo '</Response>';
        exit;
    }

    /**
     * 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: uid={$user_id}, call_sid={$call_sid}, loop={$loops}, digits={$digits}");
        
        header('Content-Type: text/xml; charset=UTF-8');
        echo '<?xml version="1.0" encoding="UTF-8"?>';
        echo '<Response>';
        
        // DIGIT 2: Take over the call
        if ($digits === '2') {
            $conference_name = 'vmo_inbound_' . $call_sid;
            error_log("VMO Ext Monitor: User taking over call {$call_sid}");
            
            // Redirect the inbound call into a conference (interrupts AI)
            $rec_enabled = $this->is_recording_enabled($user_id);
            $rec_cb_url = $this->twilio_public_url('recording');
            $conf_rec_attr = $rec_enabled ? ' record="record-from-start" recordingStatusCallback="' . esc_attr($rec_cb_url) . '" recordingStatusCallbackEvent="completed"' : '';
            try {
                $this->twilio_request('POST', "/Calls/{$call_sid}.json", array(
                    'Twiml' => '<Response><Dial><Conference startConferenceOnEnter="true" endConferenceOnExit="true" beep="false" waitUrl=""' . $conf_rec_attr . '>' . $conference_name . '</Conference></Dial></Response>'
                ));
            } catch (Throwable $e) {
                error_log("VMO Ext Monitor: Failed to redirect inbound call: " . $e->getMessage());
            }
            
            // Mark as user-handled
            global $wpdb;
            // phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery, WordPress.DB.DirectDatabaseQuery.NoCaching
            $wpdb->update(
                $wpdb->prefix . 'vmo_phone_calls',
                array('ai_handled' => 0, 'topic' => 'conference:' . $conference_name . ' (ext-takeover)'),
                array('call_sid' => $call_sid),
                array('%d', '%s'),
                array('%s')
            );
            
            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>';
            exit;
        }
        
        // 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;
    }
    
    /**
     * 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($user_id, $call_id));
                }
            }
        }
        
        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
     * @return array|WP_Error Result or error
     */
    public function call_action($action_type, $call_sid, $conference_sid, $user_id, $via = 'auto') {
        try {
            $external_phone = get_user_meta($user_id, 'vmo_external_phone', true);
            $vmo_phone = $this->get_user_phone($user_id);
            $use_browser = ($via === 'browser');

            switch ($action_type) {
                case 'join':
                case 'barge':
                    $conference_name = !empty($conference_sid) ? $conference_sid : 'vmo_call_' . $call_sid;

                    // Redirect the active Twilio call into the conference
                    // (interrupts any Gather/AI conversation in progress)
                    $this->twilio_request('POST', "/Calls/{$call_sid}.json", array(
                        'Twiml' => '<Response><Dial><Conference startConferenceOnEnter="true" endConferenceOnExit="false" beep="false" waitUrl="">' . $conference_name . '</Conference></Dial></Response>'
                    ));
                    sleep(1);

                    // Update DB with real conference name for tracking
                    global $wpdb;
                    // phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery, WordPress.DB.DirectDatabaseQuery.NoCaching
                    $wpdb->update(
                        $wpdb->prefix . 'vmo_phone_calls',
                        array('topic' => 'conference:' . $conference_name, 'ai_handled' => 0),
                        array('call_sid' => $call_sid),
                        array('%s', '%d'),
                        array('%s')
                    );

                    $result = array(
                        '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':
                    // MONITOR MODE: Keep the AI active on the caller's leg.
                    // The dashboard user monitors via transcript updates.
                    //
                    // ARCHITECTURE FIX: Previously redirected the inbound call
                    // into a conference, killing the AI <Gather> loop and
                    // leaving the caller in silence. Now we keep the AI
                    // running and return the current transcript to the browser
                    // for real-time display. The browser can then "take over"
                    // by calling the 'join' action which redirects the caller
                    // into a conference at that point.
                    global $wpdb;
                    $call_record = $wpdb->get_row($wpdb->prepare(
                        "SELECT ai_handled, transcript, status FROM {$wpdb->prefix}vmo_phone_calls WHERE call_sid = %s LIMIT 1",
                        $call_sid
                    ));

                    // 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(
                        'message'         => 'Monitoring AI conversation — caller is being helped by the AI receptionist',
                        'mode'            => 'transcript',
                    );

                    // Include any transcript the AI already gathered
                    if ($call_record && !empty($call_record->transcript)) {
                        $result['transcript'] = $call_record->transcript;
                    }

                    return $result;

                case 'resume_ai':
                    // Hand call back to AI after user takeover
                    // Redirect the call back to the voice webhook (AI Gather loop)
                    $voice_url = $this->phone_gateway_url('voice', array('uid' => $user_id));
                    $this->twilio_request('POST', "/Calls/{$call_sid}.json", array(
                        'Url'    => $voice_url,
                        'Method' => 'POST',
                    ));

                    // 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')
                    );

                    return array(
                        'message' => 'Call handed back to AI receptionist',
                    );

                default:
                    return new WP_Error('invalid_action', 'Invalid action type');
            }
        } catch (Exception $e) {
            error_log('VMO Twilio call_action error: ' . $e->getMessage());
            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 {
            // 1) Tell Twilio to complete the call
            $this->twilio_request('POST', "/Calls/{$call_sid}.json", array(
                'Status' => 'completed'
            ));

            // 2) Immediately update local DB — don't wait for status callback.
            //    This prevents phantom ringing caused by stale DB status.
            global $wpdb;
            $table = $wpdb->prefix . 'vmo_phone_calls';
            if ($wpdb->get_var("SHOW TABLES LIKE '{$table}'") === $table) {
                // phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery, WordPress.DB.DirectDatabaseQuery.NoCaching
                $updated = $wpdb->update(
                    $table,
                    array('status' => 'completed'),
                    array('call_sid' => $call_sid),
                    array('%s'),
                    array('%s')
                );

                // 3) If no row existed (webhook INSERT never happened), create one now
                //    so call history always shows the call.
                if ($updated === 0 && !$wpdb->get_var($wpdb->prepare("SELECT id FROM {$table} WHERE call_sid=%s LIMIT 1", $call_sid))) {
                    $user_id = !empty($extra['user_id']) ? intval($extra['user_id']) : get_current_user_id();
                    $direction = !empty($extra['direction']) ? sanitize_text_field($extra['direction']) : 'outbound';
                    $caller = !empty($extra['caller_number']) ? sanitize_text_field($extra['caller_number']) : '';
                    // phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery, WordPress.DB.DirectDatabaseQuery.NoCaching
                    $wpdb->insert($table, array(
                        'user_id'       => $user_id,
                        'call_sid'      => $call_sid,
                        'caller_number' => $caller,
                        'direction'     => $direction,
                        'status'        => 'completed',
                        '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 ' . $call_sid);
                }
            }
            // Also update power dialer calls
            $pd_table = $wpdb->prefix . 'vmo_power_dialer_calls';
            if ($wpdb->get_var("SHOW TABLES LIKE '{$pd_table}'") === $pd_table) {
                // phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery, WordPress.DB.DirectDatabaseQuery.NoCaching
                $wpdb->update(
                    $pd_table,
                    array('status' => 'completed'),
                    array('call_sid' => $call_sid),
                    array('%s'),
                    array('%s')
                );
            }

            return array('message' => 'Call ended successfully');
            
        } catch (Exception $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.
     * Also auto-categorize: if summary indicates purchase/customer → 'customer', else 'lead'.
     */
    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|customer|closed deal|closed)\b/', $lower_summary)) {
            $category = 'customer';
        } 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) . '"');
        }
    }

    public function link_call_to_contact($call_id, $caller_number, $user_id) {
        global $wpdb;
        $contacts_table = $wpdb->prefix . 'vmo_contacts';
        $interactions_table = $wpdb->prefix . 'vmo_contact_interactions';
        $calls_table = $wpdb->prefix . 'vmo_phone_calls';
        
        // Normalize phone number for matching
        $normalized = preg_replace('/[^0-9]/', '', $caller_number);
        
        // Try to find existing contact
        $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($normalized) . '%'
        ));
        
        if (!$contact) {
            // Create new contact from caller — generate a proper contact_id UUID
            $uuid = 'vmo_' . bin2hex(random_bytes(12));
            // phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery, WordPress.DB.DirectDatabaseQuery.NoCaching
            $wpdb->insert($contacts_table, array(
                'user_id' => $user_id,
                'contact_id' => $uuid,
                'full_name' => 'Caller ' . substr($caller_number, -4),
                'phone' => $caller_number,
                'source' => 'phone_call',
                'primary_category' => 'lead',
                'lifecycle_stage' => 'new',
                'created_at' => current_time('mysql'),
                'updated_at' => current_time('mysql')
            ));
            
            $contact_id = $wpdb->insert_id;
            $contact_uuid = $uuid;
        } else {
            $contact_id = $contact->id;
            $contact_uuid = $contact->contact_id;
            
            // Update last contacted
            // phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery, WordPress.DB.DirectDatabaseQuery.NoCaching
            $wpdb->update(
                $contacts_table,
                array('updated_at' => current_time('mysql')),
                array('id' => $contact_id),
                array('%s'),
                array('%d')
            );
        }
        
        // 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')
        );
        
        // Log interaction — use the VARCHAR contact_id (UUID), not the numeric id,
        // since vmo_contact_interactions.contact_id is VARCHAR(255).
        // phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery, WordPress.DB.DirectDatabaseQuery.NoCaching
        $wpdb->insert($interactions_table, array(
            'contact_id' => $contact_uuid,
            'user_id' => $user_id,
            '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',
            'created_at' => current_time('mysql')
        ));
        
        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): string {
        // ── 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: " . mb_substr($sumtext, 0, 500) . "\n";
                        }
                    } elseif (is_string($profile->business_summary) && $profile->business_summary !== '') {
                        $profile_ctx .= "\nBusiness Summary: " . mb_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: " . mb_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);
                if (is_array($pages)) {
                    $useful_pages = array();
                    foreach ($pages as $slug => $page_data) {
                        $title = is_array($page_data) ? ($page_data['title'] ?? $slug) : $slug;
                        $content = is_array($page_data) ? ($page_data['content'] ?? '') : (is_string($page_data) ? $page_data : '');
                        if (is_string($content) && $content !== '' && strlen($content) < 800) {
                            $useful_pages[] = "{$title}: " . wp_strip_all_tags($content);
                        }
                    }
                    if (!empty($useful_pages)) {
                        $website_ctx = "\nWebsite Content:\n" . implode("\n\n", array_slice($useful_pages, 0, 5)) . "\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('/');
        }
        $live_site_ctx = '';
        if ($user_id > 0 && $scan_url !== '') {
            $cache_key = 'vmo_site_scan_' . md5($scan_url) . '_' . $user_id;
            $cached = get_transient($cache_key);
            if ($cached !== false) {
                $live_site_ctx = $cached;
            } else {
                $base_url = $scan_url;
                if (strpos($base_url, 'http') !== 0) {
                    $base_url = 'https://' . $base_url;
                }
                $base_url = rtrim($base_url, '/');

                // Scrape homepage + common subpages for comprehensive business context
                $pages_to_scan = array(
                    '' => 'Homepage',
                    '/about' => 'About',
                    '/about-us' => 'About Us',
                    '/services' => 'Services',
                    '/products' => 'Products',
                    '/pricing' => 'Pricing',
                    '/menu' => 'Menu',
                    '/contact' => 'Contact',
                    '/faq' => 'FAQ',
                );
                $all_text = '';
                $pages_scraped = 0;
                $max_total_chars = 4000; // budget across all pages
                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' => false,
                        '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
                    $budget_left = $max_total_chars - strlen($all_text);
                    $page_text = mb_substr($text, 0, min(1200, $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 {BUSINESS_NAME}. You speak as a representative of this business — first person plural ("we offer", "our team", "we'd love to"). You are NOT a separate service. You are NOT a third-party AI assistant. You are an employee of this business.

ABSOLUTE PROHIBITIONS:
- NEVER say "I don't see that feature" or "I don't have access to that" or "that's not visible on my side."
- NEVER refer to "VMO" or "Virtual Media Office" as a separate company or platform.
- NEVER tell callers to "contact VMO" or "check with VMO" or "call them to verify."
- 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. Keep responses concise (1-3 sentences per turn). This is a live phone call — no monologues.
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: You MUST respond in ' . $this->prompt_lang() . '. All your responses should be in ' . $this->prompt_lang() . '. Only switch languages if the caller explicitly speaks a different language.
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 .= $brief_ctx;
            $prompt .= $profile_ctx;
            $prompt .= $consult_ctx;
            $prompt .= $meta_profile_ctx;
            $prompt .= $store_ctx;
            $prompt .= $booking_ctx;
            $prompt .= $website_ctx;
            $prompt .= $live_site_ctx;
        }

        // gpt-5.1 supports large context — allow up to 12000 chars for system prompt
        if (strlen($prompt) > 12000) {
            $prompt = mb_substr($prompt, 0, 11950) . "\n[...truncated for brevity]";
        }

        return $prompt;
    }

    /**
     * Send email alert for call events
     */
    private function send_call_alert($user_id, $type, $data) {
        $user = get_userdata($user_id);
        if (!$user) return;

        // Gate: if user disabled incoming call email alerts, skip 'incoming' type
        if ($type === 'incoming') {
            $alerts_pref = get_user_meta($user_id, 'vmo_email_incoming_alerts', true);
            if ($alerts_pref === '0') return;
        }
        
        $to = $user->user_email;
        $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');
        
        $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) ? 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;
        
        $headers = array('Content-Type: text/html; charset=UTF-8');
        wp_mail($to, $subject, $message, $headers);
    }

    /**
     * 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;
        }

        $user_id = (int) $call->user_id;
        $user = get_userdata($user_id);
        if (!$user) return;

        // Gate: if user disabled post-call summary emails, skip
        $summaries_pref = get_user_meta($user_id, 'vmo_email_call_summaries', true);
        if ($summaries_pref === '0') return;

        $to = $user->user_email;
        $business_name = get_user_meta($user_id, 'business_name', true) ?: get_bloginfo('name');
        $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 : '';

        // 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'];
        } else {
            $display_summary = $summary_text;
        }

        // ── 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;">';
        $html .= '<a href="' . esc_url($dashboard_link) . '" 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>';
        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>';

        // Direct recording link
        if (!empty($recording_url)) {
            $html .= '<p style="font-size:12px;color:#888;margin-bottom:16px;">Or <a href="' . esc_url($recording_url) . '" style="color:#1a73e8;">download the recording directly</a>.</p>';
        }

        // ── Transcript ──
        if (!empty($transcript_text)) {
            $truncated = (strlen($transcript_text) > 2500) ? mb_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 = array('Content-Type: text/html; charset=UTF-8');
        wp_mail($to, $subject, $html, $headers);

        error_log('VMO: Enhanced post-call report email sent for ' . $call_sid . ' to ' . $to);
    }

    /**
     * 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
                );
            } 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");
            }

            // 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.";

            // 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';
        
        // 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','no-answer'))
                 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) {
                    // phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery, WordPress.DB.DirectDatabaseQuery.NoCaching
                    $wpdb->update(
                        $phone_calls_table,
                        array('status' => 'completed'),
                        array('call_sid' => $sid),
                        array('%s'),
                        array('%s')
                    );
                    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');
                $caller = (string) ($r['caller_number'] ?? '');
                $from = ($direction === 'outbound') ? $biz_number : $caller;
                $to   = ($direction === 'outbound') ? $caller     : $biz_number;
                $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,
                );
            }
        }

        // 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;

        return array('calls' => $calls);
    }

    /**
     * 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');
        }
        
        // 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);
        
        try {
            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);
}
