<?php
/**
 * ivr.php — Windshields To Go USA · Twilio TwiML IVR Order System
 * Upload to: /home/auto11/public_html/ivr/ivr.php
 *
 * Twilio webhook URL: https://autoglasshosting.com/ivr/ivr.php
 *
 * Flow: Welcome+ZIP → Year → Make → Model → Style → Glass
 *       → First → Last → Street → City → State → Phone → Email
 *       → PO/RO → Unit → Notes → Write order → SMS + Email VIN link
 *
 * Account routing by Twilio "To" param (original RC DNIS passed through):
 *   +18778072201 → PTL   (Penske)
 *   +18778457544 → LINDE
 *   +18772240909 → LINCARE
 *
 * STT: Deepgram Nova-2 via Twilio <Gather>
 * Press 9 at any time to start over
 * Press 0 at any time to transfer to live agent
 */

// ─── CONFIG ───────────────────────────────────────────────────────────────────
$config = require '/home/auto11/var/config.php';

define('TWILIO_ACCOUNT_SID', $config['twilio_account_sid'] ?? '');
define('TWILIO_API_KEY_SID', $config['twilio_api_key_sid'] ?? '');
define('TWILIO_API_SECRET',  $config['twilio_api_secret']  ?? '');
define('TWILIO_NUMBER',      $config['twilio_number']      ?? '');
define('DEEPGRAM_API_KEY',   $config['deepgram_api_key']   ?? '');

define('RC_CLIENT_ID',       'XH2IPXECRWdczFx0giHgxh');
define('RC_CLIENT_SECRET',   'bOEeTScZ7pwepvzW5tA9gvXa1pQNT1zRZbgSyNBWJap8');
define('RC_SERVER',          'https://platform.ringcentral.com');
define('RC_SMS_FROM',        '+18059785161');

define('DB_HOST',  'localhost');
define('DB_USER',  'windshi_quote');
define('DB_PASS',  $config['db_password'] ?? '');
define('DB_NAME',  'windshi_main');

define('IVR_COMPANY',    'Windshields To Go USA');
define('IVR_TRANSFER',   '+18005494470');
define('PROMPT_BASE',    'https://autoglasshosting.com/ivr/prompts/');
define('VIN_LINK_BASE',  'https://autoglasshosting.com/ivr/vin.php');
define('PAY_LINK_BASE',  'https://autoglasshosting.com/ivr/pay.php');
define('W2G_EMAIL',      'w2gusa@gmail.com');
define('SESSION_DIR',    '/tmp/ivr_sessions');
define('LOG_FILE',       '/tmp/ivr_twilio.log');

// ─── LATENCY TUNING ───────────────────────────────────────────────────────────
// Flip these one at a time and listen to a test call after each.
// Set all to false to get the original (slow) behavior back.
define('FAST_BACKGROUND_STT', true);  // #1 transcribe while filler plays, not after
define('FAST_GATHER_FIELDS',  true);  // #2 zip/glass/date via Deepgram Gather, no Whisper
define('FAST_FILLER_LOOP',    true);  // #1b short seamless loop + polling, not a 9.5s block
define('FAST_TRIM_PAUSES',    true);  // #5 Record silence timeout 3s -> 2s

// filler_loop.wav is a 1.34s seamless cut of silence_filler.mp3 (its underlying
// bed repeats every 0.67s, so 2 cycles loop with no audible seam). WAV rather
// than MP3 on purpose: MP3 encoder padding puts a click between loop iterations.
//
// If your host or Twilio won't play the WAV, set FILLER_USE_WAV to false. That
// switches to filler_x1..x4.mp3 — the same bed pre-repeated 1-4 times inside a
// single file, so no <Play loop> attribute and no MP3 gap.
define('FILLER_USE_WAV',     true);
define('FILLER_LOOP',        'joined/filler_loop.wav');
define('FILLER_MP3_PREFIX',  'joined/filler_x');            // + "1".."4" + ".mp3"
define('FILLER_BLOCK',       'joined/silence_filler.mp3');  // original 9.5s
define('FILLER_FIRST_LOOPS', 2);   // ~2.7s on the first pass, covers most transcriptions
define('FILLER_MAX_POLLS',   6);   // then ~8s more of 1.34s polls before giving up

define('REC_SILENCE_TIMEOUT', FAST_TRIM_PAUSES ? 2 : 3);

// ─── GATHER ENDPOINTING ───────────────────────────────────────────────────────
// 'auto' lets Twilio detect end-of-speech instead of waiting a fixed silence
// timer. This is the single biggest source of dead air on the Gather fields
// (zip, glass, other-glass, install date). Set to '3' to restore the old fixed
// behavior if auto ever clips people mid-answer.
define('GATHER_SPEECH_TIMEOUT', '1');
// Digit strings get chunked ("nine three one... oh five") and a 1s timeout cuts
// people off mid-number. A retry costs ~8s, far more than the 2s this spends.
define('GATHER_SPEECH_TIMEOUT_DIGITS', '2');

// Standalone welcome + separate ZIP question. Both play in one TwiML response,
// so splitting the audio costs nothing in latency. The retry prompt is the
// question alone — the greeting is never replayed.
define('WELCOME_MP3',      'joined/01_welcome1.mp3');
define('ASK_ZIP_MP3',      'joined/02_ask_zip.mp3');
define('ASK_ZIP_RETRY_MP3','joined/02_ask_zip_retry.mp3');
// First-time prompt = greeting + question, played back-to-back in one response.
define('WELCOME_ZIP_PROMPT', array(WELCOME_MP3, ASK_ZIP_MP3));
// NOTE: with input="speech dtmf" this timer runs TWICE — once waiting for input
// to start, and again after you stop speaking while Twilio waits to see if you
// say another word or press a key. It is additive with speechTimeout, so it is
// half of any post-answer silence you hear.
define('GATHER_START_TIMEOUT',  2);

// numDigits=5 makes keypad ZIP entry submit instantly on the 5th key instead of
// waiting out GATHER_START_TIMEOUT. Trade-off: it disables the single-key
// escapes (2 restart / 3 agent / 4 order) on the ZIP prompt only, because
// Twilio then waits for 5 keys before posting. Off by default.
define('ZIP_NUM_DIGITS', false);

// Account profiles keyed by DNIS
$ACCOUNTS = array(
    '+18778072201' => array('code' => 'PTL',     'company' => 'Penske',  'ask_po' => true,  'po_prefill' => '',         'aff_id' => 0, 'requires_payment' => false),
    '+18778457544' => array('code' => 'LINDE',   'company' => 'Linde',   'ask_po' => true,  'po_prefill' => '',         'aff_id' => 0, 'requires_payment' => true),
    '+18772240909' => array('code' => 'LINCARE', 'company' => 'Lincare', 'ask_po' => true,  'po_prefill' => '16020568', 'aff_id' => 0, 'requires_payment' => false),
);
$DEFAULT_ACCOUNT = array('code' => 'W2G', 'company' => '', 'ask_po' => false, 'po_prefill' => '', 'ask_unit' => false, 'aff_id' => 0, 'requires_payment' => true);

// Used when the caller picks FLEET at the mode prompt but the number they dialed
// isn't one of the known fleet DNISes above (e.g. testing from a direct line).
// Without this, "fleet" callers get $DEFAULT_ACCOUNT and are quoted retail prices.
$FLEET_ACCOUNT = array('code' => 'FLEET', 'company' => '', 'ask_po' => true, 'po_prefill' => '', 'ask_unit' => true, 'aff_id' => 0, 'requires_payment' => false);

// ─── BOOTSTRAP ────────────────────────────────────────────────────────────────
error_reporting(0);
// Buffer output so ivr_flush_response() can send a correct Content-Length on
// hosts without fastcgi_finish_request(). PHP flushes this at shutdown anyway.
if (ob_get_level() === 0) ob_start();
if (!is_dir(SESSION_DIR)) mkdir(SESSION_DIR, 0700, true);

function ivr_log($msg) {
    $t = microtime(true);
    $stamp = date('[Y-m-d H:i:s', (int)$t) . sprintf('.%03d] ', ($t - floor($t)) * 1000);
    file_put_contents(LOG_FILE, $stamp . $msg . "\n", FILE_APPEND);
}

// ─── ERROR HANDLER ────────────────────────────────────────────────────────────
// Catches PHP errors and returns valid TwiML instead of crashing
register_shutdown_function(function() {
    $e = error_get_last();
    if ($e && in_array($e['type'], array(E_ERROR, E_PARSE, E_CORE_ERROR, E_COMPILE_ERROR))) {
        file_put_contents(LOG_FILE, date('[Y-m-d H:i:s] ') . 'PHP FATAL: ' . $e['message'] . ' in ' . $e['file'] . ':' . $e['line'] . "\n", FILE_APPEND);
        // Only output TwiML if headers not sent yet
        if (!headers_sent()) {
            header('Content-Type: text/xml');
            $sid_err = $_GET['sid'] ?? $_POST['CallSid'] ?? '';
            $sf = SESSION_DIR . '/' . preg_replace('/[^a-zA-Z0-9_-]/', '_', $sid_err) . '.json';
            $sd = @json_decode(@file_get_contents($sf), true) ?: array();
            $last = $sd['last_action'] ?? 'welcome';
            $skip = array('process_yearmake','process_zip','process_joined','lookup_retail_price');
            if (in_array($last, $skip)) $last = 'welcome';
            $fail_count = (int)($sd['fail_count'] ?? 0) + 1;
            $sd['fail_count'] = $fail_count;
            @file_put_contents($sf, json_encode($sd));
            $base = 'https://autoglasshosting.com/ivr/ivr.php';
            echo '<?xml version="1.0" encoding="UTF-8"?>' . "\n";
            echo '<Response>' . "\n";
            if ($fail_count >= 2) {
                echo '  <Play>https://autoglasshosting.com/ivr/prompts/joined/39_sorry_error2.mp3</Play>' . "\n";
                echo '  <Dial>+18005494470</Dial>' . "\n";
            } else {
                echo '  <Play>https://autoglasshosting.com/ivr/prompts/joined/39_sorry_error.mp3</Play>' . "\n";
                echo '  <Gather input="dtmf" timeout="8" numDigits="1" action="' . $base . '?action=error_choice&sid=' . urlencode($sid_err) . '&retry=' . urlencode($last) . '" method="POST"><Pause length="8"/></Gather>' . "\n";
                echo '  <Redirect method="POST">' . $base . '?action=' . urlencode($last) . '&sid=' . urlencode($sid_err) . '</Redirect>' . "\n";
            }
            echo '</Response>' . "\n";
        }
    }
});

// ─── VALIDATE TWILIO REQUEST ──────────────────────────────────────────────────
function validate_twilio() {
    // Basic validation — check expected Twilio params present
    if (empty($_POST['CallSid']) && empty($_GET['CallSid'])) {
        ivr_log('WARNING: Request missing CallSid — possible non-Twilio request');
    }
}

// ─── SESSION ──────────────────────────────────────────────────────────────────
function sess_path($id) {
    return SESSION_DIR . '/' . preg_replace('/[^a-zA-Z0-9_-]/', '_', $id) . '.json';
}
function sess_load($id) {
    $f = sess_path($id);
    return file_exists($f) ? (json_decode(file_get_contents($f), true) ?: []) : [];
}
function sess_save($id, $data) {
    file_put_contents(sess_path($id), json_encode($data));
}
function sess_del($id) {
    $f = sess_path($id);
    if (file_exists($f)) unlink($f);
}

// ─── NAGS PART LOOKUP ────────────────────────────────────────────────────────
// ─── NS AFFILIATE VEHICLES LOOKUP ────────────────────────────────────────────
// Check custom pricing table before falling back to standard NAGS
function lookup_affiliate_vehicle($year, $make, $model, $style) {
    global $config;
    $db = new mysqli(DB_HOST, DB_USER, DB_PASS, DB_NAME);
    if ($db->connect_error) return null;

    $aff_id = 108; // W2G affiliate ID
    $y   = $db->real_escape_string($year);
    $mk  = $db->real_escape_string($make);
    $mod = $db->real_escape_string($model);
    $st  = $db->real_escape_string($style);

    // Try exact year/make/model/style match first
    $sql = "SELECT part, netPrice, netLabor, body_style FROM nsAffiliateVehicles
            WHERE affiliate=$aff_id AND year='$y' AND make='$mk'
            AND model LIKE '%$mod%'
            AND scope IN ('ALL','J+W2G','W2G')
            ORDER BY ID DESC LIMIT 1";
    $r = $db->query($sql);
    if ($r && $r->num_rows > 0) {
        $row = $r->fetch_assoc();
        $db->close();
        ivr_log("nsAffiliateVehicles hit: $year $make $model → part={$row['part']} price={$row['netPrice']} labor={$row['netLabor']}");
        return array(
            'part'      => $row['part'],
            'netPrice'  => (float)$row['netPrice'],
            'netLabor'  => (float)$row['netLabor'],
            'installed' => (float)$row['netPrice'] + (float)$row['netLabor'],
            'style'     => $row['body_style'],
        );
    }

    // Try without style constraint (body_style may vary)
    $sql2 = "SELECT part, netPrice, netLabor, body_style FROM nsAffiliateVehicles
             WHERE affiliate=$aff_id AND year='$y' AND make='$mk'
             AND model LIKE '%$mod%'
             AND scope IN ('ALL','J+W2G','W2G')
             ORDER BY ID DESC LIMIT 1";
    $r2 = $db->query($sql2);
    if ($r2 && $r2->num_rows > 0) {
        $row = $r2->fetch_assoc();
        $db->close();
        ivr_log("nsAffiliateVehicles hit (no style): $year $make $model → part={$row['part']} price={$row['netPrice']} labor={$row['netLabor']}");
        return array(
            'part'      => $row['part'],
            'netPrice'  => (float)$row['netPrice'],
            'netLabor'  => (float)$row['netLabor'],
            'installed' => (float)$row['netPrice'] + (float)$row['netLabor'],
            'style'     => $row['body_style'],
        );
    }

    // Try year range ±1 if no exact year match
    $y_low  = (int)$year - 1;
    $y_high = (int)$year + 1;
    $sql3 = "SELECT part, netPrice, netLabor, body_style FROM nsAffiliateVehicles
             WHERE affiliate=$aff_id AND year BETWEEN $y_low AND $y_high
             AND make='$mk' AND model LIKE '%$mod%'
             AND scope IN ('ALL','J+W2G','W2G')
             ORDER BY ABS(year-$y) ASC, ID DESC LIMIT 1";
    $r3 = $db->query($sql3);
    if ($r3 && $r3->num_rows > 0) {
        $row = $r3->fetch_assoc();
        $db->close();
        ivr_log("nsAffiliateVehicles hit (year±1): $year $make $model → part={$row['part']} price={$row['netPrice']} labor={$row['netLabor']}");
        return array(
            'part'      => $row['part'],
            'netPrice'  => (float)$row['netPrice'],
            'netLabor'  => (float)$row['netLabor'],
            'installed' => (float)$row['netPrice'] + (float)$row['netLabor'],
            'style'     => $row['body_style'],
        );
    }

    $db->close();
    return null;
}

