/**
 * workers/voice-agent/index.ts
 *
 * Processo persistente (mesmo padrão dos outros workers/ — roda no
 * Dockerfile.voice-agent via tsx, um processo, sem HTTP exposto pra fora).
 *
 * TODA mídia (entrada e saída) vai por AudioSocket (TCP puro,
 * audioSocketBridge.ts), não por RTP/externalMedia/bridge ARI. Motivo:
 * testado ao vivo (28/08) que bridge de mixing do Asterisk nunca relay
 * áudio injetado por um canal externalMedia de volta pro outro membro — só
 * silêncio. Limitação conhecida do chan_rtp/externalMedia pra esse padrão
 * de uso, não bug nosso.
 *
 * Grava em `voice_calls` (provider='sip'), não numa tabela própria —
 * unificada com a chamada de voz por WhatsApp (WaCalls, #628/#697) na
 * migration 0348, porque duas tabelas de chamada que não conversam era o
 * primeiro problema que a triagem do PR #677 apontou. O vocabulário de
 * `status` (starting/ringing/connected/ended) é do binário WaCalls
 * upstream — reaproveitado aqui, não estendido; granularidade extra
 * (no_answer/busy/failed/canceled) vai em `end_reason`, livre de propósito.
 *
 * Dois fluxos:
 *  - SAÍDA (POST /api/v1/calls): originateCall já entrega direto pro
 *    dialplan [voice-agent-out] (context/extension no create, não app) —
 *    esse canal NUNCA passa pela Stasis app deste worker.
 *  - ENTRADA (from-trunk): entra na Stasis só de PASSAGEM — o suficiente
 *    pra resolver org/agente pelo número discado e criar a linha em
 *    voice_calls — e devolve o controle pro dialplan (continueDialplan) rumo
 *    ao [from-trunk-audiosocket], que chama AudioSocket() igual à saída.
 *
 * Os dois fluxos convergem no mesmo lugar: startAudioSocketServer() aceita
 * a conexão TCP, lê o frame de UUID, acha a linha em voice_calls por
 * asterisk_channel_id (== o UUID, de propósito) e sobe UMA
 * AudioSocketCallBridge — não importa se a chamada é de entrada ou saída.
 *
 * O `event_log` ganhou UM tipo nesta fatia (#4365): `voice_call.missed`, quando
 * a RECEBIDA termina tocando sem ninguém atender — ver
 * `lib/voip/evento-de-ligacao-perdida.ts`. É o único fato daqui com consumidor
 * real (o motor de automação); `call.ended`/`call.answered` continuam sem
 * publicação porque nenhum handler em `lib/event-log/register-handlers.ts` os
 * consome — ver nota na migration 0347. Sumarização/sentimento pós-chamada
 * seguem fora do escopo deste esqueleto.
 */

import net from "node:net";
import { randomUUID } from "node:crypto";
import {
  connectAriEvents,
  hangupChannel,
  setChannelVariable,
  continueDialplan,
  type AriEvent,
} from "@/lib/voip/ariClient";
import { AudioSocketCallBridge } from "./audioSocketBridge";
import { idsDaLinhaDoCanal, uuidDoAudioSocketDeEntrada } from "./canal-de-entrada";
import { createAdminClient } from "@/lib/supabase/admin";
import { getActiveVoiceAgent } from "@/lib/ai/agents";
import { resolveOrCreateCallerContact, type ContatoDaChamada } from "@/lib/voip/resolve-caller";
import {
  deveRecusarChamada,
  deveRecusarChamadaPessoal,
  END_REASON_CONTACT_BLOCKED,
  END_REASON_CONTACT_PERSONAL,
} from "./recusa-bloqueado";
import { garantirLeadDaConversa } from "@/lib/leads/nascimento-do-lead";
import { buscarConhecimento, resolverAcervoDoAgente } from "@/lib/ai/knowledge/busca";
import { ehOperante } from "@/lib/organizacao/operante";
import { registrarChamadaDeIa } from "@/lib/ai/usage/registrar-chamada";
import { logger } from "@/lib/logger";
import { publicarLigacaoPerdida } from "@/lib/voip/evento-de-ligacao-perdida";
import { linhaDaLigacao } from "./uso-da-sessao";

