/**
 * lib/waha/ingest.ts — pipeline de ingestão WAHA compartilhado pelos dois route
 * handlers de webhook (`/waha` global e `/waha/[token]` per-tenant).
 *
 * Fonte única da verdade para: parse de identidade WhatsApp, resolução de
 * contato/conversa e persistência de mensagem. Resolução é ATÔMICA via RPC
 * (fn_upsert_wa_contact / fn_upsert_wa_conversation) — o padrão check-then-act
 * antigo criava um contato/conversa novo a cada mensagem porque o WAHA NOWEB
 * emite `message` E `message.any` para a mesma mensagem (corrida). Ver migration
 * 0027 para o modelo de identidade canônica.
 */
import { createHmac, timingSafeEqual } from "node:crypto";
import { lancarFalhaDeIngestao } from "@/lib/waha/falha-transitoria";

import type { SupabaseClient } from "@supabase/supabase-js";

import { audit } from "@/lib/audit";
import { descartarSessaoQueNuncaPareou } from "@/lib/channels/descartar-sem-parear";
import { sincronizarSaudeDaConexao } from "@/lib/channels/health";
import { marcarConversaComMensagem } from "@/lib/channels/marcar-conversa";
import { gravarReacaoDeMensagem } from "@/lib/channels/reacao";
import { aplicarEfeitosPosEntrada } from "@/lib/channels/pos-entrada";
import { ORIGEM_DO_WHATSAPP_OPERADOR } from "@/lib/channels/origem-do-negocio";
import { garantirLeadDaConversa } from "@/lib/leads/nascimento-do-lead";
import {
  MOTIVO_COMANDO_OFF,
  pausarIaDuravelmente,
  pausarIaPorAtendimentoManual,
  type FalhaDaPausa,
} from "@/lib/escalacao/atendimento-manual";
import { agenteAceitaComandoDeCelular, lerComandoDeControle } from "@/lib/escalacao/comando-de-canal";
import { devolverAtendimentoAoAgente } from "@/lib/escalacao/retomada";
import { getWahaClient } from "@/lib/waha/client";
import { acelerarPipelineDeEventos } from "@/lib/dev/kick-local-pipeline";
import { canonicalPhoneBR } from "@/lib/channels/phone-variants";
import { estamparAtribuicaoDoContato, metadataDoAnuncio } from "@/lib/leads/atribuicao-de-anuncio";
import { ancoraCtwaDaMensagem } from "@/lib/leads/prova-meta-lead-submitted";
import { extrairEEstamparAtribuicaoGoogle } from "@/lib/plataformas-de-anuncio/google/atribuicao";
import { extrairAtribuicaoWaha } from "@/lib/waha/atribuicao-de-anuncio";
import { criarIngestDeGrupoDb, gravarMensagemDeGrupo } from "@/lib/grupos/ingest";
import type { RemetenteDeGrupo } from "@/lib/messaging/remetente-de-grupo";
import { previaDaMensagem } from "@/lib/messaging/previa-da-mensagem";
import { corpoDaLocalizacao, lerLocalizacao, type Localizacao } from "@/lib/messaging/localizacao";
import { semAssinatura } from "@/lib/messaging/assinatura";
import type { createAdminClient } from "@/lib/supabase/admin";
import { ackToStatus } from "@/lib/types/messaging";
import { fraseDaFalhaDeCanal } from "@/lib/channels/frases-de-falha";
import { emitirFalhaDeEntrega, telefoneDoEmbed, type EmbedDoContato } from "@/lib/messaging/falha-de-entrega";
import type { WahaEnvelope, WahaPayload } from "@/lib/waha/envelope";
import { bareWaMessageId, chatIdFromWaMessageId, wahaEchoExternalIds } from "@/lib/waha/message-id";
import { logger } from "@/lib/logger";
import { numeroJaAquecido } from "@/lib/ai/pacing-knobs";
import { nomeDeArquivoLimpo } from "@/lib/messaging/media/nome-de-arquivo";
import {
  ehNumeroInternoDeAviso,
  registrarMensagemIgnorada,
  type ChatIdentidade,
} from "@/lib/escalacao/numero-interno-de-aviso";
import { consumirRespostaDoSuporteNoWhatsApp } from "@/lib/escalacao/resposta-whatsapp-ao-caso";

export type Admin = ReturnType<typeof createAdminClient>;

/**
 * Aviso durável de que a pausa da IA NÃO foi garantida (#534) — o `#off` e a
 * resposta manual pelo celular passam por `pausarIaPorAtendimentoManual` com
 * `avisar`, e é aqui que a
 * falha fica registrada com motivo nomeado. A ingestão segue (perder mensagem é
 * pior que duplicar); o que não segue é a mentira de que a IA está pausada.
 */
function avisarPausaNaoGravada(falha: FalhaDaPausa): Promise<void> {
  return audit({
    action: "ai.pausa_nao_gravada",
    organizationId: falha.organizationId,
    resourceType: "conversation",
    resourceId: falha.conversationId,
    metadata: {
      canal: falha.canal ?? "waha",
      motivo: falha.motivo,
      detalhe: falha.detalhe ?? null,
    },
  });
}

/**
 * A pausa da IA quando uma pessoa responde pelo celular vive em
 * `lib/escalacao/atendimento-manual.ts` (`pausarIaPorAtendimentoManual`), e não
 * mais aqui. Era `silenciarBotPorRetomadaHumana`, exclusiva deste arquivo e do
 * WhatsApp; o gesto é o mesmo em qualquer canal (o Zernio tem o mesmo caminho de
 * saída-por-fora-do-CRM), e duas encarnações da mesma regra divergiriam na
 * primeira vez que alguém mexesse numa só. O helper unificado mantém o que esta
 * função garantia — prazo que expira sozinho, renovado a cada fala humana, e
 * silêncio maior NUNCA encurtado — e acrescenta o rastro de handoff.
 *
 * Os comandos `#on`/`#off` também entram por aqui, em
 * `handleOutboundFromUserPhone`, SÓ para o agente que ligou "Comandos pelo
 * celular": são lidos por `lerComandoDeControle` e escondidos do cliente com
 * `revogarComando`.
 */

/**
 * Quanto tempo um envio nosso pode ficar "em voo" antes de o eco deixar de ser
 * explicável por ele.
 *
 * 60s é folgado de propósito: o custo de errar para o lado permissivo é uma
 * digitação real do celular não silenciar a IA por um minuto; o custo de errar
 * para o outro lado é a IA muda por três horas. Os dois erros não são simétricos.
 */
const JANELA_DO_ECO_MS = 60_000;

/**
 * Os estados em que uma linha NOSSA já SAIU para o canal — e portanto o eco
 * dela pode estar voltando.
 *
 * `sent` faz parte desta lista, e a falta dele era o defeito do #3138: `sent`
 * SEM `external_id` não é acidente nem estado impossível, é DESFECHO escrito de
 * propósito nos dois caminhos que enviam por nós, exatamente quando o eco vence
 * o `unique (organization_id, external_id)`:
 *
 *   - `app/api/v1/messages/_handler.ts:1254-1257` — "a mensagem SAIU e fica
 *     `sent` sem o id — nunca `queued`, que é pedir para ser reenviada";
 *   - `lib/agent-engine/edge/crm/session-reconciler.ts:471-479` — o watchdog
 *     reenvia, `markRedriveSent` devolve true (o eco já ocupava o id),
 *     `removeRedriveEcho` APAGA a linha do eco e `stampExternalIdAfterEcho` é
 *     BLINDADO: falhando ele, a linha NOSSA fica `sent` sem id e a linha do eco
 *     já não existe.
 *
 * Com essas duas condições, uma REENTREGA do mesmo eco não tinha com o que
 * casar: o dedup por `external_id` não acha id nenhum (a linha do eco foi
 * apagada pela nossa própria limpeza), a re-checagem pós-insert exige uma linha
 * NOSSA COM o id, e esta régua da janela recusava a linha porque o status já
 * dizia `sent`. O eco do nosso próprio envio era lido como "uma pessoa
 * respondeu pelo canal": IA pausada por 1 h, linha duplicada e lead nascido
 * pela conversa do celular (issue #3138).
 *
 * `failed` fica FORA de propósito: ali nada foi ao canal, então não existe eco
 * que explique a mensagem. A comparação de corpo abaixo limita o resto — frase
 * diferente continua sendo digitação de verdade (os controles de #519/#2066).
 */
const STATUS_DE_ENVIO_NOSSO = ["queued", "sending", "sent", "delivered", "read"];

/**
 * A mensagem `fromMe` que chegou é o eco de um envio que ESTE CRM acabou de
 * fazer — e não alguém digitando no celular?
 *
 * A prova exigida é forte: uma linha nossa na MESMA conversa, com o MESMO corpo,
 * dentro da janela, e UMA destas duas marcas:
 *
 *   - ainda SEM `external_id`, num dos estados em que a linha saiu
 *     (`STATUS_DE_ENVIO_NOSSO` — `sent` inclusive, #3138), ou
 *   - já com `external_id` igual a um dos ids que o CANAL entregou nesta mensagem
 *     — o carimbo do envio (#3165).
 *
 * Qualquer uma dessas faltando, a resposta é "não sei" — e "não sei" silencia,
 * porque é o desfecho seguro do lado do atendente humano (#371).
 *
 * Mídia não tem corpo comparável (o eco traz `media_url`, não texto): ali a
 * prova cai para "existe envio nosso em voo do mesmo tipo na janela", que é mais
 * permissivo e assumidamente mais fraco.
 *
 * ⚠️ A SEGUNDA marca existe porque a janela não é só "antes de o envio confirmar":
 * o carimbo pode cair DURANTE a ingestão — depois da re-checagem pós-insert e
 * antes desta decisão (#3165). Sem ela, a linha já `sent` não é vista, o eco volta
 * a ser lido como resposta pelo celular e a IA pausa por 1 h (o relato do #3138,
 * com o estado final de uma linha só, a nossa, já com o id).
 *
 * E o que fecha essa janela é a LEITURA ÚNICA: o id e o texto saem da MESMA linha
 * do MESMO instante, então nenhuma escrita pode cair "entre" as duas perguntas.
 * Duas consultas em sequência — o id numa, o texto na outra — ENCOLHEM a janela e
 * não a fecham: o carimbo que cai entre elas faz a IA pausar de novo.
 * `lib/waha/ingest-eco-carimbado-no-meio.test.ts` é a catraca, e ele carimba o id
 * depois de CADA leitura da janela, não num ponto só.
 */
async function ehEcoDeEnvioNosso(
  admin: Admin,
  organizationId: string,
  conversationId: string,
  p: WahaPayload,
): Promise<boolean> {
  const desde = new Date(Date.now() - JANELA_DO_ECO_MS).toISOString();
  const { data, error } = await admin
    .from("messages")
    .select("id, body, type, external_id, status")
    .eq("organization_id", organizationId)
    .eq("conversation_id", conversationId)
    .eq("direction", "outbound")
    // `sent_via` separa o que NASCEU aqui do que veio do celular: a linha do
    // celular é gravada como `external_device` e nunca pode servir de álibi.
    //
    // `automation` entrou junto do carimbo novo (#652). A mensagem que a REGRA
    // manda nasceu aqui tanto quanto a da IA e a do composer; sem ela nesta
    // lista, o eco do próprio envio da regra era lido como resposta pelo celular
    // e a IA ficava pausada na conversa por causa de uma mensagem que o CRM
    // mandou sozinho. Lista e carimbo andam juntos: quem escreve estes valores é
    // `origemDaMensagem`, em `app/api/v1/messages/_handler.ts`.
    //
    // `system` ENTRA pela mesma razão, e o sintoma seria idêntico: é o valor que
    // o envio por TOKEN DE SERVIDOR grava (#866). Fora desta lista, a linha da
    // integração deixa de ser reconhecida como envio NOSSO, o eco do próprio
    // envio vira "resposta pelo celular" e cala a IA por três horas.
    .in("sent_via", ["ai", "user", "automation", "system"])
    // ⚠️ O `external_id` NÃO é filtro aqui, de propósito (#3165): as duas marcas
    // da prova — id nulo (em voo) e id igual ao que o canal entregou (carimbado
    // durante a ingestão) — são decididas sobre a MESMA linha desta leitura, no
    // corpo do laço. Filtrar por id no SQL exigiria OU duas consultas (a janela
    // que este conserto fecha: o carimbo cai entre elas) OU um `.or()` de DSL que
    // a consulta não precisa para ser correta.
    //
    // O `status` estreita o conjunto: `sent` entra porque é o status que o envio
    // grava JUNTO do id (`marcarEnviada`); `queued`/`sending` são o "em voo" de
    // sempre. Qual status vale para qual das duas marcas é o laço abaixo — aqui é
    // só o recorte de linhas que a decisão pode usar.
    .in("status", STATUS_DE_ENVIO_NOSSO)
    // `updated_at`, e não `created_at`: o reenvio (`POST /messages/{id}/retry`)
    // põe na fila uma linha criada há muito tempo, e o eco dele tem de casar do
    // mesmo jeito. Para o envio novo dá no mesmo (`updated_at` nasce igual a
    // `created_at` e só cresce), então a janela só fica mais larga, nunca mais estreita.
    .gte("updated_at", desde)
    .limit(20);

  if (error) {
    // Falha de leitura não pode virar "é eco": na dúvida, silencia — o
    // desfecho seguro é o do atendente humano.
    console.error("[waha.ingest] checagem de eco falhou", error.message);
    return false;
  }

  const corpo = (p.body ?? "").trim();
  // As formas que o id desta mensagem pode ter no banco: o composto que o webhook
  // entregou e a cauda que o envio grava (`marcarEnviada` → `canonicalExternalId`).
  // Mesmo par que a re-checagem pós-insert usa, algumas linhas acima.
  //
  // Payload sem id (não é o caso deste caminho — `fromMe` traz o composto, e a
  // re-checagem acima já depende disso) não casa ninguém: a lista fica vazia e a
  // MARCA 2 não dispara.
  const idDoEco = p.id ?? null;
  const caudaDoEco = idDoEco === null ? null : bareWaMessageId(idDoEco);
  const idsDaMensagem = idDoEco === null ? [] : caudaDoEco === idDoEco ? [idDoEco] : [idDoEco, caudaDoEco];

  for (const linha of data ?? []) {
    const l = linha as {
      body: string | null;
      type?: string | null;
      external_id?: string | null;
      status?: string | null;
    };

    // ── MARCA 2 (#3165): o CARIMBO do envio já está NESTA linha, com o id do
    // canal que acabou de chegar. É a MESMA mensagem — e é prova mais forte que a
    // de baixo, porque o id do canal não se repete entre mensagens: o corpo nem
    // precisa ser comparado.
    //
    // A leitura é UMA só (o `.in("status", …)` e o `.select` acima), então esta
    // pergunta e a comparação de corpo abaixo leem o MESMO retrato: o carimbo que
    // cair no meio da ingestão aparece aqui, e não escapa para o lado de fora da
    // janela como escapava quando o filtro era `.is("external_id", null)` no SQL.
    if (l.external_id != null && idsDaMensagem.includes(l.external_id)) return true;

    // ── MARCA 1: linha ainda SEM id, num status de `STATUS_DE_ENVIO_NOSSO` (o
    // filtro do SQL acima; `sent` sem id é o #3138). Linha com id que NÃO é desta mensagem (outro envio
    // nosso, na mesma janela) não entra: ela nunca foi a janela do eco DESTA
    // mensagem, e era exatamente o que o `is null` do SQL garantia.
    if (l.external_id != null) continue;

    if (p.type && p.type !== "chat") {
      // Mídia: sem corpo para comparar, a existência do envio em voo é a prova
      // possível. Mais fraco, e escrito para ninguém supor o contrário.
      if ((l.type ?? "chat") !== "chat") return true;
      continue;
    }
    // Com a assinatura do emissor ligada (#2066), o eco traz `*Nome*\ntexto` e a
    // linha guarda `texto`: sem tirar a linha do nome, o eco do próprio envio
    // assinado seria lido como digitação no celular e calaria a IA.
    const gravado = (l.body ?? "").trim();
    if (corpo.length > 0 && (gravado === corpo || gravado === semAssinatura(corpo).trim())) return true;
  }
  return false;
}

