import { assertProspectingDelivery } from "@/lib/prospecting/guard";
import { assertAgentOperationSupabase, mesmaOperacao } from "@/lib/ai/agents/operation";
import {
  assertApprovedReplySupabase,
  recordApprovedReplyReceiptSupabase,
  ApprovedReplyReceiptPersistenceError,
  prepareApprovedReplySupabase,
} from "@/lib/ai/replies/delivery";
import { assertMeetingDeliverySupabase } from "@/lib/agenda/meet-delivery";
import { AgendaDeferredError } from "@/lib/agenda/protecao-followup";
import { AgendaPuladoError, MOTIVO_DO_PULO } from "@/lib/agenda/escolha-no-envio";
import { assertAgendaEffectSupabase, guardAgendaEffect } from "@/lib/agenda/efeito";
import { CHAVE_DO_PULO, retratoDoPulo } from "@/lib/inbox/pulo-da-agenda";
import { mesmaFronteira, StaleServiceBoundaryError } from "@/lib/atendimento/fronteira";
import { assertServiceBoundarySupabase } from "@/lib/atendimento/origem";
import {
  currentExecutionAgentOperation,
  currentExecutionBoundary,
  guardServiceEffect,
} from "@/lib/atendimento/fronteira-server";
import { logger } from "@/lib/logger";
/**
 * Core handlers para messages (list + send).
 *
 * Reusados por:
 *  - POST /api/v1/messages (sendMessageHandler)
 *  - GET  /api/v1/conversations/[id]/messages (listMessagesHandler)
 *  - MCP tools (S-13.04)
 */
import type { SupabaseClient } from "@supabase/supabase-js";

import { ApiError } from "@/lib/api/types";
import { decidirPreGoLiveDoCanalViaSupabase } from "@/lib/ai/elegibilidade/consulta-pre-go-live";
import type { Actor, HandlerCtx } from "@/lib/api/handlers/types";
import { audit, isServiceRoleConfigured } from "@/lib/audit";
import { traduzir } from "@/lib/i18n/dicionario";
import { assertOrgOperante } from "@/lib/organizacao/operante";
import {
  CHANNEL_SESSION_REF_COLUMNS,
  DEFAULT_CHANNEL_PROVIDER,
  getAdapter,
  resolveSessionRef,
  type ChannelSessionRef,
} from "@/lib/channels";
import { ARCHIVED_AT, queryTolerantToMissingArchived } from "@/lib/channels/archived";
import { canalDesativado } from "@/lib/channels/desativado";
import { botoesDaDefinicao } from "@/lib/channels/botoes-da-definicao";
import { conferirDefinicao } from "@/lib/channels/conferir-definicao";
import { CHAVE_DOS_BOTOES_DO_MODELO } from "@/lib/messaging/botoes-do-modelo";
import { estadoDaJanela } from "@/lib/channels/janela";
import {
  CHAVE_DO_NOME_NA_METADATA,
  nomeDeArquivoLimpo,
  nomeDoAnexoParaEnviar,
} from "@/lib/messaging/media/nome-de-arquivo";
import { isMediaPathOwnedBy } from "@/lib/messaging/media/upload-validation";
import { assertUrlDeMidiaSegura } from "@/lib/messaging/media/url-de-midia-externa";
import {
  nomeDeArquivoSeguro,
  nomeDoDocumentoParaEnvio,
} from "@/lib/messaging/media/nome-para-envio";
import { semChavesDaDerivacao } from "@/lib/messaging/media/leitura-adiada";
import {
  buildVcard,
  normalizePhoneForDisplay,
  parseDialablePhone,
  phoneToWhatsappId,
} from "@/lib/messaging/contact-card";
import type { ListMessagesQuery, SendMessageInput } from "@/lib/schemas";
import { sendTemplateForSession } from "@/lib/channels/meta/send-template-for-session";
import {
  CODIGO_NUMERO_DE_OUTRO,
  conversaQueContinua,
  MENSAGEM_DO_NUMERO,
  mensagemDaContinua,
  quadroDoNumero,
  vereditoDoNumero,
} from "@/lib/channels/numero-do-atendente";
import { emitirFalhaDeEntrega } from "@/lib/messaging/falha-de-entrega";
import { previaDaMensagem } from "@/lib/messaging/previa-da-mensagem";
import { pedirReenvioDoDesacelerado, reenvioDesaceleradoAtende } from "@/lib/messaging/reenvio-desacelerado";
import { esperaPedidaPeloCanal } from "@/lib/channels/erro-da-plataforma";
import { aplicarAssinatura, configAssinatura, linhaDeAssinatura } from "@/lib/messaging/assinatura";
import { nomeDoAgenteDeIA } from "@/lib/messaging/nome-do-agente-de-ia";
import { nomeDoContato } from "@/lib/contacts/rotulo-do-contato";
import { nomesDosAtendentes } from "@/lib/users/nome-do-atendente";
import { createAdminClient } from "@/lib/supabase/admin";
import { enderecoDaUltimaRecebida } from "@/lib/channels/endereco-da-ultima-recebida";
import type { Message } from "@/lib/types/messaging";

type SB = SupabaseClient;

/**
 * Remove a linha que o WEBHOOK criou para a mensagem que ESTE envio acabou de
 * mandar — o "eco do próprio envio".
 *
 * A janela: a linha do envio nasce antes de falar com o canal (`queued`,
 * `external_id` NULL) e só recebe o id depois que o adapter volta. Todo envio
 * retorna pelo webhook como `fromMe=true`; o eco que chega nesse intervalo não
 * acha nada para casar e vira uma segunda linha com a mesma frase.
 *
 * POR QUE AQUI E NÃO NO WEBHOOK. Lá, a única coisa disponível para casar seria a
 * própria linha `queued` — e ela não carrega nada que a identifique como sendo
 * daquela mensagem. Casar por ela é casar por "existe um envio em voo nesta
 * conversa", o que vale para o eco e também para uma mensagem legítima que o
 * atendente digitou no celular enquanto o envio estava em voo: medido, esperado
 * 2 mensagens e obtido 1, com a do celular descartada. Seria o defeito do #108
 * de volta — e permanente, porque nada tira uma linha de `queued`.
 *
 * ATUALIZAÇÃO (issue #129): o cron `recover-stuck-messages` passou a existir
 * (`app/api/v1/cron/recover-stuck-messages/route.ts`), mas ele cobre `sending`,
 * não `queued` — e a diferença é deliberada. `queued` é estado de espera com
 * DONO: o watchdog redirige (no transporte por QR), e a mensagem que o canal
 * mandou desacelerar volta pelo reenvio desacelerado
 * (`lib/messaging/reenvio-desacelerado.handler.ts`).
 * Um cron marcando `queued` como falha depois de 5 min brigaria com essa
 * retentativa e perderia mensagem que ia sair. `sending` não tem dono nenhum —
 * é ali que a linha morre em silêncio.
 *
 * Aqui não há ambiguidade: o canal acabou de devolver o id EXATO da mensagem que
 * mandamos. Casa por id, nunca por proximidade.
 *
 * QUAIS formas o mesmo id pode ter é conhecimento do CANAL, não de quem envia —
 * então os candidatos chegam prontos, de `adapter.echoExternalIds`. Um canal
 * simétrico não implementa o método e o chamador cai no próprio `externalId`.
 *
 * O escopo é deliberadamente estreito — mesma conversa e só o que nasceu de
 * `external_device`. O bare pode colidir entre mensagens diferentes (garantia do
 * WhatsApp, não nossa); restringir mantém o estrago de uma colisão dentro do
 * único lugar onde ela seria de fato a nossa mensagem, e impede de apagar linha
 * do próprio CRM.
 */
async function removerEcoDoProprioEnvio(
  supabase: SB,
  organizationId: string,
  conversationId: string,
  minhaLinhaId: string,
  externalId: string | null,
  candidatos: string[],
): Promise<void> {
  if (!externalId || candidatos.length === 0) return;

  // BLINDADO DE PROPÓSITO. Esta chamada roda dentro do `try` do envio, e a
  // mensagem JÁ SAIU quando chegamos aqui: deixar uma exceção subir faria o
  // `catch` de baixo marcar como `failed` uma mensagem que o cliente recebeu —
  // trocar uma duplicata visível por um status mentiroso. O pior caso aceitável
  // é não conseguir remover, que é exatamente o mundo de antes desta função.
  try {
    const { error } = await supabase
      .from("messages")
      .delete()
      .eq("organization_id", organizationId)
      .eq("conversation_id", conversationId)
      .eq("sent_via", "external_device")
      .in("external_id", candidatos)
      // ⚠️ SEGUNDA CAMADA, SEM COBERTURA POSSÍVEL — escrito porque medi: trocar
      // este `neq` por um que nunca casa deixa a suíte VERDE. O filtro de
      // `sent_via` acima já exclui a linha deste envio (que nasce `user`, `ai`,
      // `automation` ou `system`, nunca `external_device`), então nenhum teste
      // alcança esta cláusula.
      // Fica porque o desfecho que ela impede é o pior que esta função poderia
      // produzir: apagar a própria mensagem que acabou de ser entregue. Quem
      // mexer no filtro de cima não vai ser avisado por teste nenhum.
      .neq("id", minhaLinhaId);
    if (error)
      console.error("[messages.send] não consegui remover o eco do próprio envio", error.message);

    // O LIMITE CONHECIDO de `wahaEchoExternalIds` (@lid × @c.us): o composto que
    // construímos usa o chat do ENVIO, e o NOWEB pode ecoar com o outro formato
    // do mesmo contato — medido em 06/10/2026: envio para `…@lid`, eco
    // `true_5513…@c.us_3EB0…`. O id bare do WhatsApp é único (20+ caracteres
    // aleatórios), então casar pelo SUFIXO `_<bare>` alcança qualquer formato
    // de chat sem alcançar outra mensagem. Só para id composto (`<fromMe>_<chat>_<id>`) do canal.
    const bare = externalId.slice(externalId.lastIndexOf("_") + 1);
    if (bare.length >= 16 && candidatos.some((c) => c.startsWith("true_"))) {
      const { error: erroSufixo } = await supabase
        .from("messages")
        .delete()
        .eq("organization_id", organizationId)
        .eq("conversation_id", conversationId)
        .eq("sent_via", "external_device")
        .like("external_id", `%\\_${bare}`)
        .neq("id", minhaLinhaId);
      if (erroSufixo)
        console.error("[messages.send] não consegui remover o eco por sufixo", erroSufixo.message);
    }
  } catch (err) {
    console.error(
      "[messages.send] a remoção do eco lançou",
      err instanceof Error ? err.message : err,
    );
  }
}

