/**
 * As AÇÕES da campanha — preparar, iniciar, agendar, pausar, retomar, cancelar,
 * duplicar e testar.
 *
 * Moram aqui, e não em oito rotas, porque as oito fazem a mesma coisa em volta:
 * carregar a campanha da organização certa, perguntar à máquina de estados se a
 * transição vale, escrever, auditar. Espalhado, esse "em volta" diverge — e o
 * dia em que uma rota esquecer de conferir o estado é o dia em que uma campanha
 * cancelada volta a enviar.
 *
 * Cada função devolve `{ ok: false, codigo, mensagem, status }` em vez de lançar:
 * a rota traduz para `fail()` sem interpretar exceção, e o motivo chega ao
 * operador com o texto real (Regra nº 1).
 */
import { randomUUID } from "node:crypto";

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

import type { ApiErrorCode } from "@/lib/api/errors";
import { sendMessageHandler } from "@/app/api/v1/messages/_handler";
import { PACING_DEFAULTS } from "@/lib/agent-engine/pacing/defaults";
import { beginServiceAtOrigin } from "@/lib/atendimento/origem";

import { baloesDaCampanha, enviarEmBaloes, pausaEntreBaloes } from "./baloes";
import { baseLegalValida, motivoParaExcluir, recusouMarketing } from "./elegibilidade";
import { ehStatusDaCampanha, podeTransitar } from "./maquina-de-estados";
import { prepararCampanha } from "./preparacao";
import { nomeDoContato } from "@/lib/contacts/rotulo-do-contato";
import { camposDoDestinatario } from "./consulta-de-audiencia";
import { codigoDaPlataforma, fraseDaFalha } from "@/lib/channels/erro-da-plataforma";
import {
  camposDoNegocioUsados,
  type ConteudoDoEnvio,
  resolverValoresDaCampanha,
  textoDoModeloParaAConversa,
  variaveisDaCampanhaSchema,
} from "./modelo";
import { logger } from "@/lib/logger";
import { conferirModeloDaCampanha } from "./modelo-da-conexao";
import type { ModeloPreparado } from "./preparacao";
import { renderizar } from "./renderizador";
import { encerrarTransmissao } from "./transmissao";
import type { ModoDaMensagem, StatusDaCampanha, VelocidadeDaCampanha } from "./tipos";

export interface CampanhaCarregada {
  id: string;
  organization_id: string;
  name: string;
  status: StatusDaCampanha;
  /** `null` = campanha de E-MAIL (#3441): ela não fala por número nenhum. */
  channel_session_id: string | null;
  message_body: string | null;
  base_legal: string;
  lia_ref: string | null;
  audience_filter: unknown;
  audience_version: number;
  content_version: number;
  scheduled_at: string | null;
  intervalo_segundos: number | null;
  janela_inicio_hora: number | null;
  janela_fim_hora: number | null;
  teto_diario: number | null;
  teto_horario: number | null;
  description: string | null;
  /** Migration 0710: `template` = modelo aprovado pelo número oficial; 9334: `email`. */
  message_mode: ModoDaMensagem;
  /** Migration 9334 (#3441): assunto do e-mail; `null` nos demais modos. */
  email_subject: string | null;
  provider_template_name: string | null;
  provider_template_language: string | null;
  provider_template_variables: unknown;
  /** Migration 0711: `rapida` = rajada na rodada (só com modelo); 0712: `transmissao`. */
  velocidade: VelocidadeDaCampanha;
  /** Migration 0712: o id da transmissão no provedor, depois que a lista foi entregue. */
  provider_broadcast_id: string | null;
  /** Migration 0765: texto sai em balões, um por parágrafo. */
  split_messages: boolean;
}

export type Recusa = {
  ok: false;
  codigo: ApiErrorCode;
  mensagem: string;
  status: number;
  /**
   * O que a ação JÁ gravou antes de falhar. Só o cancelamento parcial preenche:
   * a campanha virou `cancelled` e a fila não fechou. A rota audita isto — o
   * estado mudou, e a trilha não pode dizer que nada aconteceu.
   */
  gravado?: Record<string, unknown>;
};
export type Desfecho<T = unknown> = ({ ok: true } & T) | Recusa;

