/**
 * voice-core.ts — neutral helpers shared by the Twilio and SIP voice bridges.
 *
 * Single source of truth for: OpenAI key resolution, session/agent lookup,
 * branch context, instruction prompt assembly, transcript persistence,
 * and final session-status updates. This file is the ONLY shared file
 * between the Twilio and SIP engines.
 *
 * ─── SIP CHANNEL FILE MAP (Task #312) ────────────────────────────────
 * If the SIP/jambonz add-on is removed, delete EVERY file below; this
 * `voice-core.ts` file stays (Twilio depends on it).
 *
 *   src/server/engine/sip/                              (entire folder)
 *   src/server/services/telephone/sip/                  (entire folder)
 *   src/server/services/telephone/jambonz/              (entire folder)
 *   src/server/validators/sip.validator.ts
 *   src/client/api/sip.ts
 *   src/app/api/webhooks/sip/                           (entire folder)
 *   src/app/api/telephone/sip/                          (entire folder)
 *   src/app/(dashboard)/ai-agent-config/phone-numbers/sip/  (entire folder)
 *
 * Also revert: provider/sip_provider_label columns on phone_numbers,
 * restaurants.sip_enabled, the SipJambonzEngine registration in
 * engine/index.ts, and the provider='sip' branch in
 * app/api/telephone/calls/outbound/route.ts.
 * ────────────────────────────────────────────────────────────────────
 */
import { db } from '@server/db/drizzle';
import { sql } from 'drizzle-orm';
import { AgentConfig } from './types';
import { childLogger } from '@server/logger';
import { findOrCreateCustomerByPhone } from '@server/services/customers.service';
import { getActiveOffersForChannel, type ActiveOfferSummary } from '@server/services/coupons.service';
const log = childLogger('engine.voice-core');

import {
  DEFAULT_REALTIME_MODEL,
  DEFAULT_REALTIME_VOICE as DEFAULT_VOICE,
} from '@server/config/ai-defaults';

export const DEFAULT_SYSTEM_PROMPT = `You are a friendly and professional AI phone agent for a restaurant. Your job is to assist callers efficiently and warmly over the phone.

Guidelines:
- Greet callers warmly and make them feel welcome.
- Answer questions about the restaurant using your knowledge base search tool whenever possible.
- Keep responses concise — callers are on the phone, so avoid long paragraphs.
- Speak naturally and confirm back important details before finalising.
- Do not make up menu items, prices, or hours you are not certain about.
- Always be polite, patient, and professional.`;

export interface BranchContext {
  name: string;
  address: string | null;
  phone: string | null;
  hours: Record<string, { open: string; close: string; closed?: boolean }> | null;
  timezone: string | null;
}

export interface CallerContext {
  customerId: string | null;
  customerName: string | null;
  isNew: boolean;
  activeOffers: ActiveOfferSummary[];
  branchId: string | null;
  /** Total visits AFTER this call's bump (>=1 for any matched/created caller). */
  totalVisits: number;
  /** Previous last_seen_at (before this call's bump). Null on the very first contact. */
  lastSeenAt: Date | null;
  /** Most recent completed/delivered order timestamp, if any. */
  lastOrderAt: Date | null;
  /** The caller's phone number as captured from the inbound call. Null for outbound calls
   *  or when no number was captured. Used to pre-populate phone fields in tools so the
   *  agent never has to ask callers for a number they already dialled from. */
  callerPhone: string | null;
  /** Branch UUID the caller confirmed on a previous call. When set (and the routing mode
   *  is ask_caller or geo_detect), the agent offers this branch first so the caller can
   *  confirm with a single word rather than choosing again from the full list. */
  preferredBranchId: string | null;
}

export interface TranscriptEntry {
  role: 'user' | 'assistant';
  text: string;
  ts: string;
}