const supabaseAdmin = createAdminClient();

const AUDIOSOCKET_PORT = parseInt(process.env.AUDIOSOCKET_PORT ?? "9092", 10);

// ---------- Stasis (só ENTRADA, só de passagem) ----------

/**
 * Exportada para o teste do fio (`fio-recusa-bloqueado.test.ts`) provar que
 * este caminho USA `deveRecusarChamada` — função pura órfã não conta como
 * implementação.
 */
export async function handleStasisStart(event: AriEvent) {
  const channel = event.channel;
  if (!channel) return;

  // chamada de entrada: resolve o tenant pelo número discado (phone_numbers)
  const dialedNumber = channel.dialplan?.exten ?? "unknown";
  const routing = await resolveInboundNumber(dialedNumber);
  if (!routing) {
    console.error(`[voice-agent] número ${dialedNumber} não mapeado em phone_numbers`);
    await hangupChannel(channel.id, "normal");
    return;
  }

  const callerNumber = channel.caller?.number ?? "unknown";

  // Identificador de ligações: acha (ou cria) o contato pelo número de quem
  // liga, com o bloqueio lido na mesma consulta. Não bloqueia a chamada se
  // falhar — o pior caso é a tela mostrar só o número, igual antes desta
  // função existir. Fail-open: erro de leitura loga e segue,
  // nunca recusa no escuro.
  let contatoDeQuemLiga: ContatoDaChamada | null = null;
  try {
    contatoDeQuemLiga = await resolveOrCreateCallerContact(
      supabaseAdmin,
      routing.organization_id,
      callerNumber,
    );
  } catch (err) {
    console.error(`[voice-agent] falha ao resolver contato de ${callerNumber}:`, err);
  }
  const callerContactId = contatoDeQuemLiga?.id ?? null;

  // BLOQUEADO NA LIGAÇÃO É RECUSADO: depois do contato resolvido,
  // antes do insert, antes do dialplan e antes da IA. Grava a linha já
  // encerrada (rastreável no histórico como Cancelada) e
  // desliga — sem negócio, sem IA, sem tocar, sem alerta.
  // SABOTAGEM DO FIO: remover a chamada a `deveRecusarChamada` abaixo (manter
  // a função pura existindo mas sem uso) = teste do fio vermelho.
  if (deveRecusarChamada(contatoDeQuemLiga?.is_blocked)) {
    const agora = new Date().toISOString();
    const { error: refuseError } = await supabaseAdmin.from("voice_calls").insert({
      organization_id: routing.organization_id,
      provider: "sip",
      direction: "inbound",
      status: "ended",
      end_reason: END_REASON_CONTACT_BLOCKED,
      peer_phone: callerNumber,
      contact_id: callerContactId,
      asterisk_channel_id: randomUUID(),
      started_at: agora,
      answered_at: null,
      ended_at: agora,
    });
    if (refuseError) {
      console.error(`[voice-agent] falha ao gravar recusa de bloqueado:`, refuseError.message);
    } else {
      console.info(`[voice-agent] chamada recusada de bloqueado`);
    }
    await hangupChannel(channel.id, "normal");
    return;
  }

  // PESSOAL NA LIGAÇÃO É RECUSADO COMO BLOQUEADO (spec 21, etapa 14 —
  // critério 11): depois do contato resolvido, antes do insert, antes do
  // dialplan e antes da IA. Grava a linha já encerrada (ESCONDIDA do
  // histórico, como a mensagem — volta ao desmarcar) e desliga — sem negócio,
  // sem IA, sem tocar, sem alerta. `end_reason` próprio, nunca o de bloqueio.
  // SABOTAGEM DO FIO: remover a chamada a `deveRecusarChamadaPessoal` abaixo
  // (manter a função pura existindo mas sem uso) = teste do fio vermelho.
  if (deveRecusarChamadaPessoal(contatoDeQuemLiga?.is_personal)) {
    const agora = new Date().toISOString();
    const { error: refuseError } = await supabaseAdmin.from("voice_calls").insert({
      organization_id: routing.organization_id,
      provider: "sip",
      direction: "inbound",
      status: "ended",
      end_reason: END_REASON_CONTACT_PERSONAL,
      peer_phone: callerNumber,
      contact_id: callerContactId,
      asterisk_channel_id: randomUUID(),
      started_at: agora,
      answered_at: null,
      ended_at: agora,
    });
    if (refuseError) {
      console.error(`[voice-agent] falha ao gravar recusa de pessoal:`, refuseError.message);
    } else {
      console.info(`[voice-agent] chamada recusada de pessoal`);
    }
    await hangupChannel(channel.id, "normal");
    return;
  }

  // channel.id é o identificador NATIVO do canal no Asterisk (formato
  // "<epoch>.<sequencia>", ex.: "1789081888.0") — válido pras chamadas REST
  // do ARI (hangup/setChannelVariable/continueDialplan), mas NAO é um UUID:
  // app_audiosocket.c rejeita com "Failed to parse UUID" se receber isto
  // (visto ao vivo, toda chamada de entrada). Na SAÍDA isso nunca aparece
  // porque lá QUEM escolhe channel.id somos nós (route.ts gera o UUID e
  // passa em `channelId` pro ARI no originate) — na ENTRADA o Asterisk já
  // criou o canal, com o id dele, antes deste código rodar. Por isso aqui
  // se usa um UUID SEPARADO só pra correlação do AudioSocket/voice_calls;
  // as chamadas ARI continuam endereçando o canal por channel.id. Ele é
  // DERIVADO do channel.id, não sorteado: é assim que o ChannelDestroyed
  // reencontra esta linha quando o AudioSocket nunca conecta (canal-de-entrada.ts).
  const audioSocketUuid = uuidDoAudioSocketDeEntrada(channel.id);

  const { data: callRow, error } = await supabaseAdmin
    .from("voice_calls")
    .insert({
      organization_id: routing.organization_id,
      provider: "sip",
      direction: "inbound",
      status: "ringing",
      peer_phone: callerNumber,
      contact_id: callerContactId,
      asterisk_channel_id: audioSocketUuid,
      started_at: new Date().toISOString(),
    })
    .select()
    .single();

  if (error || !callRow) {
    console.error(`[voice-agent] falha ao criar voice_calls pra chamada de entrada:`, error?.message);
    await hangupChannel(channel.id, "normal");
    return;
  }

  // A ligação é uma demanda nova, igual a uma mensagem de WhatsApp: sem isto,
  // quem liga e não é atendido fica de fora do funil e do radar de risco (os
  // dois trabalham sobre crm_leads, não sobre voice_calls). Idempotente por
  // contato — reusa o mesmo mecanismo do WhatsApp (nascimento-do-lead.ts),
  // só troca o rótulo/source de origem. Best-effort: não derruba a ligação.
  if (callerContactId) {
    try {
      const nascimento = await garantirLeadDaConversa(supabaseAdmin, {
        organizationId: routing.organization_id,
        contactId: callerContactId,
        conversationId: callRow.id,
        nomeDoContato: channel.caller?.name ?? null,
        origem: { rotulo: "telefone", source: "voip", motivo: "primeira ligação recebida" },
      });
      if (!nascimento.criado) {
        console.info(`[voice-agent] lead não criado para ${callerNumber}: ${nascimento.motivo}`);
      }
    } catch (err) {
      console.error(`[voice-agent] falha ao garantir lead da chamada de ${callerNumber}:`, err);
    }
  }

  // audioSocketUuid é o asterisk_channel_id gravado acima — vai como UUID do
  // AudioSocket (mesmo padrão da saída), pra handleAudioSocketConnection
  // achar a linha certa assim que a conexão TCP chegar.
  await setChannelVariable(channel.id, "AUDIOSOCKET_UUID", audioSocketUuid);
  await continueDialplan(channel.id, "from-trunk-audiosocket", "s", 1);
}