interface Session {
  id: string;
  organization_id: string;
  /**
   * Nome da sessão no WAHA. Só é usado para chamar o transporte de volta (ex.:
   * revogar o comando `#on`/`#off`). Opcional porque há chamadas sintéticas
   * (testes, caminhos internos) que não passam por uma linha de `channel_sessions`.
   */
  waha_session_name?: string | null;
}

/**
 * O formato do fio mora em `lib/waha/envelope.ts`, onde é um schema Zod — e o
 * tipo NASCE dele (`z.infer`). Re-exportado aqui porque este módulo era o dono
 * do tipo e quem já o importava não precisa saber que ele mudou de casa.
 */
export type { WahaEnvelope, WahaPayload } from "@/lib/waha/envelope";

export type ChatIdentity =
  | { kind: "phone"; phone: string; lid: null }
  | { kind: "lid"; phone: null; lid: string } // lid = somente dígitos
  | { kind: "group"; phone: null; lid: null }
  | { kind: "unknown"; phone: null; lid: null };

/**
 * Corta o chatId no `@` do sufixo — o que `replace(/@.*$/, "")` fazia aqui, sem
 * o custo quadrático que fez o CodeQL apontar as duas linhas (js/polynomial-redos,
 * alertas #6 e #7).
 *
 * ⚠️ NÃO troque por `indexOf("@")` nem por `lastIndexOf("@")`: nenhum dos dois é
 * equivalente. `.` não casa terminador de linha e `$` (sem /m) só casa no fim da
 * string, então o `@` que a regex achava é o PRIMEIRO **depois do ÚLTIMO
 * terminador de linha**. Em `"@@@\n@lid"` a regex devolvia `"@@@\n"`; `indexOf`
 * devolveria `""` e `lastIndexOf`, `"@@@\n@"`. Medido por varredura exaustiva
 * (37.449 strings, alfabeto `{@ a \n \r LS PS + espaço}`): esta formulação diverge
 * em 0; `indexOf` em 11.760; `lastIndexOf` em 11.798.
 *
 * Os quatro terminadores são exatamente os que `.` não casa (`\n \r U+2028 U+2029`
 * — NEL, TAB e NBSP casam, então não entram). Escritos como escape de propósito:
 * a versão com o caractere cru é indistinguível a olho da versão corrompida por
 * um copy-paste, e `tsc`/`eslint` dão verde nas duas — só a semântica muda
 * (4.582 divergências em 37.449).
 *
 * Por que era caro: o motor reinicia a tentativa a partir de CADA `@`, e quando há
 * um terminador de linha no meio todas falham — O(n²). O `endsWith("@lid")` acima
 * NÃO protege: `"@".repeat(n) + "\n@lid"` passa por ele. Medido nesta função,
 * `String.replace` sendo síncrono (trava o event loop do processo inteiro, todos
 * os tenants): 64 KB de `from` custam ~2,9 s; 256 KB, ~48 s. A entrada é externa —
 * `payload.from` vem do corpo do webhook, e `WAHA_WEBHOOK_REQUIRE_SIGNATURE` é
 * `false` por padrão. Esta varredura é linear: 1 MB em 0,7 ms.
 *
 * O que MUDOU desde que isto foi escrito: o corpo chegava por
 * `JSON.parse(rawBody) as WahaEnvelope` — cast, sem validação —, então `from`
 * podia nem ser string e o `.endsWith` acima lançava. Hoje o contrato é um
 * schema (`lib/waha/envelope.ts`) e a rota recusa antes de chegar aqui. O
 * TAMANHO continua livre, que é por isso que esta função segue linear.
 */
function semSufixoDeChat(chatId: string): string {
  const aposQuebra =
    Math.max(
      chatId.lastIndexOf("\n"),
      chatId.lastIndexOf("\r"),
      chatId.lastIndexOf("\u2028"),
      chatId.lastIndexOf("\u2029"),
    ) + 1;
  const arroba = chatId.indexOf("@", aposQuebra);
  return arroba === -1 ? chatId : chatId.slice(0, arroba);
}

/**
 * Resolve um chatId WAHA em identidade canônica:
 *  - `{number}@c.us` | `@s.whatsapp.net` -> phone E.164 ("+55...")
 *  - `{lid}@lid` -> lid (somente dígitos; número protegido pelo WhatsApp)
 *  - `@g.us` -> group (skip binding CRM — descarte ESPERADO, por doutrina)
 *  - qualquer outra coisa -> unknown (descarte que DEIXA RASTRO)
 *
 * A quarta variante existe porque este `return` final classificava tudo o que
 * não reconhecia como "grupo", e o ingest descarta grupo: "não sei ler isto"
 * virava "descarta calado" — a mesma família do defeito que sumia com a mensagem
 * digitada no celular (PR #108), inclusive o mesmo sintoma de webhook devolvendo
 * 200 sem erro. `@newsletter` e `@broadcast` já existem em produção e caíam
 * aqui; o próximo formato do WhatsApp reproduziria o caso inteiro.
 *
 * Grupo e desconhecido têm o MESMO desfecho (não viram contato) e naturezas
 * opostas: um é decisão de produto, o outro é buraco de conhecimento. Só o
 * segundo é anomalia, então só ele emite evento.
 */

export function parseChatId(chatId: string): ChatIdentity {
  if (chatId.endsWith("@g.us")) return { kind: "group", phone: null, lid: null };
  if (chatId.endsWith("@lid")) {
    return { kind: "lid", phone: null, lid: semSufixoDeChat(chatId) };
  }
  if (chatId.endsWith("@c.us") || chatId.endsWith("@s.whatsapp.net")) {
    // `replace(/^\+/, "")` fica: é ancorado em `^`, casa 1 caractere, O(1) — não é
    // o que o CodeQL apontou.
    const digits = semSufixoDeChat(chatId).replace(/^\+/, "");
    // Mesma faixa de `telefoneDoJid` e do CHECK contacts_phone_e164_format.
    // Avisos do WhatsApp (0@s.whatsapp.net) não são contatos discáveis: o
    // importador deve ignorá-los, em vez de abortar no upsert com telefone +0.
    if (!/^\d{8,15}$/.test(digits)) return { kind: "unknown", phone: null, lid: null };
    return { kind: "phone", phone: "+" + digits, lid: null };
  }
  return { kind: "unknown", phone: null, lid: null };
}

/** Só estes dois viram contato no CRM — ver a guarda de `upsertContact`. */
function ehEnderecavel(parsed: ChatIdentity): boolean {
  return parsed.kind === "phone" || parsed.kind === "lid";
}

/**
 * O SUFIXO responde "que formato é este?"; o resto identifica uma pessoa.
 *
 * Registro operacional não é cópia de dado de contato — mesma linha de
 * `markConversation`, que deliberadamente não copia o texto da mensagem. Sem
 * isso, o log de diagnóstico vira depósito de número de telefone.
 */
function sufixoDeChatId(chatId: string): string {
  const at = chatId.lastIndexOf("@");
  if (at !== -1) return chatId.slice(at);
  return chatId === "" ? "(vazio)" : "(sem @)";
}

/**
 * Um chatId que não sabemos endereçar é ANOMALIA — tem que ser contável.
 *
 * `select count(*) from event_log where event_type = 'whatsapp.chat_id_not_recognized'`
 * responde "o WhatsApp mudou de formato e estamos perdendo mensagem?", que antes
 * não tinha como ser respondido: o descarte não deixava nada para trás.
 */
async function avisarChatNaoReconhecido(
  admin: Admin,
  organizationId: string,
  sessionId: string,
  chatId: string,
  direction: "inbound" | "outbound",
): Promise<void> {
  const { error } = await admin.rpc("emit_event" as never, {
    p_event_type: "whatsapp.chat_id_not_recognized",
    p_entity_kind: "channel_session",
    p_entity_id: sessionId,
    p_payload: { sufixo: sufixoDeChatId(chatId), direction },
    p_metadata: { severity: "warn" },
    p_organization_id: organizationId,
  } as never);
  if (error) {
    console.error("[waha.ingest] o aviso de chat não reconhecido também falhou", error.message);
  }
}


export function verifyHmacSha512(
  rawBody: string,
  signatureHeader: string | null,
  secret: string,
): boolean {
  if (!signatureHeader) return false;
  const expected = createHmac("sha512", secret).update(rawBody, "utf8").digest("hex");
  const got = signatureHeader.replace(/^sha512=/i, "").trim();
  if (got.length !== expected.length) return false;
  try {
    return timingSafeEqual(Buffer.from(got, "hex"), Buffer.from(expected, "hex"));
  } catch {
    return false;
  }
}

/**
 * Prévia da conversa (`conversations.last_message_preview`), gravada pelos dois
 * ramos da ingestão.
 *
 * ⚠️ O corpo NÃO é só `p.body`: o payload NOWEB de um cartão de contato vem sem
 * `body` e o vCard mora em `_data.message.contactMessage.vcard` (#2742). Antes,
 * essa função caía em `[contact]` e a lista da Inbox mostrava o literal no lugar
 * do nome do contato compartilhado. `bodyOf` é o MESMO caminho que grava o
 * `body` da mensagem; `previaDaMensagem` lê o `FN:` do cartão (`parseVcard`,
 * #2712) e devolve `Contato: <nome>`.
 */
export function previewFromMessage(p: WahaPayload): string {
  return previaDaMensagem(bodyOf(p), resolveMessageType(p));
}

/** URL da mídia: WAHA novo (payload.media.url) com fallback legado (payload.mediaUrl). */
export function mediaUrlOf(p: WahaPayload): string | null {
  return p.mediaUrl ?? p.media?.url ?? null;
}

/** MIME da mídia: idem (payload.media.mimetype é o campo do NOWEB atual). */
export function mediaMimeOf(p: WahaPayload): string | null {
  return p.mimetype ?? p.media?.mimetype ?? null;
}

/**
 * Nome ORIGINAL do arquivo (`payload.media.filename`, o campo que o WAHA
 * documenta em "Receive messages": `"some-file.pdf"` em documento, `null` em
 * imagem/áudio) — a chave `metadata.media_filename` que o cartão do Inbox lê
 * (#2613).
 *
 * Guarda por VALOR, não por presença: `null`, não-string e string em branco
 * não é nome, e a chave só entra no `metadata` quando sobra algo. O path de
 * storage é canônico (`{org}/{conversa}/{mensagem}.{ext}`) e apaga o nome que o
 * cliente mandou — sem isto aqui, o cartão nunca deixa de mostrar o rótulo de
 * extensão.
 */
export function mediaFilenameOf(p: WahaPayload): string | null {
  return nomeDeArquivoLimpo(p.media?.filename);
}

/**
 * `payload.timestamp` em ISO-8601, robusto à UNIDADE. O WAHA manda segundos
 * (epoch s), mas um proxy/integrador pode mandar milissegundos ou
 * nanossegundos — e `new Date(ns * 1000).toISOString()` LANÇA `RangeError:
 * Invalid time value`, derrubando o webhook inteiro (medido em 2026-09-18).
 * Aqui a unidade é inferida pela ordem de grandeza; valor ausente/ inválido cai
 * no `agora`. Nunca lança.
 */
export function dataDoTimestamp(timestamp: number | null | undefined, agora: string): string {
  if (typeof timestamp !== "number" || !Number.isFinite(timestamp) || timestamp <= 0) {
    return agora;
  }
  // `Date` aceita até 8.64e15 ms. Segundos (~1.7e9) ×1000; ms (~1.7e12) direto;
  // ns (~1.7e18) ÷1e6. Faixas separadas por ordem de grandeza.
  const ms =
    timestamp >= 1e16
      ? timestamp / 1e6 // nanossegundos
      : timestamp >= 1e11
        ? timestamp // milissegundos
        : timestamp * 1000; // segundos
  const d = new Date(ms);
  return Number.isNaN(d.getTime()) ? agora : d.toISOString();
}

/**
 * Mapeia o `type` cru do WAHA NOWEB para o vocabulário de messages.type do CRM
 * (check constraint messages_type_check). WAHA usa `chat` p/ texto, `ptt` p/
 * áudio de voz, `vcard` p/ contato, etc. Sem esse mapa o INSERT viola a
 * constraint e a mensagem some. O type cru fica em metadata.raw_type.
 */
const WA_TYPE_MAP: Record<string, string> = {
  chat: "text",
  text: "text",
  ptt: "audio",
  audio: "audio",
  image: "image",
  video: "video",
  document: "document",
  sticker: "sticker",
  location: "location",
  vcard: "contact",
  contact: "contact",
  multi_vcard: "contact",
  reaction: "reaction",
};

function mapWahaMessageType(raw: string | undefined): string {
  if (!raw) return "text";
  // Fallback "text": só chegamos ao insert com body/mídia presente (guarda acima),
  // então tratar tipo desconhecido como texto não perde a mensagem.
  return WA_TYPE_MAP[raw.toLowerCase()] ?? "text";
}

/**
 * NOWEB (WAHA 2026.x) não envia `type` no payload — o tipo real está nas
 * chaves de `_data.message` (imageMessage, stickerMessage, …). Ordem de
 * resolução: `type` explícito → chave do message → prefixo do MIME → text.
 */
const NOWEB_MESSAGE_KEY_TYPE: Record<string, string> = {
  stickerMessage: "sticker",
  imageMessage: "image",
  videoMessage: "video",
  ptvMessage: "video", // video note (bolinha)
  audioMessage: "audio",
  documentMessage: "document",
  documentWithCaptionMessage: "document",
  contactMessage: "contact",
};