const DAY_ORDER = ['monday','tuesday','wednesday','thursday','friday','saturday','sunday'];
const DAY_LABELS: Record<string, string> = {
  monday: 'Mon', tuesday: 'Tue', wednesday: 'Wed',
  thursday: 'Thu', friday: 'Fri', saturday: 'Sat', sunday: 'Sun',
};

export async function getOpenAIKey(restaurantId: string): Promise<string | null> {
  try {
    /* raw: SELECT encrypted_api_key FROM restaurant_api_keys WHERE restaurant_id = $1 AND provider_name = 'openai' AND is_active = true LIMIT 1 */
    const row = await db.execute(sql`SELECT encrypted_api_key FROM restaurant_api_keys
       WHERE restaurant_id = ${restaurantId} AND provider_name = 'openai' AND is_active = true
       LIMIT 1`);
    if (row.rows[0]?.encrypted_api_key) {
      const key = row.rows[0].encrypted_api_key as string;
      if (key.startsWith('sk-')) return key;
      try {
        const { decrypt } = await import('@server/utils/crypto');
        return decrypt(key);
      } catch {
        return key;
      }
    }
  } catch {
    /* fall through to env */
  }
  return process.env.OPENAI_API_KEY || null;
}

export function mapAgentRow(r: Record<string, unknown>): AgentConfig {
  const rawMode = (r.branch_routing_mode as string) || 'assigned';
  const branchRoutingMode: AgentConfig['branch_routing_mode'] =
    rawMode === 'ask_caller' ? 'ask_caller'
    : rawMode === 'geo_detect' ? 'geo_detect'
    : 'assigned';
  return {
    session_id: r.session_id as string,
    restaurant_id: r.restaurant_id as string,
    agent_id: (r.agent_id as string) || null,
    system_prompt: (r.system_prompt as string) || null,
    greeting_script: (r.greeting_script as string) || null,
    closing_script: (r.closing_script as string) || null,
    voice_id: (r.voice_id as string) || DEFAULT_VOICE,
    realtime_model: (r.realtime_model as string) || DEFAULT_REALTIME_MODEL,
    fallback_rules: (r.fallback_rules as AgentConfig['fallback_rules']) || null,
    capabilities: (r.capabilities as AgentConfig['capabilities']) || null,
    vad_threshold: r.vad_threshold != null ? Number(r.vad_threshold) : null,
    vad_prefix_padding_ms: r.vad_prefix_padding_ms != null ? Number(r.vad_prefix_padding_ms) : null,
    vad_silence_duration_ms: r.vad_silence_duration_ms != null ? Number(r.vad_silence_duration_ms) : null,
    menu_category_ids: Array.isArray(r.menu_category_ids) ? (r.menu_category_ids as string[]) : null,
    branch_routing_mode: branchRoutingMode,
  };
}

export async function lookupAgentBySessionId(sessionId: string): Promise<AgentConfig | null> {
  try {
    const result = await db.execute(sql`SELECT s.id AS session_id, s.restaurant_id, s.agent_id,
        a.system_prompt, a.greeting_script, a.closing_script,
        a.fallback_rules, a.capabilities, a.realtime_model,
        a.vad_threshold, a.vad_prefix_padding_ms, a.vad_silence_duration_ms,
        a.menu_category_ids,
        COALESCE(vm.voice_id, ${DEFAULT_VOICE}) AS voice_id,
        COALESCE(pn.branch_routing_mode, 'assigned') AS branch_routing_mode
      FROM sip_call_sessions s
      LEFT JOIN ai_agents a ON a.id = s.agent_id
      LEFT JOIN voice_models vm ON vm.id = a.voice_model_id
      LEFT JOIN phone_numbers pn ON pn.id = s.phone_number_id
      WHERE s.id = ${sessionId} AND s.status = 'active' LIMIT 1`);
    if (!result.rows[0]) return null;
    return mapAgentRow(result.rows[0] as Record<string, unknown>);
  } catch (e) {
    log.error({ err: e }, 'VoiceCore DB lookup error');
    return null;
  }
}