type RoteamentoInbound = {
  organization_id: string;
  routing_mode: "ai" | "human" | "ai_then_human";
  default_ai_agent_id: string | null;
  fallback_user_id: string | null;
};

async function resolveInboundNumber(dialedNumber: string): Promise<RoteamentoInbound | null> {
  // Trunks de DID único mandam a extensão "s" no Request-URI em vez dos
  // dígitos do número discado (ver extensions.conf, contexto [from-trunk] —
  // "extension not found" antes desta função nem rodar era o sintoma).  Sem
  // DNIS real não dá pra saber QUAL número foi discado — mas com exatamente
  // UM número ativo cadastrado não tem ambiguidade nenhuma: só pode ser ele.
  // Se um dia existir mais de um, isto recusa (ambíguo) em vez de adivinhar.
  if (dialedNumber === "s") {
    const { data, error } = await supabaseAdmin
      .from("phone_numbers")
      .select("organization_id, routing_mode, default_ai_agent_id, fallback_user_id")
      .eq("is_active", true)
      .limit(2);
    if (error || !data || data.length !== 1) {
      console.error(
        `[voice-agent] extensão "s" (trunk de DID único) só resolve com exatamente 1 número ativo — achei ${data?.length ?? 0}`,
      );
      return null;
    }
    return data[0] as RoteamentoInbound;
  }

  const { data, error } = await supabaseAdmin
    .rpc("fn_resolve_inbound_number", { p_number: dialedNumber })
    .single();
  if (error || !data) return null;
  return data as RoteamentoInbound;
}

