import { NextRequest, NextResponse } from 'next/server';
import { initDatabase } from '@server/db/init';
import { db } from '@server/db/drizzle';
import { sql } from 'drizzle-orm';
import { generateResponse, TranscriptEntry } from '@server/services/voice/llm.service';
import { synthesizeSpeech } from '@server/services/voice/tts.service';
import { validateTwilioSignature } from '@server/services/voice/twilio-signature';
import { getPlanLimits } from '@server/utils/features';
import { generateWsToken } from '@server/engine/twilio';

import { childLogger } from '@server/logger';
import { wrapRouteHandler } from '@server/logger/request';
import {
  resolveCallerContext,
  buildInstructions,
  fetchBranchContext,
} from '@server/engine/voice-core';
const log = childLogger('webhook.twilio.voice');

// --- TTS audio file management ---
// Files are stored in a private .tts-temp/ directory (never under public/) and
// served via a one-shot API route (/api/internal/tts-audio/[filename]) that
// deletes each file the moment Twilio fetches it.  Three additional safety nets:
//   1. Per-session cleanup in handleCallEnd (synchronous, on call end)
//   2. 2-minute safety-net setTimeout (abnormal call endings)
//   3. Startup orphan sweep (removes files > 15 min old left by a crashed server)

// eslint-disable-next-line @typescript-eslint/no-var-requires
const _fsSync = require('fs') as typeof import('fs');
// eslint-disable-next-line @typescript-eslint/no-var-requires
const _pathSync = require('path') as typeof import('path');
const TTS_AUDIO_DIR = _pathSync.join(process.cwd(), '.tts-temp');

let _ttsStartupSweepDone = false;

function runStartupTtsSweep(): void {
  if (_ttsStartupSweepDone) return;
  _ttsStartupSweepDone = true;
  try {
    if (!_fsSync.existsSync(TTS_AUDIO_DIR)) return;
    const MAX_AGE_MS = 15 * 60 * 1000;
    const now = Date.now();
    let removed = 0;
    for (const file of _fsSync.readdirSync(TTS_AUDIO_DIR)) {
      if (!file.endsWith('.wav')) continue;
      const filePath = _pathSync.join(TTS_AUDIO_DIR, file);
      try {
        const { mtimeMs } = _fsSync.statSync(filePath);
        if (now - mtimeMs > MAX_AGE_MS) { _fsSync.unlinkSync(filePath); removed++; }
      } catch { /* ignore individual file errors */ }
    }
    if (removed > 0) log.info({ removed }, 'TTS startup sweep: removed orphan files');
  } catch (err) {
    log.warn({ err }, 'TTS startup sweep failed');
  }
}

function cleanupSessionTtsFiles(sessionId: string): void {
  if (!_fsSync.existsSync(TTS_AUDIO_DIR)) return;
  try {
    let removed = 0;
    for (const file of _fsSync.readdirSync(TTS_AUDIO_DIR)) {
      if (!file.startsWith(`${sessionId}_`) || !file.endsWith('.wav')) continue;
      try { _fsSync.unlinkSync(_pathSync.join(TTS_AUDIO_DIR, file)); removed++; } catch { /* ignore */ }
    }
    if (removed > 0) log.info({ sessionId, removed }, 'TTS session cleanup: removed files');
  } catch (err) {
    log.warn({ err, sessionId }, 'TTS session cleanup failed');
  }
}

interface AgentRecord {
  id: string;
  system_prompt: string | null;
  greeting_script: string | null;
  closing_script: string | null;
  llm_model_id: string | null;
  voice_model_id: string | null;
  voice_language_code: string | null;
  realtime_model: string | null;
  voice_provider_name: string | null;
  fallback_rules: Array<{ trigger: string; action: string; priority?: string }> | null;
  capabilities: Record<string, boolean> | null;
}

interface PhoneRecord {
  id: string;
  restaurant_id: string;
  assigned_agent_id: string | null;
}