export interface BranchListEntry {
  id: string;
  name: string;
  address: string | null;
}

export async function fetchAllBranches(restaurantId: string): Promise<BranchListEntry[]> {
  try {
    const { rows } = await db.execute(sql`
      SELECT id, name, address
        FROM branches
       WHERE restaurant_id = ${restaurantId}
         AND is_active = true
       ORDER BY name ASC
    `);
    return rows as unknown as BranchListEntry[];
  } catch {
    return [];
  }
}

export async function fetchBranchContext(sessionId: string): Promise<BranchContext | null> {
  try {
    const result = await db.execute(sql`SELECT b.name, b.address, b.phone, b.hours, b.timezone
       FROM sip_call_sessions s
       JOIN phone_numbers pn ON pn.id = s.phone_number_id
       JOIN branches b ON b.id = pn.branch_id
       WHERE s.id = ${sessionId} LIMIT 1`);
    if (!result.rows[0]) return null;
    return result.rows[0] as unknown as BranchContext;
  } catch {
    return null;
  }
}

function formatBranchHours(ctx: BranchContext): string | null {
  const h = ctx.hours;
  if (!h || typeof h !== 'object') return null;
  const lines: string[] = [];
  for (const day of DAY_ORDER) {
    const slot = h[day];
    if (!slot) continue;
    lines.push(slot.closed ? `${DAY_LABELS[day]}: Closed` : (slot.open && slot.close ? `${DAY_LABELS[day]}: ${slot.open}–${slot.close}` : ''));
  }
  const filtered = lines.filter(Boolean);
  if (filtered.length === 0) return null;
  const tzNote = ctx.timezone ? ` (${ctx.timezone})` : '';
  return `Working hours${tzNote}: ${filtered.join(', ')}`;
}

/**
 * Resolves caller-side personalization context for an inbound voice
 * session: looks the caller up by phone (creating a customer row when
 * none exists) and pulls the top voice-channel offers they're eligible
 * for. Returns nulls (never throws) so a lookup failure can NEVER break
 * the call — voice-core just falls back to a generic greeting.
 */
export async function resolveCallerContext(sessionId: string): Promise<CallerContext> {
  const empty: CallerContext = {
    customerId: null, customerName: null, isNew: false, activeOffers: [], branchId: null,
    totalVisits: 0, lastSeenAt: null, lastOrderAt: null, callerPhone: null, preferredBranchId: null,
  };
  try {
    const { rows } = await db.execute(sql`
      SELECT s.restaurant_id, s.caller_number, s.direction, pn.branch_id
        FROM sip_call_sessions s
        LEFT JOIN phone_numbers pn ON pn.id = s.phone_number_id
       WHERE s.id = ${sessionId}
       LIMIT 1
    `);
    const row = rows[0] as { restaurant_id: string; caller_number: string | null; direction: string | null; branch_id: string | null } | undefined;
    if (!row) return empty;

    const branchId = row.branch_id ?? null;
    const callerPhone = row.caller_number ?? null;

    // F-14: For outbound calls the caller_number is the restaurant's own DID, not
    // a customer number. Skip customer lookup entirely to avoid creating a ghost
    // customer record for the restaurant's own phone number.
    if (row.direction === 'outbound') {
      return { ...empty, branchId, callerPhone: null };
    }

    let customerId: string | null = null;
    let customerName: string | null = null;
    let isNew = false;
    let totalVisits = 0;
    let lastSeenAt: Date | null = null;
    let lastOrderAt: Date | null = null;
    let preferredBranchId: string | null = null;
    try {
      const found = await findOrCreateCustomerByPhone(row.restaurant_id, row.caller_number, { sourceLabel: 'voice call' });
      if (found) {
        customerId = found.customer.id;
        customerName = found.customer.name;
        isNew = found.isNew;
        totalVisits = found.customer.total_visits;
        lastSeenAt = found.customer.last_seen_at;
        lastOrderAt = found.customer.last_order_at;
        preferredBranchId = found.customer.preferred_branch_id ?? null;
      }
    } catch (e) {
      log.warn({ err: e, sessionId }, 'caller customer lookup failed; continuing without personalization');
    }

    let activeOffers: ActiveOfferSummary[] = [];
    try {
      activeOffers = await getActiveOffersForChannel({
        restaurantId: row.restaurant_id,
        branchId,
        channel: 'voice',
        customerId,
        limit: 2,
      });
    } catch (e) {
      log.warn({ err: e, sessionId }, 'active offers lookup failed; continuing without offers');
    }

    return { customerId, customerName, isNew, activeOffers, branchId, totalVisits, lastSeenAt, lastOrderAt, callerPhone, preferredBranchId };
  } catch (e) {
    log.warn({ err: e, sessionId }, 'resolveCallerContext failed');
    return empty;
  }
}