// ---------- AudioSocket (entrada E saída convergem aqui) ----------

interface ActiveAudioSocketCall {
  bridge: AudioSocketCallBridge;
  callRowId: string;
  organizationId: string;
  agentId: string;
  contactId: string | null;
  answeredAt: string;
  transcript: { speaker: string; text: string; ts: string }[];
}
const activeAudioSocketCalls = new Map<string, ActiveAudioSocketCall>(); // key = uuid (== voice_calls.asterisk_channel_id)

function bytesToUuid(buf: Buffer): string {
  const hex = buf.toString("hex");
  return `${hex.slice(0, 8)}-${hex.slice(8, 12)}-${hex.slice(12, 16)}-${hex.slice(16, 20)}-${hex.slice(20, 32)}`;
}

function appendAudioSocketTranscriptTurn(uuid: string, turn: { speaker: string; text: string }) {
  const call = activeAudioSocketCalls.get(uuid);
  if (!call) return;
  call.transcript.push({ ...turn, ts: new Date().toISOString() });
}

async function finalizeAudioSocketCall(uuid: string) {
  const call = activeAudioSocketCalls.get(uuid);
  if (!call) return;
  activeAudioSocketCalls.delete(uuid);

  const endedAt = new Date();
  // duration_ms não é generated column em voice_calls (era em crm_calls) —
  // calcula na mão a partir do answered_at que a gente mesmo gravou.
  const durationMs = endedAt.getTime() - new Date(call.answeredAt).getTime();

  await supabaseAdmin
    .from("voice_calls")
    .update({
      status: "ended",
      end_reason: "user_ended",
      ended_at: endedAt.toISOString(),
      duration_ms: durationMs,
      transcript: call.transcript,
    })
    .eq("id", call.callRowId);

  // ─── O uso da sessão vai para `llm_calls` ───────────────────────────────
  //
  // Sem isto a voz — o gasto mais caro por minuto do produto — não aparecia
  // em Uso de IA. Tokens medidos pela própria OpenAI (`response.done`), duração
  // da ligação em `latency_ms`, custo nulo por limitação declarada (tarifa de
  // áudio fora do catálogo — ver `./uso-da-sessao.ts`). Nunca lança.
  const sessao = call.bridge.usoDaSessao();
  await registrarChamadaDeIa(
    supabaseAdmin,
    linhaDaLigacao({
      organizationId: call.organizationId,
      agentId: call.agentId,
      contactId: call.contactId,
      modelo: sessao.modelo,
      uso: sessao.uso,
      duracaoMs: durationMs,
      erro: sessao.erro,
    }),
  );
  logger.info("[voice-agent] uso da ligação registrado", {
    organization_id: call.organizationId,
    call_id: call.callRowId,
    model: sessao.modelo,
    respostas: sessao.uso.respostas,
    entrada_audio: sessao.uso.entradaAudio,
    entrada_texto: sessao.uso.entradaTexto,
    entrada_cache: sessao.uso.entradaCache,
    saida_audio: sessao.uso.saidaAudio,
    saida_texto: sessao.uso.saidaTexto,
    duracao_ms: durationMs,
  });
}