/**
 * De quem é esta linha, no vocabulário de `messages.sent_via`.
 *
 * A pergunta era UMA só (`!== "user"`), e por isso a automação se apresentava
 * como IA: tudo que não era pessoa saía `'ai'`, inclusive um template fixo de
 * regra — sem IA nenhuma no caminho. A decisão do mantenedor na #652 é
 * categoria própria para "nem pessoa nem IA", e `'automation'` é o valor que o
 * CHECK de `messages.sent_via` já aceitava e que o balão sabe nomear.
 *
 * `api_token` (integração com token de servidor) segue `'ai'` de propósito: a
 * #866 decide o valor daquele caminho, e trocar aqui sem aquele PR misturaria
 * duas decisões numa linha.
 *
 * Quem lê estes valores: o filtro de eco da ingestão do canal (a lista anda
 * junto), o resgate da fila (`session-reconciler.ts`), a métrica de atrito e o
 * rótulo do balão (`components/inbox/MessageBubble.tsx`).
 */
export function origemDaMensagem(actor: Actor): "user" | "ai" | "automation" | "system" {
  if (actor.type === "user") return "user";
  // TOKEN DE SERVIDOR é integração, não IA (#866): quem manda é um sistema de
  // fora, e chamar isso de "IA" inflava o número do agente no painel e punha o
  // rótulo errado no balão. Um mecanismo só decide os quatro valores — quando
  // eram dois (uma função e um mapa), o mesmo contrato tinha duas verdades.
  if (actor.type === "api_token") return "system";
  // A regra dispara, mas nem sempre ESCREVE. A ação "Mensagem escrita pela IA"
  // manda texto de um agente publicado com este mesmo ator, e a decisão da #652
  // é por AUTORIA: ali a linha é da IA. Decidir só pelo tipo do ator carimbaria
  // "Automação" no balão e tiraria a mensagem de `envios_por_ia`.
  if (actor.type === "webhook_source") {
    // Os dois retornos são LITERAIS de propósito: `rotulo-de-origem-tem-emissor`
    // lê o corpo desta função e conta como emissor cada literal devolvido, para
    // saber quais rótulos o motor de fato produz. Escrito como ternário, o gate
    // deixa de enxergar `automation` e acusa a tela de prometer uma distinção
    // que ninguém grava — foi o que aconteceu na primeira versão deste conserto.
    // (E o comentário não pode conter a forma que o extrator procura: a segunda
    // versão trazia um exemplo literal aqui, e o gate o leu como emissor real.)
    if (actor.textoEscritoPelaIA) return "ai";
    return "automation";
  }
  return "ai";
}

const MSG_COLS =
  "id, organization_id, conversation_id, channel_session_id, contact_id, external_id, type, direction, status, ack, error_code, error_message, body, media_url, media_mime, media_size_bytes, media_storage_path, media_derived_text, media_derived_status, sent_via, sent_by_user_id, sent_on_behalf_of_user_id, sent_at, delivered_at, read_at, metadata, edited_at, revoked_at, reply_to_message_id, created_at, template_name, template_language, updated_at";

/**
 * `Actor.type` → o vocabulário de `messages.sent_via` (o CHECK da coluna:
 * 'crm', 'external_device', 'automation', 'ai', 'user', 'system').
 *
 * ⚠️ O TOKEN DE SERVIDOR NÃO É A IA — e o mapa é `Record<Actor["type"], …>` de
 * propósito. O ternário que vivia aqui (`actor.type === "user" ? "user" : "ai"`)
 * dizia `ai` para TUDO que não fosse pessoa, então uma variante NOVA de `Actor`
 * caía nesse `ai` sem ninguém decidir nada: foi assim que o envio de uma
 * integração passou a ser contado como fala da IA (issue #866) — a leitura de
 * `por_ia` no baseline conta exatamente `sent_via = 'ai'`, e a ingestão de canal
 * tratava a linha como envio NASCIDO aqui (álibi de eco que só a IA e o humano
 * merecem). Com o `Record`, variante nova de `Actor` não COMPILA até alguém
 * escrever a autoria dela — o defeito deixa de ser possível por omissão.
 *
 * `webhook_source` continua `ai`: é divergência CONHECIDA das outras escalas de
 * autoria do repo (`actorParaAtividade`, `especieDe` e `autorParaTimeline` mandam
 * tudo que não é pessoa nem agente para `system`), porque a automação hoje se
 * apresenta como IA no balão da conversa e mover o valor dela mexe no dedup de
 * eco e nas telas que contam "quanto a IA falou". Decisão de produto registrada
 * em `components/inbox/MessageBubble.tsx`, com issue própria.
 */
function actorAuditPayload(actor: Actor): {
  actorUserId: string | null;
  metadataActor: Record<string, unknown>;
} {
  if (actor.type === "user") {
    return { actorUserId: actor.id, metadataActor: { actor_type: "user" } };
  }
  return {
    actorUserId: null,
    metadataActor: {
      actor_type: actor.type,
      actor_id: actor.id,
      ...(actor.type === "ai_agent" && actor.api_token_id
        ? { actor_api_token_id: actor.api_token_id }
        : {}),
    },
  };
}

/**
 * O `api_tokens.id` de quem enviou, para a coluna `actor_api_token_id` do
 * audit. Só os atores que SÃO token têm essa ponta — `user` e
 * `webhook_source` não, e `undefined` mantém a coluna nula do jeito que a
 * auditoria já espera.
 */
function apiTokenIdDoActor(actor: Actor): string | undefined {
  if (actor.type === "api_token") return actor.id;
  if (actor.type === "ai_agent") return actor.api_token_id;
  return undefined;
}

// ---------------------------------------------------------------------------
// list
// ---------------------------------------------------------------------------

interface MsgCursorPayload {
  sent_at: string;
  id: string;
  /**
   * O desempate do meio (ver a ordem em `listMessagesHandler`). Opcional porque
   * um cursor emitido antes dele — numa aba aberta durante a atualização — não
   * o traz, e tem de continuar paginando como antes em vez de virar 400.
   */
  created_at?: string;
}

function encodeMsgCursor(p: MsgCursorPayload): string {
  return Buffer.from(JSON.stringify(p), "utf8").toString("base64url");
}
/**
 * Os valores do cursor entram por interpolação no `.or()` do PostgREST. Os
 * filtros de organização e de conversa são parâmetros separados (somados com
 * AND), então um cursor forjado não sai da conversa — mas vírgula ou parêntese
 * ali reescreveria o filtro de dentro dela. Só passa o FORMATO que o servidor
 * emite: UUID e instante ISO-8601.
 */
const UUID_DO_CURSOR = /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i;
const INSTANTE_DO_CURSOR = /^\d{4}-\d{2}-\d{2}T\d{2}:\d{2}:\d{2}(\.\d{1,6})?(Z|[+-]\d{2}:\d{2})$/;

function decodeMsgCursor(raw: string): MsgCursorPayload | null {
  try {
    const parsed = JSON.parse(Buffer.from(raw, "base64url").toString("utf8")) as MsgCursorPayload;
    if (typeof parsed.id !== "string" || typeof parsed.sent_at !== "string") return null;
    if (parsed.created_at !== undefined && typeof parsed.created_at !== "string") return null;
    if (!UUID_DO_CURSOR.test(parsed.id) || !INSTANTE_DO_CURSOR.test(parsed.sent_at)) return null;
    if (parsed.created_at !== undefined && !INSTANTE_DO_CURSOR.test(parsed.created_at)) return null;
    return parsed;
  } catch {
    return null;
  }
}

export interface ListMessagesResult {
  messages: Message[];
  cursor: string | null;
  has_more: boolean;
}

export async function listMessagesHandler(
  supabase: SB,
  ctx: HandlerCtx,
  conversationId: string,
  q: ListMessagesQuery,
): Promise<ListMessagesResult> {
  // Pessoal não é alcançável nem pelo histórico (spec 21, etapa 7): a conversa
  // sumiu da lista e o link direto dá 404, então o histórico recusa junto —
  // defesa em profundidade, com o mesmo 404 mudo para não revelar a conversa.
  // Duas consultas planas (sem embed, sem `maybeSingle`): o dublê do invariante
  // de paginação traduz a cadeia em SQL literal e só modela esses métodos.
  const { data: donas } = await supabase
    .from("conversations")
    .select("contact_id")
    .eq("id", conversationId)
    .eq("organization_id", ctx.organization_id)
    .limit(1);
  const contatoId = ((donas ?? []) as Array<{ contact_id?: string | null }>)[0]?.contact_id ?? null;
  let ehPessoal = false;
  if (contatoId) {
    const { data: contato } = await supabase
      .from("contacts")
      .select("is_personal")
      .eq("id", contatoId)
      .eq("organization_id", ctx.organization_id)
      .limit(1);
    ehPessoal = ((contato ?? []) as Array<{ is_personal?: boolean }>)[0]?.is_personal === true;
  }
  if (ehPessoal) {
    throw new ApiError(
      404,
      "not_found",
      undefined,
      ctx.requestId,
      traduzir("Conversa não encontrada.", ctx.idioma ?? "pt-BR"),
    );
  }

  // A CONSULTA VAI DO MAIS NOVO PARA O MAIS VELHO — de propósito.
  //
  // Antes era `ascending: true`: a primeira página trazia as `limit` mensagens
  // MAIS ANTIGAS da conversa, e as novas ficavam atrás do cursor. Numa conversa
  // com mais mensagens que o limite (50, o padrão), o atendente simplesmente
  // NÃO VIA o que acabou de chegar — a tela travava num ponto do passado e não
  // se mexia mais, por mais que o cliente escrevesse.
  //
  // Medido numa instalação real: conversa com 64 mensagens: a tela parava na
  // #50 (16:15) e as 14 seguintes (16:20 → 16:48) eram invisíveis, embora
  // gravadas. E piora com o uso: quanto mais se conversa com alguém, mais
  // mensagens novas somem. Num CRM de WhatsApp, é a conversa mais importante
  // que fica pior.
  //
  // Chat lê de baixo para cima: o padrão certo é buscar as ÚLTIMAS N e paginar
  // para trás ao rolar. O cursor, portanto, passa a andar para o passado
  // (`lt`), e não mais para o futuro.
  let query = supabase
    .from("messages")
    .select(MSG_COLS)
    .eq("conversation_id", conversationId)
    .eq("organization_id", ctx.organization_id)
    .order("sent_at", { ascending: false })
    // DESEMPATE PELA CHEGADA, não pelo id.
    //
    // `sent_at` vem do canal com precisão de SEGUNDO (a API oficial da Meta manda
    // `timestamp` em segundos). Quem manda 3 fotos de uma vez gera 3 mensagens no
    // mesmo `sent_at`, e o `id` é `uuid_generate_v4()` — sorteado. Desempatar por
    // ele punha as fotos em ordem aleatória: medido numa instalação real em
    // 09/11/2026, Foto 1-2-3 e PDF 1-2-3 apareceram 3-2-1. `created_at` é o
    // instante (com microssegundos) em que cada uma foi gravada, na ordem em que
    // o canal as entregou. O `id` fica por último, só para a ordem ser total.
    .order("created_at", { ascending: false })
    .order("id", { ascending: false })
    .limit(q.limit + 1);

  if (q.cursor) {
    const c = decodeMsgCursor(q.cursor);
    if (!c) {
      throw new ApiError(
        400,
        "invalid_cursor",
        undefined,
        ctx.requestId,
        traduzir("Cursor inválido.", ctx.idioma ?? "pt-BR"),
      );
    }
    query = query.or(
      c.created_at
        ? `sent_at.lt.${c.sent_at},` +
            `and(sent_at.eq.${c.sent_at},created_at.lt.${c.created_at}),` +
            `and(sent_at.eq.${c.sent_at},created_at.eq.${c.created_at},id.lt.${c.id})`
        : // Cursor anterior ao desempate pela chegada: segue o critério antigo.
          `sent_at.lt.${c.sent_at},and(sent_at.eq.${c.sent_at},id.lt.${c.id})`,
    );
  }

  const { data, error } = await query;
  if (error) {
    throw new ApiError(500, "internal_error", undefined, ctx.requestId, error.message);
  }

  const rows = (data ?? []) as unknown as Message[];
  const hasMore = rows.length > q.limit;
  const page = hasMore ? rows.slice(0, q.limit) : rows;

  // Em ordem decrescente, o ÚLTIMO da página é o mais antigo dela — é dele que
  // sai o cursor, porque a próxima página é a que vem ANTES no tempo.
  const oldest = page[page.length - 1];
  const cursor =
    hasMore && oldest
      ? encodeMsgCursor({ sent_at: oldest.sent_at, created_at: oldest.created_at, id: oldest.id })
      : null;

  // A RESPOSTA continua cronológica (antigo → novo), igual a antes: o consumidor
  // renderiza de cima para baixo sem mudar nada. O que mudou foi QUAIS mensagens
  // entram na página, não a ordem em que saem.
  return { messages: page.slice().reverse(), cursor, has_more: hasMore };
}