function isPlaceholderName(name: string | null): boolean {
  if (!name) return true;
  return /^(Caller |WhatsApp )/i.test(name.trim());
}

/** Threshold (inclusive) at which a returning caller earns a VIP greeting. */
const VIP_VISIT_THRESHOLD = 5;
/** Window in which the caller's last visit is recent enough to namedrop. */
const RECENT_VISIT_WINDOW_DAYS = 60;

function describeRelativeTime(when: Date, now: Date = new Date()): string {
  const diffMs = Math.max(0, now.getTime() - when.getTime());
  const days = Math.floor(diffMs / 86_400_000);
  if (days <= 0) return 'earlier today';
  if (days === 1) return 'yesterday';
  if (days < 14) return `${days} days ago`;
  const weeks = Math.floor(days / 7);
  if (weeks < 8) return `about ${weeks} weeks ago`;
  const months = Math.floor(days / 30);
  if (months < 12) return `about ${months} ${months === 1 ? 'month' : 'months'} ago`;
  const years = Math.floor(days / 365);
  return `about ${years} ${years === 1 ? 'year' : 'years'} ago`;
}

function buildLoyaltyHint(caller: CallerContext): string | null {
  if (caller.isNew || caller.totalVisits <= 1) return null;
  const now = new Date();
  const recentSeen = caller.lastSeenAt
    && (now.getTime() - caller.lastSeenAt.getTime()) <= RECENT_VISIT_WINDOW_DAYS * 86_400_000;
  const isVip = caller.totalVisits >= VIP_VISIT_THRESHOLD;
  if (!isVip && !recentSeen && !caller.lastOrderAt) return null;

  const segments: string[] = [`${caller.totalVisits} prior contacts`];
  if (caller.lastOrderAt) segments.push(`last order ${describeRelativeTime(caller.lastOrderAt, now)}`);
  else if (caller.lastSeenAt) segments.push(`last spoke ${describeRelativeTime(caller.lastSeenAt, now)}`);
  const stats = segments.join(', ');

  if (isVip) {
    return `- Loyalty: VIP returning caller (${stats}). Acknowledge them warmly as a regular — e.g. "always great to hear from you again" — without reading the numbers out loud.`;
  }
  return `- Loyalty: returning caller (${stats}). It's natural to acknowledge that you've spoken before — e.g. "good to hear from you again" — without reading the numbers out loud.`;
}

function describeOffer(o: ActiveOfferSummary): string {
  const value = o.type === 'percent' ? `${o.value}% off`
              : o.type === 'fixed'   ? `${o.value} off`
              : 'a buy-one-get-one deal';
  const label = o.display_name?.trim() || o.description?.trim() || value;
  return `"${label}" (code ${o.code})`;
}