export async function handleAudioSocketConnection(socket: net.Socket, uuid: string, leftover: Buffer) {
  const { data: callRow, error } = await supabaseAdmin
    .from("voice_calls")
    .select("*")
    .eq("asterisk_channel_id", uuid)
    .single();

  if (error || !callRow) {
    console.error(`[audiosocket] uuid ${uuid} não corresponde a nenhuma voice_calls — encerrando`);
    socket.end();
    return;
  }

  // Recusada pela tela enquanto tocava (`lib/voip/recusar-chamada-sip.ts`): a
  // linha já está `ended`. Fechar o socket devolve o canal ao dialplan, que
  // desliga (`Hangup()` em [from-trunk-audiosocket]) — sem abrir a sessão de IA,
  // que é paga por minuto, e sem regravar a linha como `connected`.
  // SABOTAGEM: remover este bloco = o caso "recusada" de fio-recusada-pela-tela fica vermelho.
  if (callRow.status === "ended") {
    console.info(`[audiosocket] chamada ${callRow.id} já encerrada (${callRow.end_reason ?? "sem motivo"}) — desligando`);
    socket.end();
    return;
  }

  // Organização parada (suspensa, redigida, arquivada) não atende por voz: a
  // sessão em tempo real é o gasto mais caro por minuto do produto. Falha de
  // leitura também encerra — sem saber o status, não se abre a sessão paga.
  const { data: org, error: orgErr } = await supabaseAdmin
    .from("organizations")
    .select("status")
    .eq("id", callRow.organization_id)
    .maybeSingle();
  if (orgErr || !ehOperante(org?.status)) {
    console.warn(`[audiosocket] voz_org_suspensa org ${callRow.organization_id} — encerrando`);
    socket.end();
    return;
  }

  const agent = await getActiveVoiceAgent(callRow.organization_id);
  if (!agent) {
    console.error(`[audiosocket] nenhum agente de voz ativo pra org ${callRow.organization_id}`);
    socket.end();
    return;
  }

  // Mesmo acervo que o agente de texto (WhatsApp) já usa — reaproveita
  // resolverAcervoDoAgente/buscarConhecimento em vez de reimplementar RAG
  // pro canal de voz. NÃO aguardado aqui: dispara em paralelo com a abertura
  // do WebSocket da OpenAI dentro do bridge (linha abaixo) -- antes disto
  // rodava em série (aguardado ANTES de sequer abrir o WS), e a soma dos
  // dois round-trips era exatamente a demora sentida antes da IA "notar" que
  // o cliente já estava falando (ver AudioSocketCallContext.knowledgeSourceIdsPromise).
  const knowledgeSourceIdsPromise = resolverAcervoDoAgente(
    supabaseAdmin,
    callRow.organization_id,
    agent.id,
  ).catch((err) => {
    console.error(`[audiosocket] falha ao resolver acervo do agente de voz:`, err);
    return [] as string[];
  });

  // O `connected` é um claim: só vale sobre linha que ainda toca. Sem o filtro
  // de status, a recusa pela tela que chega entre a leitura acima e esta escrita
  // era sobrescrita, e a IA atendia a ligação recusada. Antes do bridge, para a
  // sessão paga nem abrir quando o claim perde.
  const answeredAt = new Date().toISOString();
  const { data: atendida, error: claimErr } = await supabaseAdmin
    .from("voice_calls")
    .update({ status: "connected", answered_at: answeredAt, handled_by: "ai" })
    .eq("id", callRow.id)
    .in("status", ["starting", "ringing"])
    .select("id");
  if (!atendida || atendida.length === 0) {
    if (claimErr) console.error(`[audiosocket] falha ao marcar chamada ${callRow.id} como atendida:`, claimErr.message);
    else console.info(`[audiosocket] chamada ${callRow.id} deixou de tocar antes do atendimento — desligando`);
    socket.end();
    return;
  }

  const bridge = new AudioSocketCallBridge(socket, {
    callId: callRow.id,
    organizationId: callRow.organization_id,
    agentInstructions: agent.systemPrompt,
    voice: agent.voice,
    voiceSpeed: agent.voiceSpeed,
    voiceModel: agent.voiceModel,
    apiKey: agent.apiKey,
    onTranscriptTurn: (turn) => appendAudioSocketTranscriptTurn(uuid, turn),
    onCallEnded: () => finalizeAudioSocketCall(uuid),
    // Recebida: quem atende fala primeiro. Feita por nós: quem atende diz "alô".
    falaPrimeiro: callRow.direction === "inbound",
    knowledgeSourceIdsPromise,
    searchKnowledge: async (pergunta: string) => {
      const knowledgeSourceIds = await knowledgeSourceIdsPromise;
      if (knowledgeSourceIds.length === 0) return { trechos: [] };
      const resultado = await buscarConhecimento(supabaseAdmin, {
        organizationId: callRow.organization_id,
        knowledgeSourceIds,
        pergunta,
        topK: agent.ragTopK,
        limiar: agent.ragSimilarityThreshold,
      });
      return { trechos: resultado.trechos };
    },
  });

  activeAudioSocketCalls.set(uuid, {
    bridge,
    callRowId: callRow.id,
    organizationId: callRow.organization_id,
    agentId: agent.id,
    contactId: (callRow.contact_id as string | null | undefined) ?? null,
    answeredAt,
    transcript: [],
  });

  // Se algum byte de áudio já chegou GRUDADO no mesmo pacote TCP do frame de
  // UUID (TCP é stream contínuo, não datagramas), reinjeta no socket depois
  // que o listener do bridge já está montado (constructor roda síncrono acima).
  if (leftover.length > 0) socket.emit("data", leftover);
}