const COLUNAS =
  "id, organization_id, name, status, channel_session_id, message_body, base_legal, lia_ref, " +
  "audience_filter, audience_version, content_version, scheduled_at, description, " +
  "intervalo_segundos, janela_inicio_hora, janela_fim_hora, teto_diario, teto_horario, " +
  "message_mode, email_subject, provider_template_name, provider_template_language, " +
  "provider_template_variables, velocidade, provider_broadcast_id, split_messages";

export async function carregarCampanha(
  admin: SupabaseClient,
  organizationId: string,
  campanhaId: string,
): Promise<Desfecho<{ campanha: CampanhaCarregada }>> {
  const { data } = await admin
    .from("campaigns")
    .select(COLUNAS)
    .eq("organization_id", organizationId)
    .eq("id", campanhaId)
    .maybeSingle();
  if (!data) {
    return {
      ok: false,
      codigo: "campanha_nao_encontrada",
      mensagem: "Campanha não encontrada.",
      status: 404,
    };
  }
  const campanha = data as unknown as CampanhaCarregada;
  if (!ehStatusDaCampanha(campanha.status)) {
    return {
      ok: false,
      codigo: "campanha_estado_invalido",
      mensagem: `A campanha está num estado que este sistema não conhece ("${campanha.status}").`,
      status: 409,
    };
  }
  return { ok: true, campanha };
}

function recusaDeTransicao(de: StatusDaCampanha, para: StatusDaCampanha): Recusa | null {
  const r = podeTransitar(de, para);
  return r.pode ? null : { ok: false, codigo: "campanha_estado_invalido", mensagem: r.motivo, status: 409 };
}

/** O que toda campanha precisa ter antes de qualquer envio — inclusive o de teste. */
function faltaParaEnviar(c: CampanhaCarregada): Recusa | null {
  // E2a (#3441) entrega CONTEÚDO e PÚBLICO; o despacho do e-mail é a fatia
  // seguinte. Preparar/agendar uma campanha de e-mail agora só criaria um
  // snapshot que nenhum worker sabe mandar — a recusa é explícita, e não um
  // silêncio que alguém interpretaria como "quase pronto".
  if (c.message_mode === "email") {
    return {
      ok: false,
      codigo: "campanha_conteudo_invalido",
      mensagem:
        "Campanha de e-mail ainda não é enviada: esta entrega guarda o assunto, o corpo e o público. O envio entra na fatia seguinte.",
      status: 422,
    };
  }
  if (c.message_mode === "template") {
    if ((c.provider_template_name ?? "").trim() === "" || (c.provider_template_language ?? "").trim() === "") {
      return {
        ok: false,
        codigo: "campanha_conteudo_invalido",
        mensagem: "Escolha o modelo aprovado antes de preparar a campanha.",
        status: 422,
      };
    }
  } else if ((c.message_body ?? "").trim() === "") {
    return {
      ok: false,
      codigo: "campanha_conteudo_invalido",
      mensagem: "Escreva a mensagem antes de preparar a campanha.",
      status: 422,
    };
  }
  if (!baseLegalValida({ baseLegal: c.base_legal, liaRef: c.lia_ref })) {
    return {
      ok: false,
      codigo: "campanha_base_legal_invalida",
      mensagem:
        "Interesse legítimo exige a referência da avaliação (LIA). Sem ela não há como responder " +
        "a quem perguntar com base em quê recebeu a mensagem.",
      status: 422,
    };
  }
  return null;
}

/**
 * O modelo da campanha oficial, conferido AGORA — entre salvar e preparar a
 * plataforma pode ter pausado o modelo, ou alguém pode ter apagado a definição.
 * A mesma conferência de salvar (`conferirModeloDaCampanha`). Campanha de texto
 * devolve `{ ok: true }` sem modelo.
 */