export function buildInstructions(
  agent: AgentConfig,
  branch?: BranchContext | null,
  caller?: CallerContext | null,
  branchList?: BranchListEntry[] | null,
): string {
  const parts = [agent.system_prompt?.trim() || DEFAULT_SYSTEM_PROMPT];

  const caps = agent.capabilities || {};

  const enabledCapLabels: Record<string, string> = {
    order_taking: 'taking food orders',
    reservation_booking: 'booking table reservations',
    faq_answering: 'answering frequently asked questions',
    escalation: 'escalating to a human agent when needed',
  };
  const enabledCaps = Object.entries(caps)
    .filter(([, v]) => v)
    .map(([k]) => enabledCapLabels[k] || k.replace(/_/g, ' '));
  if (enabledCaps.length > 0) {
    parts.push(`\n\nYou are enabled to assist with: ${enabledCaps.join(', ')}.`);
  }

  const rules = (agent.fallback_rules || []).filter(r => r.trigger?.trim() && r.action?.trim());
  if (rules.length > 0) {
    const sorted = [...rules].sort((a, b) => {
      const ord: Record<string, number> = { high: 0, medium: 1, low: 2 };
      return (ord[a.priority || 'medium'] ?? 1) - (ord[b.priority || 'medium'] ?? 1);
    });
    parts.push(`\n\nFallback rules:\n${sorted.map(r => `- If ${r.trigger}: ${r.action}`).join('\n')}`);
  }

  // ─── Multi-branch routing preamble ──────────────────────────────────────
  const routingMode = agent.branch_routing_mode || 'assigned';
  if (routingMode !== 'assigned' && branchList && branchList.length > 0) {
    const branchTable = branchList
      .map((b, i) => `  ${i + 1}. ${b.name}${b.address ? ` — ${b.address}` : ''}`)
      .join('\n');

    // When the caller has a preferred branch on file, offer it first so they
    // can confirm with a single word instead of choosing from the full list.
    const preferredBranch = caller?.preferredBranchId
      ? branchList.find(b => b.id === caller.preferredBranchId) ?? null
      : null;

    if (routingMode === 'ask_caller') {
      if (preferredBranch) {
        parts.push(
          `\n\n[BRANCH ROUTING — ASK CALLER]\n` +
          `This phone number serves multiple locations. This caller has used our ${preferredBranch.name} location before. At the VERY START of this call — before taking any order, booking, or answering menu questions — greet the caller and ask: "Last time you called us from our ${preferredBranch.name} location — shall I connect you there again?" If they confirm, immediately call set_active_branch with branch ID ${preferredBranch.id}. If they say no or want a different location, present the full list and wait for their choice:\n` +
          `${branchTable}\n` +
          `Once they name a branch, call set_active_branch with that branch's ID and a brief confirmation phrase. Only after set_active_branch succeeds should you proceed with any other assistance. Do NOT accept an order or booking before the branch is confirmed.`
        );
      } else {
        parts.push(
          `\n\n[BRANCH ROUTING — ASK CALLER]\n` +
          `This phone number serves multiple locations. At the VERY START of this call — before taking any order, booking, or answering menu questions — you MUST greet the caller and present the available branches so they can choose:\n` +
          `${branchTable}\n` +
          `Ask clearly: "Which location would you like?" Wait for their answer. Once they name a branch (or give a close enough description), call the set_active_branch tool with that branch's ID and a brief confirmation phrase (e.g. "Great, I'll connect you to our Main Street location"). Only after set_active_branch succeeds should you proceed with any other assistance. Do NOT accept an order or booking before the branch is confirmed.`
        );
      }
    } else if (routingMode === 'geo_detect') {
      if (preferredBranch) {
        parts.push(
          `\n\n[BRANCH ROUTING — GEO-DETECT]\n` +
          `This phone number serves multiple locations. This caller has used our ${preferredBranch.name} location before. At the VERY START of this call — before taking any order, booking, or answering menu questions — greet the caller and ask: "Last time you called us from our ${preferredBranch.name} location — shall I connect you there again?" If they confirm, immediately call set_active_branch with branch ID ${preferredBranch.id}. If they say no or want a different location, ask for their current location (city, neighbourhood, or zip/postal code) and reason over the branch addresses below to find the closest one:\n` +
          `${branchTable}\n` +
          `Confirm your selection with the caller before calling set_active_branch. Only after set_active_branch succeeds should you proceed with any other assistance. Do NOT accept an order or booking before the branch is confirmed.`
        );
      } else {
        parts.push(
          `\n\n[BRANCH ROUTING — GEO-DETECT]\n` +
          `This phone number serves multiple locations. At the VERY START of this call — before taking any order, booking, or answering menu questions — you MUST ask the caller for their location (city, neighbourhood, or zip/postal code). Then reason over the branch addresses below to identify the closest one, and confirm your selection with the caller:\n` +
          `${branchTable}\n` +
          `Example: "It sounds like our City Hub location is closest to you — shall I proceed with that branch?" Wait for confirmation. Once confirmed, call the set_active_branch tool with that branch's ID and a brief confirmation phrase. Only after set_active_branch succeeds should you proceed with any other assistance. Do NOT accept an order or booking before the branch is confirmed.`
        );
      }
    }
  }

  if (branch) {
    const contactParts: string[] = [`Branch: ${branch.name}`];
    if (branch.phone) contactParts.push(`Phone: ${branch.phone}`);
    if (branch.address) contactParts.push(`Address: ${branch.address}`);
    const hoursStr = formatBranchHours(branch);
    if (hoursStr) contactParts.push(hoursStr);
    parts.push(`\n\nRestaurant contact information: ${contactParts.join(' | ')}`);
  }

  parts.push('\n\nWhen callers ask about opening hours, policies, FAQs, or general restaurant-specific information, use the search_knowledge_base tool. When callers ask about the menu, available dishes, prices, or what they can order, ALWAYS call the list_menu tool first — it returns the live menu with per-item availability for THIS branch right now. Skip items where orderable=false when reading the menu aloud. If a caller asks for an item by name and it is not orderable, briefly explain using the unavailable_message (e.g. "the lamb biryani is sold out today" or "pancakes are available from 08:00 tomorrow") and suggest an orderable alternative. Never recite menu items, prices, or availability from memory.');

  if (caps.order_taking) {
    const phoneNote = caller?.callerPhone
      ? `The caller's phone number (${caller.callerPhone}) is already known from this inbound call — do NOT ask for it again; it will be pre-filled automatically. `
      : 'Ask for their phone number if not already known. ';
    parts.push(`\n\nWhen a caller wants to place a food order: collect all items and quantities, ask for their name, and whether it is dine-in, takeaway, or delivery. ${phoneNote}For delivery orders specifically, confirm the phone number with the caller before calling place_order so kitchen staff can follow up if needed. Confirm the full order back to them, then call the place_order tool. Do not call place_order until the caller has confirmed. Do not offer items that list_menu marks as unavailable.`);
  }
  if (caps.reservation_booking) {
    const phoneNote = caller?.callerPhone
      ? `The caller's phone number (${caller.callerPhone}) is already known from this inbound call — do NOT ask for it again; it will be pre-filled automatically. `
      : 'Ask for their phone number if not already known. ';
    parts.push(`\n\nWhen a caller wants to book a table: collect their name, party size, preferred date, and time. ${phoneNote}Confirm the details back to them, then call the book_table tool. Do not call book_table until the caller has confirmed.`);
  }
  if (caps.escalation) {
    parts.push('\n\nIf a caller asks to speak to a person, a manager, or a human, or if you cannot resolve their query, immediately call the escalate_call tool with the reason. Do not keep trying to help after the caller has asked for a human.');
  }

  // ─── Personalization (Task #323) ────────────────────────────────────────
  // Caller name + new-vs-returning + 1–2 active voice-channel offers. The
  // block is intentionally last so it shapes the opening line and overrides
  // the generic greeting_script when we know who is calling.
  if (caller) {
    const personalParts: string[] = ['\n\nCaller personalization for this call:'];
    const knownName = !isPlaceholderName(caller.customerName) ? caller.customerName!.trim() : null;
    if (knownName) {
      personalParts.push(`- Caller name: ${knownName} (returning customer). Greet them by name in your very first sentence.`);
    } else if (caller.isNew) {
      personalParts.push("- Caller name: unknown — this is a NEW caller we have not spoken with before. Greet them warmly, and at a natural early point in the conversation politely ask their name. When they share it, call the update_customer_name tool ONCE so we can remember them next time.");
    } else {
      personalParts.push("- Caller name: unknown. Politely ask their name early in the conversation, then call the update_customer_name tool ONCE with the name they share.");
    }

    const loyaltyHint = buildLoyaltyHint(caller);
    if (loyaltyHint) personalParts.push(loyaltyHint);

    if (caller.activeOffers.length > 0) {
      const offerLines = caller.activeOffers.map(o => `  • ${describeOffer(o)}`).join('\n');
      personalParts.push(`- Active offers you may mention naturally (only if it fits the conversation, and only mention each offer at most once):\n${offerLines}`);
      personalParts.push("- Offer guardrails: NEVER invent offers that are not in the list above. NEVER read out internal mechanics like redemption caps, per-customer limits, or expiry dates unless the caller explicitly asks. If no offer fits the conversation, simply do not mention any — do not say things like \"we have no offers right now\".");
      personalParts.push("- If the caller explicitly accepts one of these offers (e.g. \"yes, I'll use that\"), call the redeem_offer tool ONCE with the coupon code so we can attribute the redemption. Do NOT call redeem_offer just because you mentioned the offer — only when the caller has agreed to use it.");
    } else {
      personalParts.push("- No active voice offers right now. Do NOT mention offers, discounts, or coupons unless the caller asks.");
    }

    parts.push(personalParts.join('\n'));
  }

  // Tool-result sentinel: any tool result that begins with "[system:" is an
  // internal signal for the platform and must NEVER be read aloud or
  // paraphrased to the caller. Silently continue the conversation as normal.
  parts.push('\n\nInternal rule: if any tool returns a result that starts with "[system:", treat it as a silent internal note — do not mention it to the caller and do not apologize for any failure.');

  const greeting = agent.greeting_script?.trim();
  // When we have a known caller name we let the personalization block above
  // drive the opener so we can address them by name; the literal greeting
  // script is only forced when we have no name to personalize with.
  const hasKnownName = !!caller && !isPlaceholderName(caller.customerName);
  if (greeting && !hasKnownName) {
    parts.push(`\n\nIMPORTANT: Begin the conversation by saying exactly: "${greeting}"`);
  } else if (greeting && hasKnownName) {
    parts.push(`\n\nReference greeting (adapt it to include the caller's name instead of saying it verbatim): "${greeting}"`);
  }

  return parts.join('');
}