function startAudioSocketServer() {
  const server = net.createServer((socket) => {
    let recvBuffer = Buffer.alloc(0);

    const onFirstData = (chunk: Buffer) => {
      recvBuffer = Buffer.concat([recvBuffer, chunk]);
      if (recvBuffer.length < 3) return;

      const type = recvBuffer[0] ?? 0;
      const len = recvBuffer.readUInt16BE(1);
      if (recvBuffer.length < 3 + len) return; // frame de UUID incompleto, espera mais

      socket.off("data", onFirstData);

      if (type !== 0x01) {
        console.error(`[audiosocket] primeiro frame não é UUID (tipo 0x${type.toString(16)}) — fechando`);
        socket.end();
        return;
      }

      const uuid = bytesToUuid(recvBuffer.subarray(3, 3 + len));
      const leftover = recvBuffer.subarray(3 + len);
      void handleAudioSocketConnection(socket, uuid, leftover);
    };

    socket.on("data", onFirstData);
    socket.on("error", (err) => console.error("[audiosocket] erro antes do UUID:", err.message));
  });

  server.listen(AUDIOSOCKET_PORT, () => {
    console.info(`[audiosocket] servidor TCP escutando na porta ${AUDIOSOCKET_PORT}`);
  });
}

// ---------- bootstrap ----------

function assertEnv() {
  const required = ["ARI_URL", "ARI_WS_URL", "ARI_USERNAME", "ARI_PASSWORD", "OPENAI_API_KEY"];
  const missing = required.filter((k) => !process.env[k]);
  if (missing.length > 0) {
    throw new Error(`[voice-agent] env faltando: ${missing.join(", ")}`);
  }
}