export function resolveMessageType(p: WahaPayload): string {
  if (p.type) return mapWahaMessageType(p.type);
  // NOWEB não manda `type`: o pino se reconhece pelas coordenadas legíveis.
  if (localizacaoDoPayload(p)) return "location";
  const msg = p._data?.message;
  if (msg && typeof msg === "object") {
    for (const [key, mapped] of Object.entries(NOWEB_MESSAGE_KEY_TYPE)) {
      if (key in msg) return mapped;
    }
  }
  const mime = mediaMimeOf(p);
  if (mime) {
    if (mime === "image/webp") return "sticker";
    if (mime.startsWith("image/")) return "image";
    if (mime.startsWith("video/")) return "video";
    if (mime.startsWith("audio/")) return "audio";
    return "document";
  }
  return "text";
}

export function notifyNameOf(p: WahaPayload): string | null {
  return p._data?.notifyName ?? p._data?.pushName ?? null;
}

/**
 * A LOCALIZAÇÃO (pino) da mensagem, ou null.
 *
 * O WAHA entrega o pino com `body` VAZIO e as coordenadas em `location`
 * (`WALocation`); o NOWEB também as deixa em `_data.message.locationMessage`
 * (`degreesLatitude`/`degreesLongitude`, como o Baileys). Sem ler nenhum dos
 * dois, a guarda de "mensagem sem corpo nem mídia" descartava o pino — nas DUAS
 * direções, sem log: o cliente mandava a localização e a conversa não a mostrava;
 * o dono mandava do celular e ela sumia do CRM (medido numa instalação real em
 * 02/11/2026: em 30 dias, nenhum pino gravado pelo número pareado, 22 pelo oficial).
 *
 * A leitura e a validação são as de todo canal (`lib/messaging/localizacao.ts`):
 * fora da faixa da Terra, ou (0, 0), é null.
 */
export function localizacaoDoPayload(p: WahaPayload): Localizacao | null {
  const direta = lerLocalizacao(p.location);
  if (direta) return direta;
  const msg = p._data?.message;
  if (!msg || typeof msg !== "object") return null;
  const m = msg as { locationMessage?: unknown; liveLocationMessage?: unknown };
  const pino = m.locationMessage ?? m.liveLocationMessage;
  if (!pino || typeof pino !== "object") return null;
  const o = pino as Record<string, unknown>;
  return lerLocalizacao({
    latitude: o.degreesLatitude,
    longitude: o.degreesLongitude,
    name: o.name,
    address: o.address,
  });
}

/** Corpo textual: WAHA nem sempre preenche `body` em cartões de contato NOWEB. */
export function bodyOf(p: WahaPayload): string | null {
  // O pino vira o corpo legível de todo canal (nome/endereço + link do mapa) —
  // é o que o agente lê e a prévia da lista mostra. Antes de `p.body`: no WEBJS
  // o `body` de uma localização é a miniatura em base64, não texto.
  const pino = localizacaoDoPayload(p);
  if (pino) return corpoDaLocalizacao(pino);
  if (p.body) return p.body;
  const msg = p._data?.message;
  if (!msg || typeof msg !== "object") return null;
  // `_data.message` é `unknown` no schema Zod (`lib/waha/envelope.ts`), de
  // propósito: a forma NOWEB varia por tipo de mensagem e exigi-la aqui só
  // criaria uma porta nova de descartar a mensagem inteira. O estreitamento é
  // explícito, no mesmo estilo do `cm as {…}` logo abaixo.
  const cm = (msg as { contactMessage?: unknown }).contactMessage;
  if (cm && typeof cm === "object") {
    const o = cm as { vcard?: string; displayName?: string };
    if (o.vcard) return o.vcard;
    if (o.displayName) return o.displayName;
  }
  return null;
}

/**
 * O telefone REAL de quem escreveu, quando o chat chega como `@lid`.
 *
 * `from` vem opaco (`100000000000001@lid`), mas `_data.key.remoteJidAlt` traz
 * `5511900000001@s.whatsapp.net`. Em grupo, o equivalente é `participantAlt`.
 *
 * Devolve E.164 (`+55…`) ou null. **Só aceita o que parece telefone**: o campo é
 * de fora, e um valor estranho aqui viraria `phone_number` — que é chave de
 * reencontro de contato e endereço de envio. Na dúvida, nulo: contato sem
 * telefone é incômodo, contato com telefone ERRADO manda mensagem para
 * estranho.
 */
export function telefoneAlternativoDe(p: WahaPayload): string | null {
  return telefoneDoJid(p._data?.key?.remoteJidAlt ?? p._data?.key?.participantAlt ?? null);
}

/**
 * Quem escreveu, para o corte do número de avisos: o `@lid` leva junto o
 * telefone de `telefoneAlternativoDe`. Sem ele, o número cadastrado em modo
 * privacidade não é reconhecido — o transporte resolve o destino do aviso por
 * telefone, e o `destino_jid` gravado nunca termina em `@lid`.
 */
export function identidadeDoRemetente(p: WahaPayload, parsed: ChatIdentity): ChatIdentidade {
  return parsed.kind === "lid" ? { ...parsed, phone: telefoneAlternativoDe(p) } : parsed;
}

/**
 * O miolo de `telefoneAlternativoDe`, separado para o remetente de GRUPO usar a
 * MESMA régua sobre `participantAlt` — sem o `remoteJidAlt` na frente, que em
 * grupo não é de quem escreveu. Mesma entrada de fora, mesmas guardas.
 */
function telefoneDoJid(bruto: string | null | undefined): string | null {
  if (!bruto) return null;
  // ⚠️ `endsWith`/`indexOf` e NÃO regex — este valor vem de FORA (é campo de
  // webhook) e a versão com `/@(s\.whatsapp\.net|c\.us)$/` foi apontada pelo
  // CodeQL como ReDoS de severidade alta: o motor tenta casar a partir de CADA
  // `@` da string, então um payload com milhares deles faz o tempo explodir e
  // trava o processo que ingere as mensagens de todo mundo.
  //
  // Comparação de sufixo literal é linear e diz exatamente a mesma coisa. Um
  // teto de tamanho vem antes, porque nem trabalho linear sobre entrada
  // arbitrária é de graça.
  //
  // Só sufixos de NÚMERO: `@lid` significaria que o campo repetiu a identidade
  // opaca, e `@g.us` é grupo — nenhum dos dois é telefone de pessoa.
  if (bruto.length > 128) return null;
  if (!bruto.endsWith("@s.whatsapp.net") && !bruto.endsWith("@c.us")) return null;
  const semSufixo = bruto.slice(0, bruto.indexOf("@"));
  let digitos = "";
  for (const ch of semSufixo) {
    if (ch >= "0" && ch <= "9") digitos += ch;
  }
  // Faixa E.164: 8 a 15 dígitos. Fora disso não é número discável, e o CHECK
  // `contacts_phone_e164_format` recusaria — falhar aqui é melhor que abortar a
  // ingestão inteira da mensagem lá na frente.
  if (digitos.length < 8 || digitos.length > 15) return null;
  return `+${digitos}`;
}

/** O primeiro valor que É texto não vazio — o resto (null, número, objeto) é ignorado. */
function primeiroTexto(...valores: unknown[]): string | null {
  for (const v of valores) if (typeof v === "string" && v.length > 0) return v;
  return null;
}

const LID_DE_REMETENTE = /^\d{5,40}$/;
const TELEFONE_DE_REMETENTE = /^\+\d{8,15}$/;

/**
 * QUEM escreveu uma mensagem de grupo — tirado do AUTOR, nunca do `from` (que,
 * em grupo, é o próprio grupo). Doutrina: "Sender é `p.author`, não `p.from`".
 *
 * ─── De onde vem cada pedaço, e o que foi medido ────────────────────────────
 *
 * O autor é o primeiro texto entre `participant` e `author` (os dois que o WAHA
 * declara no `WAMessage`) e `_data.key.participant` (a chave do Baileys, que o
 * NOWEB repassa). O formato exato no webhook NÃO foi medido nesta instalação —
 * a sonda da Task 0 mediu a LISTAGEM de grupos, onde o participante vem como
 * `@lid` e o telefone mora à parte. Por isso a leitura aceita as três casas e
 * confere o tipo de cada uma.
 *
 * O telefone segue o que a conversa individual já faz (`telefoneAlternativoDe`):
 * se o autor é `@lid`, o número real vem em `_data.key.participantAlt` — medido
 * no 1:1 como 76 de 76 payloads @lid trazendo o `*Alt`. Aqui só o
 * `participantAlt` conta: o `remoteJidAlt`, em grupo, não é de quem escreveu.
 *
 * ─── Nunca lança ────────────────────────────────────────────────────────────
 *
 * Cada campo que não passa na régua do `remetenteDeGrupoSchema` vira `null`, em
 * vez de reprovar o objeto inteiro: o schema é estrito e um nome de 201
 * caracteres jogaria fora também o telefone. Sem nada aproveitável, `null` — a
 * mensagem entra do mesmo jeito, e a tela mostra "Participante".
 */
export function remetenteDoGrupo(p: WahaPayload): RemetenteDeGrupo | null {
  const autorBruto = primeiroTexto(p.participant, p.author, p._data?.key?.participant);
  const { lid, phone } = identidadeDoParticipante(autorBruto, primeiroTexto(p._data?.key?.participantAlt));
  const nome = notifyNameOf(p)?.trim().slice(0, 200) || null;

  if (!nome && !phone && !lid) return null;
  return { name: nome, phone, lid };
}

/**
 * Um JID de PARTICIPANTE → `{ lid, phone }`, pela régua do remetente.
 *
 * Existe separado porque a MENÇÃO de grupo (#3566) precisa exatamente disto: o
 * `mentionedJid` do Baileys é a mesma forma de JID que o autor da mensagem, com
 * o mesmo sufixo de dispositivo possível, e a informação disponível é a mesma —
 * identificador opaco quando é `@lid`, número quando é `@c.us`/`@s.whatsapp.net`.
 * Duas leituras do mesmo campo divergiriam no dia em que o WhatsApp mudasse o
 * formato; aqui é uma só.
 *
 * Quem chama decide o que fazer com o resultado: `remetenteDoGrupo` usa o
 * `nome` do payload, e a menção não tem nome no payload — o rótulo dela vem do
 * contato no CRM (`lib/grupos/ingest.ts`).
 */
function identidadeDoParticipante(
  jidBruto: string | null,
  altBruto: string | null,
): { lid: string | null; phone: string | null } {
  // Sufixo de DISPOSITIVO (`:4`, `:12`...) que o WhatsApp multi-device às vezes
  // ancora antes do `@` — `123456:4@lid` e `5511999990000:12@s.whatsapp.net`
  // são o MESMO lid/telefone de sempre, só que com o dispositivo colado. Sem
  // remover, o teto de dígitos de `LID_DE_REMETENTE`/`TELEFONE_DE_REMETENTE`
  // reprova os dois e o participante se perde.
  const autor = jidBruto ? jidBruto.replace(/:\d+@/, "@") : null;
  // Teto antes de qualquer varredura: é campo de fora, como o `from`.
  const id = autor && autor.length <= 128 ? parseChatId(autor) : null;

  const doAutor = id?.kind === "phone" ? canonicalPhoneBR(id.phone) : null;
  const doAlt = telefoneDoJid(altBruto);
  const telefone = [doAutor, doAlt ? canonicalPhoneBR(doAlt) : null].find(
    (t): t is string => !!t && TELEFONE_DE_REMETENTE.test(t),
  );
  const lid = id?.kind === "lid" && LID_DE_REMETENTE.test(id.lid) ? id.lid : null;
  return { lid: lid ?? null, phone: telefone ?? null };
}

/**
 * QUEM FOI MENCIONADO numa mensagem de grupo (#3566) — a lista que faltava
 * guardar. O texto do WhatsApp traz a menção como `@<lid>`, e a lista real está
 * no `contextInfo.mentionedJid` do Baileys (o mesmo lugar de onde a citação já
 * é lida).
 *
 * Cada entrada passa pelo MESMO leitor de JID do remetente
 * (`identidadeDoParticipante`) e sai no MESMO formato (`{ name, phone, lid }`,
 * `lib/messaging/remetente-de-grupo.ts`), com `name` nulo: o payload não traz
 * nome por menção, e o rótulo é resolvido no ingest de grupo pelo contato do
 * CRM.
 *
 * Nunca lança e nunca derruba a mensagem: campo de fora, forma variável. Lista
 * ausente, item de outro tipo e JID irreconhecível são simplesmente ignorados.
 * Teto de 50 entradas porque é o limite prático do WhatsApp numa mensagem — e
 * porque lista de fora sem teto é lista que cresce com o payload.
 */
export function mencoesDoGrupo(p: WahaPayload): RemetenteDeGrupo[] {
  const contexto = contextoDoBaileys(p);
  const lista = contexto?.mentionedJid;
  if (!Array.isArray(lista)) return [];
  const mencoes: RemetenteDeGrupo[] = [];
  const vistos = new Set<string>();
  for (const item of lista) {
    if (typeof item !== "string") continue;
    const { lid, phone } = identidadeDoParticipante(item, null);
    // Sem lid e sem telefone não há como resolver nada depois — guardar
    // `{ name: null, phone: null, lid: null }` só encheria o metadata.
    if (lid === null && phone === null) continue;
    const chave = lid !== null ? `lid:${lid}` : `phone:${phone}`;
    if (vistos.has(chave)) continue;
    vistos.add(chave);
    mencoes.push({ name: null, phone, lid });
    if (mencoes.length >= 50) break;
  }
  return mencoes;
}


/**
 * O chat de GRUPO de uma mensagem `fromMe`, ou null.
 *
 * O chat do outbound sai de `to ?? id ?? from` (ver `handleOutboundFromUserPhone`),
 * e em grupo a segunda fonte engana: o id de grupo tem QUATRO segmentos
 * (`{fromMe}_{chat}_{id}_{participante}`), e `chatIdFromWaMessageId` devolve
 * `chat_id` colados — lixo que não termina em `@g.us`. O NOWEB manda o grupo em
 * `from`; o WEBJS, em `to`. Qualquer um dos dois terminando em `@g.us` basta.
 */
function grupoDoEnvio(p: WahaPayload, chatId: string): string | null {
  for (const c of [chatId, p.to, p.from]) {
    if (typeof c === "string" && c.endsWith("@g.us")) return c;
  }
  return null;
}

/**
 * Entrega a mensagem de grupo a `lib/grupos/ingest.ts`, que decide se o grupo
 * está LIGADO. Desligado: nada é escrito, como antes da feature.
 *
 * O que este caminho NÃO chama, e cada ausência é de propósito (spec
 * "grupos na inbox", tabela "Nunca dispara com grupo"): `upsertContact` (não há
 * contato por participante), a resolução de avatar/lid, a atribuição de anúncio,
 * `aplicarEfeitosPosEntrada` (opt-out, lead, agente), `acelerarPipelineDeEventos`,
 * os audits `message.received`/`message.sent` e `pausarIaPorAtendimentoManual`
 * (a IA nunca responde em grupo — não há o que pausar).
 *
 * Falha de banco não sobe: a conversa individual também registra e segue quando
 * o INSERT falha, e um throw aqui só faria o provedor reentregar o que o dedup
 * por `external_id` já recusaria.
 */