/**
 * Task #325 — record that the voice agent surfaced a given offer during
 * this call. Appends the coupon UUID to sip_call_sessions.mentioned_offer_ids
 * (deduped, atomic via JSONB array concat). Failures are swallowed: missing
 * attribution must NEVER break a live call.
 */
export async function recordOfferMention(sessionId: string, offerId: string): Promise<void> {
  if (!sessionId || !offerId) return;
  try {
    await db.execute(sql`
      UPDATE sip_call_sessions
         SET mentioned_offer_ids = (
           SELECT COALESCE(jsonb_agg(DISTINCT x), '[]'::jsonb)
             FROM jsonb_array_elements_text(
               COALESCE(mentioned_offer_ids, '[]'::jsonb) || to_jsonb(${offerId}::text)
             ) AS x
         )
       WHERE id = ${sessionId}
    `);
  } catch (e) {
    log.warn({ err: e, sessionId, offerId }, 'recordOfferMention failed');
  }
}

/**
 * Builds a transcript collector that ALSO scans new assistant messages for
 * mentions of any of the supplied offer codes / display names. Each offer
 * is recorded at most once per call (in-memory `seen` set + DB DISTINCT).
 *
 * We treat both the literal coupon code (case-insensitive whole-word match)
 * and the display name (case-insensitive substring) as a "mention", since
 * the agent may read out either depending on context.
 */