// ---------------------------------------------------------------------------
// send
// ---------------------------------------------------------------------------

export function previewFrom(input: {
  body?: string;
  media_url?: string;
  media_storage_path?: string;
  type?: string;
}): string {
  if (!input.body && !input.media_url && !input.media_storage_path) return "";
  return previaDaMensagem(input.body ?? null, input.type ?? "media");
}

/**
 * Atendente respondeu manualmente → IA fica quieta nesta conversa por uma janela curta,
 * renovada a cada mensagem humana (sliding window). Sem isto, a IA só "parecia" quieta
 * por coincidência de timing (nenhum turno novo disparado) e voltava a responder junto
 * com o humano assim que o cliente mandava a próxima mensagem — `isLeadInHandoff`
 * (lib/agent-engine/agent/human-handoff.ts) só olhava `force_human`/`bot_silenced_until`,
 * e nenhum envio manual tocava nenhum dos dois.
 */
const HUMAN_REPLY_SILENCE_MS = 5 * 60 * 1000;

/**
 * Postgres 'infinity' (handoff permanente — regex/tool/orquestrador) chega do PostgREST
 * como o literal texto "infinity", que `new Date(...)` não parseia. Nunca encurtar isso
 * para uma janela de 5min: se já está travado pra sempre, este helper não mexe.
 */
function extendBotSilence(current: string | null, now: string): string | undefined {
  if (current === "infinity") return undefined;
  const candidate = new Date(new Date(now).getTime() + HUMAN_REPLY_SILENCE_MS);
  if (current && new Date(current) >= candidate) return undefined;
  return candidate.toISOString();
}