async function ingerirMensagemDeGrupo(
  admin: Admin,
  session: Session,
  p: WahaPayload,
  groupChatId: string,
  direction: "inbound" | "outbound",
): Promise<void> {
  if (!p.id) return;
  const now = new Date().toISOString();
  try {
    await gravarMensagemDeGrupo(criarIngestDeGrupoDb(admin as unknown as SupabaseClient), {
      organizationId: session.organization_id,
      channelSessionId: session.id,
      groupChatId,
      direction,
      externalId: p.id,
      type: resolveMessageType(p),
      body: bodyOf(p),
      mediaUrl: mediaUrlOf(p),
      mediaMime: mediaMimeOf(p),
      temMidia: p.hasMedia === true,
      sentAt: dataDoTimestamp(p.timestamp, now),
      // `fromMe`: quem escreveu é o dono do número — o `pushName` é o da loja.
      remetente: direction === "inbound" ? remetenteDoGrupo(p) : null,
      // #3566 — quem foi mencionado vai junto, nas duas direções: a lista está
      // no payload de qualquer mensagem de grupo, e o eco de um envio nosso
      // também tem as menções que o celular escreveu.
      mencoes: mencoesDoGrupo(p),
      rawType: p.type ?? null,
    });
  } catch (err) {
    logger.error("waha.ingest: mensagem de grupo não gravada", {
      organization_id: session.organization_id,
      direcao: direction,
      detail: err instanceof Error ? err.message : String((err as { message?: unknown })?.message ?? err),
    });
  }
}

/**
 * Upsert atômico de contato pela identidade canônica. Retorna null se a
 * identidade for de grupo ou a RPC falhar.
 */
async function upsertContact(
  admin: Admin,
  orgId: string,
  parsed: ChatIdentity,
  chatId: string,
  notifyName: string | null,
  telefoneAlt: string | null = null,
): Promise<string | null> {
  // ALLOWLIST, não denylist — e a diferença aqui não é estilo.
  //
  // `fn_upsert_wa_contact` NÃO valida `p_kind`, e `contacts.wa_identity` é coluna
  // GERADA que só produz `phone:`/`lid:`; qualquer outro kind a deixa NULL. Como
  // o `on conflict` da RPC é `(organization_id, wa_identity) where wa_identity is
  // not null`, uma linha NULL nunca conflita — nasceria UM CONTATO NOVO A CADA
  // WEBHOOK, que é exatamente o anti-pattern que a migration 0027 veio matar.
  //
  // Com `kind === "group"` (a forma antiga), acrescentar uma variante à união
  // abria esse buraco em silêncio: o TS não reclama de um `===` que deixou de
  // cobrir todos os casos. Perguntar quem PODE passar falha fechado sozinho.
  //
  // ⚠️ SEGUNDA CAMADA, SEM COBERTURA POSSÍVEL — e isto está escrito porque medi:
  // trocar esta linha de volta pela denylist deixa a suíte inteira VERDE (35/35,
  // typecheck 0). Os dois chamadores já barram o não-endereçável antes de chegar
  // aqui, então nenhum teste consegue alcançá-la; é defesa em profundidade na
  // fronteira com uma RPC que não valida nada. Quem mexer aqui não vai ser
  // avisado por teste nenhum — só por este comentário.
  if (!ehEnderecavel(parsed)) return null;
  const { data, error } = await admin.rpc("fn_upsert_wa_contact" as never, {
    p_org: orgId,
    p_kind: parsed.kind,
    // O telefone vem de dois lugares e é UM parâmetro: do próprio chatId quando
    // ele já é um número, ou de `_data.key.remoteJidAlt` quando o chat é `@lid`.
    // Resolver aqui, e não no SQL, foi o que permitiu manter a assinatura da
    // função (e portanto os grants e os invariantes de hardening) intacta.
    p_phone: parsed.kind === "phone"
      ? canonicalPhoneBR(parsed.phone)
      : telefoneAlt
        ? canonicalPhoneBR(telefoneAlt)
        : null,
    p_lid: parsed.kind === "lid" ? parsed.lid : null,
    p_chat_id: chatId,
    p_notify: notifyName,
  } as never);
  if (error) {
    lancarFalhaDeIngestao("fn_upsert_wa_contact", error);
  }
  return (data as string) ?? null;
}

async function upsertConversation(
  admin: Admin,
  orgId: string,
  contactId: string,
  sessionId: string,
): Promise<string | null> {
  const { data, error } = await admin.rpc("fn_upsert_wa_conversation" as never, {
    p_org: orgId,
    p_contact: contactId,
    p_session: sessionId,
  } as never);
  if (error) {
    lancarFalhaDeIngestao("fn_upsert_wa_conversation", error);
  }
  return (data as string) ?? null;
}

/**
 * Carimba a conversa com a mensagem que acabou de entrar.
 *
 * ⚠️ FALHA BAIXO, MAS CONTA — e a diferença entre as duas coisas é o motivo
 * desta função existir com corpo próprio. A mensagem JÁ foi inserida quando
 * chegamos aqui; bloquear a ingestão porque o carimbo falhou deixaria o
 * histórico refém de uma coluna derivada. Então não se bloqueia.
 *
 * Mas `console.error` sozinho não é "falhar baixo": ele **não bloqueia e também
 * não conta** (anti-pattern nº 14 do CLAUDE.md, e a mesma doutrina já escrita em
 * `lib/leads/activity-write-failure.ts`). Log de servidor sem destino não vira
 * alerta de ninguém — e o efeito prático é que "a RPC falha às vezes" nunca sai
 * de OPINIÃO para NÚMERO. Em 25/07 isso custou caro: a suspeita de que esta
 * chamada falhava foi levada a sério por horas, e não havia como medi-la porque
 * cada falha tinha sumido no log de um processo que já não existia.
 *
 * O evento é o que torna a pergunta respondível: `select count(*) from event_log
 * where event_type = 'whatsapp.conversation_mark_failed'`.
 *
 * ⚠️ O CORPO MUDOU DE CASA, e o motivo está em `lib/channels/marcar-conversa.ts`:
 * Meta e Zernio chamavam a mesma RPC e tratavam a falha pior — a Meta ignorava
 * o retorno inteiro. Esta função continua existindo com a assinatura que os dois
 * chamadores daqui usam; quem decide o que fazer com a falha é uma só.
 */
async function markConversation(
  admin: Admin,
  organizationId: string,
  convId: string,
  direction: "inbound" | "outbound",
  preview: string,
  at: string,
): Promise<void> {
  await marcarConversaComMensagem(admin as unknown as SupabaseClient, {
    organizationId,
    conversationId: convId,
    direction,
    preview,
    at,
    canal: "waha",
  });
}

/**
 * Mensagem recebida (fromMe=false). Contato = remetente (`from`).
 */
async function mensagemIngeridaPorExternalId(
  admin: Admin,
  orgId: string,
  externalId: string,
): Promise<{ id: string; contact_id: string; body: string | null } | null> {
  const { data, error } = await admin
    .from("messages")
    .select("id, contact_id, body")
    .eq("organization_id", orgId)
    .eq("external_id", externalId)
    .eq("direction", "inbound")
    .maybeSingle();
  if (error) {
    logger.warn("waha.ingest: dedup sem ler mensagem existente", { detail: error.message });
    return null;
  }
  return data ?? null;
}

/** Objeto simples (nem array, nem null) — a guarda para dado que vem do fio. */
function objetoSimples(v: unknown): Record<string, unknown> | null {
  return typeof v === "object" && v !== null && !Array.isArray(v) ? (v as Record<string, unknown>) : null;
}

/** String não-vazia — a régua do `texto` do envelope (`envelope.ts`), em runtime. */
function textoNaoVazio(v: unknown): string | null {
  return typeof v === "string" && v !== "" ? v : null;
}

/**
 * O payload é de REAÇÃO, mesmo quando chega por `message.any`?
 *
 * O WAHA emite `message.any` para QUALQUER mensagem nova, e o payload de uma
 * reação é um desses — com `type: "reaction"` e o miolo `reaction` do fio
 * (#2797). Sem este reconhecimento, o caminho normal gravaria uma linha SEM
 * emoji e SEM alvo (medido: a prova `ingest-reacao.test.ts` falhava com DUAS
 * linhas para uma reação) e ainda carimbaria a conversa. A linha certa é a do
 * ramo `message.reaction`, ligada ao alvo.
 */
export function ehReacao(p: WahaPayload): boolean {
  return p.type === "reaction" || objetoSimples(p.reaction) !== null;
}

/**
 * O `contextInfo` do Baileys — o id cru da citada e a mensagem citada inteira.
 *
 * É daqui também que sai a LISTA DE MENÇÕES (`mentionedJid`, #3566): o mesmo
 * objeto, lido por `mencoesDoGrupo`. Por isso o nome é do CONTEXTO e não da
 * citação — quem lê só metade do que mora ali nomeia o campo pela metade.
 *
 * O formato varia: `extendedTextMessage.contextInfo` é o caso do texto (o
 * medido em produção), uma resposta com mídia carrega o dela em
 * `imageMessage.contextInfo`, e alguns formatos trazem direto na mensagem.
 * Varrer os valores evita acoplar a leitura a uma lista de tipos que o WhatsApp
 * muda sem avisar.
 */
function contextoDoBaileys(p: WahaPayload): Record<string, unknown> | null {
  const msg = objetoSimples(p._data?.message);
  if (!msg) return null;
  const direto = objetoSimples(msg.contextInfo);
  if (direto) return direto;
  for (const valor of Object.values(msg)) {
    const ctx = objetoSimples(objetoSimples(valor)?.contextInfo);
    if (ctx) return ctx;
  }
  return null;
}

/**
 * A CITAÇÃO DO "RESPONDER EM CIMA" (issue #2474): o id da mensagem respondida
 * e, quando vem, o texto citado.
 *
 * Duas fontes porque o WAHA entrega as duas: o `replyTo` normalizado (medido em
 * produção — `payload.replyTo.id` com o texto) e o cru em
 * `_data.message.<tipo>.contextInfo` (`stanzaId` + `quotedMessage`), que é onde
 * o texto aparece quando o normalizado não o traz. Sem id não há o que
 * resolver: devolve `null` e a mensagem entra solta, como sempre entrou.
 */
function citacaoDoPayload(p: WahaPayload): { id: string; texto: string | null } | null {
  const direto = objetoSimples(p.replyTo);
  const contexto = contextoDoBaileys(p);
  const id = textoNaoVazio(direto?.id) ?? textoNaoVazio(contexto?.stanzaId);
  if (!id) return null;
  const citada = objetoSimples(contexto?.quotedMessage);
  const texto =
    textoNaoVazio(direto?.body) ??
    textoNaoVazio(citada?.conversation) ??
    textoNaoVazio(objetoSimples(citada?.extendedTextMessage)?.text) ??
    null;
  return { id, texto };
}

/**
 * Acha a linha da mensagem CITADA, presa à MESMA conversa.
 *
 * ─── Por que o filtro de conversa é obrigatório ─────────────────────────────
 *
 * A bolha renderiza a citada pelo TEXTO (`ChatThread` → `MessageBubble`), então
 * apontar para fora da conversa mostraria conteúdo de outro atendimento. A
 * régua (e o motivo) é a mesma do envio (`app/api/v1/messages/_handler.ts`). O
 * filtro de organização vai explícito porque service role bypassa RLS.
 *
 * ─── Por que DUAS consultas ─────────────────────────────────────────────────
 *
 * O id do fio chega em formas diferentes conforme quem escreveu a citada:
 * recebida do cliente é gravada COMPLETA (`false_<chat>_<bare>`), do nosso lado
 * é BARE (`<bare>`) — e o `replyTo.id` pode chegar em qualquer uma delas, com o
 * outro formato de chat (`@lid` × `@c.us`). Os candidatos de
 * `wahaEchoExternalIds` (completo × bare × `true_<chat>_<bare>`, o composto
 * legado e o eco de envio nosso) resolvem a maioria; o SUFIXO `_<bare>` fecha
 * o resto, o mesmo raciocínio de `removerEcoDoProprioEnvio` (`_handler.ts`): o
 * bare do WhatsApp tem 20+ caracteres aleatórios, então casar pela cauda
 * alcança qualquer formato sem alcançar outra mensagem.
 */
async function resolverMensagemCitada(
  admin: Admin,
  organizationId: string,
  conversationId: string,
  citacaoId: string,
  chatId: string,
): Promise<string | null> {
  const formas = wahaEchoExternalIds(citacaoId, chatId);
  const { data, error } = await admin
    .from("messages")
    .select("id")
    .eq("organization_id", organizationId)
    .eq("conversation_id", conversationId)
    .in("external_id", formas)
    .limit(1)
    .maybeSingle();
  if (error) {
    logger.warn("waha.ingest: não consegui ler a mensagem citada", { detail: error.message });
  }
  if (data) return (data as { id: string }).id;

  const bare = bareWaMessageId(citacaoId);
  // Teto de 16: um id real do WhatsApp tem 20+ caracteres aleatórios; abaixo
  // disso o LIKE casaria outra mensagem por coincidência em vez de achar esta.
  if (bare.length < 16) return null;
  const { data: porSufixo, error: erroSufixo } = await admin
    .from("messages")
    .select("id")
    .eq("organization_id", organizationId)
    .eq("conversation_id", conversationId)
    .like("external_id", `%\\_${bare}`)
    .limit(1)
    .maybeSingle();
  if (erroSufixo) {
    logger.warn("waha.ingest: não consegui ler a mensagem citada por sufixo", { detail: erroSufixo.message });
  }
  return (porSufixo as { id: string } | null)?.id ?? null;
}

/**
 * A forma em que a ENTRADA grava `messages.external_id`: o id completo do webhook
 * (`false_<chat>_<id>`). Fonte única, usada por `handleInbound` e pelo importador
 * de histórico: as duas trilhas gravam a MESMA string, senão a unique não recusa
 * a segunda linha e a mensagem duplica.
 */
export function externalIdDeEntrada(p: WahaPayload): string | null {
  return p.id ?? null;
}

/**
 * A forma em que a SAÍDA (eco do celular) grava `messages.external_id`: o bare id
 * (#196, #1855). Fonte única, ver `externalIdDeEntrada`.
 */
export function externalIdDeSaida(p: WahaPayload): string | null {
  return p.id ? bareWaMessageId(p.id) : null;
}