export function makeTranscriptCollectorWithOfferTracking(
  transcript: TranscriptEntry[],
  sessionId: string,
  offers: ActiveOfferSummary[]
) {
  const baseCollector = makeTranscriptCollector(transcript);
  const seen = new Set<string>();
  const matchers = offers.map(o => ({
    id: o.id,
    codeRe: new RegExp(`\\b${escapeForRegex(o.code)}\\b`, 'i'),
    name: (o.display_name || '').trim().toLowerCase(),
  }));
  // Cursor advances after each event so every new assistant entry is scanned
  // exactly once regardless of conversation length. The `seen` set still
  // prevents double-counting if the same offer appears in multiple turns.
  let lastScannedIndex = -1;
  return (items: unknown) => {
    baseCollector(items);
    if (matchers.length === 0) return;
    const startFrom = lastScannedIndex + 1;
    lastScannedIndex = transcript.length - 1;
    for (let i = startFrom; i < transcript.length; i++) {
      const entry = transcript[i];
      if (entry.role !== 'assistant') continue;
      const text = entry.text;
      const lowered = text.toLowerCase();
      for (const m of matchers) {
        if (seen.has(m.id)) continue;
        if (m.codeRe.test(text) || (m.name.length >= 4 && lowered.includes(m.name))) {
          seen.add(m.id);
          void recordOfferMention(sessionId, m.id);
        }
      }
      if (seen.size === matchers.length) break;
    }
  };
}