export async function modeloDaCampanha(
  admin: SupabaseClient,
  c: CampanhaCarregada,
): Promise<Desfecho<{ modelo?: ModeloPreparado }>> {
  if (c.message_mode !== "template") return { ok: true };
  const numero = c.channel_session_id;
  if (numero === null) {
    return {
      ok: false,
      codigo: "campanha_canal_indisponivel",
      mensagem: "Campanha com modelo aprovado exige o número oficial que tem o modelo.",
      status: 409,
    };
  }
  const variaveis = variaveisDaCampanhaSchema.safeParse(c.provider_template_variables ?? {});
  if (!variaveis.success) {
    return {
      ok: false,
      codigo: "campanha_modelo_variaveis",
      mensagem: "O mapa de variáveis do modelo está ilegível. Escolha as origens de novo.",
      status: 422,
    };
  }
  const conferencia = await conferirModeloDaCampanha(
    admin,
    {
      organizationId: c.organization_id,
      channelSessionId: numero,
      name: c.provider_template_name ?? "",
      language: c.provider_template_language ?? "",
    },
    variaveis.data,
  );
  if (!conferencia.ok) {
    return { ok: false, codigo: conferencia.codigo, mensagem: conferencia.mensagem, status: 422 };
  }
  return {
    ok: true,
    modelo: { definicao: conferencia.definicao, contrato: conferencia.contrato, variaveis: variaveis.data },
  };
}

/** Já saiu alguma mensagem desta campanha? Reconstruir snapshot depois disso é proibido. */
async function jaEnviou(admin: SupabaseClient, campanhaId: string): Promise<boolean> {
  const { count } = await admin
    .from("campaign_recipients")
    .select("id", { count: "exact", head: true })
    .eq("campaign_id", campanhaId)
    .not("sent_at", "is", null);
  return (count ?? 0) > 0;
}

export async function prepararAcao(
  admin: SupabaseClient,
  c: CampanhaCarregada,
  agora: Date,
): Promise<Desfecho<{ resumo: { total: number; elegiveis: number; excluidos: number } }>> {
  const recusa = recusaDeTransicao(c.status, "preparing") ?? faltaParaEnviar(c);
  if (recusa) return recusa;
  const doModelo = await modeloDaCampanha(admin, c);
  if (!doModelo.ok) return doModelo;
  if (await jaEnviou(admin, c.id)) {
    return {
      ok: false,
      codigo: "campanha_nao_editavel",
      mensagem: "Esta campanha já enviou mensagens; refazer a lista mudaria o que já foi dito.",
      status: 409,
    };
  }

  // Compare-and-set: dois cliques simultâneos, e só um entra em `preparing`.
  const { data: entrou } = await admin
    .from("campaigns")
    .update({ status: "preparing" })
    .eq("id", c.id)
    .eq("status", "draft")
    .select("id");
  if ((entrou ?? []).length === 0) {
    return {
      ok: false,
      codigo: "campanha_preparando",
      mensagem: "A preparação desta campanha já está em andamento.",
      status: 409,
    };
  }

  try {
    const resumo = await prepararCampanha(admin, {
      campanhaId: c.id,
      organizationId: c.organization_id,
      filtro: c.audience_filter,
      corpo: c.message_body ?? "",
      contentVersion: c.content_version,
      agora,
      ...(doModelo.modelo ? { modelo: doModelo.modelo } : {}),
    });
    if (resumo.total === 0) {
      await voltarAoRascunho(admin, c.id, "audiencia_vazia");
      return {
        ok: false,
        codigo: "campanha_sem_audiencia",
        mensagem: "O recorte não encontrou nenhum contato. Ajuste o filtro.",
        status: 422,
      };
    }
    if (resumo.elegiveis === 0) {
      await voltarAoRascunho(admin, c.id, "sem_elegiveis");
      return {
        ok: false,
        codigo: "campanha_sem_elegiveis",
        mensagem:
          "O recorte encontrou contatos, mas nenhum pode receber — veja os motivos na prévia.",
        status: 422,
      };
    }

    const { error: erroAoConcluir } = await admin
      .from("campaigns")
      .update({
        status: "ready",
        prepared_at: agora.toISOString(),
        snapshot_total: resumo.total,
        snapshot_eligible: resumo.elegiveis,
        snapshot_excluded: resumo.excluidos,
        audience_version: c.audience_version + 1,
      })
      .eq("id", c.id)
      .eq("status", "preparing");
    if (erroAoConcluir) {
      await voltarAoRascunho(admin, c.id, "erro_na_preparacao");
      return {
        ok: false,
        codigo: "internal_error",
        mensagem: `Não foi possível concluir a preparação da campanha: ${erroAoConcluir.message}`,
        status: 500,
      };
    }

    return { ok: true, resumo };
  } catch (err) {
    // A campanha não pode ficar presa em `preparing`: quem tentou preparar
    // precisa poder corrigir o filtro e tentar de novo.
    await voltarAoRascunho(admin, c.id, "erro_na_preparacao");
    return {
      ok: false,
      codigo: "campanha_sem_audiencia",
      mensagem: err instanceof Error ? err.message : String(err),
      status: 422,
    };
  }
}