async function handleInbound(
  admin: Admin,
  session: Session,
  p: WahaPayload,
  requestId: string,
): Promise<void> {
  // Reação não é mensagem: o ramo `message.reaction` do roteador grava a linha
  // LIGADA ao alvo (#2797). Ver `ehReacao` — sem este corte o `message.any`
  // gravaria a linha cega e carimbaria a conversa.
  if (ehReacao(p)) return;
  const chatId = p.from ?? "";
  const parsed = parseChatId(chatId);
  if (parsed.kind === "group") {
    // Grupo só entra se estiver LIGADO em Conexões › Grupos; o resto é descartado,
    // como antes. Nada de contato por participante, pós-entrada, lead, opt-out ou
    // pipeline de IA: ver `ingerirMensagemDeGrupo` e lib/grupos/ingest.ts.
    await ingerirMensagemDeGrupo(admin, session, p, chatId, "inbound");
    return;
  }
  if (!p.id) return;
  // WAHA emite eventos vazios p/ status/read-receipt/presence — não viram mensagem.
  const texto = bodyOf(p);
  if (!texto && !mediaUrlOf(p) && !p.hasMedia) return;
  // Daqui para baixo era para ser uma mensagem de verdade: se o chat não é
  // endereçável, PERDEMOS uma — e isso precisa ser contável. O aviso fica depois
  // das guardas acima de propósito; antes delas, todo evento de presença viraria
  // um registro, e log que enche sozinho é log que ninguém lê.
  if (!ehEnderecavel(parsed)) {
    await avisarChatNaoReconhecido(admin, session.organization_id, session.id, chatId, "inbound");
    return;
  }

  // ── O NÚMERO INTERNO DE AVISOS NÃO VIRA ATENDIMENTO ─────────────────────
  //
  // Aqui, e não em `pos-entrada`: é o INSERT da conversa (logo abaixo) que
  // dispara o pedido de rodízio pelo banco. Cortar depois já teria criado
  // contato, conversa e uma "conversa do suporte" na fila de um atendente — e o
  // "cancelar" que alguém da equipe digitasse bloquearia esse contato.
  if (await consumirRespostaDoSuporteNoWhatsApp(admin, {
    organizationId: session.organization_id,
    channelSessionId: session.id,
    identidade: identidadeDoRemetente(p, parsed),
    direction: "inbound",
    texto,
    externalId: p.id,
  })) {
    return;
  }

  const contactId = await upsertContact(
    admin,
    session.organization_id,
    parsed,
    chatId,
    notifyNameOf(p),
    telefoneAlternativoDe(p),
  );
  if (!contactId) return;

  // Best-effort: o dado do anúncio (se houver) vai embutido na PRÓPRIA
  // mensagem que o app do cliente manda ao clicar num anúncio "Clique para o
  // WhatsApp" — não é exclusivo da API oficial. O WAHA NOWEB pode entregar
  // `externalAdReply`; formas não reconhecidas seguem silenciosas e nunca
  // derrubam o inbound.
  // `estamparAtribuicaoDoContato` só grava na primeira vez — se o
  // contato já tem atribuição, o UPDATE casa zero linhas.
  const atribuicao = extrairAtribuicaoWaha(p._data?.message);
  if (atribuicao) await estamparAtribuicaoDoContato(admin, session.organization_id, contactId, atribuicao);

  // Irmão do bloco acima, para o Google: o token vem no PRÓPRIO texto da
  // mensagem (não há payload de ad-reply equivalente para essa plataforma) —
  // ver o cabeçalho de `lib/plataformas-de-anuncio/google/atribuicao.ts`. Best-effort.
  await extrairEEstamparAtribuicaoGoogle(admin, session.organization_id, contactId, texto);

  const conversationId = await upsertConversation(admin, session.organization_id, contactId, session.id);
  if (!conversationId) return;

  // A citação da mensagem respondida (issue #2474), resolvida ANTES do insert
  // porque o ponteiro é coluna da linha que está nascendo. Sem citação no
  // payload, `null` — e a mensagem entra solta, como sempre entrou.
  const citacao = citacaoDoPayload(p);
  const citadaId = citacao
    ? await resolverMensagemCitada(admin, session.organization_id, conversationId, citacao.id, chatId)
    : null;

  const now = new Date().toISOString();
  const nomeDoArquivo = mediaFilenameOf(p);
  const { data: insertedMessage, error: insertErr } = await admin
    .from("messages")
    .insert({
      organization_id: session.organization_id,
      conversation_id: conversationId,
      channel_session_id: session.id,
      contact_id: contactId,
      external_id: externalIdDeEntrada(p),
      type: resolveMessageType(p),
      direction: "inbound",
      status: "delivered",
      ack: p.ack ?? null,
      body: texto,
      media_url: mediaUrlOf(p),
      media_mime: mediaMimeOf(p),
      sent_via: "external_device",
      sent_at: dataDoTimestamp(p.timestamp, now),
      delivered_at: now,
      reply_to_message_id: citadaId,
      metadata: {
        raw_type: p.type,
        ack_name: p.ackName,
        // O pino em `metadata.location` é o que a tela desenha (`LocationCard`) e o
        // que `localizacaoDaMensagem` lê — a mesma chave do canal oficial.
        ...(localizacaoDoPayload(p) ? { location: localizacaoDoPayload(p) } : {}),
        // O texto citado só entra quando NÃO há ponteiro: com a linha
        // resolvida, a citada no banco é a fonte da verdade — uma edição
        // posterior mudaria o que a tela mostra, e a cópia ficaria velha.
        ...(citacao?.texto && !citadaId ? { reply_to_body: citacao.texto } : {}),
        // O anúncio DESTA mensagem vai junto — o contato guarda só o primeiro, e
        // quem volta por outro anúncio precisa deixar rastro. Ver `metadataDoAnuncio`.
        ...metadataDoAnuncio(atribuicao),
        ...ancoraCtwaDaMensagem(atribuicao),
        // Nome original do anexo (#2613). Só quando há nome: a chave nunca é
        // gravada vazia, e gravação é de linha NOVA (o INSERT é o único caminho
        // que escreve `metadata` aqui), então nada do que já está é sobrescrito.
        ...(nomeDoArquivo ? { media_filename: nomeDoArquivo } : {}),
      },
    })
    .select("id")
    .maybeSingle();

  // Idempotência: 23505 = unique (organization_id, external_id) já ingerido.
  if (insertErr && insertErr.code !== "23505") {
    // Era `console.error` + `return`, e a rota devolvia 200: a mensagem do
    // cliente sumia. Agora lança — transitória vira 503 (o WAHA reentrega) e
    // fica marcada para o cron `webhook-replay`. Ver `falha-transitoria.ts`.
    lancarFalhaDeIngestao("messages.insert inbound", insertErr);
  }
  if (insertErr?.code === "23505") {
    // O `return` está certo — reingerir duplicaria a mensagem do cliente. Mas
    // sair MUDO era o defeito: "5 mensagens, 4 jobs" fica indistinguível entre
    // dedup legítimo e mensagem perdida por outro caminho, e a pergunta "cadê o
    // turno dessa?" passa a não ter resposta no log.
    //
    // Não é erro, é evento esperado — por isso `info` e não `error`. O que ele
    // paga é a CONTAGEM: sem a linha, o silêncio de um dedup normal e o de uma
    // perda têm a mesma cara.
    logger.info("waha.ingest: inbound ja ingerido, dedup por external_id", {
      organization_id: session.organization_id,
      conversation_id: conversationId,
      external_id: p.id,
      direcao: "inbound",
    });
    // A 1ª entrega pode ter gravado a mensagem e estourado o tempo ANTES de
    // `aplicarEfeitosPosEntrada` — a reentrega cai aqui. Reacelerar só o
    // pipeline (sem re-despachar o agente) destrava o match_reply.
    const existente = await mensagemIngeridaPorExternalId(admin, session.organization_id, p.id);
    if (existente) {
      try {
        await acelerarPipelineDeEventos(admin, {
          organizationId: session.organization_id,
          contactId: existente.contact_id,
          messageId: existente.id,
          texto: existente.body,
        });
      } catch (err) {
        logger.warn("waha.ingest: dedup nao reacelerou pipeline", {
          organization_id: session.organization_id,
          external_id: p.id,
          detail: err instanceof Error ? err.message : String(err),
        });
      }
    }
    return;
  }

  await markConversation(admin, session.organization_id, conversationId, "inbound", previewFromMessage(p), dataDoTimestamp(p.timestamp, now));

  await audit({
    action: "message.received",
    organizationId: session.organization_id,
    resourceType: "message",
    requestId,
    metadata: { conversation_id: conversationId, type: p.type, external_id: p.id },
  });

  // ── OS EFEITOS DE NEGÓCIO, agora ATRÁS DO SEAM ──────────────────────────────
  //
  // Opt-out, nascimento do lead e despacho do agente moravam AQUI DENTRO, em
  // linha. Enquanto este era o único canal isso não incomodava; quando entrou o
  // número oficial, ele passou a gravar a mensagem e não fazer nenhum dos três —
  // sem erro e sem log. Medido: 806 despachos deste lado, 0 do outro.
  //
  // A ordem dos três é regra de negócio e está documentada em
  // `lib/channels/pos-entrada.ts`, junto com o motivo de cada posição. O
  // comportamento aqui é o MESMO de antes, campo a campo — o que mudou é quem o
  // executa.
  await aplicarEfeitosPosEntrada(admin, {
    organizationId: session.organization_id,
    contactId,
    conversationId,
    messageId: insertedMessage?.id ?? null,
    channelSessionId: session.id,
    texto,
    nomeDoContato: notifyNameOf(p),
    requestId,
    origem: "waha_webhook",
  });

  // ── POR QUE NÃO SE EMITE `message.received` AQUI ────────────────────────────
  //
  // Porque o BANCO já emite. O gatilho `trg_messages_emit_event` roda AFTER
  // INSERT em `messages`, sem filtrar canal, e chama `fn_emit_message_event`.
  // Esta função emitia a SEGUNDA cópia — só neste canal.
  //
  // Medido em produção antes de sair: 805 mensagens com DOIS eventos deste lado
  // e 30 com UM do outro. Os quatro consumidores registrados rodavam nas duas
  // linhas, então cada mensagem daqui era classificada duas vezes pelo modelo de
  // sentimento (duas chamadas pagas), a automação do usuário disparava duas
  // vezes, e a chave de idempotência do follow-up não protegia porque inclui o
  // id da LINHA de evento — que é diferente nas duas.
  //
  // O critério de aceite escrito em `docs/stories/epics/EPIC-03-inbox-messaging.md`
  // já dizia "2 events 'message.received'? NÃO — só 1". O duplicado gêmeo, o de
  // leads, foi aposentado na migration 0043; este passou despercebido porque a
  // guarda de `entity_kind` não separa os dois emissores (ambos usam "message").
  //
  // Quem precisar do preview do corpo: ele está na própria linha de `messages`,
  // alcançável pelo `message_id` que o gatilho manda.
  if (insertedMessage?.id) {
    const inboundMessageId = insertedMessage.id;
    if (mediaUrlOf(p)) {
      admin
        .rpc("emit_event" as never, {
          p_event_type: "media.persist_requested",
          p_entity_kind: "message",
          p_entity_id: inboundMessageId,
          p_payload: { message_id: inboundMessageId, conversation_id: conversationId },
          p_metadata: { source: "waha_webhook", request_id: requestId },
          p_organization_id: session.organization_id,
        } as never)
        .then(({ error }) => {
          if (error) console.error("[waha.ingest] emit media.persist_requested failed", error.message);
        });
    }
  }
}

/**
 * Esconde do cliente os comandos de controle (`#on`/`#off`).
 *
 * O operador digita o comando no MESMO chat do cliente — o celular dele é o
 * número do bot —, então sem revogar o cliente recebe literalmente "#off".
 * `DELETE .../messages/{id}` com `fromMe: true` é "apagar para todos" no WAHA.
 *
 * BEST-EFFORT de propósito: a mensagem JÁ está gravada e o efeito (pausar/ligar)
 * JÁ foi aplicado quando chegamos aqui. Falhar em revogar só deixa o comando
 * visível — não pode derrubar a ingestão nem desfazer a decisão.
 */
async function revogarComando(
  session: Session,
  chatId: string,
  messageId: string | undefined,
): Promise<void> {
  if (!messageId) return;
  const sessionName = session.waha_session_name;
  if (!sessionName) return;
  const client = getWahaClient();
  if (!client) return;
  try {
    await client.deleteMessage(sessionName, chatId, messageId);
  } catch (err) {
    logger.warn("[waha.ingest] não consegui revogar o comando do celular", {
      organization_id: session.organization_id,
      message_id: messageId,
      detail: err instanceof Error ? err.message.slice(0, 160) : "erro",
    });
  }
}

/**
 * A conversa que COMEÇA pelo celular também vira lead (issue #2448).
 *
 * ═══ O defeito, medido ═══
 *
 * `garantirLeadDaConversa` só era alcançada por `aplicarEfeitosPosEntrada`
 * (`lib/channels/pos-entrada.ts`), que roda exclusivamente no caminho
 * RECEBIDO. A primeira mensagem que o operador digita no aparelho conectado ao
 * WAHA passa por este handler — grava a conversa, pausa a IA, audita — e saía
 * sem passar pelo funil: a conversa aparecia no CRM e ninguém era dona dela
 * (invariante 4 — "conversa fora do funil não é cobrada por ninguém"). Nada
 * reclamava, que é o pior modo de falhar.
 *
 * ═══ Por que AQUI, e não na pós-entrada ═══
 *
 * Este caminho não chama `aplicarEfeitosPosEntrada`: os três efeitos dela
 * (opt-out, lead, despacho) são de mensagem QUE CHEGA, e o recebido não muda.
 * O que falta aqui é só o nascimento, e ele acontece no MESMO evento em que a
 * conversa nasce — a mesma entrega de webhook, sem esperar a pessoa responder,
 * que é exatamente o que a issue pede.
 *
 * ═══ Idempotência — chamada a cada mensagem, não só na primeira ═══
 *
 * `garantirLeadDaConversa` recusa quando já existe lead aberto para o contato
 * (um por DEMANDA, não por mensagem) e a RPC `fn_nascer_lead_da_conversa`
 * serializa por (organização, contato) e devolve NULL quando já existe um.
 * Conversa que já tinha card não ganha um segundo; conversa antiga que ficou
 * órfã ganha o seu na primeira fala seguinte.
 *
 * ═══ Só contato que NUNCA teve lead ═══
 *
 * Aqui a régua é mais estreita que a do recebido. Lá, lead fechado (ganho ou
 * perdido) + a pessoa volta a escrever = demanda nova, e nasce outro card. Aqui
 * quem fala é o OPERADOR: o código de rastreio depois da venda, o "chegou
 * certinho?" — falar com cliente de lead fechado não é demanda nova. Sem esta
 * guarda, toda mensagem do celular para um contato sem lead ABERTO abria card
 * no funil de entrada e disparava `lead.created`. A issue pede o número que
 * "ainda não tem conversa nem lead"; contato com qualquer lead, aberto ou
 * fechado, não nasce nada por aqui.
 *
 * Best-effort, como todo efeito desta ingestão: a mensagem JÁ está gravada.
 * Uma exceção daqui subiria para o webhook, o WAHA reenviaria tudo, e
 * trocaríamos um lead que não nasceu por uma tempestade de reentrega.
 */