interface SessionRecord {
  id: string;
  agent_id: string | null;
  restaurant_id: string | null;
  sip_call_id: string | null;
  transcript: TranscriptEntry[];
}

const AGENT_COLS = `a.id, a.system_prompt, a.greeting_script, a.closing_script,
  a.llm_model_id, a.voice_model_id, a.voice_language_code, a.realtime_model,
  a.fallback_rules, a.capabilities,
  vp.name AS voice_provider_name`;

function twimlResponse(xml: string): NextResponse {
  return new NextResponse(xml, { headers: { 'Content-Type': 'text/xml' } });
}

function escapeXml(str: string): string {
  return str
    .replace(/&/g, '&amp;')
    .replace(/</g, '&lt;')
    .replace(/>/g, '&gt;')
    .replace(/"/g, '&quot;');
}

function agentLanguageForGather(agent: AgentRecord | null): string {
  const code = agent?.voice_language_code || 'hi-IN';
  const map: Record<string, string> = {
    'hi-IN': 'hi-IN', 'en-IN': 'en-IN', 'ta-IN': 'ta-IN', 'te-IN': 'te-IN',
    'gu-IN': 'gu-IN', 'kn-IN': 'kn-IN', 'ml-IN': 'ml-IN', 'mr-IN': 'mr-IN',
    'pa-IN': 'pa-IN', 'od-IN': 'en-IN', 'bn-IN': 'bn-IN',
  };
  return map[code] || code;
}

async function resolveTokenByRestaurantId(restaurantId: string): Promise<string | null> {
  /* raw: SELECT twilio_auth_token, twilio_connected FROM telephone_settings WHERE restaurant_id = $1 */
  const { rows } = await db.execute(sql`
    SELECT twilio_auth_token, twilio_connected FROM telephone_settings WHERE restaurant_id = ${restaurantId}
  `);
  const settingsRow = rows[0] as { twilio_auth_token: string | null; twilio_connected: boolean } | undefined;
  if (settingsRow?.twilio_connected && settingsRow.twilio_auth_token) {
    try {
      const { decrypt } = await import('@server/utils/crypto');
      return decrypt(settingsRow.twilio_auth_token);
    } catch {
      return settingsRow.twilio_auth_token;
    }
  }
  return null;
}

async function resolveAuthToken(opts: { callSid?: string; sessionId?: string; fromNumber?: string; toNumber?: string }): Promise<string | null> {
  const envToken = process.env.TWILIO_AUTH_TOKEN;

  if (opts.sessionId) {
    try {
      const { rows } = await db.execute(sql`SELECT restaurant_id FROM sip_call_sessions WHERE id = ${opts.sessionId}`);
      const session = rows[0] as { restaurant_id: string } | undefined;
      if (session) {
        const token = await resolveTokenByRestaurantId(session.restaurant_id);
        if (token) return token;
      }
    } catch { /* fall through */ }
  }

  if (opts.callSid) {
    try {
      const { rows } = await db.execute(sql`SELECT restaurant_id FROM sip_call_sessions WHERE sip_call_id = ${opts.callSid}`);
      const session = rows[0] as { restaurant_id: string } | undefined;
      if (session) {
        const token = await resolveTokenByRestaurantId(session.restaurant_id);
        if (token) return token;
      }
    } catch { /* fall through */ }
  }

  const numbers = [opts.toNumber, opts.fromNumber].filter(Boolean);
  for (const num of numbers) {
    if (!num) continue;
    try {
      const didNumber = num.replace(/^\+/, '');
      const { rows } = await db.execute(sql`
        SELECT restaurant_id FROM phone_numbers WHERE number IN (${sql.join([num, didNumber].map(n => sql`${n}`), sql`, `)}) AND is_active = true LIMIT 1
      `);
      const phoneRow = rows[0] as { restaurant_id: string } | undefined;
      if (phoneRow) {
        const token = await resolveTokenByRestaurantId(phoneRow.restaurant_id);
        if (token) return token;
      }
    } catch { /* fall through */ }
  }

  return envToken || null;
}

async function verifyRequest(req: NextRequest, params: Record<string, string>): Promise<boolean> {
  const signature = req.headers.get('x-twilio-signature');
  const callSid = params['CallSid'] || '';
  const sessionId = params['sessionId'] || req.nextUrl.searchParams.get('sessionId') || '';
  const authToken = await resolveAuthToken({
    callSid, sessionId,
    fromNumber: params['From'] || '',
    toNumber: params['To'] || '',
  });

  if (process.env.NODE_ENV === 'production' && !authToken) {
    log.error('no auth token found in production');
    return false;
  }

  if (!signature) {
    if (process.env.NODE_ENV === 'production') {
      log.warn('missing X-Twilio-Signature in production');
      return false;
    }
    return true;
  }

  if (!authToken) return true;

  const url = `https://${req.headers.get('host')}${req.nextUrl.pathname}${req.nextUrl.search}`;
  return validateTwilioSignature(authToken, signature, url, params);
}

async function extractPostParams(req: NextRequest): Promise<Record<string, string>> {
  const params: Record<string, string> = {};
  if (req.method === 'POST') {
    try {
      const formData = await req.formData();
      formData.forEach((val, key) => { params[key] = String(val); });
    } catch (formErr: unknown) {
      log.warn({ err: formErr }, 'failed to parse POST body');
    }
  }
  return params;
}

async function handleVoiceWebhook(req: NextRequest): Promise<NextResponse> {
  await initDatabase();
  runStartupTtsSweep();

  const postParams = await extractPostParams(req);

  const sigOk = await verifyRequest(req, postParams);
  log.info(
    {
      signatureValid: sigOk,
      event: postParams['CallStatus'] || 'voice',
      id: postParams['CallSid'] || null,
      type: 'webhook.received',
    },
    'webhook.received'
  );
  if (!sigOk) {
    return new NextResponse('Forbidden', { status: 403 });
  }

  const qp = req.nextUrl.searchParams;

  const transferSessionId = qp.get('transfer_session_id') || '';
  if (transferSessionId) {
    return handleTransferCallback(transferSessionId, qp.get('transfer_role'));
  }

  const conferenceName = qp.get('conferenceName') || '';
  if (conferenceName) {
    return twimlResponse(`<?xml version="1.0" encoding="UTF-8"?>
<Response>
  <Say>You are joining the conference.</Say>
  <Dial><Conference>${escapeXml(conferenceName)}</Conference></Dial>
</Response>`);
  }

  const callSid = postParams['CallSid'] || '';
  const from = postParams['From'] || '';
  const to = postParams['To'] || '';
  const speechResult = postParams['SpeechResult'] || '';
  const callStatus = postParams['CallStatus'] || '';
  const sessionId = postParams['sessionId'] || qp.get('sessionId') || '';
  const silenceCount = Math.max(0, parseInt(qp.get('silence') || '0', 10) || 0);

  if (callStatus === 'completed' || callStatus === 'failed' ||
      callStatus === 'busy' || callStatus === 'no-answer') {
    return handleCallEnd(callSid, callStatus, postParams['CallDuration'] || '0');
  }

  const resolvedHost =
    req.headers.get('host') ||
    process.env.WEBHOOK_BASE_URL?.replace(/^https?:\/\//, '');
  if (!resolvedHost) {
    log.error('cannot determine base URL: set WEBHOOK_BASE_URL to your public HTTPS app URL');
    return new NextResponse('Service misconfiguration', { status: 503 });
  }
  const baseUrl = `https://${resolvedHost}`.replace(/\/+$/, '');

  if (!speechResult && !sessionId) {
    return handleNewCall(callSid, from, to, baseUrl);
  }

  if (sessionId) {
    return handleSpeechInput(sessionId, speechResult, silenceCount, baseUrl);
  }

  return twimlResponse(`<?xml version="1.0" encoding="UTF-8"?>
<Response>
  <Say>Thank you for calling. Goodbye!</Say>
  <Hangup/>
</Response>`);
}

export const POST = wrapRouteHandler((req: Request) => handleVoiceWebhook(req as NextRequest));
export const GET = wrapRouteHandler((req: Request) => handleVoiceWebhook(req as NextRequest));

async function handleTransferCallback(sessionId: string, role: string | null): Promise<NextResponse> {
  try {
    const { rows } = await db.execute(sql`
      SELECT metadata FROM sip_call_sessions WHERE id = ${sessionId}
    `);
    const row = rows[0] as { metadata: Record<string, unknown> | null } | undefined;
    const pt = row?.metadata?.pending_transfer as { type?: string; target?: string; name?: string } | undefined;

    if (!pt?.type) {
      log.warn({ sessionId }, 'transfer callback: no pending_transfer found');
      return twimlResponse(`<?xml version="1.0" encoding="UTF-8"?><Response><Hangup/></Response>`);
    }

    if (pt.type === 'dial' && pt.target) {
      return twimlResponse(`<?xml version="1.0" encoding="UTF-8"?><Response><Say voice="Polly.Joanna">Please hold while we connect you.</Say><Dial>${escapeXml(pt.target)}</Dial></Response>`);
    }

    if (pt.type === 'conference' && pt.name) {
      if (role === 'staff') {
        return twimlResponse(`<?xml version="1.0" encoding="UTF-8"?><Response><Say voice="Polly.Joanna">You are joining a live customer call.</Say><Dial><Conference>${escapeXml(pt.name)}</Conference></Dial></Response>`);
      }
      return twimlResponse(`<?xml version="1.0" encoding="UTF-8"?><Response><Say voice="Polly.Joanna">You are being connected to a conference.</Say><Dial><Conference>${escapeXml(pt.name)}</Conference></Dial></Response>`);
    }

    log.warn({ sessionId, type: pt.type }, 'transfer callback: unrecognized pending_transfer type');
    return twimlResponse(`<?xml version="1.0" encoding="UTF-8"?><Response><Hangup/></Response>`);
  } catch (err) {
    log.error({ err, sessionId }, 'transfer callback: DB lookup failed');
    return twimlResponse(`<?xml version="1.0" encoding="UTF-8"?><Response><Hangup/></Response>`);
  }
}

async function handleCallEnd(callSid: string, status: string, durationStr: string) {
  const duration = parseInt(durationStr, 10) || 0;
  const dbStatus = status === 'completed' ? 'completed' : 'failed';

  /* raw: UPDATE sip_call_sessions SET status = $1, ended_at = NOW(), duration_seconds = $2 WHERE sip_call_id = $3 AND status = 'active' RETURNING id */
  const { rows: endedRows } = await db.execute(sql`
    UPDATE sip_call_sessions SET status = ${dbStatus}, ended_at = NOW(), duration_seconds = ${duration}
    WHERE sip_call_id = ${callSid} AND status = 'active'
    RETURNING id
  `);

  for (const row of endedRows as Array<{ id: string }>) {
    cleanupSessionTtsFiles(row.id);
  }

  return twimlResponse(`<?xml version="1.0" encoding="UTF-8"?>\n<Response/>`);
}

const AGENT_JOIN_SQL = `ai_agents a
  LEFT JOIN voice_models vm ON vm.id = a.voice_model_id
  LEFT JOIN voice_providers vp ON vp.id = vm.voice_provider_id`;

async function resolveAgent(phoneRow: PhoneRecord): Promise<AgentRecord | null> {
  if (phoneRow.assigned_agent_id) {
    /* raw: SELECT agent cols FROM ai_agents a LEFT JOIN ... WHERE a.id = $1 AND a.is_active = true AND 'voice' = ANY(a.channels) */
    const { rows } = await db.execute(sql`
      SELECT ${sql.raw(AGENT_COLS)} FROM ${sql.raw(AGENT_JOIN_SQL)}
      WHERE a.id = ${phoneRow.assigned_agent_id} AND a.is_active = true AND 'voice' = ANY(a.channels)
    `);
    const agent = rows[0] as unknown as AgentRecord | undefined;
    if (agent) return agent;
  }
  const { rows } = await db.execute(sql`
    SELECT ${sql.raw(AGENT_COLS)} FROM ${sql.raw(AGENT_JOIN_SQL)}
    WHERE a.restaurant_id = ${phoneRow.restaurant_id} AND a.is_default = true AND a.is_active = true AND 'voice' = ANY(a.channels)
  `);
  return (rows[0] as unknown as AgentRecord | undefined) ?? null;
}

async function resolveAgentById(agentId: string): Promise<AgentRecord | null> {
  const { rows } = await db.execute(sql`
    SELECT ${sql.raw(AGENT_COLS)} FROM ${sql.raw(AGENT_JOIN_SQL)} WHERE a.id = ${agentId}
  `);
  return (rows[0] as unknown as AgentRecord | undefined) ?? null;
}

function buildGatherTwiml(
  webhookUrl: string,
  language: string,
  promptAudioUrl: string | null,
  promptText: string,
  timeoutMessage: string
): string {
  const gather = promptAudioUrl
    ? `<Play>${escapeXml(promptAudioUrl)}</Play>`
    : `<Say>${escapeXml(promptText)}</Say>`;

  return `<?xml version="1.0" encoding="UTF-8"?>
<Response>
  ${gather}
  <Gather input="speech" action="${escapeXml(webhookUrl)}" method="POST" speechTimeout="auto" language="${escapeXml(language)}" actionOnEmptyResult="true">
  </Gather>
  <Say>${escapeXml(timeoutMessage)}</Say>
  <Hangup/>
</Response>`;
}

function buildRealtimeStreamUrl(baseUrl: string, sessionId: string): string {
  const tok = generateWsToken(sessionId);
  const customStreamUrl = process.env.TWILIO_REALTIME_STREAM_URL;
  if (customStreamUrl) {
    return `${customStreamUrl}/stream/${encodeURIComponent(sessionId)}?tok=${tok}`;
  }
  const host = baseUrl.replace(/^https?:\/\//, '').replace(/:\d+$/, '');
  return `wss://${host}/stream/${encodeURIComponent(sessionId)}?tok=${tok}`;
}

async function handleNewCall(callSid: string, from: string, to: string, baseUrl: string) {
  const didNumber = to.replace(/^\+/, '');

  /* raw: SELECT id, restaurant_id, assigned_agent_id FROM phone_numbers WHERE (number = $1 OR number = $2) AND is_active = true LIMIT 1 */
  const { rows: phoneRows } = await db.execute(sql`
    SELECT id, restaurant_id, assigned_agent_id FROM phone_numbers
    WHERE (number = ${to} OR number = ${didNumber}) AND is_active = true LIMIT 1
  `);
  const phoneRow = phoneRows[0] as unknown as PhoneRecord | undefined;

  if (!phoneRow) {
    return twimlResponse(`<?xml version="1.0" encoding="UTF-8"?>
<Response>
  <Say>This number is not configured. Please contact the restaurant directly.</Say>
  <Hangup/>
</Response>`);
  }

  const agent = await resolveAgent(phoneRow);

  if (!agent) {
    return twimlResponse(`<?xml version="1.0" encoding="UTF-8"?>
<Response>
  <Say>We're sorry, voice assistance is not available for this number right now. Please contact us through our website or try again later. Thank you.</Say>
  <Hangup/>
</Response>`);
  }

  // Enforce concurrent_voice_calls plan limit before creating an inbound session
  try {
    const limits = await getPlanLimits(phoneRow.restaurant_id);
    if (limits.concurrent_voice_calls !== null) {
      const { rows: activeRows } = await db.execute(sql`
        SELECT COUNT(*) AS count FROM sip_call_sessions
        WHERE restaurant_id = ${phoneRow.restaurant_id} AND status = 'active'
      `);
      const active = parseInt((activeRows[0] as { count: string })?.count ?? '0', 10);
      if (active >= limits.concurrent_voice_calls) {
        log.warn({ restaurantId: phoneRow.restaurant_id, active, limit: limits.concurrent_voice_calls }, 'concurrent_voice_calls limit reached for inbound call');
        return twimlResponse(`<?xml version="1.0" encoding="UTF-8"?>
<Response>
  <Say>We are currently experiencing high call volume and cannot take your call at this time. Please try again later.</Say>
  <Hangup/>
</Response>`);
      }
    }
  } catch (err) {
    log.warn({ err }, 'failed to check concurrent_voice_calls limit; allowing call');
  }

  /* raw: INSERT INTO sip_call_sessions (...) VALUES (...) RETURNING id */
  const { rows: sessionRows } = await db.execute(sql`
    INSERT INTO sip_call_sessions (restaurant_id, phone_number_id, agent_id, sip_call_id, caller_number, callee_number, direction, status, transcript)
    VALUES (${phoneRow.restaurant_id}, ${phoneRow.id}, ${agent.id}, ${callSid}, ${from}, ${to}, 'inbound', 'active', '[]'::jsonb)
    RETURNING id
  `);
  const session = sessionRows[0] as { id: string } | undefined;

  if (!session) {
    log.error({ callSid }, 'failed to create session for call');
    return twimlResponse(`<?xml version="1.0" encoding="UTF-8"?>
<Response>
  <Say>Sorry, there was a problem setting up your call. Please try again.</Say>
  <Hangup/>
</Response>`);
  }

  const streamUrl = buildRealtimeStreamUrl(baseUrl, session.id);
  log.info({ callSid, sessionId: session.id, streamUrl }, 'routing call → Realtime engine');
  return twimlResponse(`<?xml version="1.0" encoding="UTF-8"?>
<Response>
  <Connect>
    <Stream url="${escapeXml(streamUrl)}" />
  </Connect>
</Response>`);
}

async function handleSpeechInput(sessionId: string, speechText: string, silenceCount: number, baseUrl: string) {
  /* raw: SELECT id, agent_id, restaurant_id, sip_call_id, transcript FROM sip_call_sessions WHERE id = $1 */
  const { rows: sessionRows } = await db.execute(sql`
    SELECT id, agent_id, restaurant_id, sip_call_id, transcript FROM sip_call_sessions WHERE id = ${sessionId}
  `);
  const session = sessionRows[0] as unknown as SessionRecord | undefined;

  if (!session) {
    return twimlResponse(`<?xml version="1.0" encoding="UTF-8"?>
<Response>
  <Say>Sorry, there was an error. Goodbye!</Say>
  <Hangup/>
</Response>`);
  }

  const agent = session.agent_id ? await resolveAgentById(session.agent_id) : null;
  const language = agentLanguageForGather(agent);
  const langCode = agent?.voice_language_code || undefined;
  const webhookUrl = `${baseUrl}/api/webhooks/twilio/voice?sessionId=${sessionId}`;

  if (!speechText) {
    const transcript = Array.isArray(session.transcript) ? session.transcript : [];
    const isFirstInteraction = transcript.length === 0;

    if (isFirstInteraction) {
      if (!session.sip_call_id) {
        log.warn({ sessionId }, 'outbound call session has no sip_call_id — hanging up');
        return twimlResponse(`<?xml version="1.0" encoding="UTF-8"?>
<Response>
  <Say>Sorry, there was a problem setting up the call. Goodbye!</Say>
  <Hangup/>
</Response>`);
      }
      const streamUrl = buildRealtimeStreamUrl(baseUrl, sessionId);
      log.info({ sipCallId: session.sip_call_id, sessionId, streamUrl }, 'routing outbound call → Realtime engine');
      return twimlResponse(`<?xml version="1.0" encoding="UTF-8"?>
<Response>
  <Connect>
    <Stream url="${escapeXml(streamUrl)}" />
  </Connect>
</Response>`);
    }

    const closingText = agent?.closing_script?.trim() || 'Thank you for calling. Goodbye!';

    // Progressive silence: retry up to 3 times before closing.
    // The webhook URL carries ?silence=N so each consecutive empty-speech
    // callback increments the counter without storing state server-side.
    const nextSilence = silenceCount + 1;
    if (nextSilence >= 3) {
      log.info({ sessionId, silenceCount: nextSilence }, 'three consecutive silences — closing call');
      const closingAudioUrl = await generateTtsAudioUrl(
        closingText, agent?.voice_model_id || null, baseUrl, sessionId, 'closing',
        session.restaurant_id, langCode
      );
      const closingTag = closingAudioUrl
        ? `<Play>${escapeXml(closingAudioUrl)}</Play>`
        : `<Say>${escapeXml(closingText)}</Say>`;
      return twimlResponse(`<?xml version="1.0" encoding="UTF-8"?>
<Response>
  ${closingTag}
  <Hangup/>
</Response>`);
    }

    const silenceText = 'I didn\'t catch that, could you please repeat?';
    const silenceAudioUrl = await generateTtsAudioUrl(
      silenceText, agent?.voice_model_id || null, baseUrl, sessionId, 'silence',
      session.restaurant_id, langCode
    );
    const retryWebhookUrl = `${baseUrl}/api/webhooks/twilio/voice?sessionId=${sessionId}&silence=${nextSilence}`;
    return twimlResponse(buildGatherTwiml(retryWebhookUrl, language, silenceAudioUrl, silenceText, closingText));
  }

  // F-5: Use voice-core's resolveCallerContext + buildInstructions so the
  // fallback (non-Realtime) path gets the same personalization, loyalty
  // hints, active-offer injection, and caller-phone guidance as the
  // Realtime bridge path. Replaces the local buildSystemPrompt() call.
  const [branchCtx, callerCtx] = await Promise.all([
    fetchBranchContext(sessionId),
    resolveCallerContext(sessionId),
  ]);
  const agentConfig = {
    session_id: sessionId,
    restaurant_id: session.restaurant_id ?? '',
    agent_id: session.agent_id,
    system_prompt: agent?.system_prompt ?? null,
    greeting_script: agent?.greeting_script ?? null,
    closing_script: agent?.closing_script ?? null,
    voice_id: '',
    realtime_model: '',
    fallback_rules: agent?.fallback_rules ?? null,
    capabilities: agent?.capabilities ?? null,
    vad_threshold: null,
    vad_prefix_padding_ms: null,
    vad_silence_duration_ms: null,
    menu_category_ids: null,
  };
  const systemPrompt = buildInstructions(agentConfig, branchCtx, callerCtx);
  const transcript = Array.isArray(session.transcript) ? session.transcript : [];

  const llmResponse = await generateResponse(
    agent?.llm_model_id || null,
    systemPrompt,
    transcript,
    speechText,
    session.restaurant_id
  );

  transcript.push(
    { role: 'user', text: speechText, ts: new Date().toISOString() },
    { role: 'assistant', text: llmResponse.text, ts: new Date().toISOString() }
  );

  /* raw: UPDATE sip_call_sessions SET transcript = $1 WHERE id = $2 */
  await db.execute(sql`UPDATE sip_call_sessions SET transcript = ${JSON.stringify(transcript)}::jsonb WHERE id = ${sessionId}`);

  if (llmResponse.shouldEscalate) {
    /* raw: UPDATE sip_call_sessions SET escalated_to_human = true, status = 'completed', ended_at = NOW(), ... WHERE id = $1 */
    await db.execute(sql`
      UPDATE sip_call_sessions SET escalated_to_human = true,
       status = 'completed', ended_at = NOW(),
       duration_seconds = EXTRACT(EPOCH FROM (NOW() - started_at))::INTEGER
       WHERE id = ${sessionId}
    `);

    const closingText = agent?.closing_script?.trim() || 'Thank you for calling. Goodbye!';
    const replyAudioUrl = await generateTtsAudioUrl(
      llmResponse.text, agent?.voice_model_id || null, baseUrl, sessionId, 'escalate',
      session.restaurant_id, langCode
    );
    const closingAudioUrl = await generateTtsAudioUrl(
      closingText, agent?.voice_model_id || null, baseUrl, sessionId, 'closing',
      session.restaurant_id, langCode
    );

    /* raw: SELECT pn.fallback_number FROM sip_call_sessions s JOIN phone_numbers pn ON pn.id = s.phone_number_id WHERE s.id = $1 */
    const { rows: fallbackRows } = await db.execute(sql`
      SELECT pn.fallback_number FROM sip_call_sessions s
      JOIN phone_numbers pn ON pn.id = s.phone_number_id
      WHERE s.id = ${sessionId}
    `);
    const fallbackRow = fallbackRows[0] as { fallback_number: string | null } | undefined;

    const replyPart = replyAudioUrl
      ? `<Play>${escapeXml(replyAudioUrl)}</Play>`
      : `<Say>${escapeXml(llmResponse.text)}</Say>`;
    const closingPart = closingAudioUrl
      ? `<Play>${escapeXml(closingAudioUrl)}</Play>`
      : `<Say>${escapeXml(closingText)}</Say>`;

    if (fallbackRow?.fallback_number) {
      return twimlResponse(`<?xml version="1.0" encoding="UTF-8"?>
<Response>
  ${replyPart}
  ${closingPart}
  <Dial>${escapeXml(fallbackRow.fallback_number)}</Dial>
</Response>`);
    }

    return twimlResponse(`<?xml version="1.0" encoding="UTF-8"?>
<Response>
  ${replyPart}
  ${closingPart}
  <Hangup/>
</Response>`);
  }

  const replyAudioUrl = await generateTtsAudioUrl(
    llmResponse.text, agent?.voice_model_id || null, baseUrl, sessionId, 'response',
    session.restaurant_id, langCode
  );

  const timeoutMsg = agent?.closing_script?.trim() || 'Thank you for calling. Goodbye!';

  const replyPart = replyAudioUrl
    ? `<Play>${escapeXml(replyAudioUrl)}</Play>`
    : `<Say>${escapeXml(llmResponse.text)}</Say>`;

  return twimlResponse(`<?xml version="1.0" encoding="UTF-8"?>
<Response>
  ${replyPart}
  <Gather input="speech" action="${escapeXml(webhookUrl)}" method="POST" speechTimeout="auto" language="${escapeXml(language)}" actionOnEmptyResult="true">
  </Gather>
  <Say>${escapeXml(timeoutMsg)}</Say>
  <Hangup/>
</Response>`);
}

async function generateTtsAudioUrl(
  text: string,
  voiceModelId: string | null,
  baseUrl: string,
  sessionId: string,
  label: string,
  restaurantId?: string | null,
  languageCode?: string
): Promise<string | null> {
  try {
    const ttsResult = await synthesizeSpeech(text, voiceModelId, restaurantId, languageCode);
    if (!_fsSync.existsSync(TTS_AUDIO_DIR)) _fsSync.mkdirSync(TTS_AUDIO_DIR, { recursive: true });

    const filename = `${sessionId}_${label}_${Date.now()}.wav`;
    const filePath = _pathSync.join(TTS_AUDIO_DIR, filename);
    _fsSync.writeFileSync(filePath, ttsResult.audioBuffer);

    // Safety-net: if Twilio never fetches the file (network error, abnormal call end)
    // and handleCallEnd was not triggered, delete after 2 minutes.
    setTimeout(() => {
      try { _fsSync.unlinkSync(filePath); } catch { /* already deleted */ }
    }, 120000);

    // The one-shot serve route (/api/internal/tts-audio/[filename]) reads and
    // immediately deletes the file the moment Twilio fetches it.
    return `${baseUrl}/api/internal/tts-audio/${filename}`;
  } catch (err: unknown) {
    log.warn({ err, voiceModelId: voiceModelId ?? null, label }, 'TTS failed to generate audio');
    return null;
  }
}