function lookup_affiliate_product($part) {
    $db = new mysqli(DB_HOST, DB_USER, DB_PASS, DB_NAME);
    if ($db->connect_error) return null;
    $aff_id = 108;
    $p = $db->real_escape_string($part);
    $r = $db->query("SELECT netPrice, netLabor, isInstallable FROM nsAffiliateProduct
        WHERE affiliate=$aff_id AND part='$p'
        AND scope IN ('self','ALL','J+W2G','W2G')
        AND isExcluded='N'
        ORDER BY ID DESC LIMIT 1");
    if ($r && $r->num_rows > 0) {
        $row = $r->fetch_assoc();
        $db->close();
        $price    = (float)$row['netPrice'];
        $labor    = (float)$row['netLabor'];
        $installed = $price + $labor;
        return array('netPrice'=>$price,'netLabor'=>$labor,'installed'=>$installed,'isInstallable'=>$row['isInstallable']);
    }
    $db->close();
    return null;
}

function lookup_nags($year, $make, $model, $style, $glass, $zip) {
    $opening_map = array(
        'windshield' => 'WS', 'driver' => 'DR', 'passenger' => 'DR',
        'rear' => 'BK', 'back' => 'BK', 'quarter' => 'QT',
    );
    $opening = 'WS';
    $glass_lower = strtolower($glass);
    foreach ($opening_map as $k => $v) {
        if (strpos($glass_lower, $k) !== false) { $opening = $v; break; }
    }

    // Try requested year first, then fall back up to 5 years earlier
    $years_to_try = array($year);
    $year_int = (int)$year;
    // Year fallback reduced — new proxy handles model normalization
    for ($i = 1; $i <= 2; $i++) {
        $years_to_try[] = (string)($year_int - $i);
    }

    foreach ($years_to_try as $try_year) {
        $url = 'https://autoglasshosting.com/ivr/proxy_nags_lookup.php'
            . '?year='    . urlencode($try_year)
            . '&make='    . urlencode($make)
            . '&model='   . urlencode($model)
            . '&style='   . urlencode($style)
            . '&opening=' . urlencode($opening)
            . '&zip='     . urlencode($zip);

        $ch = curl_init($url);
        curl_setopt_array($ch, array(
            CURLOPT_RETURNTRANSFER => true,
            CURLOPT_TIMEOUT        => 20,
            CURLOPT_SSL_VERIFYPEER => false,
        ));
        $body = curl_exec($ch);
        curl_close($ch);
        $data = json_decode($body, true);
        ivr_log("NAGS lookup $try_year $make $model '$style' opening=$opening: " . substr($body, 0, 200));

        // If we got a part return it
        if (!empty($data['part'])) {
            if ($try_year !== $year) {
                ivr_log("NAGS year fallback: $year → $try_year for $make $model");
            }
            return $data;
        }

        // If error is not "no styles found" stop trying
        if (empty($data['styles']) && isset($data['status']) && $data['status'] === 'error') {
            $err = isset($data['error']) ? $data['error'] : '';
            if (strpos(strtolower($err), 'style') === false && strpos(strtolower($err), 'body') === false) {
                break; // Not a year/style issue — stop trying
            }
        }
    }

    return $data ?: array();
}
// ─── YMM FUZZY MATCH (local NHTSA DB) ────────────────────────────────────────
function ymm_correct_make($spoken_make, $year) {
    $spoken = strtolower(trim($spoken_make));

    // ── Hardcoded STT corrections ─────────────────────────────────────────────
    $make_corrections = array(
        'chevy'        => 'Chevrolet',
        'chevi'        => 'Chevrolet',
        'vw'           => 'Volkswagen',
        'volkswagon'   => 'Volkswagen',
        'volkswagen'   => 'Volkswagen',
        'also again'   => 'Volkswagen',  // known Deepgram mishearing
        'also a game'  => 'Volkswagen',
        'benz'         => 'Mercedes-Benz',
        'mercedes'     => 'Mercedes-Benz',
        'hundai'       => 'Hyundai',
        'hyundia'      => 'Hyundai',
        'infinity'     => 'Infiniti',
        'dodge ram'    => 'Ram',
        'navistar'     => 'International',
        'freightliner' => 'Freightliner',
        'freight liner' => 'Freightliner',
        'kenworth'     => 'Kenworth',
        'ken worth'    => 'Kenworth',
        'peterbilt'    => 'Peterbilt',
        'peter built'  => 'Peterbilt',
        'international' => 'International',
        'sprinter'     => 'Mercedes-Benz',
    );

    if (isset($make_corrections[$spoken])) {
        ivr_log("Make hardcoded correction: '$spoken_make' → '{$make_corrections[$spoken]}'");
        return $make_corrections[$spoken];
    }

    // Also check partial matches
    foreach ($make_corrections as $wrong => $right) {
        if (strpos($spoken, $wrong) !== false) {
            ivr_log("Make partial correction: '$spoken_make' → '$right'");
            return $right;
        }
    }

    // ── NHTSA YMM DB fuzzy match ──────────────────────────────────────────────
    $db = new mysqli('localhost', DB_USER, DB_PASS, DB_NAME);
    if ($db->connect_error) return ucwords(strtolower($spoken_make));

    $year_int   = (int)$year;
    $normalized = $spoken;

    // Get all makes for this year
    $esc_year = (int)$year_int;
    $result = $db->query("SELECT DISTINCT make FROM ivr_ymm_cache WHERE year=$esc_year ORDER BY make");
    if (!$result || $result->num_rows === 0) {
        $db->close();
        return ucwords(strtolower($spoken_make));
    }

    $best_make  = '';
    $best_score = -1;

    while ($row = $result->fetch_assoc()) {
        $candidate = strtolower($row['make']);

        // Exact match
        if ($normalized === $candidate || $spoken === $candidate) { $best_make = $row['make']; break; }

        // Contains match
        $score = 0;
        if (strpos($candidate, $normalized) !== false) $score = 90;
        elseif (strpos($normalized, $candidate) !== false) $score = 85;
        else {
            // Word match + levenshtein
            $s_words = preg_split('/[\s\-]+/', $normalized);
            $c_words = preg_split('/[\s\-]+/', $candidate);
            $matches = 0;
            foreach ($s_words as $sw) {
                if (strlen($sw) < 2) continue;
                foreach ($c_words as $cw) {
                    if ($sw === $cw) { $matches += 3; break; }
                    if (strlen($sw) > 3 && levenshtein($sw, $cw) <= 2) { $matches += 2; break; }
                    if (strpos($cw, $sw) !== false) { $matches += 1; break; }
                }
            }
            $total = max(count($s_words), count($c_words));
            $score = $total > 0 ? min(80, intval(($matches / ($total * 3)) * 100)) : 0;
        }

        if ($score > $best_score) { $best_score = $score; $best_make = $row['make']; }
    }

    $db->close();

    if ($best_make && $best_score >= 50) {
        $best_make = ucwords(strtolower($best_make));
        ivr_log("YMM make: '$spoken_make' → '$best_make' (score=$best_score)");
        return $best_make;
    }
    return ucwords(strtolower($spoken_make));
}

function ymm_correct_model($spoken_model, $make, $year) {
    $spoken = strtolower(trim($spoken_model));

    // ── Hardcoded STT corrections ─────────────────────────────────────────────
    // Add known Deepgram mishearings here as you discover them
    $model_corrections = array(
        // Volkswagen
        'tigua'     => 'Tiguan',
        'tiqua'     => 'Tiguan',
        'take 1'    => 'Tiguan',
        'take one'  => 'Tiguan',
        'tegan'     => 'Tiguan',
        'teagan'    => 'Tiguan',
        't'         => 'Tiguan',  // single letter — likely cut off
        // Toyota
        'camery'    => 'Camry',
        'camrey'    => 'Camry',
        'corrolla'  => 'Corolla',
        'corrola'   => 'Corolla',
        'highlander' => 'Highlander',
        'sequioa'   => 'Sequoia',
        'tundra'    => 'Tundra',
        'tacoma'    => 'Tacoma',
        // Ford
        'f-150'     => 'F-150',
        'f150'      => 'F-150',
        'f 150'     => 'F-150',
        'f-250'     => 'F-250',
        'f250'      => 'F-250',
        'f 250'     => 'F-250',
        'exploror'  => 'Explorer',
        'exporer'   => 'Explorer',
        'esscape'   => 'Escape',
        // Chevrolet
        'silverado' => 'Silverado',
        'siverado'  => 'Silverado',
        'equinocks' => 'Equinox',
        'equanox'   => 'Equinox',
        'suburben'  => 'Suburban',
        // Honda
        'accordian' => 'Accord',
        'crv'       => 'CR-V',
        'cr-v'      => 'CR-V',
        'hrv'       => 'HR-V',
        'hr-v'      => 'HR-V',
        // Dodge/Ram
        'charger'   => 'Charger',
        'challanger' => 'Challenger',
        // Freightliner
        'cascadia'     => 'Cascadia',
        'vn'           => 'VN',
        'vnl'          => 'VNL',
        'vnm'          => 'VNM',
        'm-2'          => 'M2',
        'm 2'          => 'M2',
        'm two'        => 'M2',
        'mtwo'         => 'M2',
        'm. two'       => 'M2',
        'em two'       => 'M2',
        'am two'       => 'M2',
        'em 2'         => 'M2',
        'am 2'         => 'M2',
        // Sprinter
        'sprinter'  => 'Sprinter',
    );

    if (isset($model_corrections[$spoken])) {
        ivr_log("Model hardcoded correction: '$spoken_model' → '{$model_corrections[$spoken]}'");
        return $model_corrections[$spoken];
    }

    // Partial match — only if spoken is a substring of the wrong key, not vice versa
    // This prevents 'mustang' matching 'tiguan' etc.
    foreach ($model_corrections as $wrong => $right) {
        if ($spoken === $wrong) {
            ivr_log("Model exact correction: '$spoken_model' → '$right'");
            return $right;
        }
    }

    // ── NHTSA YMM DB fuzzy match ──────────────────────────────────────────────
    $db = new mysqli('localhost', DB_USER, DB_PASS, DB_NAME);
    if ($db->connect_error) return ucwords(strtolower($spoken_model));
    $year_int = (int)$year;
    $esc_make = $db->real_escape_string($make);

    $result = $db->query("SELECT DISTINCT model FROM ivr_ymm_cache 
                          WHERE year=$year_int AND make='$esc_make' 
                          ORDER BY model");

    if (!$result || $result->num_rows === 0) {
        // Try partial make match
        $esc_make_like = $db->real_escape_string('%' . $make . '%');
        $result = $db->query("SELECT DISTINCT model FROM ivr_ymm_cache 
                              WHERE year=$year_int AND make LIKE '$esc_make_like' 
                              ORDER BY model");
    }

    if (!$result || $result->num_rows === 0) {
        $db->close();
        return ucwords(strtolower($spoken_model));
    }

    $best_model = '';
    $best_score = -1;

    while ($row = $result->fetch_assoc()) {
        $candidate = strtolower($row['model']);

        if ($spoken === $candidate) { $best_model = $row['model']; break; }

        $score = 0;
        if (strpos($candidate, $spoken) !== false) $score = 90;
        elseif (strpos($spoken, $candidate) !== false) $score = 85;
        else {
            $s_words = preg_split('/[\s\-\/]+/', $spoken);
            $c_words = preg_split('/[\s\-\/]+/', $candidate);
            $matches = 0;
            foreach ($s_words as $sw) {
                if (strlen($sw) < 2) continue;
                foreach ($c_words as $cw) {
                    if ($sw === $cw) { $matches += 3; break; }
                    if (strlen($sw) > 3 && levenshtein($sw, $cw) <= 2) { $matches += 2; break; }
                    if (strpos($cw, $sw) !== false || strpos($sw, $cw) !== false) { $matches += 1; break; }
                }
            }
            $total = max(count($s_words), count($c_words));
            $score = $total > 0 ? min(80, intval(($matches / ($total * 3)) * 100)) : 0;
        }

        if ($score > $best_score) { $best_score = $score; $best_model = $row['model']; }
    }

    $db->close();

    if ($best_model && $best_score >= 50) {
        ivr_log("YMM model: '$spoken_model' → '$best_model' (score=$best_score)");
        return $best_model;
    }
    return ucwords(strtolower($spoken_model));
}

// ─── DB PRICE LOOKUP (quote_inquiry) ─────────────────────────────────────────
function lookup_price_from_db($make, $model, $glass, $zip, $year = '') {
    $db = new mysqli('localhost', DB_USER, DB_PASS, DB_NAME);
    if ($db->connect_error) { ivr_log('Price DB error: ' . $db->connect_error); return null; }

    $glass_map = array(
        'windshield' => 'Windshield', 'driver' => 'Driver',
        'passenger'  => 'Passenger',  'rear'   => 'Back Glass',
        'back'       => 'Back Glass', 'quarter' => 'Quarter',
    );
    $glass_search = 'Windshield';
    foreach ($glass_map as $k => $v) {
        if (strpos(strtolower($glass), $k) !== false) { $glass_search = $v; break; }
    }

    $esc_make  = $db->real_escape_string($make);
    $esc_model = $db->real_escape_string($model);
    $esc_glass = $db->real_escape_string($glass_search);
    $esc_year  = $db->real_escape_string($year);

    $row = null;

    // Step 1: Exact year + make + model + glass, last 12 months
    if ($esc_year) {
        $sql = "SELECT MIN(amount) as min_price, parts, quoteData
                FROM quote_inquiry
                WHERE quoteData LIKE '$esc_year %$esc_make%$esc_model%$esc_glass%'
                AND amount > 0 AND Is_install='Y'
                AND inquiry_date >= DATE_SUB(NOW(), INTERVAL 12 MONTH)
                LIMIT 1";
        $r = $db->query($sql);
        if ($r && $r->num_rows > 0) $row = $r->fetch_assoc();
    }

    // Step 2: Make + model + glass, last 12 months (any year)
    if (!$row || !$row['min_price']) {
        $sql2 = "SELECT MIN(amount) as min_price, parts, quoteData
                 FROM quote_inquiry
                 WHERE quoteData LIKE '%$esc_make%$esc_model%$esc_glass%'
                 AND amount > 0 AND Is_install='Y'
                 AND inquiry_date >= DATE_SUB(NOW(), INTERVAL 12 MONTH)
                 LIMIT 1";
        $r2 = $db->query($sql2);
        if ($r2 && $r2->num_rows > 0) $row = $r2->fetch_assoc();
    }

    // Step 3: No date restriction
    if (!$row || !$row['min_price']) {
        $sql3 = "SELECT MIN(amount) as min_price, parts, quoteData
                 FROM quote_inquiry
                 WHERE quoteData LIKE '%$esc_make%$esc_model%$esc_glass%'
                 AND amount > 0 AND Is_install='Y' LIMIT 1";
        $r3 = $db->query($sql3);
        if ($r3 && $r3->num_rows > 0) $row = $r3->fetch_assoc();
    }

    // Step 4: Make + glass only (broader fallback)
    if (!$row || !$row['min_price']) {
        $sql4 = "SELECT MIN(amount) as min_price, parts, quoteData
                 FROM quote_inquiry
                 WHERE quoteData LIKE '%$esc_make%$esc_glass%'
                 AND amount > 0 AND Is_install='Y'
                 AND inquiry_date >= DATE_SUB(NOW(), INTERVAL 12 MONTH)
                 LIMIT 1";
        $r4 = $db->query($sql4);
        if ($r4 && $r4->num_rows > 0) $row = $r4->fetch_assoc();
    }

    $db->close();

    if ($row && $row['min_price'] > 0) {
        ivr_log("Quote DB price: $year $make $model $glass = \${$row['min_price']} part={$row['parts']} from: {$row['quoteData']}");
        return array('price' => (float)$row['min_price'], 'nspart' => $row['parts'] ?? '', 'style' => '', 'source' => 'quote_inquiry');
    }
    ivr_log("No quote_inquiry price for $year $make $model $glass");
    return null;
}

// ─── NAGS + PROXY PRICE LOOKUP ───────────────────────────────────────────────
function lookup_price_from_nags($year, $make, $model, $style, $glass, $zip, $hint_part = '') {
    global $config;
    $proxy_auth = (isset($config['proxy_user']) ? $config['proxy_user'] : '') . ':' . (isset($config['proxy_pass']) ? $config['proxy_pass'] : '');

    // If we have a part number hint from DB, try pricing that directly first
    if ($hint_part) {
        $opening_map = array(
            'windshield' => 'WS', 'driver' => 'DR', 'passenger' => 'DR',
            'rear' => 'BK', 'back' => 'BK', 'quarter' => 'QT',
        );
        $opening = 'WS';
        foreach ($opening_map as $k => $v) {
            if (strpos(strtolower($glass), $k) !== false) { $opening = $v; break; }
        }
        $url = 'https://autoglasshosting.com/proxy/proxy_getprice.php'
            . '?part='    . urlencode($hint_part)
            . '&zip='     . urlencode($zip)
            . '&acct=w2gcsr'
            . '&opening=' . urlencode($opening);
        $ch = curl_init($url);
        curl_setopt_array($ch, array(
            CURLOPT_RETURNTRANSFER => true,
            CURLOPT_TIMEOUT        => 10,
            CURLOPT_SSL_VERIFYPEER => false,
            CURLOPT_USERPWD        => $proxy_auth,
        ));
        $body = curl_exec($ch);
        curl_close($ch);
        $data = json_decode($body, true);
        $installed = isset($data['price_installed']) ? (float)$data['price_installed'] : 0;
        if (!$installed && isset($data['price_glass']) && isset($data['price_install'])) {
            $installed = (float)$data['price_glass'] + (float)$data['price_install'];
        }
        if ($installed > 0) {
            ivr_log("DB part hint price: part=$hint_part price=$installed");
            return array('price' => $installed, 'nspart' => $hint_part, 'style' => '', 'source' => 'db_hint');
        }
    }

    // Full NAGS lookup
    $nags = lookup_nags($year, $make, $model, $style, $glass, $zip);
    if (empty($nags['part'])) return null;

    $part    = $nags['part'];
    $opening = $nags['opening'] ?? 'WS';

    $url = 'https://autoglasshosting.com/proxy/proxy_getprice.php'
        . '?part='    . urlencode($part)
        . '&zip='     . urlencode($zip)
        . '&acct=w2gcsr'
        . '&opening=' . urlencode($opening);

    $ch = curl_init($url);
    curl_setopt_array($ch, array(
        CURLOPT_RETURNTRANSFER => true,
        CURLOPT_TIMEOUT        => 15,
        CURLOPT_SSL_VERIFYPEER => false,
        CURLOPT_USERPWD        => $proxy_auth,
    ));
    $body = curl_exec($ch);
    curl_close($ch);
    $data = json_decode($body, true);
    ivr_log("NAGS price part=$part zip=$zip: " . substr($body, 0, 150));

    $installed = isset($data['price_installed']) ? (float)$data['price_installed'] : 0;
    if (!$installed && isset($data['price_glass']) && isset($data['price_install'])) {
        $installed = (float)$data['price_glass'] + (float)$data['price_install'];
    }

    if ($installed > 0) {
        return array('price' => $installed, 'nspart' => $part, 'style' => $nags['style'] ?? '', 'source' => 'nags');
    }
    return null;
}

// ─── VEHICLE EXTRACTION FROM SINGLE UTTERANCE ────────────────────────────────
function extract_vehicle($transcript) {
    $t = strtolower(trim($transcript));
    // Strip common spoken fillers before vehicle info
    $t = preg_replace('/^(it\'s|its|it is|i have|i\'ve got|i need|this is|we have|we\'ve got|the)\s+a?\s*/i', '', $t);
    $t = preg_replace('/^(a|an)\s+/i', '', $t);
    $t = preg_replace('/\b(a|an)\s+(20\d\d|19\d\d)/i', '$2', $t);
    $result = array('year' => '', 'make' => '', 'model' => '', 'glass' => '');

    // Year — numeric first, then spoken
    preg_match('/\b(19[89]\d|20[012]\d|2030)\b/', $t, $ym);
    if ($ym) {
        $result['year'] = $ym[1];
    } else {
        // Spoken year patterns
        $spoken_years = array(
            'twenty twenty six' => '2026', 'twenty twenty five' => '2025',
            'twenty twenty four' => '2024', 'twenty twenty three' => '2023',
            'twenty twenty two' => '2022', 'twenty twenty one' => '2021',
            'twenty twenty'     => '2020', 'twenty nineteen'   => '2019',
            'twenty eighteen'   => '2018', 'twenty seventeen'  => '2017',
            'twenty sixteen'    => '2016', 'twenty fifteen'    => '2015',
            'twenty fourteen'   => '2014', 'twenty thirteen'   => '2013',
            'twenty twelve'     => '2012', 'twenty eleven'     => '2011',
            'twenty ten'        => '2010', 'twenty oh nine'    => '2009',
            'twenty oh eight'   => '2008', 'twenty oh seven'   => '2007',
            'twenty oh six'     => '2006', 'twenty oh five'    => '2005',
        );
        foreach ($spoken_years as $spoken => $year) {
            if (strpos($t, $spoken) !== false) {
                $result['year'] = $year;
                break;
            }
        }
    }

    // Makes — same list as voice-quote.html plus fleet vehicles
    $makes = array(
        'acura','alfa romeo','audi','bmw','bentley','buick','cadillac',
        'chevrolet','chevy','chrysler','dodge','ferrari','fiat','ford',
        'gmc','genesis','honda','hyundai','infiniti','jaguar','jeep',
        'kia','lamborghini','land rover','lexus','lincoln','maserati',
        'mazda','mercedes-benz','mercedes','mini','mitsubishi','nissan',
        'porsche','ram','rolls royce','subaru','tesla','toyota',
        'volkswagen','vw','volvo',
        // Fleet
        'freightliner','kenworth','peterbilt','international','isuzu',
        'hino','mack','sterling','sprinter','ford transit','transit',
    );
    // Common mishearings → correct make
    $make_fixes = array(
        'chevy' => 'Chevrolet', 'vw' => 'Volkswagen',
        'volkswagon' => 'Volkswagen', 'mercedes' => 'Mercedes-Benz',
        'merc' => 'Mercedes-Benz', 'benz' => 'Mercedes-Benz',
        'hundai' => 'Hyundai', 'hyundia' => 'Hyundai',
        'infinity' => 'Infiniti', 'transit' => 'Ford',
    );
    foreach ($makes as $make) {
        if (strpos($t, $make) !== false) {
            $result['make'] = isset($make_fixes[$make]) ? $make_fixes[$make] : ucwords($make);
            break;
        }
    }
    // Apply mishearing fixes
    foreach ($make_fixes as $wrong => $right) {
        if (strpos($t, $wrong) !== false && empty($result['make'])) {
            $result['make'] = $right;
            break;
        }
    }

    // Verify/correct make using NHTSA DB BEFORE model extraction
    // This ensures make-specific model corrections (e.g. Volvo BN→VN) use the right make
    if (!empty($result['make']) && !empty($result['year'])) {
        $corrected_make = ymm_correct_make($result['make'], $result['year']);
        if ($corrected_make) $result['make'] = $corrected_make;
    }

    // Models — check fleet specific first, then passenger vehicles
    $models = array(
        // Fleet — most specific first
        'business class m2','business class','cascadia','prostar','lonestar',
        'w900','t680','t880','coronado','114sd','122sd','108sd',
        'm two','mtwo','m-2','m2',
        // Passenger
        'f-150','f150','f150','f-250','f250','f-350','f350',
        'f 150','f 250','f 350','150','250','350',
        'silverado','camry','accord','civic','altima','sentra',
        'corolla','rav4','cr-v','crv','pilot','odyssey',
        'highlander','4runner','tacoma','tundra','explorer',
        'escape','edge','fusion','mustang','malibu','equinox',
        'traverse','suburban','tahoe','yukon','sierra','canyon',
        'colorado','trailblazer','blazer','impala','camaro','corvette',
        'charger','challenger','durango','grand cherokee','wrangler',
        'cherokee','compass','renegade','pacifica','elantra','tucson',
        'santa fe','sonata','telluride','sportage','sorento','forte',
        'soul','carnival','pathfinder','rogue','murano','armada',
        'frontier','titan','versa','maxima','kicks',
        'model 3','model y','model s','model x','cybertruck',
        'outback','forester','impreza','legacy','crosstrek','ascent',
        'prius','sienna','venza','land cruiser','sequoia',
        'passat','jetta','golf','tiguan','atlas',
        'xc90','xc60','xc40','range rover','discovery','defender',
        'a4','a6','q5','q7','q3','x3','x5','x7',
        'gle','glc','escalade','xt5','xt6','enclave','encore',
        'navigator','aviator','tlx','mdx','rdx','ridgeline',
        '1500','2500','3500','transit','sprinter','express','savana',
        'wagoneer','gladiator','stelvio','giulia',
    );
    $model_normalize = array(
        'business class m2' => 'M2', 'business class' => 'M2', 'business m2' => 'M2',
        'm two' => 'M2', 'mtwo' => 'M2', 'm-2' => 'M2', 'm2' => 'M2',
        'vn' => 'VN', 'vnl' => 'VNL', 'vnm' => 'VNM', 'vn series' => 'VN', 'vnl series' => 'VNL',
        '150' => 'F-150', '250' => 'F-250', '350' => 'F-350', '450' => 'F-450',
        'f 150' => 'F-150', 'f 250' => 'F-250', 'f 350' => 'F-350',
        't680' => 'T680', 't880' => 'T880', 'w900' => 'W900',
        'cr-v' => 'CR-V', 'crv' => 'CR-V', 'hr-v' => 'HR-V',
        'rav4' => 'RAV4', '4runner' => '4Runner',
        'xc90' => 'XC90', 'xc60' => 'XC60', 'xc40' => 'XC40',
        'gle' => 'GLE', 'glc' => 'GLC',
        'mdx' => 'MDX', 'rdx' => 'RDX', 'tlx' => 'TLX',
        'xt5' => 'XT5', 'xt6' => 'XT6',
        '114sd' => '114SD', '122sd' => '122SD', '108sd' => '108SD',
        'a4' => 'A4', 'a6' => 'A6', 'q3' => 'Q3', 'q5' => 'Q5', 'q7' => 'Q7',
        'x3' => 'X3', 'x5' => 'X5', 'x7' => 'X7',
    );
    foreach ($models as $model) {
        if (strpos($t, $model) !== false && !is_numeric($model)) {
            $result['model'] = isset($model_normalize[$model]) ? $model_normalize[$model] : ucwords($model);
            break;
        }
    }

    // Volvo truck model correction — VN often misheard as DN/BN/FN
    if (isset($result['make']) && strtolower($result['make']) === 'volvo') {
        $volvo_model = isset($result['model']) ? strtoupper($result['model']) : '';
        $vn_mishearings = array('DN','BN','FN','TN','PN','CN','GN','HN','JN','KN','LN','MN');
        if (in_array($volvo_model, $vn_mishearings)) $result['model'] = 'VN';
        $vnl_mishearings = array('DNL','BNL','FNL','TNL');
        if (in_array($volvo_model, $vnl_mishearings)) $result['model'] = 'VNL';
    }

    // Volvo truck model correction — VN often misheard as DN/BN/FN
    if (isset($result['make']) && strtolower($result['make']) === 'volvo') {
        $volvo_model = isset($result['model']) ? strtoupper($result['model']) : '';
        $vn_mishearings = array('DN','BN','FN','TN','PN','CN','GN','HN','JN','KN','LN','MN');
        if (in_array($volvo_model, $vn_mishearings)) $result['model'] = 'VN';
        $vnl_mishearings = array('DNL','BNL','FNL','TNL');
        if (in_array($volvo_model, $vnl_mishearings)) $result['model'] = 'VNL';
    }

    // If model still empty, try NHTSA DB fuzzy match
    if (empty($result['model']) && !empty($result['make'])) {
        // Extract remaining words after make as potential model
        $make_lower = strtolower($result['make']);
        $remaining = trim(preg_replace('/\b' . preg_quote($make_lower, '/') . '\b/i', '', $t));
        $remaining = trim(preg_replace('/\b(20\d\d|19\d\d)\b/', '', $remaining));
        $remaining = trim(preg_replace('/\b(two|four|2|4)\s*(door|dr)\b/i', '', $remaining));
        $words = preg_split('/\s+/', trim($remaining));
        if (!empty($words[0])) {
            $year_for_lookup = !empty($result['year']) ? $result['year'] : date('Y');
            $corrected = ymm_correct_model($words[0], $result['make'], $year_for_lookup);
            if ($corrected && strtolower($corrected) !== strtolower($words[0])) {
                $result['model'] = $corrected;
            } elseif ($corrected) {
                $result['model'] = $corrected;
            }
        }
    }

    // Glass type
    $glass_map = array(
        'windshield' => 'Windshield',
        'wind shield' => 'Windshield',
        'front glass' => 'Windshield',
        'rear' => 'Back Glass',
        'back glass' => 'Back Glass',
        'back window' => 'Back Glass',
        'driver' => 'Driver Side',
        'passenger' => 'Passenger Side',
        'quarter' => 'Quarter Glass',
    );
    foreach ($glass_map as $k => $v) {
        if (strpos($t, $k) !== false) { $result['glass'] = $v; break; }
    }
    // Default to windshield if glass type not specified
    if (empty($result['glass'])) $result['glass'] = 'Windshield';

    return $result;
}

// ─── WHISPER TRANSCRIPTION ────────────────────────────────────────────────────
function whisper_transcribe($recording_url) {
    global $config;
    if (empty($recording_url)) return '';
    $audio_url = rtrim($recording_url, '/') . '.mp3';
    $ch = curl_init($audio_url);
    curl_setopt_array($ch, array(
        CURLOPT_RETURNTRANSFER => true,
        CURLOPT_TIMEOUT        => 30,
        CURLOPT_SSL_VERIFYPEER => false,
        CURLOPT_USERPWD        => ($config['twilio_account_sid'] ?? '') . ':' . ($config['twilio_auth_token'] ?? $config['twilio_api_secret'] ?? ''),
    ));
    $audio_data = curl_exec($ch);
    $dl_code    = curl_getinfo($ch, CURLINFO_HTTP_CODE);
    curl_close($ch);
    // Twilio recordings are occasionally not readable the instant the action
    // callback fires. One short retry, otherwise this reads as "caller said nothing".
    if (!$audio_data || $dl_code !== 200) {
        usleep(500000);
        $ch = curl_init($audio_url);
        curl_setopt_array($ch, array(
            CURLOPT_RETURNTRANSFER => true,
            CURLOPT_TIMEOUT        => 30,
            CURLOPT_SSL_VERIFYPEER => false,
            CURLOPT_USERPWD        => ($config['twilio_account_sid'] ?? '') . ':' . ($config['twilio_auth_token'] ?? $config['twilio_api_secret'] ?? ''),
        ));
        $audio_data = curl_exec($ch);
        $dl_code    = curl_getinfo($ch, CURLINFO_HTTP_CODE);
        curl_close($ch);
        ivr_log('Whisper: recording download retried, HTTP ' . $dl_code);
    }
    if (!$audio_data) { ivr_log('Whisper: failed to download audio from ' . $audio_url); return ''; }
    $tmp = tempnam('/tmp', 'ivr_audio_') . '.mp3';
    file_put_contents($tmp, $audio_data);
    $ch = curl_init('https://api.openai.com/v1/audio/transcriptions');
    $post = array(
        'file'     => new CURLFile($tmp, 'audio/mpeg', 'audio.mp3'),
        'model'    => 'whisper-1',
        'language' => 'en',
    );
    curl_setopt_array($ch, array(
        CURLOPT_RETURNTRANSFER => true,
        CURLOPT_POST           => true,
        CURLOPT_POSTFIELDS     => $post,
        CURLOPT_HTTPHEADER     => array('Authorization: Bearer ' . ($config['openai_api_key'] ?? '')),
        CURLOPT_TIMEOUT        => 60,
    ));
    $resp = curl_exec($ch);
    curl_close($ch);
    unlink($tmp);
    $data = json_decode($resp, true);
    $text = isset($data['text']) ? trim(rtrim(trim($data['text']), '. ')) : '';
    ivr_log('Whisper transcript: ' . substr($text, 0, 200));
    if (is_stt_hallucination($text)) {
        ivr_log('Whisper: discarded hallucination on near-silent audio');
        return '';
    }
    return $text;
}

// whisper-1 emits stock filler when handed silence or line noise — "Thank you",
// "you", "Thanks for watching". Left alone these become customer names and
// street addresses. Treat them as an empty transcript so the retry prompt fires.
function is_stt_hallucination($text) {
    $t = strtolower(preg_replace('/[^a-z0-9. ]/i', '', $text));
    $t = trim(preg_replace('/[.\s]+/', ' ', $t));
    if ($t === '') return true;
    $junk = array(
        'you', 'thank you', 'thanks', 'thank you very much', 'thanks for watching',
        'thanks for watching!', 'please subscribe', 'subscribe to my channel',
        'bye', 'bye bye', 'goodbye', 'okay', 'ok', 'oh', 'um', 'uh', 'hmm', 'mm',
        'the', 'a', 'and', 'so', 'yeah', 'yes', 'no', 'hello', 'hi',
        'subtitles by the amara.org community', 'transcription by eso translations',
        'im sorry', 'silence', 'music', 'applause', 'blank audio',
    );
    return in_array($t, $junk, true);
}

// ─── CLAUDE ORDER EXTRACTION ──────────────────────────────────────────────────
function claude_extract_order($transcript, $acct) {
    global $config;
    if (empty($transcript)) return array();
    $account_code = isset($acct['code']) ? $acct['code'] : 'W2G';
    $is_fleet     = !empty($acct['ask_po']);
    $prompt = "Extract order information from this phone call transcript and return ONLY a JSON object with these exact fields:
year, make, model, style, glass, first, last, street, city, state, zip, phone, email, po, unit, notes, install_date

Rules:
- year: 4-digit vehicle year
- make: vehicle manufacturer (e.g. Volkswagen, Ford, Freightliner)
- model: vehicle model (e.g. Tiguan, F-150, Cascadia)
- style: body style (e.g. 4 Door Utility, 2 Door Coupe, Crew Cab)
- glass: type of glass needed (Windshield, Back Glass, Driver Side, Passenger Side, Quarter Glass)
- first/last: customer first and last name
- street: street address
- city, state, zip: location
- phone: 10 digit phone number digits only
- email: email address
- po: purchase order or RO number" . ($is_fleet ? " (this is a $account_code fleet account)" : '') . "
- unit: vehicle unit or fleet number
- notes: any special instructions
- install_date: requested service date YYYY-MM-DD or empty string

Leave missing fields as empty string. Return ONLY the JSON object.

Transcript: $transcript";

    $ch = curl_init('https://api.anthropic.com/v1/messages');
    curl_setopt_array($ch, array(
        CURLOPT_RETURNTRANSFER => true,
        CURLOPT_POST           => true,
        CURLOPT_POSTFIELDS     => json_encode(array(
            'model'      => 'claude-sonnet-4-6',
            'max_tokens' => 1000,
            'messages'   => array(array('role' => 'user', 'content' => $prompt)),
        )),
        CURLOPT_HTTPHEADER => array(
            'Content-Type: application/json',
            'x-api-key: ' . ($config['anthropic_api_key'] ?? ''),
            'anthropic-version: 2023-06-01',
        ),
        CURLOPT_TIMEOUT => 30,
    ));
    $resp = curl_exec($ch);
    curl_close($ch);
    $data = json_decode($resp, true);
    $text = isset($data['content'][0]['text']) ? $data['content'][0]['text'] : '';
    $text = preg_replace('/^```json\s*/i', '', trim($text));
    $text = preg_replace('/```$/', '', trim($text));
    $fields = json_decode(trim($text), true);
    if (!is_array($fields)) {
        ivr_log('Claude extraction failed: ' . substr($resp, 0, 200));
        return array();
    }
    return $fields;
}

// ─── ZIP CODE LOOKUP ──────────────────────────────────────────────────────────
// ─── ELEVENLABS TTS ───────────────────────────────────────────────────────────
function elevenlabs_say($text, $filename) {
    global $config;
    $api_key  = $config['elevenlabs_api_key']  ?? '';
    $voice_id = $config['elevenlabs_voice_id'] ?? '';
    if (!$api_key || !$voice_id) return '';

    $out_dir = '/home/auto11/public_html/ivr/prompts/joined/';
    if (!is_dir($out_dir)) mkdir($out_dir, 0755, true);
    $out_path = $out_dir . $filename;
    $out_url  = 'https://autoglasshosting.com/ivr/prompts/joined/' . $filename;

    // Filename is an md5 of the text, so identical text == identical audio.
    // Cache is permanent — no TTL. Delete the file to force a regen.
    if (file_exists($out_path) && filesize($out_path) > 0) {
        return $out_url;
    }

    $ch = curl_init('https://api.elevenlabs.io/v1/text-to-speech/' . $voice_id);
    curl_setopt_array($ch, array(
        CURLOPT_RETURNTRANSFER => true,
        CURLOPT_POST           => true,
        CURLOPT_POSTFIELDS     => json_encode(array(
            'text'     => $text,
            'model_id' => 'eleven_multilingual_v2',
            'voice_settings' => array('stability' => 0.5, 'similarity_boost' => 0.75),
        )),
        CURLOPT_HTTPHEADER => array(
            'xi-api-key: ' . $api_key,
            'Content-Type: application/json',
            'Accept: audio/mpeg',
        ),
        CURLOPT_TIMEOUT => 15,
    ));
    $audio = curl_exec($ch);
    $code  = curl_getinfo($ch, CURLINFO_HTTP_CODE);
    curl_close($ch);

    if ($code === 200 && $audio) {
        file_put_contents($out_path, $audio);
        ivr_log("ElevenLabs TTS generated: $filename (" . strlen($audio) . " bytes)");
        return $out_url;
    }
    ivr_log("ElevenLabs TTS failed: HTTP $code");
    return '';
}

function get_truck_nags_style($year, $make, $model, $zip, $style_hint='') {
    $url = 'https://autoglasshosting.com/ivr/proxy_nags_lookup.php'
        . '?year='    . urlencode($year)
        . '&make='    . urlencode($make)
        . '&model='   . urlencode($model)
        . '&style='
        . '&opening=WS'
        . '&zip='     . urlencode($zip);
    $ch = curl_init($url);
    curl_setopt_array($ch, array(CURLOPT_RETURNTRANSFER=>true, CURLOPT_TIMEOUT=>10, CURLOPT_SSL_VERIFYPEER=>false));
    $data = json_decode(curl_exec($ch), true);
    curl_close($ch);
    $styles = $data['styles'] ?? array();
    if (empty($styles)) return '';
    if (count($styles) === 1) return $styles[0];

    // Fuzzy match caller's door count against available styles
    $hint = strtolower($style_hint);
    $door = '';
    if (preg_match('/\b(2|two)\b/', $hint)) $door = '2';
    elseif (preg_match('/\b(4|four)\b/', $hint)) $door = '4';
    if ($door) {
        foreach ($styles as $s) {
            if (strpos($s, $door.'-Door') !== false || strpos($s, $door.' Door') !== false) return $s;
        }
        foreach ($styles as $s) {
            if (strpos($s, $door) !== false) return $s;
        }
    }
    // Prefer Conventional Cab for trucks
    foreach ($styles as $s) {
        if (stripos($s, 'Conventional') !== false) return $s;
    }
    return $styles[0];
}

// Pull email + phone out of a spoken contact answer. Claude first, regex fallback.
function parse_contact($ct, $acct) {
    $cf    = claude_extract_order($ct, $acct);
    $email = $cf['email'] ?? '';
    $phone = $cf['phone'] ?? '';
    if (!$phone) {
        $d = preg_replace('/[^0-9]/', '', $ct);
        if (strlen($d) >= 10) $phone = substr($d, -10);
    }
    if (!$email) {
        $er = preg_replace('/\s+at\s+/i',  '@', strtolower($ct));
        $er = preg_replace('/\s+dot\s+/i', '.', $er);
        $er = preg_replace('/\s+/',        '',  $er);
        if (strpos($er, '@') !== false) $email = $er;
    }
    return array('email' => $email, 'phone' => $phone);
}

// Clean up a spoken street address, then try to normalize it via Nominatim.
// Falls back to the spoken form if the lookup misses or times out.
function validate_street($street_transcript, $zip, $sid = '') {
    $street = ucwords(strtolower(trim($street_transcript)));
    if ($street === '') return '';
    $query   = urlencode($street . ' ' . $zip . ' USA');
    $nom_url = 'https://nominatim.openstreetmap.org/search?q=' . $query . '&format=json&limit=1&addressdetails=1';
    $ch = curl_init($nom_url);
    curl_setopt_array($ch, array(
        CURLOPT_RETURNTRANSFER => true, CURLOPT_TIMEOUT => 5,
        CURLOPT_SSL_VERIFYPEER => false,
        CURLOPT_USERAGENT      => 'W2G-IVR/1.0 (w2gusa@gmail.com)',
    ));
    $nom_data = json_decode(curl_exec($ch), true);
    curl_close($ch);
    if (!empty($nom_data[0]['address'])) {
        $addr = $nom_data[0]['address'];
        $h = $addr['house_number'] ?? '';
        $r = $addr['road']         ?? '';
        if ($h && $r) {
            ivr_log("[$sid] Address validated: '$street' → '$h $r'");
            return $h . ' ' . $r;
        }
    }
    return $street;
}

// Normalize a spoken or keyed ZIP: "nine two six eight eight", "92688", "9 2 6 8 8"
function parse_zip($text) {
    $zip = preg_replace('/[^0-9]/', '', (string)$text);
    if (strlen($zip) === 5) return $zip;
    if (strlen($zip) > 5)   return substr($zip, 0, 5);
    $words = array('zero'=>'0','one'=>'1','two'=>'2','three'=>'3','four'=>'4','five'=>'5',
                   'six'=>'6','seven'=>'7','eight'=>'8','nine'=>'9','oh'=>'0','o'=>'0');
    $digits = '';
    foreach (preg_split('/[\s,\-]+/', strtolower((string)$text)) as $w) {
        $w = trim($w, '.');
        if (isset($words[$w]))   $digits .= $words[$w];
        elseif (ctype_digit($w)) $digits .= $w;
    }
    return (strlen($digits) === 5) ? $digits : $zip;
}

// Local table first (sub-millisecond). Falls back to the zippopotam.us API only
// if the ZIP isn't in ivr_zipcodes, and writes the result back so the next call
// for that ZIP is local too.
function zip_to_city_state($zip) {
    $zip = preg_replace('/[^0-9]/', '', (string)$zip);
    if (strlen($zip) !== 5) return array('city' => '', 'state' => '');

    $db = @new mysqli(DB_HOST, DB_USER, DB_PASS, DB_NAME);
    if ($db && !$db->connect_error) {
        $z = $db->real_escape_string($zip);
        $r = $db->query("SELECT city, state FROM ivr_zipcodes WHERE zip='$z' LIMIT 1");
        if ($r && $r->num_rows > 0) {
            $row = $r->fetch_assoc();
            $db->close();
            return array('city' => $row['city'], 'state' => $row['state']);
        }
    }

    // Miss — fall back to the API
    ivr_log("ZIP $zip not in ivr_zipcodes, falling back to API");
    $ch = curl_init('https://api.zippopotam.us/us/' . urlencode($zip));
    curl_setopt_array($ch, array(
        CURLOPT_RETURNTRANSFER => true,
        CURLOPT_TIMEOUT        => 3,
        CURLOPT_SSL_VERIFYPEER => false,
    ));
    $body = curl_exec($ch);
    curl_close($ch);
    $data = json_decode($body, true);

    $out = array('city' => '', 'state' => '');
    if (!empty($data['places'][0])) {
        $out['city']  = $data['places'][0]['place name'] ?? '';
        $out['state'] = $data['places'][0]['state abbreviation'] ?? '';
        // Backfill so we only ever pay for this ZIP once
        if ($db && !$db->connect_error && $out['city'] !== '') {
            $c = $db->real_escape_string($out['city']);
            $s = $db->real_escape_string($out['state']);
            $db->query("INSERT IGNORE INTO ivr_zipcodes (zip, city, state, source)
                        VALUES ('$z', '$c', '$s', 'api')");
        }
    }
    if ($db && !$db->connect_error) $db->close();
    return $out;
}

// Render one or more <Play> verbs. Accepts a filename or an array of filenames,
// so a multi-part prompt (welcome + question) plays in ONE TwiML response
// instead of costing a <Redirect> round-trip between the parts.
function play_tags($prompt_mp3) {
    $out = '';
    foreach ((array)$prompt_mp3 as $mp3) {
        if ($mp3 === '' || $mp3 === null) continue;
        $out .= '  <Play>' . PROMPT_BASE . htmlspecialchars($mp3) . '</Play>' . "\n";
    }
    return $out;
}

// Record after playing prompt — mic opens after prompt finishes
// After recording, Twilio posts RecordingUrl back to $next_action
function twiml_record($prompt_mp3, $next_action, $call_sid, $max_length = 10, $silence = null) {
    $action_url = next_url($next_action, $call_sid);
    $silence    = ($silence === null) ? REC_SILENCE_TIMEOUT : $silence;
    echo '<?xml version="1.0" encoding="UTF-8"?>' . "\n";
    echo '<Response>' . "\n";
    echo play_tags($prompt_mp3);
    echo '  <Pause length="1"/>' . "\n";
    echo '  <Record action="' . htmlspecialchars($action_url) . '" method="POST"'
        . ' maxLength="' . $max_length . '"'
        . ' timeout="' . $silence . '"'
        . ' transcribe="false"'
        . ' playBeep="false"'
        . ' finishOnKey="">' . "\n";
    echo '  </Record>' . "\n";
    echo '</Response>' . "\n";
    exit;
}

// Process a recording with Whisper — plays wait message while processing
function whisper_process($recording_url, $wait_prompt = '37_onemomment.mp3') {
    // Start Whisper transcription (this takes 2-3 seconds)
    return whisper_transcribe($recording_url);
}

// ─── BACKGROUND STT ───────────────────────────────────────────────────────────
// Hand the already-echoed TwiML to Twilio and keep the PHP process running.
// Twilio starts playing the filler immediately while we transcribe underneath it.
function ivr_flush_response() {
    if (function_exists('fastcgi_finish_request'))       { fastcgi_finish_request();  return 'fastcgi'; }
    if (function_exists('litespeed_finish_request'))     { litespeed_finish_request(); return 'litespeed'; }
    // Fallback. Content-Length is REQUIRED here — without it Twilio can't tell
    // the response has ended and will sit on the open socket until this process
    // finishes its Whisper call, which defeats the whole point.
    ignore_user_abort(true);
    $len = ob_get_length();
    if (!headers_sent()) {
        header('Content-Type: text/xml');
        header('Content-Length: ' . (int)$len);
        header('Connection: close');
    }
    while (ob_get_level() > 0) ob_end_flush();
    flush();
    return 'flush';
}

// Emit filler + redirect, flush to Twilio, THEN transcribe into $sess[$key].
// The processing pass reads $sess[$key] instead of calling Whisper itself.
// Emit "keep the caller company and come back to me" TwiML.
// $loops is how many 1.34s cycles of the ambient bed to play before returning.
function filler_twiml($next_action, $sid, $loops = 1) {
    $use_loop = FAST_BACKGROUND_STT && FAST_FILLER_LOOP;
    echo '<?xml version="1.0" encoding="UTF-8"?>' . "\n";
    echo '<Response>' . "\n";
    if (!$use_loop) {
        echo '  <Play>' . PROMPT_BASE . FILLER_BLOCK . '</Play>' . "\n";
    } elseif (FILLER_USE_WAV) {
        echo '  <Play loop="' . (int)$loops . '">' . PROMPT_BASE . FILLER_LOOP . '</Play>' . "\n";
    } else {
        // Pre-repeated MP3s: one file per cycle count, no loop attribute needed
        $n = max(1, min(4, (int)$loops));
        echo '  <Play>' . PROMPT_BASE . FILLER_MP3_PREFIX . $n . '.mp3</Play>' . "\n";
    }
    echo '  <Redirect method="POST">' . htmlspecialchars(next_url($next_action, $sid)) . '</Redirect>' . "\n";
    echo '</Response>' . "\n";
}

// $post is an optional callable($transcript, &$sess) run inside the same flushed
// window — use it for lookups that would otherwise block the processing pass.
function capture_and_transcribe($recording_url, $key, $next_action, $sid, &$sess, $post = null) {
    $sess['rec_' . $key]  = $recording_url;
    $sess[$key . '_processing'] = true;
    $sess[$key . '_polls']      = 0;
    if (FAST_BACKGROUND_STT) $sess[$key . '_stt_pending'] = true;
    sess_save($sid, $sess);

    filler_twiml($next_action, $sid, FILLER_FIRST_LOOPS);

    if (!FAST_BACKGROUND_STT) exit;

    $mode = ivr_flush_response();
    $t0   = microtime(true);
    $text = whisper_transcribe($recording_url);

    // Re-load: the processing pass may have already landed and written to session
    $fresh = sess_load($sid);
    if (is_array($fresh) && $fresh) $sess = $fresh;
    $sess[$key . '_transcript']  = $text;

    if (is_callable($post) && $text !== '') $post($text, $sess);

    $sess[$key . '_stt_pending'] = false;
    sess_save($sid, $sess);
    ivr_log("[$sid] bg STT ($mode) $key: " . round((microtime(true) - $t0) * 1000) . "ms");
    exit;
}

// Read a background transcript. If it isn't ready yet, play one more short loop
// and come back — so the caller waits in ~1.34s increments instead of a fixed
// block. $poll_action is the action to redirect back into.
function take_transcript($key, $recording_url, $sid, &$sess, $poll_action = '') {
    if (!FAST_BACKGROUND_STT) return whisper_transcribe($recording_url);

    if (isset($sess[$key . '_transcript'])) {
        $t = $sess[$key . '_transcript'];
        unset($sess[$key . '_transcript'], $sess[$key . '_stt_pending'], $sess[$key . '_polls']);
        sess_save($sid, $sess);
        return $t;
    }

    // Not ready. Keep the caller on the bed and re-enter this same state.
    if ($poll_action !== '' && FAST_FILLER_LOOP && !empty($sess[$key . '_stt_pending'])) {
        $polls = (int)($sess[$key . '_polls'] ?? 0);
        if ($polls < FILLER_MAX_POLLS) {
            $sess[$key . '_polls']      = $polls + 1;
            $sess[$key . '_processing'] = true;   // land back in the processing branch
            sess_save($sid, $sess);
            ivr_log("[$sid] STT poll " . ($polls + 1) . " for $key");
            filler_twiml($poll_action, $sid, 1);
            exit;
        }
        ivr_log("[$sid] STT poll limit hit for $key");
    }

    ivr_log("[$sid] bg STT miss on $key — falling back to blocking");
    unset($sess[$key . '_polls']);
    return whisper_transcribe($recording_url);
}

function base_url() {
    $proto = (!empty($_SERVER['HTTPS']) && $_SERVER['HTTPS'] !== 'off') ? 'https' : 'http';
    return $proto . '://' . $_SERVER['HTTP_HOST'] . strtok($_SERVER['REQUEST_URI'], '?');
}

function next_url($action, $call_sid) {
    return base_url() . '?action=' . urlencode($action) . '&sid=' . urlencode($call_sid);
}

// Play an ElevenLabs MP3 prompt and gather speech response via Deepgram
function twiml_gather($prompt_mp3, $next_action, $call_sid, $timeout = 5, $hints = '', $num_digits = 0, $speech_timeout = null) {
    $action_url = next_url($next_action, $call_sid);
    $hint_attr  = $hints ? ' hints="' . htmlspecialchars($hints) . '"' : '';
    $nd_attr    = $num_digits ? ' numDigits="' . (int)$num_digits . '"' : '';
    $sto        = ($speech_timeout === null) ? GATHER_SPEECH_TIMEOUT : $speech_timeout;
    echo '<?xml version="1.0" encoding="UTF-8"?>' . "\n";
    echo '<Response>' . "\n";
    echo '  <Gather input="speech dtmf" action="' . htmlspecialchars($action_url) . '" method="POST"'
        . ' speechTimeout="' . $sto . '"'
        . ' actionOnEmptyResult="true"'
        . ' speechModel="deepgram_nova-2"'
        . ' profanityFilter="false"'
        . ' timeout="' . $timeout . '"'
        . $nd_attr . $hint_attr . '>' . "\n";
    echo play_tags($prompt_mp3);
    echo '  </Gather>' . "\n";
    echo '  <Redirect method="POST">' . htmlspecialchars($action_url) . '</Redirect>' . "\n";
    echo '</Response>' . "\n";
    exit;
}

// Play prompt THEN gather — no barge-in, prevents echo on critical fields
function twiml_play_then_gather($prompt_mp3, $next_action, $call_sid, $timeout = 8, $hints = '', $num_digits = 0, $speech_timeout = null) {
    $action_url = next_url($next_action, $call_sid);
    $hint_attr  = $hints ? ' hints="' . htmlspecialchars($hints) . '"' : '';
    $nd_attr    = $num_digits ? ' numDigits="' . (int)$num_digits . '"' : '';
    $sto        = ($speech_timeout === null) ? GATHER_SPEECH_TIMEOUT : $speech_timeout;
    echo '<?xml version="1.0" encoding="UTF-8"?>' . "\n";
    echo '<Response>' . "\n";
    // Play prompt(s) first — mic closed during playback, no echo
    echo play_tags($prompt_mp3);
    echo '  <Gather input="speech dtmf" action="' . htmlspecialchars($action_url) . '" method="POST"'
        . ' speechTimeout="' . $sto . '"'
        . ' actionOnEmptyResult="true"'
        . ' speechModel="deepgram_nova-2"'
        . ' profanityFilter="false"'
        . ' timeout="' . $timeout . '"'
        . $nd_attr . $hint_attr . '>' . "\n";
    echo '  </Gather>' . "\n";
    echo '  <Redirect method="POST">' . htmlspecialchars($action_url) . '</Redirect>' . "\n";
    echo '</Response>' . "\n";
    exit;
}

// Play prompt with no gather (final message)
function twiml_play($prompt_mp3) {
    echo '<?xml version="1.0" encoding="UTF-8"?>' . "\n";
    echo '<Response>' . "\n";
    echo '  <Play>' . PROMPT_BASE . htmlspecialchars($prompt_mp3) . '</Play>' . "\n";
    echo '  <Hangup/>' . "\n";
    echo '</Response>' . "\n";
    exit;
}

function twiml_say($text) {
    echo '<?xml version="1.0" encoding="UTF-8"?>' . "\n";
    echo '<Response>' . "\n";
    echo '  <Say voice="alice">' . htmlspecialchars($text) . '</Say>' . "\n";
    echo '  <Hangup/>' . "\n";
    echo '</Response>' . "\n";
    exit;
}

function twiml_transfer($number) {
    echo '<?xml version="1.0" encoding="UTF-8"?>' . "\n";
    echo '<Response>' . "\n";
    echo '  <Dial>' . htmlspecialchars($number) . '</Dial>' . "\n";
    echo '</Response>' . "\n";
    exit;
}

function twiml_redirect($action, $call_sid) {
    echo '<?xml version="1.0" encoding="UTF-8"?>' . "\n";
    echo '<Response>' . "\n";
    echo '  <Redirect method="POST">' . htmlspecialchars(next_url($action, $call_sid)) . '</Redirect>' . "\n";
    echo '</Response>' . "\n";
    exit;
}

function twiml_hangup() {
    echo '<?xml version="1.0" encoding="UTF-8"?>' . "\n";
    echo '<Response><Hangup/></Response>' . "\n";
    exit;
}

// ─── RC SMS (via RingCentral) ─────────────────────────────────────────────────
function rc_token() {
    $cache = '/tmp/rc_tok_w2g.json';
    if (file_exists($cache)) {
        $t = json_decode(file_get_contents($cache), true);
        if ($t && isset($t['expires_at']) && $t['expires_at'] > time() + 60) return $t['access_token'];
    }
    global $config;
    $ch = curl_init(RC_SERVER . '/restapi/oauth/token');
    curl_setopt_array($ch, array(
        CURLOPT_RETURNTRANSFER => true,
        CURLOPT_POST           => true,
        CURLOPT_POSTFIELDS     => http_build_query(array(
            'grant_type' => 'urn:ietf:params:oauth:grant-type:jwt-bearer',
            'assertion'  => isset($config['rc_jwt']) ? $config['rc_jwt'] : '',
        )),
        CURLOPT_HTTPHEADER     => array(
            'Content-Type: application/x-www-form-urlencoded',
            'Authorization: Basic ' . base64_encode($config['rc_sms_client_id'] . ':' . $config['rc_sms_client_secret']),
        ),
    ));
    $resp = curl_exec($ch);
    curl_close($ch);
    $d = json_decode($resp, true);
    if (empty($d['access_token'])) {
        ivr_log('RC token error: ' . $resp);
        return null;
    }
    $d['expires_at'] = time() + (int)(isset($d['expires_in']) ? $d['expires_in'] : 3600);
    file_put_contents($cache, json_encode($d));
    return $d['access_token'];
}
function rc_sms($to, $msg) {
    $tok = rc_token();
    if (!$tok) {
        ivr_log('RC SMS: no token');
        return false;
    }
    $to = preg_replace('/[^0-9+]/', '', $to);
    if (strlen($to) > 0 && $to[0] !== '+') $to = '+1' . ltrim($to, '1');
    $ch = curl_init(RC_SERVER . '/restapi/v1.0/account/~/extension/~/sms');
    curl_setopt_array($ch, array(
        CURLOPT_RETURNTRANSFER => true,
        CURLOPT_POST           => true,
        CURLOPT_POSTFIELDS     => json_encode(array(
            'from' => array('phoneNumber' => RC_SMS_FROM),
            'to'   => array(array('phoneNumber' => $to)),
            'text' => $msg,
        )),
        CURLOPT_HTTPHEADER => array('Content-Type: application/json', 'Authorization: Bearer ' . $tok),
    ));
    $resp = curl_exec($ch);
    $code = curl_getinfo($ch, CURLINFO_HTTP_CODE);
    curl_close($ch);

    // If 401 — token expired, delete cache and retry once
    if ($code === 401) {
        ivr_log('RC SMS: 401 — refreshing token and retrying');
        @unlink('/tmp/rc_tok_w2g.json');
        $tok = rc_token();
        if (!$tok) return false;
        $ch = curl_init(RC_SERVER . '/restapi/v1.0/account/~/extension/~/sms');
        curl_setopt_array($ch, array(
            CURLOPT_RETURNTRANSFER => true,
            CURLOPT_POST           => true,
            CURLOPT_POSTFIELDS     => json_encode(array(
                'from' => array('phoneNumber' => RC_SMS_FROM),
                'to'   => array(array('phoneNumber' => $to)),
                'text' => $msg,
            )),
            CURLOPT_HTTPHEADER => array('Content-Type: application/json', 'Authorization: Bearer ' . $tok),
        ));
        $resp = curl_exec($ch);
        $code = curl_getinfo($ch, CURLINFO_HTTP_CODE);
        curl_close($ch);
    }

    if ($code >= 200 && $code < 300) return true;
    ivr_log('RC SMS failed: HTTP ' . $code . ' ' . substr($resp, 0, 100));
    return false;
}

// ─── EMAIL ────────────────────────────────────────────────────────────────────
function send_email($to, $subject, $html) {
    require_once '/home/auto11/public_html/vendor/autoload.php';
    try {
        $mail = new PHPMailer\PHPMailer\PHPMailer(true);
        $mail->isSMTP();
        $mail->Host       = 'smtp.gmail.com';
        $mail->SMTPAuth   = true;
        $mail->Username   = W2G_EMAIL;
        $mail->Password   = $GLOBALS['config']['gmail_app_password'] ?? '';
        $mail->SMTPSecure = 'ssl';
        $mail->Port       = 465;
        $mail->setFrom(W2G_EMAIL, IVR_COMPANY);
        $mail->addAddress($to);
        $mail->isHTML(true);
        $mail->Subject = $subject;
        $mail->Body    = $html;
        $mail->send();
        ivr_log("Email sent to $to");
        return true;
    } catch (Exception $e) {
        ivr_log('Email failed: ' . $e->getMessage());
        return false;
    }
}

// ─── WRITE ORDER TO DB ────────────────────────────────────────────────────────
function write_order(&$sess) {
    $db = new mysqli(DB_HOST, DB_USER, DB_PASS, DB_NAME);
    if ($db->connect_error) { ivr_log('DB error: ' . $db->connect_error); return false; }

    $acct         = $sess['account']  ?? [];
    $ocode        = $acct['code']     ?? 'W2G';
    $aff_id       = (int)($acct['aff_id'] ?? 0);
    $install_date_raw = isset($sess['install_date']) && $sess['install_date']
        ? $sess['install_date']
        : date('Y-m-d', strtotime('+1 day'));
    // Convert to display format: '21 July 9 to 1'
    $install_date = date('j F', strtotime($install_date_raw)) . ' 9 to 1';

    // Use quoted price for retail/Linde, $0 for AR fleet accounts
    $quoted_price = isset($sess['quoted_price']) ? (float)$sess['quoted_price'] : 0;
    $is_fleet     = (!empty($acct['ask_po']) || !empty($acct['ask_unit'])) && empty($acct['requires_payment']);
    $order_total  = (!$is_fleet && $quoted_price > 0) ? $quoted_price : 0;

    $f = [
        'zip'    => $db->real_escape_string($sess['zip']    ?? ''),
        'year'   => $db->real_escape_string($sess['year']   ?? ''),
        'make'   => $db->real_escape_string(ucwords(strtolower($sess['make']   ?? ''))),
        'model'  => $db->real_escape_string(ucwords(strtolower($sess['model']  ?? ''))),
        'style'  => $db->real_escape_string(ucwords(strtolower($sess['style']  ?? ''))),
        'glass'  => $db->real_escape_string(ucwords(strtolower($sess['glass']  ?? ''))),
        'first'  => $db->real_escape_string(ucwords(strtolower($sess['first']  ?? ''))),
        'last'   => $db->real_escape_string(ucwords(strtolower($sess['last']   ?? ''))),
        'street' => $db->real_escape_string(ucwords(strtolower($sess['street'] ?? ''))),
        'city'   => $db->real_escape_string(ucwords(strtolower($sess['city']   ?? ''))),
        'state'  => $db->real_escape_string(strtoupper($sess['state']  ?? '')),
        'phone'  => $db->real_escape_string($sess['phone']  ?? ''),
        'email'  => $db->real_escape_string(strtolower($sess['email']  ?? '')),
        'po'     => $db->real_escape_string(strtoupper($sess['po']     ?? ($acct['po_prefill'] ?? ''))),
        'unit'   => $db->real_escape_string(strtoupper($sess['unit']   ?? '')),
        'notes'  => $db->real_escape_string($sess['notes']  ?? ''),
        'company'=> $db->real_escape_string($acct['company'] ?? ''),
        'sid'    => $db->real_escape_string('ivr_' . ($sess['call_sid'] ?? '')),
    ];

    $nags_part  = $db->real_escape_string($sess['nags_part']  ?? '');
    $nags_style = $db->real_escape_string($sess['nags_style'] ?? ($sess['style'] ?? ''));

    // Look up productID — match part prefix + year range for correct display
    $product_id = 0;
    $vehicle_year = isset($f['year']) ? (int)$f['year'] : 0;
    if ($nags_part) {
        $base_part = preg_replace('/^([A-Z]{1,3}\d+)[A-Z]+$/i', '$1', $nags_part);
        $esc_base = $db->real_escape_string($base_part);
        // Try year-filtered match first for correct year display in invoice
        if ($vehicle_year > 0) {
            $yr_low = $vehicle_year - 2;
            $yr_high = $vehicle_year + 2;
            $r = $db->query("SELECT ID FROM product WHERE part LIKE '$esc_base%' AND year BETWEEN $yr_low AND $yr_high ORDER BY ABS(year-$vehicle_year) ASC LIMIT 1");
            if ($r && $r->num_rows > 0) $product_id = (int)$r->fetch_assoc()['ID'];
        }
        // Fallback: any part match
        if (!$product_id) {
            $r2 = $db->query("SELECT ID FROM product WHERE part LIKE '$esc_base%' LIMIT 1");
            if ($r2 && $r2->num_rows > 0) $product_id = (int)$r2->fetch_assoc()['ID'];
        }
    }
    ivr_log("productID=$product_id for part=$nags_part");

    // cart
    $db->query("INSERT INTO cart (stamp, subtotal, zip, ip, session, randomKey)
                VALUES (Now(), 0, '{$f['zip']}', '127.0.0.1', '{$f['sid']}', '(ivr)')");
    $cart_id = $db->insert_id;
    if (!$cart_id) { ivr_log('cart insert failed: ' . $db->error); $db->close(); return false; }

    // cartItems — parameters='inst' flags as installation order, use real productID if found
    $db->query("INSERT INTO cartItems (cartID, productID, itemPrice, quantity, parameters, partDetails)
                VALUES ($cart_id, $product_id, 0, 1, 'inst', '{$f['zip']}')");

    // Build comments — vehicle summary + NAGS match + caller notes + CID
    $ani_comment = $db->real_escape_string($sess['ani'] ?? '');
    $vehicle_summary = "{$f['year']} {$f['make']} {$f['model']} — $nags_style — {$f['glass']}";
    if ($nags_part) $vehicle_summary .= " (NAGS: $nags_part)";
    $notes_clean = $f['notes'] ? "\nNotes: {$f['notes']}" : '';
    $vehicle_str = $db->real_escape_string("[IVR] $vehicle_summary$notes_clean\nCaller ID: $ani_comment");

    // checkout
    $db->query("INSERT INTO checkout SET
        cartID=$cart_id,
        s_FirstName='{$f['first']}', s_LastName='{$f['last']}',
        s_Email='{$f['email']}', s_Address='{$f['street']}',
        s_City='{$f['city']}', s_State='{$f['state']}',
        s_Zip='{$f['zip']}', s_Phone='{$f['phone']}',
        s_VIN='', s_Unit='{$f['unit']}', s_PO='{$f['po']}',
        s_Company='{$f['company']}', s_CrossStreet='',
        s_InstallYear='" . date('Y', strtotime($install_date_raw)) . "',
        b_FirstName='{$f['first']}', b_LastName='{$f['last']}',
        b_Address='{$f['street']}', b_City='{$f['city']}',
        b_State='{$f['state']}', b_Zip='{$f['zip']}',
        b_Email='{$f['email']}', b_Phone='{$f['phone']}',
        comments='$vehicle_str', s_InstallDate='$install_date',
        b_SameAsShipping=0, b_Country='USA', s_Country='USA',
        c_num='', c_type='', c_expyear='', c_expmonth='', c_cvv='',
        subtotal=$order_total, shipping=0, total=$order_total");
    $checkout_id = $db->insert_id;

    // orderNums
    $esc_code = $db->real_escape_string($ocode);
    $db->query("INSERT INTO orderNums (stamp, refID, code, num)
                SELECT Now(), 0, '$esc_code',
                IF(MAX(num)+1 > 1, MAX(num)+1, 1)
                FROM orderNums WHERE code='$esc_code'");
    $onum_id = $db->insert_id;
    $orow    = $db->query("SELECT num FROM orderNums WHERE ID=$onum_id")->fetch_assoc();
    $onum    = (int)($orow['num'] ?? 1);
    $codenum = $ocode . '-' . sprintf('%04d', $onum);
    $ckey    = rand(1000, 9999);

    // invoice
    $db->query("INSERT INTO invoice SET
        checkoutID=$checkout_id, cartID=$cart_id,
        affiliateID=$aff_id, orderCode='$esc_code', orderNum=$onum,
        customerKey=$ckey, fulfilled='N',
        purchaseDate=Now(), installDate='$install_date_raw'");
    $invoice_id = $db->insert_id;
    $db->close();

    if (!$invoice_id) { ivr_log('invoice insert failed'); return false; }

    // Write vehicle data to ivr_vehicles (before close)
    $v_year  = $db->real_escape_string($sess['year']    ?? '');
    $v_make  = $db->real_escape_string($sess['make']    ?? '');
    $v_model = $db->real_escape_string($sess['model']   ?? '');
    $v_style = $db->real_escape_string($sess['style']   ?? '');
    $v_glass = $db->real_escape_string($sess['glass']   ?? '');
    $v_acct  = $db->real_escape_string($acct['code']    ?? '');
    $db->query("INSERT INTO ivr_vehicles
        (invoice_id, cart_id, year, make, model, style, glass, zip, account, nags_part, nags_style)
        VALUES ($invoice_id, $cart_id, '$v_year', '$v_make', '$v_model',
                '$v_style', '$v_glass', '{$f['zip']}', '$v_acct',
                '$nags_part', '$nags_style')");

    $db->close();

    $sess['invoice_id']   = $invoice_id;
    $sess['order_num']    = $codenum;
    $sess['customer_key'] = $ckey;
    $sess['install_date'] = $install_date;

    ivr_log("Order created: $codenum invoiceID=$invoice_id");
    return true;
}

// ─── BUILD ORDER EMAIL ────────────────────────────────────────────────────────
function build_order_email(&$sess, $vin_url, $pay_url = '') {
    $acct    = $sess['account']      ?? array();
    $company = $acct['company']      ?? '';
    $onum    = $sess['order_num']    ?? '';
    $install = $sess['install_date'] ?? 'Tomorrow';
    $ani     = $sess['ani']          ?? '';
    $quoted  = isset($sess['quoted_price']) ? (float)$sess['quoted_price'] : 0;

    // Format ANI for display
    $ani_digits = preg_replace('/[^0-9]/', '', $ani);
    if (strlen($ani_digits) === 11 && $ani_digits[0] === '1') $ani_digits = substr($ani_digits, 1);
    $ani_display = strlen($ani_digits) === 10
        ? '(' . substr($ani_digits,0,3) . ') ' . substr($ani_digits,3,3) . '-' . substr($ani_digits,6)
        : $ani;

    return "
<html><body style='font-family:Arial,sans-serif;font-size:14px;'>
<h2 style='color:#003366;'>" . IVR_COMPANY . " - IVR Order</h2>
<table cellpadding='6' cellspacing='0' style='width:100%;max-width:620px;border-collapse:collapse;'>
  <tr><td colspan='2' style='background:#003366;color:#fff;padding:10px;font-weight:bold;font-size:16px;'>
    Order # $onum &nbsp;|&nbsp; $company
  </td></tr>
  <tr><td style='color:#666;width:35%;border-bottom:1px solid #eee;'>Install Date</td>
      <td style='border-bottom:1px solid #eee;'>$install <em style='color:#999;'>(default — subject to confirmation)</em></td></tr>
  <tr bgcolor='#f9f9f9'><td style='color:#666;border-bottom:1px solid #eee;'>Vehicle</td>
      <td style='border-bottom:1px solid #eee;'>" . htmlspecialchars(($sess['year'] ?? '') . ' ' . ($sess['make'] ?? '') . ' ' . ($sess['model'] ?? '') . ' ' . ($sess['style'] ?? '')) . "</td></tr>
  <tr><td style='color:#666;border-bottom:1px solid #eee;'>Glass</td>
      <td style='border-bottom:1px solid #eee;'>" . htmlspecialchars($sess['glass'] ?? '') . "</td></tr>
  <tr bgcolor='#f9f9f9'><td style='color:#666;border-bottom:1px solid #eee;'>Customer</td>
      <td style='border-bottom:1px solid #eee;'>" . htmlspecialchars(($sess['first'] ?? '') . ' ' . ($sess['last'] ?? '')) . "</td></tr>
  <tr><td style='color:#666;border-bottom:1px solid #eee;'>Address</td>
      <td style='border-bottom:1px solid #eee;'>" . htmlspecialchars(($sess['street'] ?? '') . ', ' . ($sess['city'] ?? '') . ', ' . ($sess['state'] ?? '') . ' ' . ($sess['zip'] ?? '')) . "</td></tr>
  <tr bgcolor='#f9f9f9'><td style='color:#666;border-bottom:1px solid #eee;'>Phone (spoken)</td>
      <td style='border-bottom:1px solid #eee;'>" . htmlspecialchars($sess['phone'] ?? '') . "</td></tr>
  <tr><td style='color:#666;border-bottom:1px solid #eee;'><strong>Caller ID (CID)</strong></td>
      <td style='border-bottom:1px solid #eee;'><strong style='color:#003366;'>$ani_display</strong></td></tr>
  <tr bgcolor='#f9f9f9'><td style='color:#666;border-bottom:1px solid #eee;'>PO / RO</td>
      <td style='border-bottom:1px solid #eee;'>" . htmlspecialchars($sess['po'] ?? '') . "</td></tr>
  <tr><td style='color:#666;border-bottom:1px solid #eee;'>Unit #</td>
      <td style='border-bottom:1px solid #eee;'>" . htmlspecialchars($sess['unit'] ?? '') . "</td></tr>
  <tr bgcolor='#f9f9f9'><td style='color:#666;border-bottom:1px solid #eee;'>Notes</td>
      <td style='border-bottom:1px solid #eee;'>" . nl2br(htmlspecialchars($sess['notes'] ?? '')) . "</td></tr>" .
  ($quoted > 0 ? "
  <tr><td style='color:#666;border-bottom:1px solid #eee;'><strong>Quoted Price</strong></td>
      <td style='border-bottom:1px solid #eee;'><strong style='color:#003366;'>\$$quoted installed</strong></td></tr>" : "") . "
  <tr><td colspan='2' style='padding:14px 6px;'>
    <strong>VIN Entry Link:</strong><br>
    <a href='$vin_url' style='color:#003366;'>$vin_url</a>
  </td></tr>" .
  ($pay_url ? "
  <tr><td colspan='2' style='padding:0 6px 14px;'>
    <strong>Payment Link:</strong><br>
    <a href='$pay_url' style='color:#cc0000;font-weight:bold;'>$pay_url</a>
  </td></tr>" : "") . "
  <tr><td colspan='2' style='padding:6px;background:#fff8e1;font-size:12px;color:#888;'>
    Order placed via IVR &nbsp;|&nbsp; Caller ID: $ani_display &nbsp;|&nbsp; Twilio SID: " . htmlspecialchars($sess['call_sid'] ?? '') . "
  </td></tr>
</table>
</body></html>";
}

// ─── MAIN ────────────────────────────────────────────────────────────────────
header('Content-Type: text/xml');
validate_twilio();

// Get Twilio params
$call_sid = $_POST['CallSid']      ?? $_GET['CallSid']      ?? '';
$ani      = $_POST['From']         ?? $_GET['From']         ?? '';
$dnis     = $_POST['To']           ?? $_GET['To']           ?? '';
$speech   = $_POST['SpeechResult'] ?? $_GET['SpeechResult'] ?? '';
$digit    = $_POST['Digits']       ?? $_GET['Digits']       ?? '';
// Deepgram often appends a trailing period — strip it globally
$said     = trim(rtrim(trim($speech ?: $digit), '. '));
$action   = $_GET['action']        ?? 'welcome';
$sid      = $_GET['sid']           ?? $call_sid;

// Normalize DNIS — Twilio passes RC forwarded number
// Normalize to +1NXXNXXXXXX. The old version prepended '+1' to a number that
// ALREADY had its country code, then truncated — turning +18058371747 into
// +11805837174, which matches no key in $ACCOUNTS. Every fleet call was
// silently falling through to $DEFAULT_ACCOUNT (retail, requires_payment).
$dnis_digits = preg_replace('/[^0-9]/', '', $dnis);
if (strlen($dnis_digits) === 11 && $dnis_digits[0] === '1') $dnis_digits = substr($dnis_digits, 1);
$dnis_clean  = (strlen($dnis_digits) === 10) ? '+1' . $dnis_digits : '+' . $dnis_digits;

ivr_log("[$sid] action=$action dnis_raw=$dnis dnis_clean=$dnis_clean ani=$ani ForwardedFrom=" . ($_POST["ForwardedFrom"] ?? "none") . " said=" . json_encode($said));

// Load/init session
global $ACCOUNTS, $DEFAULT_ACCOUNT;
$sess = sess_load($sid);
if (empty($sess)) {
    $acct = $ACCOUNTS[$dnis_clean] ?? $DEFAULT_ACCOUNT;
    $sess = [
        'call_sid' => $call_sid,
        'ani'      => $ani,
        'dnis'     => $dnis_clean,
        'account'  => $acct,
    ];
    sess_save($sid, $sess);
}
$acct = $sess['account'] ?? $DEFAULT_ACCOUNT;

// ── Universal key presses ─────────────────────────────────────────────────────
if ($digit === '3') {
    sess_del($sid);
    twiml_transfer(IVR_TRANSFER);
}
if ($digit === '2' && $action !== 'got_mode') {
    sess_del($sid);
    $acct_new = isset($ACCOUNTS[$dnis_clean]) ? $ACCOUNTS[$dnis_clean] : $DEFAULT_ACCOUNT;
    $sess = array('call_sid' => $call_sid, 'ani' => $ani, 'dnis' => $dnis_clean, 'account' => $acct_new);
    sess_save($sid, $sess);
    twiml_redirect('welcome', $sid);
}
if (($digit === '4' || stripos($said, 'order') !== false) && $action !== 'got_price_response') {
    twiml_play_then_gather('joined/07_name.mp3', 'got_name', $sid, 8);
}

// ─── STEP ROUTER ─────────────────────────────────────────────────────────────
switch ($action) {

    // ── WELCOME + ZIP ────────────────────────────────────────────────────────
    case 'welcome':
        echo '<?xml version="1.0" encoding="UTF-8"?>' . "\n";
        echo '<Response>' . "\n";
        echo '  <Gather input="dtmf speech" action="' . htmlspecialchars(next_url('got_mode', $sid)) . '" method="POST" timeout="5" numDigits="1" speechTimeout="1" actionOnEmptyResult="true" hints="one two" speechModel="deepgram_nova-2">' . "\n";
        echo '    <Play>' . PROMPT_BASE . 'joined/42_1or2.mp3</Play>' . "\n";
        echo '  </Gather>' . "\n";
        echo '  <Redirect method="POST">' . htmlspecialchars(next_url('start_fleet', $sid)) . '</Redirect>' . "\n";
        echo '</Response>' . "\n";
        exit;

    case 'got_mode':
        // The caller's choice has to actually change $sess['account'] — every
        // downstream retail-vs-fleet test reads $acct, so without this the mode
        // prompt was cosmetic and DNIS alone decided the flow.
        $wants_retail = ($digit === '1' || trim($said) === '1' || stripos($said, 'one') !== false);
        if ($wants_retail) {
            $sess['account'] = $DEFAULT_ACCOUNT;                  // retail: price quote + payment
        } else {
            $sess['account'] = $ACCOUNTS[$dnis_clean] ?? $FLEET_ACCOUNT;   // fleet: PO + unit, no payment
        }
        $sess['mode'] = $wants_retail ? 'retail' : 'fleet';
        sess_save($sid, $sess);
        $acct = $sess['account'];
        ivr_log("[$sid] mode={$sess['mode']} account={$acct['code']} ask_po=" . (int)!empty($acct['ask_po'])
                . " requires_payment=" . (int)!empty($acct['requires_payment']));

        // Both branches ask the same first question, so answer in THIS response
        // instead of spending a redirect round-trip to get there.
        if (FAST_GATHER_FIELDS) {
            twiml_play_then_gather(WELCOME_ZIP_PROMPT, 'got_zip', $sid, GATHER_START_TIMEOUT, 'zip code', ZIP_NUM_DIGITS ? 5 : 0, GATHER_SPEECH_TIMEOUT_DIGITS);
        }
        twiml_record(WELCOME_ZIP_PROMPT, 'got_zip', $sid, 6);

    case 'start_retail':
    case 'start_fleet':
        if (FAST_GATHER_FIELDS) {
            twiml_play_then_gather(WELCOME_ZIP_PROMPT, 'got_zip', $sid, GATHER_START_TIMEOUT, 'zip code', ZIP_NUM_DIGITS ? 5 : 0, GATHER_SPEECH_TIMEOUT_DIGITS);
        }
        twiml_record(WELCOME_ZIP_PROMPT, 'got_zip', $sid, 6);

    // ── GOT ZIP ──────────────────────────────────────────────────────────────
    case 'got_zip':
        if (FAST_GATHER_FIELDS) {
            // Deepgram returns the answer in THIS webhook — no recording download,
            // no Whisper hop, no filler, no redirect. Keypad entry works too.
            $zip_transcript = $said;
        } elseif (!empty($sess['zip_processing'])) {
            unset($sess['zip_processing']);
            $rec = !empty($_POST['RecordingUrl']) ? $_POST['RecordingUrl'] : ($sess['rec_zip'] ?? '');
            $zip_transcript = take_transcript('zip', $rec, $sid, $sess, 'got_zip');
        } else {
            $recording_url = $_POST['RecordingUrl'] ?? '';
            if (empty($recording_url)) {
                // Re-entry, not a first greeting — ask the question only
                twiml_record(ASK_ZIP_MP3, 'got_zip', $sid, 6);
            }
            capture_and_transcribe($recording_url, 'zip', 'got_zip', $sid, $sess);
        }

        ivr_log("[$sid] ZIP transcript: $zip_transcript");
        $zip = parse_zip($zip_transcript);

        if (strlen($zip) !== 5) {
            // Clear the stored recording, otherwise the retry re-transcribes the
            // SAME audio and loops on the same bad answer.
            unset($sess['rec_zip'], $sess['zip_processing'], $sess['zip_transcript']);
            sess_save($sid, $sess);
            if (FAST_GATHER_FIELDS) {
                twiml_play_then_gather(ASK_ZIP_RETRY_MP3, 'got_zip', $sid, GATHER_START_TIMEOUT, 'zip code', ZIP_NUM_DIGITS ? 5 : 0, GATHER_SPEECH_TIMEOUT_DIGITS);
            }
            twiml_record(ASK_ZIP_RETRY_MP3, 'got_zip', $sid, 6);
        }

        $location = zip_to_city_state($zip);
        $sess['zip']   = $zip;
        $sess['city']  = $location['city'];
        $sess['state'] = $location['state'];
        ivr_log("[$sid] ZIP $zip → {$location['city']}, {$location['state']}");
        sess_save($sid, $sess);
        // fall through to the glass question in this same response — no redirect
        if (FAST_GATHER_FIELDS) {
            twiml_play_then_gather('joined/03_ask_glass.mp3', 'got_glass', $sid, GATHER_START_TIMEOUT, 'windshield other');
        }
        twiml_record('joined/03_ask_glass.mp3', 'got_glass', $sid, 3);

    case 'process_zip':
        if (FAST_GATHER_FIELDS) {
            twiml_play_then_gather('joined/03_ask_glass.mp3', 'got_glass', $sid, GATHER_START_TIMEOUT, 'windshield other');
        }
        twiml_record('joined/03_ask_glass.mp3', 'got_glass', $sid, 3);

    // ── GOT GLASS ────────────────────────────────────────────────────────────
    case 'got_glass':
        if (FAST_GATHER_FIELDS) {
            $glass_transcript = $said;
        } elseif (!empty($sess['glass_processing'])) {
            unset($sess['glass_processing']);
            $rec = $sess['rec_glass'] ?? '';
            $glass_transcript = take_transcript('glass', $rec, $sid, $sess, 'got_glass');
        } else {
            $recording_url = $_POST['RecordingUrl'] ?? '';
            if (empty($recording_url)) {
                twiml_record('joined/03_ask_glass.mp3', 'got_glass', $sid, 3);
            }
            capture_and_transcribe($recording_url, 'glass', 'got_glass', $sid, $sess);
        }
        ivr_log("[$sid] Glass transcript: $glass_transcript");

        if (empty(trim($glass_transcript))) {
            unset($sess['rec_glass'], $sess['glass_processing'], $sess['glass_transcript']);
            sess_save($sid, $sess);
            if (FAST_GATHER_FIELDS) {
                twiml_play_then_gather('joined/03_1_ask_glass_retry.mp3', 'got_glass', $sid, GATHER_START_TIMEOUT, 'windshield other');
            }
            twiml_record('joined/03_1_ask_glass_retry.mp3', 'got_glass', $sid, 3);
        }
        // Route based on windshield or other
        if (stripos($glass_transcript, 'other') !== false) {
            if (FAST_GATHER_FIELDS) {
                twiml_play_then_gather('joined/04_otherglass.mp3', 'got_other_glass', $sid, GATHER_START_TIMEOUT,
                    'driver passenger back rear quarter left front right front');
            }
            twiml_record('joined/04_otherglass.mp3', 'got_other_glass', $sid, 8);
        }
        // "windshield" or anything else = windshield
        $sess['glass'] = 'Windshield';
        sess_save($sid, $sess);
        twiml_record('joined/05_yearmake.mp3', 'got_yearmake', $sid, 12);

    // ── GOT OTHER GLASS ───────────────────────────────────────────────────────
    case 'got_other_glass':
        // This case used to call Whisper inline with no filler at all — the
        // caller heard several seconds of dead silence.
        if (FAST_GATHER_FIELDS) {
            $other_transcript = $said;
        } elseif (!empty($sess['otherglass_processing'])) {
            unset($sess['otherglass_processing']);
            $rec = $sess['rec_otherglass'] ?? '';
            $other_transcript = take_transcript('otherglass', $rec, $sid, $sess, 'got_other_glass');
        } else {
            $recording_url = $_POST['RecordingUrl'] ?? '';
            if (empty($recording_url)) {
                twiml_record('joined/04_otherglass.mp3', 'got_other_glass', $sid, 8);
            }
            capture_and_transcribe($recording_url, 'otherglass', 'got_other_glass', $sid, $sess);
        }
        ivr_log("[$sid] Other glass transcript: $other_transcript");
        if (empty($other_transcript)) {
            unset($sess['rec_otherglass'], $sess['otherglass_transcript']);
            sess_save($sid, $sess);
            if (FAST_GATHER_FIELDS) {
                twiml_play_then_gather('joined/04_1_otherglass_retry.mp3', 'got_other_glass', $sid, GATHER_START_TIMEOUT,
                    'driver passenger back rear quarter left front right front');
            }
            twiml_record('joined/04_1_otherglass_retry.mp3', 'got_other_glass', $sid, 8);
        }
        $other = strtolower($other_transcript);
        $glass_map = array(
            'left front'    => 'Driver Side',
            'driver'        => 'Driver Side',
            'left rear'     => 'Driver Rear',
            'right front'   => 'Passenger Side',
            'passenger'     => 'Passenger Side',
            'right rear'    => 'Passenger Rear',
            'back'          => 'Back Glass',
            'rear'          => 'Back Glass',
            'quarter'       => 'Quarter Glass',
        );
        $glass_normalized = 'Back Glass'; // default if other
        foreach ($glass_map as $k => $v) {
            if (strpos($other, $k) !== false) { $glass_normalized = $v; break; }
        }
        $sess['glass'] = $glass_normalized;
        sess_save($sid, $sess);
        twiml_record('joined/05_yearmake.mp3', 'got_yearmake', $sid, 12);

    // ── GOT YEAR/MAKE/MODEL/STYLE ─────────────────────────────────────────────
    case 'got_yearmake':
        $recording_url = isset($_POST['RecordingUrl']) ? $_POST['RecordingUrl'] : '';
        if (empty($recording_url)) {
            twiml_record('joined/05_yearmake.mp3', 'got_yearmake', $sid, 12);
        }
        capture_and_transcribe($recording_url, 'yearmake', 'process_yearmake', $sid, $sess);

    // ── PROCESS YEAR/MAKE/MODEL ───────────────────────────────────────────────
    case 'process_yearmake':
        $recording_url = $sess['rec_yearmake'] ?? '';
        $transcript = take_transcript('yearmake', $recording_url, $sid, $sess, 'process_yearmake');
        ivr_log("[$sid] YMM transcript: $transcript");
        if (empty($transcript)) {
            unset($sess['rec_yearmake'], $sess['yearmake_processing'], $sess['yearmake_transcript']);
            sess_save($sid, $sess);
            twiml_record('joined/06_yearmake_retry.mp3', 'got_yearmake', $sid, 12);
        }
        $extracted = extract_vehicle($transcript);
        ivr_log("[$sid] YMM extract from '$transcript': " . json_encode($extracted));

        if (!empty($extracted['make']) && !empty($extracted['model'])) {
            $sess['year']  = !empty($extracted['year'])  ? $extracted['year']  : (isset($sess['year']) ? $sess['year'] : date('Y'));
            $sess['make']  = $extracted['make'];
            $sess['model'] = $extracted['model'];
            $style_raw = strtolower($transcript);
            if (strpos($style_raw, '2') !== false || strpos($style_raw, 'two') !== false) {
                $sess['style'] = '2 Door';
            } elseif (strpos($style_raw, '4') !== false || strpos($style_raw, 'four') !== false) {
                $sess['style'] = '4 Door';
            } else {
                $sess['style'] = '';
            }
            sess_save($sid, $sess);
            // NOTE: a get_truck_nags_style() call used to sit here. Its result
            // ($style_check) was never read anywhere in this file, so it was a
            // wasted proxy round-trip (8s timeout) on every call. Removed.
            // Get all styles for selection
            $style_url = 'https://autoglasshosting.com/ivr/proxy_nags_lookup.php'
                . '?year=' . urlencode($sess['year']??'')
                . '&make=' . urlencode($sess['make']??'')
                . '&model=' . urlencode($sess['model']??'')
                . '&style=&opening=WS&zip=' . urlencode($sess['zip']??'90001');
            $sch = curl_init($style_url);
            curl_setopt_array($sch, array(CURLOPT_RETURNTRANSFER=>true, CURLOPT_TIMEOUT=>8, CURLOPT_SSL_VERIFYPEER=>false,
                CURLOPT_USERPWD=>($config['proxy_user']??'').':'.($config['proxy_pass']??'')));
            $sdata = json_decode(curl_exec($sch), true);
            curl_close($sch);
            $available_styles = $sdata['styles'] ?? array();
            ivr_log("[$sid] Style query returned: " . json_encode($sdata));

            if (count($available_styles) > 1) {
                // Multiple styles — ask caller to choose
                $sess['available_styles'] = $available_styles;
                sess_save($sid, $sess);
                twiml_redirect('ask_style_choice', $sid);
            } else {
                // One or zero styles — use it and continue
                if (count($available_styles) === 1) $sess['style'] = $available_styles[0];
                sess_save($sid, $sess);
                $is_retail = empty($acct['ask_po']) && !empty($acct['requires_payment']);
                if ($is_retail) { twiml_redirect('retail_price_quote', $sid); }
                twiml_record('joined/07_name.mp3', 'got_name', $sid, 8);
            }
        }
        twiml_record('joined/06_yearmake_retry.mp3', 'got_yearmake', $sid, 12);

    // ── ASK STYLE CHOICE ─────────────────────────────────────────────────────────
    case 'ask_style_choice':
        $styles = $sess['available_styles'] ?? array();
        if (empty($styles)) {
            $is_retail = empty($acct['ask_po']) && !empty($acct['requires_payment']);
            if ($is_retail) twiml_redirect('retail_price_quote', $sid);
            twiml_record('joined/07_name.mp3', 'got_name', $sid, 8);
        }
        // Build style prompt text
        $style_text = 'We found multiple body styles for your ' . ($sess['year']??'') . ' ' . ($sess['make']??'') . ' ' . ($sess['model']??'') . '. ';
        foreach ($styles as $i => $s) {
            $style_text .= 'Press ' . ($i+1) . ' for ' . $s . '. ';
        }
        // Use ElevenLabs or Alice
        echo '<?xml version="1.0" encoding="UTF-8"?>' . "\n";
        echo '<Response>' . "\n";
        echo '  <Gather input="dtmf" action="' . htmlspecialchars(next_url('got_style_choice', $sid)) . '" method="POST" timeout="8" numDigits="1">' . "\n";
        if (!empty($config['elevenlabs_price_voice'])) {
            $cache_file = 'style_' . md5($style_text) . '.mp3';
            $audio_url = elevenlabs_say($style_text, $cache_file);
            if ($audio_url) {
                echo '    <Play>' . $audio_url . '</Play>' . "\n";
            } else {
                echo '    <Say voice="alice">' . htmlspecialchars($style_text) . '</Say>' . "\n";
            }
        } else {
            echo '    <Say voice="alice">' . htmlspecialchars($style_text) . '</Say>' . "\n";
        }
        echo '  </Gather>' . "\n";
        echo '  <Redirect method="POST">' . htmlspecialchars(next_url('ask_style_choice', $sid)) . '</Redirect>' . "\n";
        echo '</Response>' . "\n";
        exit;

    case 'got_style_choice':
        $styles = $sess['available_styles'] ?? array();
        $choice = (int)$digit - 1;
        if ($choice >= 0 && $choice < count($styles)) {
            $sess['style'] = $styles[$choice];
        } elseif (!empty($styles)) {
            $sess['style'] = $styles[0]; // default to first
        }
        sess_save($sid, $sess);
        $is_retail = empty($acct['ask_po']) && !empty($acct['requires_payment']);
        if ($is_retail) { twiml_redirect('retail_price_quote', $sid); }
        twiml_record('joined/07_name.mp3', 'got_name', $sid, 8);

    // ── RETAIL PRICE QUOTE ────────────────────────────────────────────────────
    case 'retail_price_quote':
        // NAGS lookup using year/make/model/glass already captured
        $truck_makes = array('freightliner','kenworth','peterbilt','international','mack','hino','isuzu','volvo','sterling');
        $is_truck = false;
        foreach ($truck_makes as $tm) {
            if (stripos($sess['make'] ?? '', $tm) !== false) { $is_truck = true; break; }
        }
        $nags_model = isset($sess['model']) ? $sess['model'] : '';
        // Model aliases for NAGS — M2 is stored as Business Class M2
        $nags_model_aliases = array(
            'M2'        => array('Business Class M2', 'Business Class'),
            'Cascadia'  => array('Cascadia 113', 'Cascadia 125'),
            'ProStar'   => array('ProStar+'),
            'VN'        => array('VN Series Conventional Cab', 'VN Series'),
            'VNL'       => array('VNL Series Conventional Cab', 'VNL Series'),
            'VNM'       => array('VNM Series Conventional Cab', 'VNM Series'),
        );
        if ($is_truck) {
            // Try primary model first, then aliases
            $models_to_try = array($nags_model);
            if (isset($nags_model_aliases[$nags_model])) {
                $models_to_try = array_merge($models_to_try, $nags_model_aliases[$nags_model]);
            }
            $nags_style = '';
            foreach ($models_to_try as $try_model) {
                $nags_style = get_truck_nags_style($sess['year']??'', $sess['make']??'', $try_model, $sess['zip']??'90001');
                if ($nags_style) {
                    $nags_model = $try_model;
                    ivr_log("Truck NAGS style: $nags_style for {$sess['make']} $nags_model");
                    break;
                }
            }
        } else {
            // For passenger vehicles, try the captured style first, then fall back to auto-detect
            $nags_style = isset($sess['style']) ? $sess['style'] : '';
            if (empty($nags_style)) {
                $nags_style = get_truck_nags_style($sess['year']??'', $sess['make']??'', $nags_model, $sess['zip']??'90001');
            }
        }
        $nags = lookup_nags($sess['year']??'', $sess['make']??'', $nags_model, $nags_style, $sess['glass']??'Windshield', $sess['zip']??'90001');

        $price = 0;

        // Check nsAffiliateVehicles first for custom pricing
        $aff_vehicle = lookup_affiliate_vehicle($sess['year']??'', $sess['make']??'', $nags_model, $nags_style);
        if ($aff_vehicle && $aff_vehicle['installed'] > 0) {
            $price = $aff_vehicle['installed'];
            $sess['nags_part']  = $aff_vehicle['part'];
            $sess['nags_style'] = $aff_vehicle['style'];
            ivr_log("[$sid] Using affiliate pricing: part={$aff_vehicle['part']} installed=$price");
        } elseif (!empty($nags['part'])) {
            $sess['nags_part']  = $nags['part'];
            $sess['nags_style'] = $nags['style'] ?? $nags_style;
            $raw_part = preg_replace('/^([A-Z]{1,3})0*(\d+)[A-Z]*$/i', '$1$2', $nags['part']);
            // Check nsAffiliateProduct for custom part pricing first
            $aff_prod = lookup_affiliate_product($raw_part);
            if ($aff_prod && $aff_prod['installed'] > 0) {
                $price = $aff_prod['installed'];
                ivr_log("[$sid] Affiliate product pricing: part=$raw_part installed=$price");
            } else {
                $glass_map2 = array('windshield'=>'WS','driver'=>'DR','passenger'=>'DR','rear'=>'BK','back'=>'BK','quarter'=>'QT');
                $opening = 'WS';
                foreach ($glass_map2 as $k => $v) { if (stripos($sess['glass']??'', $k) !== false) { $opening=$v; break; } }
                $proxy_url = 'https://autoglasshosting.com/proxy/proxy_getprice.php'
                    . '?part=' . urlencode($raw_part) . '&zip=' . urlencode($sess['zip']??'90001')
                    . '&acct=w2gcsr&opening=' . urlencode($opening);
                $ch = curl_init($proxy_url);
                curl_setopt_array($ch, array(CURLOPT_RETURNTRANSFER=>true, CURLOPT_TIMEOUT=>10, CURLOPT_SSL_VERIFYPEER=>false,
                    CURLOPT_USERPWD=>($config['proxy_user']??'').':'.($config['proxy_pass']??'')));
                $pd = json_decode(curl_exec($ch), true);
                curl_close($ch);
                $price = isset($pd['price_installed']) ? (float)$pd['price_installed'] : 0;
                if (!$price && isset($pd['price_glass']) && isset($pd['price_install'])) {
                    $price = (float)$pd['price_glass'] + (float)$pd['price_install'];
                }
                ivr_log("[$sid] Retail price quote: part=$raw_part price=$price");
            }
        }

        $sess['retail_price'] = $price;
        $sess['quoted_price'] = $price;
        $sess['needs_pricing'] = ($price == 0);
        sess_save($sid, $sess);

        echo '<?xml version="1.0" encoding="UTF-8"?>' . "\n";
        echo '<Response>' . "\n";
        if ($price > 0) {
            $price_str   = '$' . number_format($price, 2);
            $vehicle_str = trim(($sess['year']??'') . ' ' . ($sess['make']??'') . ' ' . ($sess['model']??''));
            $quote_text  = 'Your installed price for a ' . $vehicle_str . ' ' . strtolower($sess['glass']??'windshield') . ' is ' . $price_str . '. To place an order, say order or press 1. To speak with an agent, press 3.';

            echo '  <Gather input="dtmf speech" action="' . htmlspecialchars(next_url('got_price_response', $sid)) . '" method="POST" timeout="8" numDigits="1" hints="order" speechTimeout="1" speechModel="deepgram_nova-2">' . "\n";
            if (!empty($config['elevenlabs_price_voice'])) {
                // Use ElevenLabs custom voice
                $cache_file = 'price_' . md5($quote_text) . '.mp3';
                $audio_url  = elevenlabs_say($quote_text, $cache_file);
                if ($audio_url) {
                    echo '    <Play>' . $audio_url . '</Play>' . "\n";
                } else {
                    // Fallback to Alice if ElevenLabs fails
                    // ElevenLabs failed - play silence and continue
                    echo '    <Pause length="1"/>' . "\n";
                }
            } else {
                echo '    <Pause length="1"/>' . "\n";
            }
            echo '    <Pause length="4"/>' . "\n";
            echo '  </Gather>' . "\n";
            echo '  <Redirect method="POST">' . htmlspecialchars(next_url('got_price_response', $sid)) . '</Redirect>' . "\n";
        } else {
            // No price — play message and transfer to agent ext 904
            echo '  <Play>' . PROMPT_BASE . 'joined/36_no_price.mp3</Play>' . "\n";
            echo '  <Dial>+18005494470</Dial>' . "\n";
        }
        echo '</Response>' . "\n";
        exit;

    // ── GOT PRICE RESPONSE ────────────────────────────────────────────────────
    case 'got_price_response':
        $wants_order = ($digit === '1' || stripos($said, 'order') !== false || stripos($said, 'yes') !== false);
        $wants_agent = ($digit === '3' || stripos($said, 'agent') !== false);
        if ($wants_agent) { sess_del($sid); twiml_transfer(IVR_TRANSFER); }
        if ($wants_order) {
            twiml_record('joined/07_name.mp3', 'got_name', $sid, 8);
        }
        // No response — default to order
        twiml_record('joined/07_name.mp3', 'got_name', $sid, 8);

    // ── GOT NAME ─────────────────────────────────────────────────────────────
    case 'got_name':
        if (!empty($sess['name_processing'])) {
            unset($sess['name_processing']);
            $recording_url = $sess['rec_name'] ?? '';
            $name_transcript = take_transcript('name', $recording_url, $sid, $sess, 'got_name');
        } else {
            $recording_url = $_POST['RecordingUrl'] ?? '';
            if (empty($recording_url)) {
                twiml_record('joined/07_name.mp3', 'got_name', $sid, 8);
            }
            capture_and_transcribe($recording_url, 'name', 'got_name', $sid, $sess);
        }
        ivr_log("[$sid] Name transcript: $name_transcript");
        if (empty($name_transcript)) {
            unset($sess['rec_name'], $sess['name_transcript']);
            sess_save($sid, $sess);
            twiml_record('joined/08_name_retry.mp3', 'got_name', $sid, 8);
        }
        // Strip common greeting phrases
        $name_lower = strtolower(trim($name_transcript));
        foreach (array('my name is', 'it\'s', 'this is', 'the name is') as $g) {
            $name_lower = trim(preg_replace('/^' . preg_quote($g, '/') . '\s*/i', '', $name_lower));
        }
        $parts = explode(' ', $name_lower, 2);
        $sess['first'] = ucfirst($parts[0] ?? '');
        $sess['last']  = ucwords($parts[1] ?? '');
        if (empty($sess['first'])) {
            unset($sess['rec_name'], $sess['name_transcript']);
            sess_save($sid, $sess);
            twiml_record('joined/08_name_retry.mp3', 'got_name', $sid, 8);
        }
        unset($sess['rec_name'], $sess['name_transcript']);
        sess_save($sid, $sess);
        // Ask for the street in THIS response instead of redirecting to got_street
        twiml_record('joined/09_street.mp3', 'got_street', $sid, 15);

    // ── GOT STREET (recording → Whisper → address validation) ────────────────
    case 'got_street':
        // Processing pass — triggered after wait prompt
        if (!empty($sess['street_processing'])) {
            unset($sess['street_processing']);
            $rec = $sess['rec_street'] ?? '';
            if (empty($rec)) {
                twiml_record('joined/09_street.mp3', 'got_street', $sid, 15);
            }
            $street_transcript = take_transcript('street', $rec, $sid, $sess, 'got_street');
            ivr_log("[$sid] Street transcript: $street_transcript");
            if (empty($street_transcript)) {
                unset($sess['rec_street'], $sess['street_validated']);
                sess_save($sid, $sess);
                twiml_record('joined/10_street_retry.mp3', 'got_street', $sid, 15);
            }
            // Validation already ran in the background window; only do it here
            // if we fell back to a blocking transcribe.
            $sess['street'] = $sess['street_validated']
                ?? validate_street($street_transcript, $sess['zip'] ?? '', $sid);
            unset($sess['rec_street'], $sess['street_validated']);
            sess_save($sid, $sess);
            if (!empty($acct['ask_po'])) {
                twiml_gather('joined/11_PO.mp3', 'got_po', $sid, 6);
            } else {
                // Retail — ask for install date
                if (FAST_GATHER_FIELDS) {
                    twiml_play_then_gather('joined/21_install_date.mp3', 'got_install_date', $sid, GATHER_START_TIMEOUT);
                }
                twiml_record('joined/21_install_date.mp3', 'got_install_date', $sid, 8);
            }
        }

        // First pass — check for recording URL
        $recording_url = $_POST['RecordingUrl'] ?? '';
        if (empty($recording_url)) {
            twiml_record('joined/09_street.mp3', 'got_street', $sid, 15);
        }

        // Transcribe AND geocode while the filler plays
        $street_zip = $sess['zip'] ?? '';
        capture_and_transcribe($recording_url, 'street', 'got_street', $sid, $sess,
            function ($text, &$s) use ($street_zip, $sid) {
                $s['street_validated'] = validate_street($text, $street_zip, $sid);
            });

    // ── GOT INSTALL DATE (retail only) ───────────────────────────────────────
    case 'got_install_date':
        if (FAST_GATHER_FIELDS) {
            $date_transcript = $said;
        } elseif (!empty($sess['date_processing'])) {
            unset($sess['date_processing']);
            $rec = $sess['rec_date'] ?? '';
            $date_transcript = take_transcript('date', $rec, $sid, $sess, 'got_install_date');
        } else {
            $recording_url = $_POST['RecordingUrl'] ?? '';
            if (empty($recording_url)) {
                twiml_record('joined/21_install_date.mp3', 'got_install_date', $sid, 8);
            }
            capture_and_transcribe($recording_url, 'date', 'got_install_date', $sid, $sess);
        }
        ivr_log("[$sid] Date transcript: $date_transcript");
        if (empty($date_transcript)) {
            unset($sess['rec_date'], $sess['date_transcript']);
            sess_save($sid, $sess);
            if (FAST_GATHER_FIELDS) {
                twiml_play_then_gather('joined/22_install_date_retry.mp3', 'got_install_date', $sid, GATHER_START_TIMEOUT);
            }
            twiml_record('joined/22_install_date_retry.mp3', 'got_install_date', $sid, 8);
        }
        // Extract date using Claude-style logic
        $today    = date('Y-m-d');
        $tomorrow = date('Y-m-d', strtotime('+1 day'));
        $date_lower = strtolower(trim($date_transcript));
        $install_date = $tomorrow; // default
        if (strpos($date_lower, 'today') !== false) {
            $install_date = $today;
        } elseif (strpos($date_lower, 'tomorrow') !== false) {
            $install_date = $tomorrow;
        } else {
            // Try to parse day names
            $days = array('monday'=>'next monday','tuesday'=>'next tuesday','wednesday'=>'next wednesday',
                          'thursday'=>'next thursday','friday'=>'next friday','saturday'=>'next saturday','sunday'=>'next sunday');
            foreach ($days as $day => $next) {
                if (strpos($date_lower, $day) !== false) {
                    $parsed = strtotime($next);
                    if ($parsed) $install_date = date('Y-m-d', $parsed);
                    break;
                }
            }
            // Try direct date parse
            $parsed = strtotime($date_transcript);
            if ($parsed && $parsed > strtotime('yesterday')) {
                $install_date = date('Y-m-d', $parsed);
            }
        }
        $sess['install_date'] = $install_date;
        sess_save($sid, $sess);
        twiml_record('joined/15_contact.mp3', 'got_contact', $sid, 30);

    // ── GOT PO ───────────────────────────────────────────────────────────────
    case 'got_po':
        $po = strtoupper(trim($said));
        $skip_po = (stripos($po, 'SKIP') !== false || stripos($po, 'PASS') !== false || empty($po));
        if ($skip_po) $po = '';
        if (!$skip_po && !empty($acct['po_prefill']) && empty($po)) $po = $acct['po_prefill'];
        if (empty($po) && !$skip_po) {
            twiml_gather('joined/12_PO_retry.mp3', 'got_po', $sid, 6);
        }
        $sess['po'] = $po;
        sess_save($sid, $sess);
        twiml_gather('joined/13_Unit.mp3', 'got_unit', $sid, 6);

    // ── GOT UNIT ─────────────────────────────────────────────────────────────
    case 'got_unit':
        $unit = strtoupper(trim($said));
        $skip_unit = (stripos($unit, 'SKIP') !== false || stripos($unit, 'PASS') !== false || empty($unit));
        if ($skip_unit) $unit = '';
        if (empty($unit) && !$skip_unit) {
            twiml_gather('joined/14_Unit_retry.mp3', 'got_unit', $sid, 6);
        }
        $sess['unit'] = $unit;
        sess_save($sid, $sess);
        twiml_record('joined/15_contact.mp3', 'got_contact', $sid, 30);

    // ── GOT CONTACT (recording → Whisper → Claude extract) ───────────────────
    case 'got_contact':
        if (!empty($sess['contact_processing'])) {
            unset($sess['contact_processing']);
            $rec = $sess['rec_contact'] ?? '';
            if (!$rec) twiml_record('joined/15_contact.mp3', 'got_contact', $sid, 30);
            $ct = take_transcript('contact', $rec, $sid, $sess, 'got_contact');
            ivr_log("[$sid] Contact transcript: $ct");
            if (!$ct) {
                unset($sess['rec_contact'], $sess['contact_parsed']);
                sess_save($sid, $sess);
                twiml_record('joined/16_contact_retry.mp3', 'got_contact', $sid, 30);
            }
            // Claude extraction already ran in the background window; only redo
            // it here if we fell back to a blocking transcribe.
            $parsed = $sess['contact_parsed'] ?? parse_contact($ct, $acct);
            $sess['email'] = $parsed['email'];
            $sess['phone'] = $parsed['phone'];
            unset($sess['rec_contact'], $sess['contact_parsed']);
            sess_save($sid, $sess);
            twiml_gather('joined/19_notes_prompt.mp3', 'got_notes_final', $sid, 8);
        }
        $ru = $_POST['RecordingUrl'] ?? '';
        if (!$ru) twiml_record('joined/15_contact.mp3','got_contact',$sid,30);
        // Whisper AND the Claude extraction both run while the filler plays
        capture_and_transcribe($ru, 'contact', 'got_contact', $sid, $sess,
            function ($text, &$s) use ($acct) {
                $s['contact_parsed'] = parse_contact($text, $acct);
            });

    case 'got_cell_confirm':
        $answer = strtolower(trim($said));
        $is_cell = (strpos($answer, 'yes') !== false || $digit === '1');
        $sess['phone_is_cell'] = $is_cell;
        sess_save($sid, $sess);
        twiml_gather('joined/19_notes_prompt.mp3', 'got_notes_final', $sid, 8);

    case 'got_notes_final':
        $notes_raw = trim($said);
        $skip = (stripos($notes_raw, 'skip') !== false || empty($notes_raw));

        if (!$skip) {
            // Check if they provided a cell phone number
            $extra_digits = preg_replace('/[^0-9]/', '', $notes_raw);
            if (strlen($extra_digits) >= 10) {
                $sess['sms_phone'] = substr($extra_digits, -10);
                $sess['phone_is_cell'] = true;
                ivr_log("[$sid] Cell phone from notes: {$sess['sms_phone']}");
            }
            // Store notes (strip just the phone number part if that's all they said)
            $notes_clean = trim(preg_replace('/[\d\s\-\(\)\.]+/', ' ', $notes_raw));
            if (strlen($notes_clean) > 2) {
                $sess['notes'] = $notes_raw;
            }
        }
        sess_save($sid, $sess);

        // Play thank you then process (retail and fleet both go here now — price was quoted earlier)
        echo '<?xml version="1.0" encoding="UTF-8"?>' . "\n";
        echo '<Response>' . "\n";
        if (empty($acct['ask_po']) && !empty($acct['requires_payment'])) {
            echo '  <Play>' . PROMPT_BASE . 'joined/24_retail_thanks.mp3</Play>' . "\n";
        } else {
            echo '  <Play>' . PROMPT_BASE . 'joined/20_fleet_thanks1.mp3</Play>' . "\n";
        }
        echo '  <Redirect method="POST">' . htmlspecialchars(next_url('process_joined', $sid)) . '</Redirect>' . "\n";
        echo '</Response>' . "\n";
        exit;

    // ── PROCESS JOINED ORDER ──────────────────────────────────────────────────
    case 'process_joined':
        // NAGS lookup
        // For commercial trucks, door count doesn't map to NAGS style
        // Pass empty style so NAGS auto-selects the only available option
        $truck_makes = array('freightliner','kenworth','peterbilt','international','mack','hino','isuzu','volvo','sterling');
        $is_truck = false;
        foreach ($truck_makes as $tm) {
            if (stripos($sess['make'] ?? '', $tm) !== false) { $is_truck = true; break; }
        }
        $nags_style = $is_truck ? '' : (isset($sess['style']) ? $sess['style'] : '');
        $nags_model = isset($sess['model']) ? $sess['model'] : '';

        // NAGS model aliases — try alternate model names if primary fails
        $model_aliases = array(
            'M2'        => array('Business Class M2', 'Business Class'),
            'Cascadia'  => array('Cascadia 113', 'Cascadia 125'),
            'ProStar'   => array('ProStar+'),
            'LoneStar'  => array('LoneStar'),
        );

        $nags = lookup_nags($sess['year'] ?? '', $sess['make'] ?? '', $nags_model, $nags_style, $sess['glass'] ?? 'Windshield', $sess['zip'] ?? '90001');

        // If no part found and model has aliases, try them
        if (empty($nags['part']) && isset($model_aliases[$nags_model])) {
            foreach ($model_aliases[$nags_model] as $alias) {
                ivr_log("[$sid] NAGS alias try: $nags_model → $alias");
                $nags = lookup_nags($sess['year'] ?? '', $sess['make'] ?? '', $alias, $nags_style, $sess['glass'] ?? 'Windshield', $sess['zip'] ?? '90001');
                if (!empty($nags['part'])) break;
            }
        }
        if (!empty($nags['part'])) {
            $sess['nags_part']  = $nags['part'];
            $sess['nags_style'] = $nags['style'];
        } else {
            $sess['nags_part']  = '';
            $sess['nags_style'] = $sess['style'] ?? '';
        }

        // Check nsAffiliateVehicles for custom pricing (fleet and retail)
        $aff_vehicle = lookup_affiliate_vehicle($sess['year']??'', $sess['make']??'', $nags_model, $nags_style);
        if ($aff_vehicle && $aff_vehicle['installed'] > 0) {
            if (empty($sess['nags_part'])) $sess['nags_part'] = $aff_vehicle['part'];
            $sess['quoted_price'] = $aff_vehicle['installed'];
            ivr_log("[$sid] Fleet affiliate pricing: part={$aff_vehicle['part']} installed={$aff_vehicle['installed']}");
        }

        sess_save($sid, $sess);

        $ok = write_order($sess);
        sess_save($sid, $sess);

        if ($ok) {
            $invoice_id  = $sess['invoice_id']   ?? 0;
            $order_num   = $sess['order_num']     ?? '';
            $ckey        = $sess['customer_key']  ?? '';
            $is_retail   = empty($acct['ask_po']) && !empty($acct['requires_payment']);
            $needs_pay   = !empty($acct['requires_payment']);
            $needs_price = !empty($sess['needs_pricing']);
            $retail_price= isset($sess['retail_price']) ? $sess['retail_price'] : 0;
            $vin_url     = VIN_LINK_BASE . '?order=' . $invoice_id . '&key=' . $ckey;
            $pay_url     = PAY_LINK_BASE . '?order=' . $invoice_id . '&key=' . $ckey;

            // Build subject with pricing flag if needed
            $subject = "IVR Order $order_num";
            if ($is_retail) {
                $subject .= $needs_price ? ' [NEEDS PRICING]' : ' $' . number_format($retail_price, 2);
            }
            $subject .= " - " . ($acct['company'] ?: 'Retail') . " - {$sess['year']} {$sess['make']} {$sess['model']}";

            // Always send team notification email
            send_email(W2G_EMAIL, $subject,
                build_order_email($sess, $vin_url, ($needs_pay && !$is_retail) ? $pay_url : ''));

            // SMS VIN link to caller
            $sms_to = !empty($sess['sms_phone']) ? '+1' . $sess['sms_phone'] : (isset($sess['ani']) ? $sess['ani'] : '');
            if ($sms_to) {
                $sms = IVR_COMPANY . ": Order $order_num placed. Enter VIN: $vin_url";
                $sms_sent = rc_sms($sms_to, $sms);
                ivr_log("[$sid] SMS to $sms_to: " . ($sms_sent ? 'OK' : 'FAILED'));
            }
            ivr_log("[$sid] Joined order created: $order_num" . ($needs_price ? ' [NEEDS PRICING]' : ''));
        }

        sess_del($sid);
        twiml_hangup();


    default:
        twiml_transfer(IVR_TRANSFER);
}