async function nascerLeadDaConversaPeloCelular(
  admin: Admin,
  session: Session,
  contactId: string,
  conversationId: string,
): Promise<void> {
  try {
    const { data: qualquerLead, error: erroDoHistorico } = await admin
      .from("crm_leads")
      .select("id")
      .eq("organization_id", session.organization_id)
      .eq("contact_id", contactId)
      .limit(1)
      .maybeSingle();
    if (erroDoHistorico) throw new Error(erroDoHistorico.message);
    if (qualquerLead) {
      logger.info("waha.ingest: lead nao criado a partir do celular", {
        organization_id: session.organization_id,
        conversation_id: conversationId,
        contact_id: contactId,
        motivo: "contato_ja_tem_lead",
      });
      return;
    }

    const nascimento = await garantirLeadDaConversa(admin, {
      organizationId: session.organization_id,
      contactId,
      conversationId,
      // NÃO o `pushName` do payload: em `fromMe` ele é o do OPERADOR, e o
      // título do card sairia com o nome da loja no lugar do cliente. Quem
      // batiza é o cadastro (`rotuloDoContato`), como no caminho recebido.
      nomeDoContato: null,
      origem: ORIGEM_DO_WHATSAPP_OPERADOR,
    });
    logger.info(
      nascimento.criado
        ? "waha.ingest: lead criado a partir do celular"
        : "waha.ingest: lead nao criado a partir do celular",
      {
        organization_id: session.organization_id,
        conversation_id: conversationId,
        contact_id: contactId,
        ...(nascimento.criado
          ? {
              lead_id: nascimento.leadId,
              pipeline_id: nascimento.pipelineId,
              stage_id: nascimento.stageId,
            }
          : { motivo: nascimento.motivo }),
      },
    );
  } catch (err) {
    logger.error("waha.ingest: nascimento do lead pelo celular falhou (a mensagem entra assim mesmo)", {
      organization_id: session.organization_id,
      conversation_id: conversationId,
      contact_id: contactId,
      error: err instanceof Error ? err.message.slice(0, 120) : "unknown",
    });
  }
}

/**
 * fromMe=true: operador respondeu direto do WhatsApp dele (não pelo composer).
 * Contato = destinatário (`to`). `from` é o próprio número do operador — nunca
 * vira contato. Registrado como outbound p/ o operador ver o histórico completo.
 */
async function handleOutboundFromUserPhone(
  admin: Admin,
  session: Session,
  p: WahaPayload,
  requestId: string,
): Promise<void> {
  // Idem `handleInbound`: o eco de uma reação que o OPERADOR tirou pelo celular
  // também chega por `message.any` com `fromMe`, e a linha certa é a do ramo
  // `message.reaction` (#2797).
  if (ehReacao(p)) return;
  // De onde sai o chat, em ordem de confiança:
  //   1. `to`  — o WEBJS manda; é o destinatário explícito.
  //   2. o id  — `{fromMe}_{chatId}_{bareId}` carrega o chat em qualquer engine.
  //   3. `from`— no NOWEB, mensagem fromMe traz o CHAT em `from` (não o número
  //              do operador, como acontece no WEBJS).
  //
  // O NOWEB (engine padrão do kit) **não manda `to`** aqui. Com `p.to ?? ""` o
  // chatId ficava vazio e a guarda abaixo descartava a mensagem em silêncio —
  // toda mensagem que o dono digitava no celular sumia do CRM, enquanto as
  // enviadas pelo composer e pela IA apareciam (essas nascem no banco antes do
  // webhook, então não dependiam deste caminho). O sintoma era "respondi pelo
  // celular e o CRM não mostra", sem nenhum erro em log: o webhook devolvia 200.
  const chatId = p.to ?? chatIdFromWaMessageId(p.id ?? "") ?? p.from ?? "";
  const parsed = parseChatId(chatId);
  // Grupo NÃO sai aqui: ele segue até o dedup do eco, logo abaixo, e só então
  // desvia. A resposta que o atendente mandou pela inbox volta por este webhook
  // como `fromMe`, e só o dedup por `external_id` a reconhece como nossa.
  const grupo = grupoDoEnvio(p, chatId);
  if (!p.id) return;
  // O pino não tem `body` nem mídia: sem a terceira condição ele era descartado aqui.
  if (!p.body && !localizacaoDoPayload(p) && !mediaUrlOf(p) && !p.hasMedia) return;
  // Idem inbound. Aqui o caso que mais dói é o chatId vazio: é literalmente o
  // defeito do #108 — mensagem que o dono digitou no celular sem `to`, sem id
  // composto e sem `from`. Se voltar a acontecer por um formato novo, agora sai
  // um evento em vez de silêncio.
  //
  // A metade `!chatId` da guarda anterior sai daqui junto: ela era condição
  // MORTA (varri 12 valores de `to` e nenhum a disparava, porque o único falsy
  // já era classificado como grupo uma linha acima) e voltaria a viver como
  // duplicata desta guarda, descartando calado justamente o caso que se quer ver.
  //
  // Grupo pula as duas guardas de endereço: ele não é endereçável como pessoa
  // (e não é anomalia — não emite aviso), e o número interno de avisos é sempre
  // uma pessoa.
  if (!grupo && !ehEnderecavel(parsed)) {
    await avisarChatNaoReconhecido(admin, session.organization_id, session.id, chatId, "outbound");
    return;
  }

  // ── O NÚMERO INTERNO DE AVISOS NÃO VIRA ATENDIMENTO ─────────────────────
  //
  // ANTES do dedup por `external_id` e do `upsertContact`. O aviso sai por
  // TRANSPORTE DIRETO e não grava linha em `messages`, então o reconhecimento
  // de eco não o reconhece como nosso — sem este corte, o próprio aviso que
  // acabou de sair voltaria pelo webhook, viraria conversa com o número do
  // plantão e ainda chamaria `pausarIaPorAtendimentoManual` no fim.
  // ⚠️ `identidadeDoRemetente` e NÃO `parsed`: em modo privacidade o chat chega
  // como `@lid` e `parseChatId` devolve `phone: null` — o número que a
  // organização cadastrou em `config_aviso_de_caso.telefone_destino` só aparece
  // em `_data.key.remoteJidAlt`. Com `parsed` cru o corte só casava pelo
  // `destino_jid`, que é gravado DEPOIS do envio (`registraJidDoAviso`): o
  // primeiro aviso de uma instalação em modo privacidade voltava como contato e
  // conversa com o número do plantão. É a MESMA identidade que o ramo de
  // entrada já passa (`handleInbound`) — os dois lados do aviso perguntam a
  // mesma coisa e precisam perguntar do mesmo jeito.
  if (!grupo && (await ehNumeroInternoDeAviso(admin, session.organization_id, identidadeDoRemetente(p, parsed)))) {
    await registrarMensagemIgnorada(admin, session.organization_id, {
      direction: "outbound",
      sessionId: session.id,
    });
    return;
  }

  // ECO DO PRÓPRIO ENVIO — não duplicar.
  //
  // Toda mensagem que o CRM manda (composer ou IA) volta pelo webhook como
  // `fromMe=true`. O SELECT abaixo é CHECK-THEN-ACT — leitura e depois
  // escrita, sem transação —, então ele só enxerga o mundo de ANTES: se o
  // envio carimbar o `external_id` nesse intervalo, o SELECT não vê e o INSERT
  // roda solto. Fechar a janela é trabalho do `unique (organization_id,
  // external_id)` + da captura do `23505` que este mesmo handler já faz — a
  // mesma rede do inbound.
  //
  // Mas o unique só age se os DOIS lados gravarem a MESMA string. É o que
  // estava errado: o envio grava o id "bare" (`3EB0…`) e este eco chegava com
  // o composto (`true_<chat>_3EB0…`). Strings distintas, nenhuma colisão,
  // `23505` nunca disparava — e nascia a segunda linha com a mesma frase.
  //
  // Por isso o INSERT lá embaixo grava `bare`, não `p.id`: a forma canônica, a
  // mesma que o envio grava e a mesma que `handleAck` e `wahaEchoExternalIds`
  // (que traz o bare nos candidatos) já consultam. `tests/unit/
  // dedup-external-id-waha.test.ts` é a catraca: reprova com o id cru.
  //
  // Antes isto não aparecia por acidente: sem `to`, esta função voltava cedo e
  // o eco era descartado junto com as mensagens legítimas do celular. Ao
  // consertar aquele caminho, a duplicação ficou exposta.
  //
  // Mesmo par de candidatos que o `handleAck` usa — cobre NOWEB (bare) e WEBJS
  // (full) sem depender do engine.
  const bare = externalIdDeSaida(p);
  const idCandidates = bare === p.id ? [p.id] : [p.id, bare];
  const { data: jaRegistrada } = await admin
    .from("messages")
    .select("id")
    .eq("organization_id", session.organization_id)
    .in("external_id", idCandidates)
    .limit(1)
    .maybeSingle();
  if (jaRegistrada) return; // nasceu no envio; quem atualiza o status é o ack

  // O desvio de GRUPO mora aqui, DEPOIS do dedup do eco e ANTES de qualquer
  // efeito da conversa individual (contato, pausa da IA, audit).
  //
  // O eco AINDA EM VOO (linha nossa sem `external_id`) não é recusado aqui, de
  // propósito, igual à conversa individual: `ehEcoDeEnvioNosso` decide só o
  // silêncio da IA, nunca o INSERT — "quem reaproveitar esta condição para pular
  // o INSERT reabre o #108" (ver o bloco no fim desta função).
  if (grupo) {
    await ingerirMensagemDeGrupo(admin, session, p, grupo, "outbound");
    return;
  }

  // fromMe: o pushName do payload é o do OPERADOR, não do destinatário —
  // repassá-lo batizaria o contato do cliente com o nome da loja (e o
  // `coalesce` do fn_upsert_wa_contact congelaria o nome errado).
  //
  // O TELEFONE, ao contrário, vai: aqui `_data.key.remoteJid` é o chat do
  // DESTINATÁRIO, então `remoteJidAlt` é o número do cliente, não o da loja.
  // Medido na produção — inbound 56/56 e outbound 20/20 trazem o campo, e as
  // amostras de outbound mostram o número do cliente. Nome e telefone vêm de
  // lugares diferentes do mesmo payload, e só um deles inverte no envio.
  const contactId = await upsertContact(
    admin,
    session.organization_id,
    parsed,
    chatId,
    null,
    telefoneAlternativoDe(p),
  );
  if (!contactId) return;
  const conversationId = await upsertConversation(admin, session.organization_id, contactId, session.id);
  if (!conversationId) return;

  // O operador também usa o "responder em cima" pelo celular (issue #2474):
  // mesma extração e mesma resolução do inbound, presas a esta conversa.
  const citacao = citacaoDoPayload(p);
  const citadaId = citacao
    ? await resolverMensagemCitada(admin, session.organization_id, conversationId, citacao.id, chatId)
    : null;

  // Comando de controle vindo do celular (`#on`/`#off`). Só a mensagem INTEIRA
  // conta (ver `lib/escalacao/comando-de-canal.ts`). Reconhecer não é aplicar:
  // quem decide se vale é o interruptor do agente, lá embaixo.
  const comando = lerComandoDeControle(bodyOf(p));

  const now = new Date().toISOString();
  const nomeDoArquivo = mediaFilenameOf(p);
  const { data: insertedOutbound, error: insertErr } = await admin
    .from("messages")
    .insert({
      organization_id: session.organization_id,
      conversation_id: conversationId,
      channel_session_id: session.id,
      contact_id: contactId,
      external_id: bare,
      type: resolveMessageType(p),
      direction: "outbound",
      status: "sent",
      ack: p.ack ?? null,
      body: bodyOf(p),
      media_url: mediaUrlOf(p),
      media_mime: mediaMimeOf(p),
      sent_via: "external_device",
      sent_at: dataDoTimestamp(p.timestamp, now),
      reply_to_message_id: citadaId,
      metadata: {
        raw_type: p.type,
        fromMe: true,
        // O pino que o dono mandou do celular: mesma chave do inbound.
        ...(localizacaoDoPayload(p) ? { location: localizacaoDoPayload(p) } : {}),
        // Mesmo critério do inbound: cópia do texto só quando não há ponteiro.
        ...(citacao?.texto && !citadaId ? { reply_to_body: citacao.texto } : {}),
        // Mesma regra do inbound (#2613): nome só quando há nome.
        ...(nomeDoArquivo ? { media_filename: nomeDoArquivo } : {}),
      },
    })
    .select("id")
    .maybeSingle();
  if (insertErr && insertErr.code !== "23505") {
    lancarFalhaDeIngestao("messages.insert outbound", insertErr);
  }
  if (insertErr?.code === "23505") {
    // Mesma razão do inbound: dedup é esperado, invisível não.
    logger.info("waha.ingest: outbound ja ingerido, dedup por external_id", {
      organization_id: session.organization_id,
      // A forma GRAVADA — é ela que a linha existente carimpa e que o grep por
      // `external_id` tem de achar; `p.id` é só o que o webhook entregou.
      external_id: bare,
      direcao: "outbound",
    });
    return;
  }

  // ── A CORRIDA COM O ENVIO — re-checar DEPOIS do insert ─────────────────
  //
  // `jaRegistrada` lê ANTES do insert, e o envio pode gravar o id do canal no
  // meio: entre aquela leitura e o insert desta linha. Medido em 06/10/2026
  // (campanha da Rodaê): o eco chegou 14 s depois do envio, a ingestão levou
  // 4 s, e o envio confirmou dentro dessa janela. As duas guardas erraram ao
  // mesmo tempo — `jaRegistrada` viu a linha sem id, `ehEcoDeEnvioNosso` a viu
  // já `sent` (fora de "em voo") — e a IA foi pausada por 1 h por ter falado.
  // A remoção do lado do envio também não alcançou: ela rodou antes desta
  // linha existir.
  //
  // Aqui o id já está gravado, se o envio confirmou. Achando uma linha NOSSA
  // com ele, esta é eco: sai a duplicata e não há pausa. A linha do envio nunca
  // nasce `external_device`, e o `neq` do id protege a recém-inserida.
  if (insertedOutbound?.id) {
    const { data: nossa } = await admin
      .from("messages")
      .select("id")
      .eq("organization_id", session.organization_id)
      .in("external_id", idCandidates)
      .neq("sent_via", "external_device")
      .neq("id", insertedOutbound.id)
      .limit(1)
      .maybeSingle();
    if (nossa) {
      await admin
        .from("messages")
        .delete()
        .eq("organization_id", session.organization_id)
        .eq("id", insertedOutbound.id);
      logger.info("waha.ingest: eco reconhecido depois do insert (corrida com o envio)", {
        organization_id: session.organization_id,
        external_id: p.id,
      });
      return;
    }
  }

  await markConversation(admin, session.organization_id, conversationId, "outbound", previewFromMessage(p), now);

  // ── CONTROLE DO AUTOMÁTICO NESTA CONVERSA ─────────────────────────────────
  //
  // Três desfechos para uma mensagem `fromMe` que NÃO é eco:
  //   - `#off`         → pausa DURÁVEL (só `#on` ou a tela do CRM religam)
  //   - `#on`          → devolve o atendimento à IA (limpa as 3 travas)
  //   - mensagem normal → pausa (uma pessoa assumiu pelo celular)
  // Os dois comandos e a pausa DURÁVEL da mensagem normal só existem para o
  // agente que ligou "Comandos pelo celular". Desligado (o padrão), nada muda:
  // `#on`/`#off` são texto comum e a pausa tem prazo (`PRAZO_DO_SILENCIO_MS`).
  //
  // ⚠️ A GUARDA DE ECO VEM PRIMEIRO, e a ordem importa. O eco de um envio nosso
  // (composer/IA) chega por este mesmo caminho com `fromMe`, e não pode ser lido
  // como comando nem como "humano assumiu". O `jaRegistrada` acima NÃO basta: o
  // envio grava a linha ANTES de falar com o canal (`status='queued'`,
  // `external_id` NULL), e nessa janela o dedup não casa — o eco chega e esta
  // função concluía "humano assumiu". A tela mostrava "Automático pausado", um
  // estado legítimo que ninguém investiga. (issue #519, consertada no #521)
  //
  // As DUAS decisões que eram uma só se separam aqui, e em direções OPOSTAS de
  // propósito:
  //   gravar a linha   -> tolerante (na dúvida grava; perder mensagem é pior que
  //                                  duplicar — é o #108, que já custou caro)
  //   mexer no automa. -> ESTRITO   (na dúvida NÃO age; calar/ligar a IA por
  //                                  engano é pior que não agir)
  // Quem reaproveitar esta condição para pular o INSERT reabre o #108.
  const ehEco = await ehEcoDeEnvioNosso(admin, session.organization_id, conversationId, p);
  let comandoAplicado: typeof comando = null;
  if (!ehEco) {
    let revogar = true;
    // C-076: o interruptor é do agente que atende ESTA conversa
    // (`ai_agents.config.aceita_comandos_celular`, ligado na tela). FAIL-CLOSED:
    // falha de leitura ⇒ desligado ⇒ o comportamento de antes do recurso.
    const aceita = await agenteAceitaComandoDeCelular(admin, session.organization_id, conversationId);
    comandoAplicado = aceita ? comando : null;
    if (comandoAplicado === "off") {
      await pausarIaDuravelmente(admin, {
        organizationId: session.organization_id,
        conversationId,
        canal: "waha",
        motivo: MOTIVO_COMANDO_OFF,
        avisar: avisarPausaNaoGravada,
      });
    } else if (comandoAplicado === "on") {
      const devolucao = await devolverAtendimentoAoAgente(
        {
          supabase: admin,
          organizationId: session.organization_id,
          actor: { type: "webhook_source", id: session.id },
          requestId,
        },
        { conversationId },
      );
      if (!devolucao.ok) {
        // O `#on` fica VISÍVEL no chat: é o único sinal de que o atendente
        // precisa repetir (ou devolver pela tela).
        revogar = false;
        logger.warn("waha.ingest: #on do celular nao devolveu o atendimento ao agente", {
          organization_id: session.organization_id,
          conversation_id: conversationId,
          erro: devolucao.erro,
          detalhe: devolucao.detalhe,
        });
      }
    } else {
      // #534: o aviso é o que impede a falha da pausa de virar um `false` mudo.
      await pausarIaPorAtendimentoManual(admin, {
        organizationId: session.organization_id,
        conversationId,
        canal: "waha",
        duravel: aceita,
        avisar: avisarPausaNaoGravada,
      });
    }
    // O comando não é fala de atendimento: esconde do cliente depois de aplicar.
    if (comandoAplicado && revogar) await revogarComando(session, chatId, p.id);
  }

  // ── A CONVERSA QUE COMEÇA PELO CELULAR NASCE NO FUNIL (#2448) ────────────
  //
  // Depois da guarda de eco, e SÓ para o que não é eco: o eco é o envio do
  // PRÓPRIO CRM (composer, IA, regra), cuja conversa não nasceu nesta fala do
  // aparelho. Toda a razão — e a idempotência — está em
  // `nascerLeadDaConversaPeloCelular`.
  if (!ehEco) {
    await nascerLeadDaConversaPeloCelular(admin, session, contactId, conversationId);
  }

  await audit({
    action: "message.sent",
    organizationId: session.organization_id,
    resourceType: "message",
    requestId,
    metadata: {
      conversation_id: conversationId,
      type: p.type,
      external_id: p.id,
      from_user_phone: true,
      ...(comandoAplicado ? { control_command: comandoAplicado } : {}),
    },
  });

  if (insertedOutbound?.id && mediaUrlOf(p)) {
    admin
      .rpc("emit_event" as never, {
        p_event_type: "media.persist_requested",
        p_entity_kind: "message",
        p_entity_id: insertedOutbound.id,
        p_payload: { message_id: insertedOutbound.id, conversation_id: conversationId },
        p_metadata: { source: "waha_webhook", request_id: requestId },
        p_organization_id: session.organization_id,
      } as never)
      .then(({ error }) => {
        if (error) console.error("[waha.ingest] emit media.persist_requested failed", error.message);
      });
  }
}