async function voltarAoRascunho(admin: SupabaseClient, id: string, codigo: string): Promise<void> {
  await admin
    .from("campaigns")
    .update({ status: "draft", failure_code: codigo })
    .eq("id", id)
    .eq("status", "preparing");
}

export async function iniciarAcao(
  admin: SupabaseClient,
  c: CampanhaCarregada,
  agora: Date,
): Promise<Desfecho<{ retomada: boolean }>> {
  const recusa = recusaDeTransicao(c.status, "running") ?? faltaParaEnviar(c);
  if (recusa) return recusa;
  // O modelo pode ter sido pausado pela plataforma desde a preparação — e é
  // exatamente o motivo mais comum de uma campanha oficial ter parado sozinha.
  const doModelo = await modeloDaCampanha(admin, c);
  if (!doModelo.ok) return doModelo;

  const { count } = await admin
    .from("campaign_recipients")
    .select("id", { count: "exact", head: true })
    .eq("campaign_id", c.id)
    .eq("eligibility_status", "eligible");
  if ((count ?? 0) === 0) {
    return {
      ok: false,
      codigo: "campanha_sem_elegiveis",
      mensagem: "Nenhum destinatário elegível. Prepare a campanha antes de iniciar.",
      status: 422,
    };
  }

  const retomada = c.status === "paused";
  const { data } = await admin
    .from("campaigns")
    .update({
      status: "running",
      started_at: agora.toISOString(),
      paused_at: null,
      scheduled_at: null,
      // Retomar é a decisão de quem corrigiu a causa: o motivo da pausa
      // automática (`pausarPorFalha`, na rodada) sai da tela junto.
      failure_code: null,
      failure_detail: null,
    })
    .eq("id", c.id)
    .eq("status", c.status)
    .select("id");
  if ((data ?? []).length === 0) return conflitoDeCorrida();
  return { ok: true, retomada };
}

export async function agendarAcao(
  admin: SupabaseClient,
  c: CampanhaCarregada,
  quando: Date,
  agora: Date,
): Promise<Desfecho> {
  const recusa = recusaDeTransicao(c.status, "scheduled") ?? faltaParaEnviar(c);
  if (recusa) return recusa;
  if (quando.getTime() <= agora.getTime()) {
    return {
      ok: false,
      codigo: "campanha_agenda_invalida",
      mensagem: "Escolha uma data no futuro — para enviar agora, use Iniciar.",
      status: 422,
    };
  }
  const { data } = await admin
    .from("campaigns")
    .update({ status: "scheduled", scheduled_at: quando.toISOString(), paused_at: null })
    .eq("id", c.id)
    .eq("status", c.status)
    .select("id");
  if ((data ?? []).length === 0) return conflitoDeCorrida();
  return { ok: true };
}

export async function pausarAcao(
  admin: SupabaseClient,
  c: CampanhaCarregada,
  agora: Date,
): Promise<Desfecho> {
  const recusa = recusaDeTransicao(c.status, "paused");
  if (recusa) return recusa;
  const { data } = await admin
    .from("campaigns")
    .update({ status: "paused", paused_at: agora.toISOString() })
    .eq("id", c.id)
    .eq("status", c.status)
    .select("id");
  if ((data ?? []).length === 0) return conflitoDeCorrida();
  // Quem já estava `sending` NÃO é desfeito: a mensagem pode estar na borda
  // externa neste instante, e prometer cancelamento do que já saiu é mentir.
  return { ok: true };
}

