<?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');
define('GATHER_START_TIMEOUT',  3);   // seconds to wait for speech to BEGIN

// 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) {
    file_put_contents(LOG_FILE, date('[Y-m-d H:i:s] ') . $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));
    return $text;
}

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

// 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>' . PROMPT_BASE . htmlspecialchars($prompt_mp3) . '</Play>' . "\n";
    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) {
    $action_url = next_url($next_action, $call_sid);
    $hint_attr  = $hints ? ' hints="' . htmlspecialchars($hints) . '"' : '';
    $nd_attr    = $num_digits ? ' numDigits="' . (int)$num_digits . '"' : '';
    echo '<?xml version="1.0" encoding="UTF-8"?>' . "\n";
    echo '<Response>' . "\n";
    echo '  <Gather input="speech dtmf" action="' . htmlspecialchars($action_url) . '" method="POST"'
        . ' speechTimeout="' . GATHER_SPEECH_TIMEOUT . '"'
        . ' actionOnEmptyResult="true"'
        . ' speechModel="deepgram_nova-2"'
        . ' profanityFilter="false"'
        . ' timeout="' . $timeout . '"'
        . $nd_attr . $hint_attr . '>' . "\n";
    echo '    <Play>' . PROMPT_BASE . htmlspecialchars($prompt_mp3) . '</Play>' . "\n";
    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) {
    $action_url = next_url($next_action, $call_sid);
    $hint_attr  = $hints ? ' hints="' . htmlspecialchars($hints) . '"' : '';
    $nd_attr    = $num_digits ? ' numDigits="' . (int)$num_digits . '"' : '';
    echo '<?xml version="1.0" encoding="UTF-8"?>' . "\n";
    echo '<Response>' . "\n";
    // Play prompt first — mic closed during playback, no echo
    echo '  <Play>' . PROMPT_BASE . htmlspecialchars($prompt_mp3) . '</Play>' . "\n";
    echo '  <Gather input="speech dtmf" action="' . htmlspecialchars($action_url) . '" method="POST"'
        . ' speechTimeout="' . GATHER_SPEECH_TIMEOUT . '"'
        . ' 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('joined/01_welcome.mp3', 'got_zip', $sid, GATHER_START_TIMEOUT, 'zip code', ZIP_NUM_DIGITS ? 5 : 0);
        }
        twiml_record('joined/01_welcome.mp3', 'got_zip', $sid, 6);

    case 'start_retail':
    case 'start_fleet':
        if (FAST_GATHER_FIELDS) {
            twiml_play_then_gather('joined/01_welcome.mp3', 'got_zip', $sid, GATHER_START_TIMEOUT, 'zip code', ZIP_NUM_DIGITS ? 5 : 0);
        }
        twiml_record('joined/01_welcome.mp3', '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)) {
                twiml_record('joined/01_welcome.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('joined/02_ask_zip_retry.mp3', 'got_zip', $sid, GATHER_START_TIMEOUT, 'zip code', ZIP_NUM_DIGITS ? 5 : 0);
            }
            twiml_record('joined/02_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);
}