/**
 * Chamada de SAÍDA nunca passa pela Stasis (ver cabeçalho do arquivo) -- por
 * isso nenhum outro handler fecha o ciclo de vida dela. Sem isto, uma
 * ligação que toca e não é atendida (ou cai antes do AudioSocket conectar)
 * fica com status "ringing" pra sempre na tela, mesmo com o canal já tendo
 * sumido do Asterisk há muito tempo -- foi visto ao vivo assim.
 *
 * A RECEBIDA precisa do mesmo fechamento: ela só sai da Stasis de passagem, e
 * se o AudioSocket nunca conecta, nada mais a encerra. A linha dela não guarda
 * o channel.id nativo, e sim o UUID derivado dele -- por isso a busca é pelos
 * dois (idsDaLinhaDoCanal). Procurar só pelo channel.id deixava toda recebida
 * que caía antes da IA "tocando" para sempre (medido em 2026-11-04).
 *
 * Só mexe em linha ainda "ringing": se o AudioSocket já rodou,
 * finalizeAudioSocketCall() já marcou "ended" e este handler não deve
 * sobrescrever isso (ChannelDestroyed chega DEPOIS, no fim normal da
 * ligação, não é exclusivo de "nunca atendida").
 */
export async function handleChannelDestroyed(event: AriEvent) {
  const channelId = event.channel?.id;
  if (!channelId) return;

  // As colunas vêm no `select` do UPDATE porque é este o único ponto que sabe
  // que a recebida morreu TOCANDO: quem publica o `voice_call.missed` precisa do
  // contato, do telefone e da organização, e uma segunda leitura abriria a
  // janela para a linha mudar entre o fechamento e o evento.
  const { data, error } = await supabaseAdmin
    .from("voice_calls")
    .update({ status: "ended", end_reason: "timeout", ended_at: new Date().toISOString() })
    .in("asterisk_channel_id", idsDaLinhaDoCanal(channelId))
    .eq("status", "ringing")
    .select("id, organization_id, direction, contact_id, peer_phone")
    .maybeSingle();

  if (error) {
    console.error(`[voice-agent] falha ao marcar timeout pro canal ${channelId}:`, error.message);
    return;
  }
  if (!data) return;

  console.info(
    `[voice-agent] chamada ${data.id} marcada sem resposta (${event.cause_txt ?? "motivo desconhecido"})`,
  );

  // SÓ A RECEBIDA É "PERDIDA" (#4365): quem ligou e não foi atendido é quem
  // precisa de resposta. Uma ligação FEITA daqui que tocou e caiu não gera
  // evento — quem discou já sabe, e disparar automação nela mandaria mensagem
  // para um contato que nunca ligou.
  //
  // O filtro `status = 'ringing'` acima também é a idempotência: esta linha só
  // casa uma vez, então o evento nasce uma vez por ligação. Atendida
  // (`connected`) e recusada pela tela (`ended`) não casam e não publicam.
  if (data.direction !== "inbound") return;
  await publicarLigacaoPerdida(supabaseAdmin, {
    organizationId: data.organization_id as string,
    callId: data.id as string,
    contactId: (data.contact_id as string | null) ?? null,
    peerPhone: (data.peer_phone as string | null) ?? null,
    endReason: "timeout",
  });
}

function main() {
  assertEnv();
  console.info("[voice-agent] worker iniciado, conectando ao ARI...");
  connectAriEvents(async (event) => {
    try {
      if (event.type === "StasisStart") await handleStasisStart(event);
      if (event.type === "ChannelDestroyed") await handleChannelDestroyed(event);
    } catch (err) {
      console.error("[voice-agent] erro processando evento ARI:", err);
    }
  });
  startAudioSocketServer();
}

// O teste do fio importa `handleStasisStart` deste módulo: sem esta guarda,
// importar já ligava o worker (TCP + ARI + assertEnv) dentro do vitest.
// Produção não define nenhuma das três — comportamento idêntico ao de hoje.
if (!process.env.VITEST && !process.env.VITEST_WORKER_ID && process.env.NODE_ENV !== "test") {
  main();
}