/**
 * O aviso de entrega do WhatsApp só faz o status ANDAR PARA A FRENTE
 * (`sending < sent < delivered < read`), com a mesma régua do canal Zernio
 * (`lib/channels/zernio/ingest.ts`). A rede não garante a ordem dos avisos: uma
 * rajada de avisos antigos, reenviada numa reconexão, não pode fazer uma
 * mensagem lida voltar a "entregue" nem devolver uma enviada a "enviando".
 *
 * A condição mora NO PRÓPRIO UPDATE (`.in("status", …)`), sobre a linha vigente;
 * não se lê o status antes para depois escrever. E as horas de entrega e de
 * leitura são gravadas uma vez só (`is null`). Uma confirmação do aparelho
 * (ACK 2/3) recupera `failed`, como na main: a falha pode ter sido um timeout
 * antes da confirmação. ACK 0/1 não prova entrega e não recupera a falha.
 */
async function handleAck(admin: Admin, session: Session, p: WahaPayload): Promise<void> {
  if (!p.id) return;
  const ack = p.ack ?? 0;
  const now = new Date().toISOString();

  // O ack do WAHA 2026.x vem como `{fromMe}_{chatId}_{bareId}`. O NOWEB grava
  // `external_id` = bareId (id interno), o WEBJS grava o `_serialized` completo.
  // Casar as duas formas cobre ambos os engines sem tocar no external_id de
  // inbound (que é full e sustenta o dedup 23505).
  const bare = bareWaMessageId(p.id);
  const candidates = bare === p.id ? [p.id] : [p.id, bare];

  if (ack < 0) {
    await marcarAckDeErro(admin, session, candidates, now);
    return;
  }

  // ack 0 (pendente) e ausente não mudam o status: `ackToStatus` devolve null.
  const status = ackToStatus(ack);
  if (status === null) return;

  // Estados que ainda estão ANTES do novo na ordem. `failed` também pode
  // avançar para entregue/lida: a confirmação do aparelho prevalece sobre o
  // timeout/erro anterior. O ACK 1, só do servidor, não prova essa entrega.
  const antes =
    status === "read" ? ["sending", "sent", "delivered", "failed"]
    : status === "delivered" ? ["sending", "sent", "failed"]
    : ["sending"];
  const { error } = await admin
    .from("messages")
    .update({ ack, status })
    .eq("organization_id", session.organization_id)
    .in("external_id", candidates)
    .in("status", antes);
  if (error) {
    logger.warn("waha.ingest: aviso de entrega não gravado", { detail: error.message });
  }

  // Ordem das horas: entregue antes de lido. "Lido sem entregue antes" carimba
  // as duas com o mesmo `now`, como o Zernio. Cada coluna só recebe a primeira.
  if (ack >= 2) await carimbarUmaVez(admin, session, candidates, "delivered_at", now);
  if (ack >= 3) await carimbarUmaVez(admin, session, candidates, "read_at", now);
}

async function carimbarUmaVez(
  admin: Admin,
  session: Session,
  candidates: string[],
  coluna: "delivered_at" | "read_at",
  quando: string,
): Promise<void> {
  // Só carimbar depois de a linha avançar: se o UPDATE de status falhou, uma
  // linha ainda `failed` não ganha hora de entrega sem mudar seu estado.
  const { error } = await admin
    .from("messages")
    .update({ [coluna]: quando })
    .eq("organization_id", session.organization_id)
    .in("external_id", candidates)
    .neq("status", "failed")
    .is(coluna, null);
  if (error) {
    logger.warn("waha.ingest: hora do aviso não gravada", { detail: error.message, coluna });
  }
}

/**
 * ack = -1: o WhatsApp recusou a mensagem DEPOIS de o transporte tê-la aceitado.
 * Só vale para o que nunca foi confirmado como entregue (`sending`/`sent`); uma
 * linha `delivered`/`read` não muda e não emite nada, porque o aparelho já
 * confirmou. Vira `failed` com `ack_error` — nunca volta a `sending`, o que
 * faria o `recover-stuck-messages` abrir o alerta "não chegou ao cliente" com a
 * orientação errada. Não abre aviso na Central: a falha aparece na bolha e no
 * evento `message.failed`.
 */
async function marcarAckDeErro(
  admin: Admin,
  session: Session,
  candidates: string[],
  now: string,
): Promise<void> {
  const frase = fraseDaFalhaDeCanal("ack_error");
  const { data, error } = await admin
    .from("messages")
    .update({
      ack: -1,
      status: "failed",
      error_code: "ack_error",
      error_message: frase,
      updated_at: now,
    })
    .eq("organization_id", session.organization_id)
    .in("external_id", candidates)
    .in("status", ["sending", "sent"])
    .select("id, conversation_id, contact_id, sent_via, contacts:contact_id(phone_number)");
  if (error) {
    logger.warn("waha.ingest: aviso de erro não gravado", { detail: error.message });
    return;
  }
  // Só as linhas que ESTE aviso marcou: emitir por linha que não casou seria
  // evento de uma falha que não aconteceu aqui.
  const linhas = (data ?? []) as unknown as Array<{
    id: string;
    conversation_id: string | null;
    contact_id: string | null;
    sent_via: string | null;
    contacts: EmbedDoContato;
  }>;
  for (const linha of linhas) {
    await emitirFalhaDeEntrega(admin, {
      organizationId: session.organization_id,
      source: "waha-ack-webhook",
      falha: {
        message_id: linha.id,
        conversation_id: linha.conversation_id,
        contact_id: linha.contact_id,
        contact: telefoneDoEmbed(linha.contacts),
        sent_via: linha.sent_via,
        erro: { codigo: "ack_error", titulo: frase },
      },
    });
  }
}

/**
 * O controle de ritmo já não põe teto de aquecimento neste número? Lê os knobs
 * da conexão e aplica `numeroJaAquecido`. Falha de leitura conta como "ainda
 * aquecendo": a marca errada para o lado seguro é a que diz "não".
 */
async function aquecimentoTerminou(admin: Admin, session: Session): Promise<boolean> {
  const { data, error } = await admin
    .from("channel_knobs")
    .select("number_activated_at, warmup_daily_caps")
    .eq("organization_id", session.organization_id)
    .eq("channel_session_id", session.id)
    .maybeSingle();
  if (error) {
    logger.warn("waha.ingest: não li os knobs para concluir o aquecimento", {
      detail: error.message,
    });
    return false;
  }
  return numeroJaAquecido(data);
}

interface SessionStatusRow extends Session {
  is_warmup_complete: boolean | null;
  warmup_started_at: string | null;
}

async function handleSessionStatus(
  admin: Admin,
  session: SessionStatusRow,
  p: WahaPayload,
): Promise<void> {
  const status = (p.status ?? "").toUpperCase() || null;
  if (!status) return;
  const allowed = new Set(["STARTING", "SCAN_QR_CODE", "WORKING", "STOPPED", "FAILED"]);
  if (!allowed.has(status)) return;
  const now = new Date().toISOString();

  // ─── ANTES de gravar: esta queda é de um NÚMERO ou de um QR? (#3416) ──────
  //
  // A leitura já existia, mas vinha depois do UPDATE e servia só para o apelido.
  // Ela é que decide os dois ramos novos: sem `phone_number`, o `FAILED` não é
  // conexão fora do ar — é o código expirando na tela de quem nunca pareou, e a
  // linha não deve virar uma sessão `FAILED` que ocupa o número do plano, puxa
  // a faixa vermelha "WhatsApp sem nome" e abre crítico na Central.
  //
  // `null` SÓ quando a leitura veio e a coluna estava vazia. Leitura falhada ou
  // coluna fora do select → `undefined` → nada muda aqui: sem descarte, e o
  // aviso de sempre (quem não sabe o número não silencia conexão nenhuma).
  const { data: linha, error: erroDaLeitura } = await admin
    .from("channel_sessions")
    .select("display_name, phone_number")
    .eq("organization_id", session.organization_id)
    .eq("id", session.id)
    .maybeSingle();
  const numero =
    erroDaLeitura || !linha || !("phone_number" in linha)
      ? undefined
      : (linha.phone_number as string | null);
  const apelido =
    (linha?.display_name as string | null | undefined) ??
    (linha?.phone_number as string | null | undefined) ??
    "sem nome";

  if (numero === null && status === "FAILED") {
    // O QR expirou sem parear. Arquivar em vez de gravar: a linha sai do
    // contador do plano, sai da faixa, não vira `FAILED` na tela — e o descarte
    // fecha o episódio que a própria sessão pode ter aberto.
    //
    // Só `FAILED` (a expiração medida na #3416). `STOPPED` continua sendo o de
    // sempre: parar é decisão de quem opera, não fim de QR.
    const desfecho = await descartarSessaoQueNuncaPareou(admin, {
      id: session.id,
      organization_id: session.organization_id,
      status,
      phone_number: numero,
    });
    if (desfecho === "arquivada") {
      logger.info("waha.ingest: sessão que nunca pareou foi arquivada ao expirar", {
        channel_session_id: session.id,
        organization_id: session.organization_id,
        status,
      });
      return;
    }
    // `em_execucao` (reserva viva) ou `preservada`: o evento segue o caminho de
    // sempre, que ainda assim não abre aviso — sessão sem número não avisa.
  }

  const update: Record<string, unknown> = { status, last_status_change_at: now };
  if (status === "WORKING" && !session.is_warmup_complete) {
    if (!session.warmup_started_at) {
      // O escritor que a spec 03 descreveu e nunca foi implementado (#2432): a
      // PRIMEIRA transição para `WORKING` marca o início do aquecimento
      // (`docs/specs/03-spec-whatsapp-waha.md`, §5.3). Só enquanto o aquecimento
      // não terminou: gravar isto depois do fim deixaria o início posterior a ele.
      update.warmup_started_at = now;
    }
    // O FIM não é "a próxima WORKING": essa chega na primeira reconexão ou no
    // primeiro restart do WAHA, às vezes no mesmo minuto, e marcaria aquecido um
    // número de um dia (#2432). A régua é a do controle de ritmo
    // (`numeroJaAquecido`): idade declarada do número ou "pular o aquecimento".
    //
    // Só `warmup_completed_at`: `is_warmup_complete` é `GENERATED ALWAYS AS
    // (warmup_completed_at IS NOT NULL)`, e atribuir a ela abortava o UPDATE
    // INTEIRO — inclusive o `status`, e o espelho do canal congelava sem erro.
    //
    // Limites conhecidos: a marca só anda num `session.status` WORKING; um número
    // que fica conectado sem reconectar a recebe atrasada (erra para o lado
    // seguro). E, gravada, ela não volta (o ramo só roda com `!is_warmup_complete`):
    // desligar "pular o aquecimento" depois deixa a marca "sim" com o ritmo de
    // volta a limitar — erro para o lado INSEGURO. Por isso nada no envio a lê; quem
    // decide envio calcula na leitura com a mesma função.
    if (await aquecimentoTerminou(admin, session)) update.warmup_completed_at = now;
  }
  const atualizar = admin.from("channel_sessions").update(update)
    .eq("organization_id", session.organization_id).eq("id", session.id);
  if (numero === null && status === "FAILED") {
    // O descarte pode ter perdido a corrida para o pareamento. O fallback
    // também não pode sobrescrever WORKING/número novo com o FAILED antigo.
    const { data: atualizadas, error } = await atualizar
      .is("phone_number", null).is("archived_at", null).neq("status", "WORKING").select("id");
    if (error || !atualizadas || atualizadas.length !== 1) return;
  } else {
    await atualizar;
  }

  // ─── E agora alguém precisa SABER ────────────────────────────────────────
  //
  // Até aqui esta função gravava o estado numa coluna e não contava a ninguém.
  // Foi assim que uma desconexão real passou horas despercebida: o evento
  // chegou, a coluna atualizou, e o dono só descobriu ao estranhar que ninguém
  // escrevia. O estado certo no lugar que ninguém olha não vale nada.
  //
  // O `numero` vai junto: é o que separa conexão de QR nunca pareado dentro do
  // próprio seam de aviso (#3416) — e ele é lido ANTES do UPDATE, de propósito,
  // porque é a leitura que decide o ramo do descarte logo acima.
  await sincronizarSaudeDaConexao(
    admin,
    { id: session.id, organization_id: session.organization_id, status, phone_number: numero },
    // Veio do próprio transporte: se ele conseguiu nos contar, está alcançável.
    { reachable: true, status, detail: null },
    apelido,
  );
}