export async function cancelarAcao(
  admin: SupabaseClient,
  c: CampanhaCarregada,
  agora: Date,
): Promise<Desfecho<{ cancelados: number; reconciliado?: true }>> {
  // Repetir o cancelamento não desfaz nada: termina de fechar a fila que uma
  // tentativa anterior deixou aberta (a campanha já estava gravada `cancelled`
  // quando o fechamento dos destinatários falhou). Sem pendência, continua 409.
  if (c.status === "cancelled") return reconciliarFila(admin, c, agora);
  const recusa = recusaDeTransicao(c.status, "cancelled");
  if (recusa) return recusa;
  const { data } = await admin
    .from("campaigns")
    .update({ status: "cancelled", cancelled_at: agora.toISOString() })
    .eq("id", c.id)
    .eq("status", c.status)
    .select("id");
  if ((data ?? []).length === 0) return conflitoDeCorrida();

  // A campanha já está cancelada quando esta linha roda: a rodada não escolhe
  // mais esta campanha, então não há corrida com o worker por estes pendentes.
  const fila = await fecharFila(admin, c.id, agora);
  // Por broadcast, a lista está com o provedor: cancelar lá o que ainda não
  // saiu, trazer o último retrato e fechar quem ficou `sending` (migration 0712).
  // Numa transmissão todo mundo está `sending` — sem somar estes, o operador
  // via «0 cancelados» com o cancelamento feito. Roda mesmo com a fila aberta:
  // a repetição só fecha a fila, e o provedor não pode ficar mandando.
  const fechadosNaTransmissao = await encerrarTransmissao(admin, c, agora);
  if (!fila.ok) {
    return filaNaoFechada({ cancelados: fechadosNaTransmissao, fila_fechada: false });
  }
  return { ok: true, cancelados: fila.fechados + fechadosNaTransmissao };
}

/** `pending`/`queued` da campanha → `cancelled`. Erro de banco não vira «0». */
async function fecharFila(
  admin: SupabaseClient,
  campanhaId: string,
  agora: Date,
): Promise<{ ok: true; fechados: number } | { ok: false }> {
  const { data, error } = await admin
    .from("campaign_recipients")
    .update({ status: "cancelled", cancelled_at: agora.toISOString() })
    .eq("campaign_id", campanhaId)
    .in("status", ["pending", "queued"])
    .select("id");
  if (error) {
    logger.warn("[campanha] cancelada sem fechar a fila", { campanha: campanhaId, codigo: error.code ?? null });
    return { ok: false };
  }
  return { ok: true, fechados: (data ?? []).length };
}

/**
 * A campanha já está `cancelled`: só a fila que sobrou é fechada. Não mexe em
 * `cancelled_at` da campanha (o histórico do cancelamento fica como foi), nem
 * em quem está `sending` (pode estar na borda externa), nem no provedor — o
 * broadcast foi encerrado na primeira tentativa.
 */
async function reconciliarFila(
  admin: SupabaseClient,
  c: CampanhaCarregada,
  agora: Date,
): Promise<Desfecho<{ cancelados: number; reconciliado: true }>> {
  const fila = await fecharFila(admin, c.id, agora);
  if (!fila.ok) return filaNaoFechada();
  if (fila.fechados === 0) return recusaDeTransicao(c.status, "cancelled") ?? conflitoDeCorrida();
  return { ok: true, cancelados: fila.fechados, reconciliado: true };
}

function filaNaoFechada(gravado?: Record<string, unknown>): Recusa {
  return {
    ok: false,
    codigo: "internal_error",
    mensagem:
      "A campanha está cancelada, mas não foi possível fechar a fila de quem ainda não recebeu. Cancele de novo para concluir.",
    status: 500,
    ...(gravado ? { gravado } : {}),
  };
}