function escapeForRegex(s: string): string {
  return s.replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
}

export async function saveTranscript(sessionId: string, entries: TranscriptEntry[]): Promise<void> {
  if (entries.length === 0) return;
  try {
    /* raw: UPDATE sip_call_sessions SET transcript = $1::jsonb WHERE id = $2 */
    await db.execute(sql`UPDATE sip_call_sessions SET transcript = ${JSON.stringify(entries)}::jsonb WHERE id = ${sessionId}`);
  } catch (e) {
    log.warn({ err: e }, 'VoiceCore transcript save error');
  }
}

export async function updateSessionStatus(sessionId: string, status: 'completed' | 'failed'): Promise<void> {
  try {
    /* raw: UPDATE sip_call_sessions SET status = $1, ended_at = NOW(), duration_seconds = ... WHERE id = $2 AND status = 'active' */
    await db.execute(sql`UPDATE sip_call_sessions
       SET status = ${status}, ended_at = NOW(),
           duration_seconds = EXTRACT(EPOCH FROM (NOW() - started_at))::INTEGER
       WHERE id = ${sessionId} AND status = 'active'`);
  } catch (e) {
    log.warn({ err: e }, 'VoiceCore session status update error');
  }
}

/**
 * Wires a RealtimeSession's history_updated event into a transcript array,
 * deduping consecutive identical entries. Returns the listener so callers
 * can attach it to whichever session implementation they use.
 */
export function makeTranscriptCollector(transcript: TranscriptEntry[]) {
  return (items: unknown) => {
    if (!Array.isArray(items)) return;
    for (const raw of items) {
      const item = raw as { type?: string; role?: string; content?: Array<{ type: string; transcript?: string | null; text?: string }> };
      if (item.type !== 'message') continue;
      const textContent = (item.content || []).find(c => c.type === 'text' || c.type === 'audio' || c.type === 'input_text' || c.type === 'input_audio');
      const text = (textContent?.transcript || textContent?.text || '').toString();
      if (!text.trim()) continue;
      const role: 'user' | 'assistant' = item.role === 'user' ? 'user' : 'assistant';
      if (transcript.length === 0 || transcript[transcript.length - 1].text !== text.trim()) {
        transcript.push({ role, text: text.trim(), ts: new Date().toISOString() });
      }
    }
  };
}