export async function sendMessageHandler(
  supabase: SB,
  ctx: HandlerCtx,
  input: SendMessageInput,
): Promise<Message> {
  // Organização parada (suspensa, redigida, arquivada) não envia nada. Esta é a
  // porta de saída de TODOS os chamadores, e fecha a corrida de quem passou pelo
  // gate antes da suspensão. O erro é terminal (`terminal: true`).
  await assertOrgOperante(supabase, ctx.organization_id);
  if (ctx.prospectingDelivery) await assertProspectingDelivery(supabase, ctx.prospectingDelivery);
  if (ctx.meetingDelivery) await assertMeetingDeliverySupabase(supabase, ctx.meetingDelivery);
  if (ctx.approvedReply) await assertApprovedReplySupabase(supabase, ctx.approvedReply);
  if (ctx.agentOperation) await assertAgentOperationSupabase(supabase, ctx.agentOperation);
  if (ctx.proactiveContext) await assertAgendaEffectSupabase(supabase, ctx.proactiveContext);
  await guardAgendaEffect();
  ctx = { ...ctx, serviceBoundary: ctx.serviceBoundary ?? currentExecutionBoundary() };
  if (ctx.serviceBoundary) {
    if (
      ctx.serviceBoundary.organization_id !== ctx.organization_id ||
      ctx.serviceBoundary.conversation_id !== input.conversation_id
    )
      throw new StaleServiceBoundaryError();
    await assertServiceBoundarySupabase(supabase, ctx.serviceBoundary);
  }
  // `archived_at` entra pelo helper tolerante porque este é O caminho de saída do
  // sistema inteiro (UI, automação, MCP e o agente passam por aqui): num clone que
  // subiu o código sem a migration 0106, pedir a coluna direto derrubaria TODO
  // envio com 42703. Sem a coluna, nada está arquivado — e a consulta sem ela é a
  // consulta certa (ver lib/channels/archived).
  const convSelect = (comArchived: boolean) =>
    `id, organization_id, contact_id, channel_session_id, is_group, group_chat_id, bot_silenced_until, provider_conversation_id, last_inbound_at, contacts:contact_id(phone_number, wa_identity, wa_lid, is_blocked, is_personal), channel_sessions:channel_session_id(${CHANNEL_SESSION_REF_COLUMNS}, status, metadata${comArchived ? `, ${ARCHIVED_AT}` : ""})`;
  //
  // O filtro por `organization_id` NÃO é redundância com a RLS — é a única
  // proteção que existe na metade dos chamadores. Este handler é a porta de
  // saída de TODOS eles, e eles se dividem em dois mundos:
  //
  //   - rota REST com sessão de navegador → client de RLS, a policy basta;
  //   - servidor MCP (lib/mcp/server.ts:41) e rota REST por `Bearer dsk_…`
  //     (lib/api/auth-dual.ts) → `createAdminClient()`, SERVICE ROLE, que
  //     bypassa RLS. Aqui não há policy nenhuma no caminho.
  //
  // Sem o filtro, um chamador de service-role com a org A passava um
  // `conversation_id` da org B e a linha VINHA — e daí em diante todo o resto
  // usa `c.organization_id`, a org da VÍTIMA: a mensagem era inserida na
  // conversa dela e enviada pelo canal dela. Medido, não deduzido:
  // `tests/invariants/envio-nao-alcanca-conversa-de-outro-tenant.test.ts`
  // (anti-pattern 10 do CLAUDE.md).
  const { data: conv, error: convErr } = await queryTolerantToMissingArchived(
    () =>
      supabase
        .from("conversations")
        .select(convSelect(true))
        .eq("id", input.conversation_id)
        .eq("organization_id", ctx.organization_id)
        .maybeSingle(),
    () =>
      supabase
        .from("conversations")
        .select(convSelect(false))
        .eq("id", input.conversation_id)
        .eq("organization_id", ctx.organization_id)
        .maybeSingle(),
  );

  if (convErr) {
    throw new ApiError(500, "internal_error", undefined, ctx.requestId, convErr.message);
  }
  if (!conv) {
    throw new ApiError(
      404,
      "not_found",
      undefined,
      ctx.requestId,
      traduzir("Conversa não encontrada.", ctx.idioma ?? "pt-BR"),
    );
  }

  type Joined = {
    id: string;
    organization_id: string;
    contact_id: string;
    channel_session_id: string;
    is_group: boolean;
    group_chat_id: string | null;
    bot_silenced_until: string | null;
    /** Thread do provider, quando ele endereça por thread própria (migration 0132). */
    provider_conversation_id: string | null;
    /**
     * Quando chegou a última mensagem do cliente. É a régua da espera da Fila
     * (issue #990): a resposta humana grava `awaiting_since = last_inbound_at`,
     * que é o mesmo valor que `fn_reply_record_receipt` usa no caminho do banco.
     */
    last_inbound_at: string | null;
    contacts: {
      phone_number: string | null;
      wa_identity: string | null;
      wa_lid: string | null;
      is_blocked: boolean;
      /** Spec 21: pessoal não recebe por nenhum caminho — o veto é no mesmo ponto do bloqueio. */
      is_personal: boolean;
    } | null;
    channel_sessions: (ChannelSessionRef & { status: string; metadata?: Record<string, unknown> | null; archived_at?: string | null }) | null;
  };
  const c = conv as unknown as Joined;

  if (c.contacts?.is_blocked) {
    throw new ApiError(
      403,
      "forbidden",
      undefined,
      ctx.requestId,
      traduzir("Contato bloqueou o atendimento.", ctx.idioma ?? "pt-BR"),
    );
  }

  // Contato pessoal (spec 21, etapa 11 — critério 7): TUDO recusado, manual ou
  // automático, no mesmo ponto do bloqueio — sem exceção para gerente (a spec
  // pede literalmente essa sabotagem: liberar gerente e ver o teste acusar).
  // Erro padrão, sem vazar dado do contato.
  if (c.contacts?.is_personal === true) {
    throw new ApiError(
      403,
      "forbidden",
      undefined,
      ctx.requestId,
      traduzir("Contato marcado como pessoal.", ctx.idioma ?? "pt-BR"),
    );
  }

  // ─── De quem é o número desta conversa (#3642) ─────────────────────────────
  //
  // O envio sai pela sessão da CONVERSA (`c.channel_session_id`), e essa sessão é
  // o telefone de uma PESSOA. Numa instalação com um número por vendedor, quem
  // não responde por esse número não fala por ele: depois de uma transferência
  // (ou de uma troca de carteira), o vendedor novo respondia pelo WhatsApp do
  // colega — a mensagem saía pelo número do antigo e a resposta do cliente ia
  // para o celular dele.
  //
  // A troca de dono já leva o número junto (`fn_numero_da_transferencia`,
  // migration 9039). O que sobra para esta guarda é quem TEM número próprio
  // conectado e está numa conversa no número de outra pessoa (o contato já tinha
  // conversa no número dele, ou a conversa é anterior a esta regra): aí o envio
  // recusa ALTO — nunca sai pelo número do colega. Quem não tem número segue
  // respondendo como hoje (doc 237, escolha 1-B), e organização sem um número
  // por vendedor não muda nada aqui (é o controle da regra).
  //
  // Só ator HUMANO: token de integração e IA não são "a pessoa do número" — o
  // defeito é de quem digita no Inbox, e restringir a regra a ele deixa os
  // caminhos automáticos exatamente como estavam.
  if (ctx.actor.type === "user") {
    const numero = await quadroDoNumero(supabase, {
      organizationId: ctx.organization_id,
      channelSessionId: c.channel_session_id ?? null,
      provider: c.channel_sessions?.provider ?? null,
      userId: ctx.actor.id,
    });
    if (numero.leitura === "indisponivel") {
      // Falha FECHADA: um erro transitório não pode reabrir o vazamento em
      // silêncio, e nada foi enviado.
      throw new ApiError(
        503,
        "numero_do_atendente_indisponivel",
        undefined,
        ctx.requestId,
        traduzir(
          "Não foi possível conferir o número desta conversa. Nada foi enviado.",
          ctx.idioma ?? "pt-BR",
        ),
      );
    }
    const veredito = vereditoDoNumero(numero.quadro);
    if (!veredito.ok) {
      // #3732: a recusa diz PARA ONDE IR. No caso que a issue mediu a conversa
      // já é de quem está tentando responder — ela não pôde seguir o vendedor
      // porque o contato tem outra conversa no número dele —, e mandar
      // "transfira para você" de novo seria pedir o que já foi feito. O banco
      // responde qual conversa continua (`fn_conversa_que_continua`, a MESMA
      // peça que a adoção da transferência usa); a tela abre essa conversa e
      // trava a caixa de resposta.
      //
      // Service role porque a conversa nova pode estar com outra pessoa: com a
      // RLS de quem envia a leitura viria vazia justamente no caso em que o
      // aviso mais importa. Falha aqui não muda a recusa — só o texto dela.
      const continua =
        veredito.codigo === CODIGO_NUMERO_DE_OUTRO && isServiceRoleConfigured()
          ? await conversaQueContinua(createAdminClient(), {
              organizationId: ctx.organization_id,
              conversationId: input.conversation_id,
              userId: ctx.actor.id,
            })
          : null;

      // Traduz UMA vez: quando há continuação, a frase vem do modelo já
      // traduzido (`mensagemDaContinua`), com o nome colado depois.
      const mensagem = continua
        ? mensagemDaContinua(continua, (texto) => traduzir(texto, ctx.idioma ?? "pt-BR"))
        : traduzir(MENSAGEM_DO_NUMERO[veredito.codigo], ctx.idioma ?? "pt-BR");

      throw new ApiError(
        409,
        veredito.codigo,
        {
          channel_session_id: c.channel_session_id,
          ...(continua
            ? { conversa_que_continua: { id: continua.id, nome: continua.nome, propria: continua.propria } }
            : {}),
        },
        ctx.requestId,
        mensagem,
      );
    }
  }

  // ─── A janela de 24h, ANTES de qualquer linha nascer (#1614) ──────────────
  //
  // Em canal com hetero-restrição, texto livre só sai enquanto o cliente
  // escreveu nas últimas 24h — fora disso só modelo aprovado, e a plataforma
  // recusa com 131047. Antes disto a rota respondia 201, a linha virava `sent`
  // e a recusa chegava DEPOIS, pelo webhook de status: quem integra registrava
  // "cobrança enviada" e o cliente nunca recebia.
  //
  // Só quem chega POR TOKEN (`api_token` e `ai_agent`): a tela já barre o
  // composer (components/inbox/InboxLayout.tsx, mesma régua) e o agente de IA
  // tem o gate 3.5 da cadeia `before_send`. Os dois — operador e regra de
  // automação — seguem com o contrato de antes; a issue é sobre quem integra.
  //
  // `estadoDaJanela` é a MESMA régua da tela (lib/channels/janela.ts): um só
  // lugar decide o que é "janela fechada", e é ele que a tela já trata.
  if (
    (ctx.actor.type === "api_token" || ctx.actor.type === "ai_agent") &&
    input.type !== "template"
  ) {
    const janela = estadoDaJanela(
      c.channel_sessions?.provider ?? DEFAULT_CHANNEL_PROVIDER,
      c.last_inbound_at,
      new Date(),
    );
    if (janela.tipo === "fechada") {
      throw new ApiError(
        422,
        "janela_fechada",
        {
          codigo: "janela_fechada",
          // `null` = o cliente nunca escreveu: não houve abertura, e inventar
          // um horário seria prometer uma janela que nunca existiu.
          ultima_mensagem_do_cliente: c.last_inbound_at,
          use: "template",
          // O mesmo código que a recusa chega pelo webhook de status — é a
          // prova de que a recusa de hoje é a falha silenciosa de ontem.
          codigo_plataforma: "131047",
        },
        ctx.requestId,
        traduzir(
          "Janela de 24 horas fechada: texto livre é recusado pela plataforma (131047). Envie um modelo aprovado ou aguarde o cliente escrever.",
          ctx.idioma ?? "pt-BR",
        ),
      );
    }
  }

  if (
    input.media_storage_path &&
    !isMediaPathOwnedBy(input.media_storage_path, c.organization_id, c.id)
  ) {
    throw new ApiError(
      422,
      "invalid_media_path",
      undefined,
      ctx.requestId,
      "media_storage_path fora da conversa.",
    );
  }

  // Só `media_url` (sem `media_storage_path`) é baixada pelo gateway: é esse o
  // envio que precisa da guarda anti-SSRF, e antes de a linha existir.
  if (input.media_url && !input.media_storage_path) {
    try {
      await assertUrlDeMidiaSegura(input.media_url);
    } catch (erro) {
      throw new ApiError(
        422,
        "unsafe_media_url",
        { motivo: erro instanceof Error ? erro.message : "unsafe_url" },
        ctx.requestId,
        "media_url recusada: o endereço não pode ser baixado pelo servidor.",
      );
    }
  }

  let outboundBody = input.body ?? null;
  let outboundMetadata: Record<string, unknown> = { ...(input.metadata ?? {}) };

  // ─── Assinatura do emissor (#2066) ─────────────────────────────────────────
  // Opt-in por organização (`organizations.settings.assinatura_mensagens`). A
  // assinatura entra SÓ no texto enviado ao canal (corpo de texto e legenda de
  // mídia) — o que fica gravado em `messages.body` é o que o emissor escreveu
  // (insertRow abaixo usa `input.body`). Automação e sistemas externos
  // (`automation`/`system`) ficam de fora, como o relato pede. Humano ganha o
  // nome do atendente com iniciais em maiúsculo; a IA, o nome configurável.
  const origemDoEmissor = origemDaMensagem(ctx.actor);
  // O id do agente NÃO é o `id` do ator em todo caminho (#3373). O motor do
  // turno põe o agente no `id` (`lib/agent-engine/edge/crm/send-message.ts`),
  // mas a Prospecção põe o CANDIDATO no `id` e o agente em `agent_id`
  // (`lib/prospecting/worker.ts`), e a ação "Mensagem escrita pela IA" dispara
  // com um ator da regra cujo `id` é a própria regra
  // (`lib/automation/actions/send-ai-message.ts`). A coluna certa é
  // `agent_id`; `?? id` é só a ponte do caminho do motor, que ainda não manda
  // `agent_id`.
  const idDoAgente =
    ctx.actor.type === "ai_agent"
      ? (ctx.actor.agent_id ?? ctx.actor.id)
      : ctx.actor.type === "webhook_source"
        ? (ctx.actor.agent_id ?? null)
        : null;
  // O nome do agente que enviou é lido UMA vez e serve a dois consumidores: a
  // assinatura por agente (#3310) e o balão, que mostra "IA · <nome>" a partir
  // de `metadata.ai_actor_name` gravado na linha, porque ele não faz join
  // (#3649).
  const nomeDoAgente =
    origemDoEmissor === "ai" && idDoAgente !== null
      ? await nomeDoAgenteDeIA(supabase, ctx.organization_id, idDoAgente)
      : null;
  let assinatura: string | null = null;
  if (origemDoEmissor === "user" || origemDoEmissor === "ai") {
    const { data: orgAssinatura } = await supabase
      .from("organizations")
      .select("settings")
      .eq("id", ctx.organization_id)
      .maybeSingle();
    const configAss = configAssinatura(orgAssinatura?.settings);
    const coberta =
      (origemDoEmissor === "user" && configAss.humanos) ||
      (origemDoEmissor === "ai" && configAss.ia);
    if (coberta) {
      let nomeDoAtendente: string | null = null;
      if (origemDoEmissor === "user" && ctx.actor.type === "user") {
        nomeDoAtendente = (await nomesDosAtendentes(ctx.organization_id, [ctx.actor.id])).get(ctx.actor.id) ?? null;
      }
      // #3310: com `por_agente` ligado, a IA assina com o nome do AGENTE QUE
      // ENVIOU (`ai_agents.name`) — com dois agentes no mesmo número, o cliente
      // lia o nome de um e a conversa seguia com outro. `nome_ia` fica como
      // reserva (mensagem sem agente, leitura que falhou).
      assinatura = linhaDeAssinatura(
        configAss,
        origemDoEmissor,
        nomeDoAtendente,
        configAss.porAgente ? nomeDoAgente : null,
      );
    }
  }

  /** O corpo com a assinatura, quando ela se aplica a esta origem e há texto. */
  const corpoDoCanal = (texto: string | null): string | null =>
    aplicarAssinatura(assinatura, texto) ?? null;

  if (input.type === "contact") {
    const sharedId = input.metadata?.shared_contact_id;
    const inline = input.metadata?.shared_contact;

    if (typeof sharedId === "string" && sharedId.length > 0) {
      const { data: shared, error: sharedErr } = await supabase
        .from("contacts")
        .select("id, display_name, name, phone_number, is_anonymized, is_blocked, is_personal")
        .eq("id", sharedId)
        .eq("organization_id", ctx.organization_id)
        .maybeSingle();
      if (sharedErr) {
        throw new ApiError(500, "internal_error", undefined, ctx.requestId, sharedErr.message);
      }
      if (!shared) {
        throw new ApiError(
          404,
          "not_found",
          undefined,
          ctx.requestId,
          traduzir("Contato não encontrado.", ctx.idioma ?? "pt-BR"),
        );
      }
      const row = shared as {
        id: string;
        display_name: string | null;
        name: string | null;
        phone_number: string | null;
        is_anonymized: boolean;
        is_blocked: boolean;
        is_personal: boolean;
      };
      if (row.is_anonymized) {
        throw new ApiError(
          422,
          "contact_anonymized",
          undefined,
          ctx.requestId,
          traduzir("Contato anonimizado não pode ser compartilhado.", ctx.idioma ?? "pt-BR"),
        );
      }
      // Cartão de pessoal também não sai (spec 21, etapa 11): compartilhar o
      // cartão entregaria o telefone por outra porta.
      if (row.is_personal === true) {
        throw new ApiError(
          403,
          "forbidden",
          undefined,
          ctx.requestId,
          traduzir("Contato marcado como pessoal.", ctx.idioma ?? "pt-BR"),
        );
      }
      if (!row.phone_number) {
        throw new ApiError(
          422,
          "missing_phone_number",
          undefined,
          ctx.requestId,
          traduzir("Contato sem telefone para envio como cartão.", ctx.idioma ?? "pt-BR"),
        );
      }
      const displayName = nomeDoContato(row) ?? row.phone_number;
      outboundBody = displayName;
      outboundMetadata = {
        ...outboundMetadata,
        shared_contact: {
          contact_id: row.id,
          name: displayName,
          phone_number: row.phone_number,
        },
      };
    } else if (inline && typeof inline === "object" && !Array.isArray(inline)) {
      const o = inline as Record<string, unknown>;
      const rawPhone = typeof o.phone_number === "string" ? o.phone_number : "";
      const phone = parseDialablePhone(rawPhone);
      if (!phone) {
        throw new ApiError(
          422,
          "invalid_payload",
          undefined,
          ctx.requestId,
          traduzir("Telefone inválido para envio como cartão.", ctx.idioma ?? "pt-BR"),
        );
      }
      const nameRaw = typeof o.name === "string" ? o.name.trim() : "";
      const displayName = nameRaw || phone;
      outboundBody = displayName;
      outboundMetadata = {
        ...outboundMetadata,
        shared_contact: { name: displayName, phone_number: phone },
      };
    } else {
      throw new ApiError(
        422,
        "invalid_payload",
        undefined,
        ctx.requestId,
        traduzir(
          "Informe metadata.shared_contact_id ou metadata.shared_contact com telefone.",
          ctx.idioma ?? "pt-BR",
        ),
      );
    }
  }

  const now = new Date().toISOString();
  // ─── A CITAÇÃO, e a checagem que ela obriga ────────────────────────────────
  //
  // O id da citada vem do CLIENTE. Sem confirmar que ela é da MESMA conversa,
  // alguém poderia citar uma mensagem de outra conversa — e a citação é
  // renderizada com o texto, então isso vaza conteúdo de um atendimento para
  // dentro de outro. O filtro de conversa é o que fecha isso; o de organização
  // vem de brinde por `conversation_id` já ser desta org.
  //
  // Recusar em silêncio (citar nada) seria pior que recusar alto: quem clicou
  // "responder" veria a mensagem sair sem o fio e não saberia por quê.
  let citada: { id: string; external_id: string | null } | null = null;
  if (input.reply_to_message_id) {
    const { data: alvo } = await supabase
      .from("messages")
      .select("id, external_id")
      .eq("id", input.reply_to_message_id)
      .eq("organization_id", ctx.organization_id)
      .eq("conversation_id", c.id)
      .maybeSingle();

    if (!alvo) {
      throw new ApiError(
        422,
        "validation_error",
        undefined,
        ctx.requestId,
        traduzir("A mensagem citada não é desta conversa.", ctx.idioma ?? "pt-BR"),
      );
    }
    citada = alvo as { id: string; external_id: string | null };
  }

  // ─── "Em nome de" (#1613): o CTX é a única porta ───────────────────────────
  //
  // O campo existe no input — quem o envia o declara —, mas gravar autoria é
  // decisão do ctx, que só a rota REST preenche, depois de checar o escopo
  // `messages:on_behalf` na linha do token e o membership no banco. O MESMO
  // input atravessa as tools MCP, que não têm escopo nenhum: sem esta recusa,
  // uma chamada por lá escreveria autoria de pessoa sem que ninguém a tivesse
  // concedido. Falha FECHADA — recusa alto, nunca grava por omissão.
  if (input.on_behalf_of_user_id && ctx.onBehalfOf?.userId !== input.on_behalf_of_user_id) {
    throw new ApiError(
      403,
      "forbidden",
      undefined,
      ctx.requestId,
      traduzir(
        "Envio em nome de outro usuário exige o escopo messages:on_behalf.",
        ctx.idioma ?? "pt-BR",
      ),
    );
  }

  // Os BOTÕES que o modelo leva ao cliente, gravados na própria linha: o balão
  // mostra o que o cliente recebeu, e não faz join (`lib/messaging/botoes-do-modelo.ts`).
  // Lidos do espelho da definição — nunca do `metadata` de entrada, que é do
  // cliente da API — e sem barrar nada: é dado de exibição.
  const { [CHAVE_DOS_BOTOES_DO_MODELO]: _botoesDoCliente, ...metadataDeEntrada } = input.metadata ?? {};
  // O nome que a PESSOA deu ao arquivo (#4457). O caminho no bucket nasce
  // `out-<uuid>.<ext>`, então não há nome legível nele; este é o único que
  // sobra. Passa pela guarda da ingestão porque vem do multipart, e é gravado
  // na linha porque o balão e o download leem `metadata.media_filename` — sem
  // isso o histórico continuaria mostrando o nome aleatório. `null` quando o
  // envio não traz nome (token que só manda o storage_path): aí o balão segue
  // com o rótulo de sempre, em vez de exibir um `out-<uuid>`.
  // Por `media_url` (#4524) o nome DECLARADO passa pela guarda do envio — a
  // mesma que nomeia o documento no payload — e a linha guarda esse mesmo
  // nome, para o cartão e o download não contarem outra história. Requisição
  // antiga, sem o campo, não ganha chave nova no JSONB.
  const nomeOriginalDoAnexo = input.media_storage_path
    ? nomeDeArquivoLimpo(input.media_filename)
    : nomeDeArquivoSeguro(input.media_filename);
  const botoesDoModeloEnviado =
    input.type === "template"
      ? await botoesDaDefinicao(supabase, {
          organizationId: ctx.organization_id,
          channelSessionId: c.channel_session_id ?? null,
          name: input.template_name ?? "",
          language: input.template_language ?? "",
        })
      : [];

  const insertRow = {
    ...(ctx.internalMessageId ? { id: ctx.internalMessageId } : {}),
    organization_id: c.organization_id,
    // Guardado mesmo quando o canal não sabe citar: o fio existe no NOSSO
    // histórico de qualquer jeito, e é o que a tela desenha.
    reply_to_message_id: citada?.id ?? null,
    conversation_id: c.id,
    channel_session_id: c.channel_session_id,
    contact_id: c.contact_id,
    type: input.type,
    direction: "outbound" as const,
    status: "queued",
    body: input.body ?? null,
    media_url: input.media_url ?? null,
    media_mime: input.media_mime ?? null,
    media_storage_path: input.media_storage_path ?? null,
    media_size_bytes: input.media_size_bytes ?? null,
    // Fase 6a do plano de voz: o texto ORIGINAL de uma nota de voz, para a
    // thread continuar legível depois que `body` deixou de ser o que o
    // cliente lê. Ver o comentário de `aiVoiceDerivedText` em `HandlerCtx` —
    // nunca vem do corpo da requisição.
    ...(ctx.aiVoiceDerivedText
      ? { media_derived_text: ctx.aiVoiceDerivedText, media_derived_status: "ready" }
      : {}),
    sent_via: origemDaMensagem(ctx.actor),
    sent_by_user_id: ctx.actor.type === "user" ? ctx.actor.id : null,
    // A PESSOA, não o token (#1613). Só chega aqui pelo ctx validado na rota
    // — ver a recusa acima —, e fica na coluna para consulta e auditoria.
    sent_on_behalf_of_user_id: ctx.onBehalfOf?.userId ?? null,
    sent_at: now,
    metadata: {
      // As chaves da derivação de mídia (âncora do dreno, marca de foto adiada)
      // são do servidor: o dreno converte a âncora em data e o cron pede visão
      // paga pela marca. O cliente não as forja — ver `CHAVES_DA_DERIVACAO`.
      ...semChavesDaDerivacao(metadataDeEntrada),
      // Depois do `metadata` do cliente de propósito: o nome do arquivo é dado
      // do ENVIO (#4457), e o cliente da API não o sobrescreve por metadata.
      ...(nomeOriginalDoAnexo ? { [CHAVE_DO_NOME_NA_METADATA]: nomeOriginalDoAnexo } : {}),
      ...(ctx.actor.type === "ai_agent" ? { ai_actor_id: ctx.actor.id } : {}),
      ...(origemDoEmissor === "ai" ? { ai_actor_name: nomeDoAgente } : {}),
      ...(botoesDoModeloEnviado.length > 0 ? { [CHAVE_DOS_BOTOES_DO_MODELO]: botoesDoModeloEnviado } : {}),
      // Os dois nomes viajam GRAVADOS porque o balão não faz join: "Fulano ·
      // via {token}" é desenhado da própria linha. Vêm DEPOIS de
      // `input.metadata` de propósito — metadata é entrada do cliente, e
      // deixá-lo por último seria autorizar o cliente a forjar o emissor.
      ...(ctx.onBehalfOf
        ? {
            sent_on_behalf: {
              user_id: ctx.onBehalfOf.userId,
              user_name: ctx.onBehalfOf.userName ?? null,
              token_name: ctx.onBehalfOf.tokenName ?? null,
            },
          }
        : {}),
    },
  };

  await guardServiceEffect();
  let { data: created, error: insErr } = await supabase
    .from("messages")
    .insert(insertRow)
    .select(MSG_COLS)
    .single();

  if (insErr?.code === "23505" && ctx.internalMessageId) {
    const existing = await supabase
      .from("messages")
      .select(MSG_COLS)
      .eq("organization_id", ctx.organization_id)
      .eq("id", ctx.internalMessageId)
      .eq("conversation_id", input.conversation_id)
      .single();
    if (existing.error) throw new Error("inline_message_identity_mismatch");
    created = existing.data;
    insErr = null;
    if (
      ["sent", "delivered", "read", "failed"].includes(
        String((created as unknown as Message).status),
      )
    )
      return created as unknown as Message;
  }
  if (insErr || !created) {
    throw new ApiError(
      500,
      "internal_error",
      undefined,
      ctx.requestId,
      insErr?.message ?? "insert_failed",
    );
  }
  let message = created as unknown as Message;

  // O canal vem da SESSÃO (migration 0087), não de um literal. O fallback só
  // alcança o caso em que o embed não trouxe a sessão — impossível hoje
  // (`conversations.channel_session_id` é NOT NULL com FK ON DELETE RESTRICT),
  // e ainda assim mantido para não trocar o desfecho desse ramo defensivo.
  const adapter = getAdapter(c.channel_sessions?.provider ?? DEFAULT_CHANNEL_PROVIDER);
  // Ficha com telefone extra: a resposta volta pelo número que a pessoa escreveu.
  // A porta de canal decide quem tem isso. A organização vem de `ctx` (fonte
  // confiável), não do corpo.
  const ultimoRecebidoChatId = await enderecoDaUltimaRecebida(supabase, {
    provider: c.channel_sessions?.provider,
    isGroup: c.is_group,
    organizationId: ctx.organization_id,
    conversationId: c.id,
    contactId: c.contact_id,
  });
  const chatId = adapter.resolveRecipient({
    isGroup: c.is_group,
    groupChatId: c.group_chat_id,
    phoneNumber: c.contacts?.phone_number,
    waIdentity: c.contacts?.wa_identity,
    waLid: c.contacts?.wa_lid,
    ultimoRecebidoChatId,
  });

  // Releitura no sink: o operador pode ter fechado o canal enquanto o modelo
  // gerava a resposta. Envio humano não passa por esta restrição da IA.
  const acessoAtual = ctx.actor.type === "user" ? null : await decidirPreGoLiveDoCanalViaSupabase(supabase, {
    organizationId: ctx.organization_id,
    channelSessionId: c.channel_session_id,
    contactPhoneNumber: c.contacts?.phone_number ?? "",
  }).catch(() => ({ permite: false, motivo: "pre_go_live_indisponivel" }));
  if (acessoAtual && !acessoAtual.permite) {
    const { data: updated, error } = await supabase.from("messages").update({
      status: "failed",
      error_code: acessoAtual.motivo === "pre_go_live_indisponivel" ? "pre_go_live_indisponivel" : "pre_go_live",
      error_message: acessoAtual.motivo === "pre_go_live_indisponivel"
        ? "Não foi possível verificar o acesso da IA. Nenhuma mensagem foi enviada."
        : "Envio automático bloqueado pelo modo de teste do canal.",
    }).eq("organization_id", ctx.organization_id).eq("id", message.id).select(MSG_COLS).single();
    if (error || !updated) throw new ApiError(500, "internal_error", undefined, ctx.requestId, "Não foi possível registrar o bloqueio do envio.");
    message = updated as unknown as Message;
  } else if (c.channel_sessions?.archived_at) {
    // Canal ARQUIVADO = canal excluído pelo usuário: a sessão já foi deslogada e
    // removida do transporte, e a credencial do canal oficial já foi revogada. É a
    // promessa da migration 0106 ("não é mais elegível para envio") virando
    // comportamento.
    //
    // `failed` e não `queued` de propósito: fila implica "vai sair quando der", e
    // por este canal não vai sair nunca. Falha com código é o que aparece na tela
    // e é o que o ledger do agente lê como desfecho TERMINAL — em `queued` o
    // follow-up ficaria retentando contra um número que não existe mais.
    // Vem ANTES de `isConfigured`: um canal excluído não espera configuração.
    const { data: updated } = await supabase
      .from("messages")
      .update({
        status: "failed",
        error_code: "channel_archived",
        error_message: "Este número foi excluído da Central de Conexões.",
      })
      .eq("id", message.id)
      .select(MSG_COLS)
      .maybeSingle();
    if (updated) message = updated as unknown as Message;
  } else if (canalDesativado(c.channel_sessions?.metadata)) {
    // Canal DESATIVADO pelo operador: a lei é não entrar na inbox — e ela vale
    // nos dois sentidos. `failed` terminal como no arquivado (fila implicaria
    // "vai sair quando der", e por este canal não sai enquanto desligado).
    // Reativar volta a enviar sem reimportar nada.
    const { data: updated } = await supabase
      .from("messages")
      .update({
        status: "failed",
        error_code: "channel_disabled",
        error_message: "Este canal está desativado. Reative-o na Central de Conexões para voltar a enviar.",
      })
      .eq("id", message.id)
      .select(MSG_COLS)
      .maybeSingle();
    if (updated) message = updated as unknown as Message;
  } else if (!adapter.isConfigured()) {
    const { data: updated } = await supabase
      .from("messages")
      .update({
        metadata: { ...(message.metadata ?? {}), queued_reason: adapter.codes.notConfigured },
      })
      .eq("id", message.id)
      .select(MSG_COLS)
      .maybeSingle();
    if (updated) message = updated as unknown as Message;
  } else if (!chatId) {
    const { data: updated } = await supabase
      .from("messages")
      .update({
        status: "failed",
        error_code: "missing_phone_number",
        error_message: "Contato sem telefone para envio WhatsApp.",
      })
      .eq("id", message.id)
      .select(MSG_COLS)
      .maybeSingle();
    if (updated) message = updated as unknown as Message;
  } else if (!c.channel_sessions || c.channel_sessions.status !== "WORKING") {
    const { data: updated } = await supabase
      .from("messages")
      .update({
        metadata: {
          ...(message.metadata ?? {}),
          queued_reason: "channel_session_not_working",
        },
      })
      .eq("id", message.id)
      .select(MSG_COLS)
      .maybeSingle();
    if (updated) message = updated as unknown as Message;
  } else {
    try {
      // O `guardServiceEffect` de cada corte relê, pelo pg, a fronteira e a operação do
      // escopo de execução. Quando o ctx carrega EXATAMENTE essas mesmas, reler
      // pela REST é a mesma pergunta duas vezes por corte (quatro por bolha). Sem
      // escopo, ou com escopo diferente (UI, MCP, automação), a REST continua
      // sendo a única conferência e segue aqui.
      const fronteiraJaConferida = mesmaFronteira(currentExecutionBoundary(), ctx.serviceBoundary ?? null);
      const operacaoJaConferida = mesmaOperacao(currentExecutionAgentOperation(), ctx.agentOperation);
      const checkBoundary = async () => {
        await guardServiceEffect();
        await guardAgendaEffect();
        if (ctx.prospectingDelivery) await assertProspectingDelivery(supabase, ctx.prospectingDelivery);
        if (ctx.meetingDelivery) await assertMeetingDeliverySupabase(supabase, ctx.meetingDelivery);
        if (ctx.proactiveContext) await assertAgendaEffectSupabase(supabase, ctx.proactiveContext);
        if (ctx.serviceBoundary && !fronteiraJaConferida)
          await assertServiceBoundarySupabase(supabase, ctx.serviceBoundary);
        if (ctx.approvedReply) await prepareApprovedReplySupabase(supabase, ctx.approvedReply);
        if (ctx.agentOperation && !operacaoJaConferida)
          await assertAgentOperationSupabase(supabase, ctx.agentOperation);
      };
      // O que separa mídia de texto é a presença de `media` no envelope — o
      // adapter preserva o mesmo branch (e a mesma mensagem de erro de cada
      // método) do outro lado do seam.
      let externalId: string | null;
      if (input.type === "template") {
        // Template é caminho próprio: não passa pelo `adapter.send` (que fala em
        // texto/mídia) porque o payload da plataforma é outro — e porque o envio
        // exige checar o contrato ANTES de sair (bind vigente, valores completos),
        // coisa que só faz sentido para template.
        //
        // Mas quem SABE falar template é o adapter, quando sabe. Antes disto a
        // linha de baixo era o único caminho, e ela lia `META_PHONE_NUMBER_ID` e
        // `META_SYSTEM_USER_TOKEN` do ambiente: template de QUALQUER canal saía
        // pelo número da Meta, com o token da Meta. Para o canal intermediado
        // isso não é falha de envio — é a mensagem saindo pelo número ERRADO
        // para o cliente certo, e ninguém percebe porque ela sai.
        //
        // Hoje a linha de baixo resolve a credencial DA SESSÃO e o ambiente ficou
        // só como reserva (fatia F4 da #850), então ela precisa do número desta
        // conexão: `sessionRef` sai da MESMA linha que o adapter recebe acima.
        // ─── Pré-voo ANTES de escolher transporte ──────────────────────────
        //
        // Vale para os dois caminhos, e é por isso que está aqui e não dentro
        // de um deles: a definição aprovada é contrato da plataforma, não
        // característica do transporte. O caminho de baixo já conferia; o
        // adapter postava direto, e um parâmetro a mais virava `400` cru em vez
        // de "falta o valor {{2}}".
        const contrato = await conferirDefinicao(supabase, {
          organizationId: ctx.organization_id,
          // A conexão dona da definição: dois números têm modelos diferentes, e
          // conferir a do número errado aprovaria um envio que a plataforma
          // recusa. `null` só em base anterior à 0144.
          channelSessionId: c.channel_session_id ?? null,
          name: input.template_name ?? "",
          language: input.template_language ?? "",
          values: input.template_values ?? {},
        });

        await checkBoundary();
        externalId = adapter.sendTemplate
          ? (
              await adapter.sendTemplate({
                beforeSend: checkBoundary,
                organizationId: ctx.organization_id,
                sessionRef: resolveSessionRef(c.channel_sessions),
                to: chatId,
                providerConversationId: c.provider_conversation_id,
                name: input.template_name ?? "",
                language: input.template_language ?? "",
                values: input.template_values ?? {},
                contrato,
              })
            ).externalId
          : await sendTemplateForSession(supabase, {
              beforeSend: checkBoundary,
              organizationId: ctx.organization_id,
              // A conexão desta conversa: com dois canais espelhando o mesmo
              // modelo (oficial + parceiro), sem ela a busca acha duas linhas
              // e o envio falha com template_lookup_failed.
              channelSessionId: c.channel_session_id ?? null,
              // O número DESTA conexão: é por ele (com a organização) que a
              // credencial da tela é achada. Sem ele, a resolução não casaria
              // linha nenhuma e o envio voltaria ao ambiente.
              sessionRef: resolveSessionRef(c.channel_sessions),
              to: chatId,
              name: input.template_name ?? "",
              language: input.template_language ?? "",
              values: input.template_values ?? {},
            });
      } else if (input.media_storage_path) {
        // Storage-first: signed URL curta só pro canal baixar (nunca base64).
        const admin = createAdminClient();
        const { data: signed, error: signErr } = await admin.storage
          .from("whatsapp-media")
          .createSignedUrl(input.media_storage_path, 600);
        if (signErr || !signed?.signedUrl) {
          throw new Error(`storage_sign_failed: ${signErr?.message ?? "no_url"}`);
        }
        // O nome ORIGINAL vence o basename aleatório do bucket (#4457). Sem
        // nome — chamada por token, que só conhece o storage_path —, o
        // basename continua sendo o que sai, como sempre foi.
        const filename = nomeDoAnexoParaEnviar(input.media_filename, input.media_storage_path);
        await checkBoundary();
        ({ externalId } = await adapter.send({
          beforeSend: checkBoundary,
          organizationId: ctx.organization_id,
          sessionRef: resolveSessionRef(c.channel_sessions),
          to: chatId,
          providerConversationId: c.provider_conversation_id,
          kind: input.type,
          media: {
            url: signed.signedUrl,
            mime: input.media_mime ?? "application/octet-stream",
            filename,
            caption: corpoDoCanal(input.body ?? null),
          },
          // O id que a PLATAFORMA conhece, lido da linha citada agora — não uma
          // cópia guardada no envio, que poderia divergir da linha.
          replyToExternalId: citada?.external_id ?? null,
        }));
      } else if (input.media_url) {
        // A spec da proposta comercial já previa isto ("Enviar | sendFile com
        // media_url") e nunca chegou a ser ligado: um envio só com `media_url`
        // (sem `media_storage_path` — que é só para arquivo já dentro da
        // PRÓPRIA conversa, ver `isMediaPathOwnedBy` acima) caía no `else` de
        // texto puro, com corpo vazio — nenhum arquivo saía. A URL já passou
        // pela guarda anti-SSRF antes de a linha existir
        // (`assertUrlDeMidiaSegura`): ela chega também pela API pública e
        // pelo MCP, e quem a baixa é o gateway, de dentro da rede do servidor.
        //
        // O NOME (#4524): sem `filename` no `media`, quem escolhia era o canal
        // — e o adaptador do canal grava o literal `file`, que no celular parece arquivo
        // suspeito. A ordem está em `nomeDoDocumentoParaEnvio`: campo novo
        // `media_filename`, depois o `metadata.media_filename` que a ingestão
        // já grava (#2613), depois o último segmento da URL com extensão, e
        // por fim uma reserva legível vinda do MIME. Nunca `file`, e nunca uma
        // exceção: o nome é rótulo, não condição de envio.
        const nomeDoDocumento = nomeDoDocumentoParaEnvio({
          nomeExplicito: input.media_filename,
          metadata: input.metadata,
          url: input.media_url,
          mime: input.media_mime,
        });
        await checkBoundary();
        ({ externalId } = await adapter.send({
          beforeSend: checkBoundary,
          organizationId: ctx.organization_id,
          sessionRef: resolveSessionRef(c.channel_sessions),
          to: chatId,
          providerConversationId: c.provider_conversation_id,
          kind: input.type,
          media: {
            url: input.media_url,
            mime: input.media_mime ?? "application/octet-stream",
            filename: nomeDoDocumento,
            caption: corpoDoCanal(input.body ?? null),
          },
          replyToExternalId: citada?.external_id ?? null,
        }));
      } else if (input.type === "contact") {
        const sc = outboundMetadata.shared_contact as
          { name: string; phone_number: string } | undefined;
        if (!sc?.phone_number) {
          throw new Error("contact_payload_missing");
        }
        // O envelope leva o cartão em formato AGNÓSTICO (vCard é formato, não
        // provider). Quem traduz para o payload do transporte é o adapter — o
        // de QR inclusive REESCREVE `whatsappId` e `vcard` depois de resolver o
        // wa_id real, então montá-los aqui com o nome do provider seria, além
        // de proibido pelo invariante 1, trabalho jogado fora.
        const telefone = normalizePhoneForDisplay(sc.phone_number);
        const nome = sc.name?.trim() || telefone;
        await checkBoundary();
        ({ externalId } = await adapter.send({
          beforeSend: checkBoundary,
          organizationId: ctx.organization_id,
          sessionRef: resolveSessionRef(c.channel_sessions),
          to: chatId,
          providerConversationId: c.provider_conversation_id,
          kind: "contact",
          body: outboundBody ?? nome,
          contact: {
            fullName: nome,
            phoneNumber: telefone,
            whatsappId: phoneToWhatsappId(telefone),
            vcard: buildVcard(nome, telefone),
          },
        }));
      } else {
        await checkBoundary();
        ({ externalId } = await adapter.send({
          beforeSend: checkBoundary,
          organizationId: ctx.organization_id,
          sessionRef: resolveSessionRef(c.channel_sessions),
          to: chatId,
          providerConversationId: c.provider_conversation_id,
          kind: input.type,
          body: corpoDoCanal(input.body ?? "") ?? "",
          replyToExternalId: citada?.external_id ?? null,
        }));
      }

      // The provider has accepted the send. Preserve its receipt even if authority
      // changed after the final beforeSend cut; recognition is not another send.
      if (ctx.meetingDelivery) {
        await assertMeetingDeliverySupabase(supabase, ctx.meetingDelivery);
        if (ctx.serviceBoundary) await assertServiceBoundarySupabase(supabase, ctx.serviceBoundary);
      }
      if (ctx.approvedReply) {
        message=await recordApprovedReplyReceiptSupabase(supabase,ctx.approvedReply,message.id,externalId,
          externalId?(adapter.echoExternalIds?.({externalId,recipient:chatId})??[externalId]):[]) as unknown as Message;
      } else {
      const candidatosDoEco = externalId
        ? (adapter.echoExternalIds?.({ externalId, recipient: chatId }) ?? [externalId])
        : [];
      const limparEco = () =>
        removerEcoDoProprioEnvio(supabase, ctx.organization_id, c.id, message.id, externalId, candidatosDoEco);
      // A FORMA GRAVADA É A CANÔNICA DO CANAL (`adapter.canonicalExternalId`),
      // não o id cru que o adapter devolveu: é a string que o eco do webhook
      // grava (a cauda na conversa individual, desde o #1855; o id intacto em
      // grupo), e o `unique (organization_id, external_id)` só
      // recusa a segunda linha se os DOIS lados gravarem a MESMA string — é
      // dele que vem a rede de segurança contra a corrida entre a limpeza e
      // este UPDATE.
      //
      // No NOWEB nada muda: a resposta de envio já é a cauda (`3EB0…`) e a
      // regra canônica a devolve intacta. No WEBJS ela vem como `_serialized`
      // (`true_<chat>_3EB0…`); gravada aqui, nunca colidia com a cauda do eco,
      // o `23505` não disparava e o eco que entrava nesse intervalo nascia como
      // segunda linha com a mesma frase (#196).
      //
      // Os leitores continuam achando o id: `handleAck` e `echoExternalIds`
      // procuram o par `[composto, cauda]`, sem migration e sem backfill para
      // as linhas antigas, que guardam o id completo. Editar/apagar uma linha
      // NOVA gravada em cauda reconstrói o id completo a partir do DESTINATÁRIO
      // do contato (`resolveRecipient`, que prefere o `@lid`), e não mais do chat
      // do id gravado — se isso acha a mesma mensagem no WEBJS não foi medido.
      // Canais que não implementam o método (id simétrico) gravam exatamente o
      // que o envio devolveu.
      const idCanonico =
        externalId !== null ? (adapter.canonicalExternalId?.(externalId) ?? externalId) : null;
      const marcarEnviada = (comId: boolean) =>
        supabase
          .from("messages")
          .update({
            status: "sent",
            ...(comId ? { external_id: idCanonico } : {}),
            ack: 0,
            // Colunas só do template — é o que responde custo e conformidade de
            // janela depois, sem varrer jsonb.
            ...(input.type === "template"
              ? { template_name: input.template_name, template_language: input.template_language }
              : {}),
          })
          .eq("id", message.id)
          .select(MSG_COLS)
          .maybeSingle();
      await limparEco();
      let { data: updated, error: erroAoMarcar } = await marcarEnviada(true);
      // O eco que o webhook inseriu ENTRE a limpeza e este UPDATE já ocupa o id
      // (o eco grava a mesma forma que o envio — #1855), e o unique recusa. É a
      // mesma recusa que o watchdog trata em `markRedriveSent`: limpar de novo e
      // carimbar outra vez; se ainda colidir, a mensagem SAIU e fica `sent` sem
      // o id — nunca `queued`, que é pedir para ser reenviada.
      if (erroAoMarcar?.code === "23505") {
        await limparEco();
        ({ data: updated, error: erroAoMarcar } = await marcarEnviada(true));
        if (erroAoMarcar?.code === "23505") ({ data: updated } = await marcarEnviada(false));
      }
      if (updated) message = updated as unknown as Message;
      }
    } catch (err) {
      if (err instanceof AgendaPuladoError) {
        // O pulo por compromisso chega aqui pelo recheck do corte, DEPOIS do
        // INSERT: relançar com a linha em `queued` deixava o redrive do
        // watchdog (`redriveQueued`) mandá-la pelo canal — o que o operador
        // escolheu NÃO mandar. A linha sai de `queued` como os bloqueios do
        // watchdog saem (`pre_go_live`, `channel_disabled`); só se ainda estiver
        // lá, para não rebaixar um `sent`.
        //
        // O RETRATO do compromisso vai no mesmo UPDATE (#3848): o código diz que
        // foi um pulo, não diz QUAL compromisso barrou, e a Inbox mostra o selo
        // com o motivo e o compromisso. A leitura do título é best-effort — se
        // falhar, o selo aparece sem ele; o que não pode é a linha continuar em
        // `queued` por causa de uma leitura acessória.
        const protecao = err.protection;
        let titulo: string | null = null;
        if (protecao.appointment_id) {
          // `try` porque esta leitura não decide nada: o que decide é o UPDATE
          // abaixo, e um erro aqui (tabela ausente num clone antigo, dublê sem o
          // método) não pode deixar a linha em `queued` — seria justamente o
          // pedido de reenvio que este ramo existe para evitar.
          try {
            const { data: compromisso } = await supabase
              .from("calendar_appointments")
              .select("title")
              .eq("organization_id", ctx.organization_id)
              .eq("id", protecao.appointment_id)
              .maybeSingle();
            titulo = (compromisso as { title?: string | null } | null)?.title ?? null;
          } catch {
            titulo = null;
          }
        }
        await supabase
          .from("messages")
          .update({
            status: "failed",
            error_code: MOTIVO_DO_PULO,
            error_message: "Não enviada: o contato tem horário marcado e o fluxo está configurado para não enviar.",
            metadata: {
              ...((message.metadata ?? {}) as Record<string, unknown>),
              [CHAVE_DO_PULO]: retratoDoPulo({
                appointment_id: protecao.appointment_id,
                motivo: protecao.motivo,
                titulo,
              }),
            },
          })
          .eq("id", message.id)
          .eq("organization_id", ctx.organization_id)
          .eq("status", "queued")
          .select("id")
          .maybeSingle();
        throw err;
      }
      if (err instanceof StaleServiceBoundaryError || err instanceof AgendaDeferredError || err instanceof ApprovedReplyReceiptPersistenceError) throw err;
      const msg = err instanceof Error ? err.message : adapter.codes.unknownError;
      // `storage_sign_failed` fica literal: é falha do NOSSO Storage, não do
      // canal — a URL assinada é montada antes de qualquer coisa tocar o adapter.
      const code = msg.startsWith("storage_sign_failed")
        ? "storage_sign_failed"
        : adapter.codes.sendFailed;

      // Falta de CREDENCIAL não é falha desta mensagem: é canal ainda não
      // conectado, e o desfecho certo é `queued` — a mesma coisa que o ramo de
      // `!isConfigured()` acima grava. Marcar `failed` mandaria o follow-up
      // desistir de uma mensagem que sai sozinha assim que alguém conectar.
      //
      // Este ramo existe porque nem todo canal consegue responder `isConfigured`
      // com honestidade: quando a credencial mora na SESSÃO (conta conectada
      // pela tela) e não no ambiente, um método SÍNCRONO não tem como saber, e
      // responder "não configurado" travaria em `queued` um canal que funciona.
      // Quem sabe é `send()`, que pode consultar o banco — então ele lança, e a
      // tradução do desfecho acontece aqui.
      // O canal pediu para DESACELERAR (~10 msg/min por destinatário, erro
      // 131056 da plataforma; ou a conta limitada, 429 com `Retry-After`). A
      // mensagem não tem nada de errado; falhá-la seria perder uma venda por
      // pressa. Fica `queued` com o motivo carimbado, e o PEDIDO de nova
      // tentativa sai junto, com a espera que o canal pediu. Quem tenta de novo
      // é `lib/messaging/reenvio-desacelerado.handler.ts`, na MESMA linha; a
      // espera esquecida (recover-stuck) segue como rede se ele não conseguir.
      //
      // SÓ para a mensagem sem outro dono de nova tentativa — a da pessoa e a
      // do agente (`reenvioDesaceleradoAtende`). Campanha, automação e
      // integração têm o próprio laço, e ele lê `failed`: a campanha devolve a
      // pessoa à fila pelo `ehLimiteDeVelocidade` (`lib/campanhas/rodada.ts`).
      // Gravar `queued` para elas deixaria a linha parada sem ninguém para
      // mandá-la, e o destinatário da campanha contado como enviado. Para elas o
      // desfecho segue o de antes: `failed`, com o texto do canal.
      if (
        adapter.codes.throttled &&
        msg.startsWith(adapter.codes.throttled) &&
        reenvioDesaceleradoAtende(message.sent_via ?? origemDaMensagem(ctx.actor))
      ) {
        const { data: emFila } = await supabase
          .from("messages")
          .update({
            status: "queued",
            metadata: { ...(message.metadata ?? {}), queued_reason: adapter.codes.throttled },
          })
          .eq("id", message.id)
          .select(MSG_COLS)
          .maybeSingle();
        if (emFila) message = emFila as unknown as Message;
        await pedirReenvioDoDesacelerado(supabase, {
          organizationId: ctx.organization_id,
          messageId: message.id,
          queuedReason: adapter.codes.throttled,
          esperaMs: esperaPedidaPeloCanal(msg),
        });
        return message;
      }

      if (msg.startsWith(adapter.codes.notConfigured)) {
        const { data: emFila } = await supabase
          .from("messages")
          .update({
            metadata: { ...(message.metadata ?? {}), queued_reason: adapter.codes.notConfigured },
          })
          .eq("id", message.id)
          .select(MSG_COLS)
          .maybeSingle();
        if (emFila) message = emFila as unknown as Message;
        return message;
      }

      const { data: updated } = await supabase
        .from("messages")
        .update({
          status: "failed",
          error_code: code,
          error_message: msg,
        })
        .eq("id", message.id)
        .select(MSG_COLS)
        .maybeSingle();
      if (updated) {
        message = updated as unknown as Message;
        // A linha passou a `failed` — é AGORA que o integrador tem de saber
        // (#1614). Este é o caminho das falhas de PRÉ-VOO do envio; a recusa
        // que chega depois, pelo webhook de status, emite pelo outro.
        await emitirFalhaDeEntrega(supabase, {
          organizationId: ctx.organization_id,
          source: "messages-send",
          requestId: ctx.requestId,
          falha: {
            message_id: message.id,
            conversation_id: c.id,
            contact_id: c.contact_id,
            contact: c.contacts?.phone_number ?? null,
            sent_via: message.sent_via ?? origemDaMensagem(ctx.actor),
            erro: {
              codigo: message.error_code ?? code,
              titulo: message.error_message ?? msg,
            },
          },
        });
      }
    }
  }

  if (!ctx.approvedReply) {
  const conversationUpdate: {
    last_outbound_at: string;
    last_message_at: string;
    last_message_preview: string;
    unread_count_for_assignee: number;
    bot_silenced_until?: string;
    awaiting_since: string | null;
  } = {
    last_outbound_at: now,
    last_message_at: now,
    last_message_preview: previewFrom({
      body: input.body,
      media_url: input.media_url,
      media_storage_path: input.media_storage_path,
      type: input.type,
    }),
    // Resposta humana/CRM zera pendências — espelha fn_mark_conversation_message
    // outbound, que o envio pelo CRM não chama (só atualiza colunas à mão).
    unread_count_for_assignee: 0,
    // E zera a ESPERA da Fila (issue #990): a régua é `awaiting_since`, e o valor
    // que a resposta produz é o que `fn_reply_record_receipt` grava —
    // `awaiting_since = last_inbound_at`, isto é, "a resposta cobre a última
    // mensagem do cliente". Sem esta linha, o envio pelo CRM (e pelo agente) deixa
    // a conversa contando a espera que a própria resposta acabou de encerrar.
    awaiting_since: c.last_inbound_at,
  };
  // Resposta humana pela TELA ou envio EM NOME DE uma pessoa (#3421): as duas
  // decisões são de gente da equipe, e a segunda gravava a linha sem calar a
  // IA — ela respondia junto com o atendente. O envio em nome de atravessa a
  // rota só com o escopo `messages:on_behalf` + membership validados
  // (`app/api/v1/messages/route.ts:103-179`), que é a mesma certeza de "foi
  // pessoa quem decidiu" que o `actor.type === "user"` tem. Mesmo carimbo,
  // mesma janela, mesma régua de quem lê `bot_silenced_until`; quem NÃO traz
  // `onBehalfOf` (automação, integração pura, agente) continua de fora.
  if (ctx.actor.type === "user" || ctx.onBehalfOf) {
    const silenceUntil = extendBotSilence(c.bot_silenced_until, now);
    if (silenceUntil) conversationUpdate.bot_silenced_until = silenceUntil;
  }

  await supabase.from("conversations").update(conversationUpdate).eq("id", c.id);

  // Envio pelo CRM não passa por `fn_mark_conversation_message` — carimba o
  // contato aqui para /app/contacts refletir a resposta (migration 0162).
  //
  // O `organization_id` entra explícito, e não é redundância: este handler
  // também é chamado pelo agent-engine com o client de SERVICE ROLE, que
  // BYPASSA RLS (`lib/agent-engine/edge/crm/mcp-client.ts` diz isso no próprio
  // cabeçalho: "todo uso filtra organization_id manualmente"). Sem o filtro, a
  // única coisa entre esta escrita e outro tenant seria a confiança em
  // `c.contact_id` — e o anti-pattern nº 10 do CLAUDE.md existe justamente
  // porque essa confiança já falhou antes.
  //
  // Sem `await`: é carimbo de listagem, ninguém lê antes da resposta, e esperá-lo
  // somava uma ida ao banco a cada bolha do agente.
  void supabase
    .from("contacts")
    .update({ last_activity_at: now })
    .eq("id", c.contact_id)
    .eq("organization_id", c.organization_id)
    .then(({ error }) => {
      if (error)
        logger.warn("messages.send.last_activity_failed", {
          code: error.code,
          message: error.message,
          organization_id: c.organization_id,
          message_id: message.id,
        });
    });

  }
  const a = actorAuditPayload(ctx.actor);
  // Dois atores, uma linha (#1613): o `actor_user_id` continua sendo quem
  // AUTENTICOU — nulo num envio por token, como sempre foi —, o token vai no
  // `actor_api_token_id`, e a pessoa em nome de quem ele enviou fica em
  // `metadata.on_behalf_of_user_id`. A pessoa é uma alegação do token, não uma
  // identidade provada: pô-la na coluna de autor faria o log dizer que ela
  // agiu, quando ninguém a autenticou nesta chamada.
  const emNomeDe = ctx.onBehalfOf ? { on_behalf_of_user_id: ctx.onBehalfOf.userId } : {};
  // Fire-and-forget pela doutrina de audit: `audit()` nunca lança e reporta a
  // própria falha ao Sentry.
  void audit({
    action: "message.sent",
    actorUserId: a.actorUserId,
    actorApiTokenId: ctx.onBehalfOf ? apiTokenIdDoActor(ctx.actor) : undefined,
    organizationId: c.organization_id,
    resourceType: "message",
    resourceId: message.id,
    requestId: ctx.requestId,
    metadata: { ...a.metadataActor, ...emNomeDe, status: message.status, type: message.type },
  });

  // Sem `await`: quem consome `message.sent` é o dispatcher do event_log, que lê
  // depois e por conta própria; nada nesta resposta depende de a linha já existir.
  void supabase
    .rpc("emit_event", {
      p_event_type: "message.sent",
      p_entity_kind: "message",
      p_entity_id: message.id,
      p_payload: { status: message.status, conversation_id: c.id },
      p_metadata: { request_id: ctx.requestId, ...a.metadataActor, ...emNomeDe },
      p_organization_id: c.organization_id,
    })
    .then(({ error }) => {
      if (error)
        logger.error("messages.send.emit_event_failed", {
          code: error.code,
          message: error.message,
          organization_id: c.organization_id,
          message_id: message.id,
        });
    });

  return message;
}