export async function duplicarAcao(
  admin: SupabaseClient,
  c: CampanhaCarregada,
  autorId: string,
): Promise<Desfecho<{ id: string }>> {
  const { data, error } = await admin
    .from("campaigns")
    .insert({
      organization_id: c.organization_id,
      name: `${c.name} (cópia)`.slice(0, 160),
      description: c.description,
      channel_session_id: c.channel_session_id,
      message_body: c.message_body,
      message_mode: c.message_mode,
      email_subject: c.email_subject,
      provider_template_name: c.provider_template_name,
      provider_template_language: c.provider_template_language,
      provider_template_variables: c.provider_template_variables ?? {},
      base_legal: c.base_legal,
      lia_ref: c.lia_ref,
      audience_filter: c.audience_filter,
      intervalo_segundos: c.intervalo_segundos,
      janela_inicio_hora: c.janela_inicio_hora,
      janela_fim_hora: c.janela_fim_hora,
      teto_diario: c.teto_diario,
      teto_horario: c.teto_horario,
      velocidade: c.velocidade,
      split_messages: c.split_messages,
      created_by: autorId,
      // Nada de destinatário, resultado, agenda ou carimbo de execução: a cópia
      // é uma INTENÇÃO nova, e herdar números faria a tela mostrar entrega de
      // mensagem que esta campanha nunca mandou.
    })
    .select("id")
    .single();
  if (error || !data) {
    return {
      ok: false,
      codigo: "campanha_estado_invalido",
      mensagem: error?.message ?? "Não foi possível duplicar a campanha.",
      status: 422,
    };
  }
  return { ok: true, id: (data as { id: string }).id };
}

/**
 * O teste: a MESMA conexão, o MESMO renderizador, a MESMA camada de envio.
 *
 * Um teste que passasse por outro caminho provaria o outro caminho. E ele não
 * toca nos contadores da execução oficial — não cria destinatário, não gasta
 * fila —, mas gasta o ritmo do número, porque para o WhatsApp é uma mensagem
 * como qualquer outra.
 */