/**
 * As formas em que a linha da mensagem ORIGINAL (a editada ou apagada) pode ter
 * sido gravada em `messages.external_id`.
 *
 * ─── Por que o id do evento, sozinho, não acha a mensagem recebida ──────────
 *
 * `editedMessageId`/`revokedMessageId` chegam só com a CAUDA do id do WhatsApp
 * (o bare, ex.: `3EB0…`), enquanto a mensagem RECEBIDA é gravada COMPLETA —
 * `false_<chat>_<bare>`, o mesmo `p.id` que o webhook de entrada entregou. Casar
 * só pelo id do evento acha a mensagem que NÓS mandamos (o envio grava o bare),
 * mas nunca a que o cliente mandou: a linha recebida fica intocada e o evento
 * "some" sem erro nenhum — o texto velho permanece na tela.
 *
 * O `chat` e o `fromMe` do PRÓPRIO evento reconstroem o composto. O prefixo é o
 * mesmo do id do WAHA (`false_` para recebida, `true_` para enviada) e o chat
 * vem de `payload.from`; assim o casamento EXIGE o chat — a mensagem de outra
 * conversa tem outro composto e NÃO é tocada. A cauda preserva o caminho da
 * mensagem enviada, que já funcionava (a linha do envio é bare).
 */
function formasDaMensagemOriginal(
  idDoEvento: string,
  chat: string,
  fromMe: boolean | null | undefined,
): string[] {
  const bare = bareWaMessageId(idDoEvento);
  const prefixo = fromMe ? "true" : "false";
  // `idDoEvento` entra como veio (para o engine que já manda o composto), `bare`
  // cobre o envio nosso, e a composta cobre a mensagem recebida.
  const composta = chat ? `${prefixo}_${chat}_${bare}` : null;
  return [
    ...new Set([idDoEvento, bare, ...(composta ? [composta] : [])].filter((f) => f.length > 0)),
  ];
}

/**
 * O autor editou a mensagem no aplicativo.
 *
 * O corpo é SOBRESCRITO, e não versionado: o que o CRM mostra tem que ser o que
 * o cliente vê agora. Guardar as versões anteriores é outra feature (histórico
 * de edição), com tela e retenção próprias — fazê-la pela metade acumularia
 * dado pessoal num campo que ninguém mostra e que a anonimização não conhece.
 *
 * `editedMessageId` é o id da mensagem ORIGINAL; o `id` do payload é o do
 * evento de edição. Casar pelo `id` não acharia nada — e o silêncio pareceria
 * "funcionou", que é exatamente o modo de falha que este arquivo já pagou caro
 * em outros lugares. O alvo é resolvido em `formasDaMensagemOriginal` porque o
 * id do evento traz só a cauda, não o composto com que a recebida foi gravada.
 */
async function handleMessageEdited(
  admin: Admin,
  session: Session,
  p: WahaPayload,
): Promise<void> {
  if (!p.editedMessageId) return;
  const corpo = typeof p.body === "string" ? p.body : null;
  if (corpo === null) return;

  await admin
    .from("messages")
    .update({ body: corpo, edited_at: new Date().toISOString() })
    .eq("organization_id", session.organization_id)
    .in("external_id", formasDaMensagemOriginal(p.editedMessageId, p.from ?? "", p.fromMe));
}

/**
 * O autor apagou a mensagem ("apagar para todos").
 *
 * A linha NÃO é removida: sumir com ela apagaria o contexto das vizinhas — uma
 * resposta passaria a responder ao nada — e o histórico de quem atendeu. O
 * corpo também não é limpo aqui: quem decide o que MOSTRAR é a régua da tela
 * (`lib/messaging/apagada.ts`), e apagar o texto no banco destruiria o registro
 * do que foi combinado — que é justamente o que o pedido de LGPD precisa
 * alcançar.
 *
 * A revogação que chega por AQUI é a que ninguém mais registra: quem apaga no
 * aplicativo não passa por tela nenhuma, então sem esta trilha um pedido de
 * LGPD/RGPD pergunta "quando e como esta mensagem foi apagada" e não há
 * resposta. Vai para a auditoria o EVENTO (ids, direção e o fato de o conteúdo
 * estar preservado) — nunca o corpo, que `api_audit_log` guardaria por 5 anos
 * fora do alcance da cascata de anonimização.
 */
async function handleMessageRevoked(
  admin: Admin,
  session: Session,
  p: WahaPayload,
  requestId: string,
): Promise<void> {
  if (!p.revokedMessageId) return;

  // Forma de WAMessageRevokedBody / session.noweb.core.ts do WAHA; nenhum payload real de revoked medido nesta instalação.
  // O chat vem em `after` (a protocolMessage de quem apagou), não no topo; o topo fica primeiro para não regredir num engine que mande flat.
  const depois =
    (p as { after?: { from?: unknown; fromMe?: unknown; id?: unknown } | null }).after ?? null;
  const chat =
    (typeof p.from === "string" && p.from) ||
    (typeof depois?.from === "string" ? depois.from : "") ||
    (typeof depois?.id === "string" ? (chatIdFromWaMessageId(depois.id) ?? "") : "");
  const fromMe =
    typeof p.fromMe === "boolean"
      ? p.fromMe
      : typeof depois?.fromMe === "boolean"
        ? depois.fromMe
        : undefined;

  const { data: apagadas, error: erroDoCarimbo } = await admin
    .from("messages")
    .update({ revoked_at: new Date().toISOString() })
    .eq("organization_id", session.organization_id)
    .in("external_id", formasDaMensagemOriginal(p.revokedMessageId, chat, fromMe))
    // Só a linha que AINDA não estava apagada. O WAHA ecoa o que o próprio
    // número faz: apagar pela tela do CRM carimba a linha na rota (e audita
    // `message.revoked`, com o ator) e, depois, o eco `message.revoked` chega
    // aqui. Sem este filtro o eco recarimbava a hora e gravava uma segunda
    // trilha dizendo que alguém apagou pelo celular — numa tabela append-only.
    // Pelo mesmo filtro, a reentrega do mesmo evento não duplica a trilha.
    .is("revoked_at", null)
    .select("id, conversation_id, direction, external_id");
  if (erroDoCarimbo) {
    // Sem este ramo o evento "some" em silêncio e a mensagem apagada segue
    // legível como se ninguém tivesse apagado — o modo de falha que este arquivo
    // já pagou caro em outros lugares.
    logger.warn("[waha] message.revoked não carimbou a linha", {
      request_id: requestId,
      error: erroDoCarimbo.message,
    });
    return;
  }
  // Uma trilha por linha carimbada AGORA: `direction` diz quem apagou (recebida
  // é o contato; enviada é o próprio número, apagando pelo aplicativo do
  // WhatsApp). O eco de um apagar feito pela tela não chega até aqui, porque a
  // rota já carimbou a linha. Limite conhecido: se o eco chegar ANTES de a rota
  // carimbar (a rota carimba logo depois de o WAHA responder), sai uma trilha
  // aqui além da `message.revoked` da rota — a do ator vem dela.
  for (const linha of apagadas ?? []) {
    await audit({
      action: "message.revoked_in_channel",
      organizationId: session.organization_id,
      resourceType: "message",
      resourceId: linha.id,
      requestId,
      metadata: {
        conversation_id: linha.conversation_id,
        direction: linha.direction,
        external_id: linha.external_id,
        conteudo_preservado: true,
      },
    });
  }
}

/**
 * Quem REAGIU — a chave que a remoção usa para apagar SÓ o gesto deste autor.
 */
function autorDaReacao(p: WahaPayload): string | null {
  // Em GRUPO o autor vem no `participant` (a mesma doutrina de
  // `remetenteDoGrupo`: sender é o participant, nunca o `from`, que em grupo é
  // o próprio grupo). No 1:1 ele é o `from` — e, em `fromMe`, o NOWEB manda o
  // CHAT em `from`, não o operador. O outro autor de uma conversa é sempre o
  // MESMO número conectado, então a etiqueta `me` o identifica sem depender de
  // `me.id`, que o payload do webhook não traz.
  if (p.fromMe === true) return "me";
  return primeiroTexto(p.participant, p.from);
}

/**
 * `message.reaction` — a reação vira linha LIGADA ao alvo, e nada mais (#2797).
 *
 * O que esta função DELIBERADAMENTE não faz: não chama `markConversation`
 * (last_message_at, janela de 24h, prévia, contador de não lidas), não chama
 * `aplicarEfeitosPosEntrada` (opt-out, lead, despacho do agente) e não audita
 * `message.received`. Um 👍 não é fala: carimbá-lo como mensagem nova é o
 * defeito medido — o cliente que reagia à proposta era cobrado pelo follow-up
 * de silêncio. Ver `lib/channels/reacao.ts`, o contrato comum dos dois canais.
 *
 * Falha de banco NÃO vira descarte: sobe como transitória, a rota devolve 503
 * e o WAHA reentrega — a reentrega é segura porque a idempotência é pela chave
 * externa da própria reação.
 */
async function handleReaction(admin: Admin, session: Session, p: WahaPayload): Promise<void> {
  const miolo = objetoSimples(p.reaction);
  const alvoId = textoNaoVazio(miolo?.messageId);
  const externalId = textoNaoVazio(p.id);
  const autor = autorDaReacao(p);
  if (!externalId || !alvoId || !autor) {
    // Sem alvo não há o que ligar — descarte visível, nunca linha cega.
    logger.warn("waha.ingest: reação sem id de alvo ou sem autor — descartada", {
      organization_id: session.organization_id,
      external_id: externalId,
      alvo: alvoId,
    });
    return;
  }

  // O chat do alvo, para montar as formas em que o `external_id` dele pode
  // estar gravado: o envio grava a CAUDA, o eco do próprio aparelho traz o
  // composto, e o chat pode vir em outro formato (`@lid` × `@c.us`) — o mesmo
  // par de candidatos do `handleAck` e da citação, mais o sufixo `_<cauda>`.
  const chat = p.to ?? chatIdFromWaMessageId(externalId) ?? p.from ?? "";
  const cauda = bareWaMessageId(alvoId);
  const desfecho = await gravarReacaoDeMensagem(admin as unknown as SupabaseClient, {
    organizationId: session.organization_id,
    channelSessionId: session.id,
    externalId,
    alvoExternalId: alvoId,
    candidatosDoAlvo: wahaEchoExternalIds(alvoId, chat),
    // Mesmo teto da resolução da citada: abaixo de 16 caracteres o LIKE
    // casaria outra mensagem por coincidência em vez de achar esta.
    sufixoDoAlvo: cauda.length >= 16 ? `_${cauda}` : null,
    // `text` vazio é a REMOÇÃO do gesto — o caso que apaga a linha anterior.
    emoji: typeof miolo?.text === "string" ? miolo.text.trim() : null,
    author: autor,
    direction: p.fromMe === true ? "outbound" : "inbound",
    sentAt: dataDoTimestamp(p.timestamp, new Date().toISOString()),
    sentVia: "external_device",
    canal: "waha",
  });

  if (desfecho.status === "falha") {
    lancarFalhaDeIngestao("messages.insert reaction", {
      // `?? undefined` porque `ErroDoBanco.code` é opcional e não-anulável —
      // `null` não compila no slot, e o `?? ""` de lá trataria igual.
      code: desfecho.codigo ?? undefined,
      message: desfecho.motivo,
    });
  }
}

/**
 * Roteador único de eventos WAHA. Os dois route handlers convergem aqui após
 * resolver a sessão e validar HMAC.
 */
export async function dispatchWahaEvent(
  admin: Admin,
  session: SessionStatusRow,
  envelope: WahaEnvelope,
  requestId: string,
): Promise<void> {
  const eventType = envelope.event ?? "unknown";
  const payload: WahaPayload = envelope.payload ?? {};

  if (eventType === "message" || eventType === "message.any") {
    if (payload.fromMe) {
      await handleOutboundFromUserPhone(admin, session, payload, requestId);
    } else {
      await handleInbound(admin, session, payload, requestId);
    }
  } else if (eventType === "message.ack") {
    await handleAck(admin, session, payload);
  } else if (eventType === "message.edited") {
    await handleMessageEdited(admin, session, payload);
  } else if (eventType === "message.revoked") {
    await handleMessageRevoked(admin, session, payload, requestId);
  } else if (eventType === "message.reaction") {
    await handleReaction(admin, session, payload);
  } else if (eventType === "session.status" || eventType === "state.change") {
    await handleSessionStatus(admin, session, payload);
  }
}