export async function testarAcao(
  admin: SupabaseClient,
  c: CampanhaCarregada,
  contactId: string,
  agora: Date,
  fuso: string,
): Promise<Desfecho<{ status: string }>> {
  const recusa = faltaParaEnviar(c);
  if (recusa) return recusa;

  const { data: contato } = await admin
    .from("contacts")
    .select("id, name, display_name, phone_number, is_blocked, is_personal, is_anonymized, consent")
    .eq("organization_id", c.organization_id)
    .eq("id", contactId)
    .maybeSingle();
  if (!contato) {
    return {
      ok: false,
      codigo: "campanha_nao_encontrada",
      mensagem: "Contato de teste não encontrado nesta organização.",
      status: 404,
    };
  }
  const linha = contato as {
    id: string;
    name: string | null;
    display_name: string | null;
    phone_number: string | null;
    is_blocked: boolean;
    is_personal: boolean;
    is_anonymized: boolean;
    consent: unknown;
  };

  // O teste respeita os MESMOS vetos: mandar teste para quem pediu para parar
  // seria furar o opt-out pela porta dos fundos.
  const motivo = motivoParaExcluir({
    contactId: linha.id,
    telefone: linha.phone_number,
    bloqueado: linha.is_blocked,
    pessoal: linha.is_personal === true,
    anonimizado: linha.is_anonymized,
    recusouMarketing: recusouMarketing(linha.consent),
  });
  if (motivo) {
    return {
      ok: false,
      codigo: "campanha_conteudo_invalido",
      mensagem: `Este contato não pode receber: ${motivo}.`,
      status: 422,
    };
  }

  const doModelo = await modeloDaCampanha(admin, c);
  if (!doModelo.ok) return doModelo;
  const modelo = doModelo.modelo;

  // Os campos personalizados que o TEXTO usa entram aqui pelo MESMO caminho da
  // prévia: teste que não resolvia `{{lead.gancho}}` reprovaria o texto que o
  // envio manda certo. Com modelo, quem diz se o negócio entra é o mapa.
  const campos = await camposDoDestinatario(admin, {
    organizationId: c.organization_id,
    contactId: linha.id,
    corpo: modelo ? "" : (c.message_body ?? ""),
    lerCamposDoNegocio: modelo ? camposDoNegocioUsados(modelo.variaveis).length > 0 : false,
  });

  let conteudo: ConteudoDoEnvio;
  if (modelo) {
    // O MESMO resolvedor da preparação: o teste manda o que o despacho mandaria.
    const r = resolverValoresDaCampanha(modelo.contrato, modelo.variaveis, {
      nomeDoContato: nomeDoContato(linha),
      camposDoNegocio: (campos.lead ?? {}) as Record<string, unknown>,
    });
    if (!r.ok) {
      return {
        ok: false,
        codigo: "campanha_conteudo_invalido",
        mensagem: `Este contato não preenche o modelo (${r.problemas.join("; ")}) — escolha outro para o teste.`,
        status: 422,
      };
    }
    conteudo = {
      type: "template",
      template_name: modelo.definicao.name,
      template_language: modelo.definicao.language,
      template_values: r.values,
      body: textoDoModeloParaAConversa(modelo.definicao, r.values),
    };
  } else {
    const render = renderizar(
      c.message_body ?? "",
      { nome: nomeDoContato(linha), ...campos },
      { agora, fuso },
    );
    if (render.faltando.length > 0) {
      return {
        ok: false,
        codigo: "campanha_conteudo_invalido",
        mensagem: `Falta ${render.faltando.join(", ")} no cadastro deste contato — escolha outro para o teste.`,
        status: 422,
      };
    }
    conteudo = { type: "text", body: render.texto };
  }

  const numeroDoTeste = c.channel_session_id;
  if (numeroDoTeste === null) {
    return {
      ok: false,
      codigo: "campanha_conteudo_invalido",
      mensagem: "Campanha de e-mail não tem teste de envio: o despacho do e-mail é a fatia seguinte.",
      status: 422,
    };
  }
  const boundary = await beginServiceAtOrigin(admin, c.organization_id, linha.id, numeroDoTeste);
  // O teste divide como a rodada divide (`baloes.ts`): quem testa precisa ver
  // no aparelho os mesmos balões que o destinatário vai receber.
  const baloes = conteudo.type === "text" ? baloesDaCampanha(conteudo.body, c.split_messages === true) : [conteudo.body];
  const { desfecho: mensagem } = await enviarEmBaloes(baloes, {
    enviar: (texto) =>
      sendMessageHandler(
        admin,
        {
          organization_id: c.organization_id,
          serviceBoundary: boundary,
          proactiveContext: { organizationId: c.organization_id, contactId: linha.id },
          actor: { type: "webhook_source", id: `campaign-test:${c.id}` },
          requestId: `campaign-test:${c.id}:${randomUUID()}`,
        } as Parameters<typeof sendMessageHandler>[1],
        {
          conversation_id: boundary.conversation_id,
          ...conteudo,
          ...(conteudo.type === "text" ? { body: texto } : {}),
          metadata: { source: "campaign_test", campaign_id: c.id },
        } as Parameters<typeof sendMessageHandler>[2],
      ),
    esperar: () => new Promise((ok) => setTimeout(ok, pausaEntreBaloes(PACING_DEFAULTS))),
  });

  const status = (mensagem as { status?: string }).status ?? "desconhecido";
  if (status === "failed") {
    // O motivo da plataforma, quando há: "falhou no canal" não diz ao operador
    // se o problema é o modelo, o número ou este contato.
    const m = mensagem as { error_code?: string | null; error_message?: string | null };
    const frase = fraseDaFalha(codigoDaPlataforma(m.error_code, m.error_message), m.error_message);
    return {
      ok: false,
      codigo: "campanha_canal_indisponivel",
      mensagem: frase
        ? `O envio de teste falhou: ${frase}`
        : "O envio de teste falhou no canal. Verifique a conexão antes de iniciar a campanha.",
      status: 409,
    };
  }
  return { ok: true, status };
}

function conflitoDeCorrida(): Recusa {
  return {
    ok: false,
    codigo: "campanha_estado_invalido",
    mensagem: "O estado da campanha mudou enquanto esta ação era processada. Recarregue a tela.",
    status: 409,
  };
}
