/**
 * O roteiro dentro do turno: chave desligada não consulta nada; começa por
 * palavra-gatilho ou pelo roteador; o turno que começa só grava o que o
 * validador leu; o log nunca leva o texto do cliente; e — no texto do turno —
 * o roteiro entra DEPOIS de tudo que silencia (achado 5 da prova do #1130).
 */
import { readFileSync } from 'node:fs';
import { join } from 'node:path';
import type pg from 'pg';
import { describe, expect, it, vi } from 'vitest';

import type { FlowGraph } from '@/lib/followup/graph-schema';
import { garantirPerguntaDoRoteiro, perguntaDoRoteiroPodeSair, prepararRoteiroDoTurno, type RoteiroDoTurno, type ValidarResposta } from './roteiro-no-turno';

const GRAFO: FlowGraph = {
  nodes: [
    { id: 't', type: 'trigger', label: 'Início', position: { x: 0, y: 0 }, config: {} },
    {
      id: 'c1',
      type: 'collect',
      label: 'Nome',
      position: { x: 0, y: 0 },
      config: { key: 'nome_completo', label: 'Nome completo', type: 'text', required: true, permite_correcao: true },
    },
    { id: 'e', type: 'end', label: 'Fim', position: { x: 0, y: 0 }, config: { outcome: 'converted' } },
  ],
  edges: [
    { id: 'a', source: 't', target: 'c1', priority: 0, condition: { type: 'always' } },
    { id: 'b', source: 'c1', target: 'e', priority: 0, condition: { type: 'always' } },
  ],
  settings: { max_tentativas_pergunta: 3, gatilhos: ['financiar'] },
};

/** Primeira pergunta sim/não: a captura determinística leria "sim" como resposta. */
const GRAFO_SIM_NAO: FlowGraph = {
  ...GRAFO,
  nodes: GRAFO.nodes.map((n) =>
    n.type === 'collect'
      ? { ...n, config: { key: 'tem_cnh', label: 'Tem CNH', type: 'boolean', required: true, permite_correcao: true } }
      : n,
  ),
};

/** #2630: o roteiro que liga pela ETAPA do funil — sem palavra-gatilho nenhuma. */
const ETAPA = '11111111-1111-4111-8111-111111111111';
/**
 * #3253 — a ENTRADA do card na etapa (`crm_leads.stage_changed_at`, carimbo da
 * 0071), fixa: o dublê não pode depender do relógio de quem roda a suíte.
 */
const ENTRADA_DO_CARD = '2026-10-01T10:00:00.000Z';
const ANTES_DA_ENTRADA = '2026-09-01T10:00:00.000Z';
const DEPOIS_DA_ENTRADA = '2026-10-02T10:00:00.000Z';
const GRAFO_POR_ETAPA: FlowGraph = {
  ...GRAFO,
  settings: { max_tentativas_pergunta: 3, etapas_do_funil: [ETAPA] },
};

/** Banco fake: um roteiro 'coletando' passa a existir quando alguém o insere. */
function banco(
  opts: {
    roteiroJaExiste?: boolean;
    grafo?: FlowGraph;
    /** A linha da mensagem do turno, como o roteiro a lê (legenda + derivado da mídia). */
    mensagem?: { body: string | null; media_derived_text?: string | null } | null;
    /** Perguntas já feitas ao cliente (eventos `roteiro_pergunta_feita`). */
    perguntasFeitas?: string[];
    /** Tentativas por pergunta (eventos `roteiro_tentativa`) — no teto, a pergunta esgota. */
    tentativas?: Record<string, number>;
    /** O LOTE inteiro (rajada): vence `mensagem` quando presente. */
    lote?: Array<{ id: string; body: string | null; media_derived_text?: string | null }>;
    /** A etapa do funil do card do contato (#2630): `crm_leads.stage_id`. */
    etapaDoContato?: string | null;
    /** `crm_leads.stage_changed_at` (#3253): a ENTRADA do card na etapa. */
    etapaDoContatoDesde?: string | null;
    /** `crm_leads.created_at` — a reserva, para o card criado direto na etapa. */
    cardCriadoEm?: string;
    /** `crm_leads.status` (#3253): o card ganho/perdido pode ficar parado na etapa. */
    statusDoCard?: string;
    /** Enrollments já nascidos do contato: (roteiro, quando nasceu). */
    enrollments?: Array<{ pointer_id: string; started_at: string }>;
  } = {},
) {
  const GRAFO_DO_BANCO = opts.grafo ?? GRAFO;
  const mensagem =
    opts.mensagem === undefined ? { body: 'quero financiar, meu nome é Lia Mendes', media_derived_text: null } : opts.mensagem;
  let existe = opts.roteiroJaExiste ?? false;
  const sqls: string[] = [];
  const chaves = new Set<string>();
  const query = vi.fn(async (sql: string, params: unknown[] = []) => {
    sqls.push(sql);
    if (/insert into followup_enrollment_events/.test(sql) && typeof params[5] === 'string') {
      if (chaves.has(params[5])) return { rows: [], rowCount: 0 };
      chaves.add(params[5]);
    }
    if (/from followup_enrollments e[\s\S]*e\.status = 'coletando'/.test(sql)) {
      return existe
        ? {
            rows: [
              {
                id: 'enr-1',
                pointer_id: 'ptr-1',
                version_id: 'ver-1',
                contact_id: 'ct',
                current_node_id: 't',
                status: 'coletando',
                nome: 'Cadastro',
                graph: GRAFO_DO_BANCO,
              },
            ],
            rowCount: 1,
          }
        : { rows: [], rowCount: 0 };
    }
    if (/select p\.id, p\.name as nome, v\.graph/.test(sql)) {
      // #3253: o NASCIMENTO mais recente do roteiro para o contato, quando o
      // SELECT o pede (`max(e.started_at)`). A comparação com a ENTRADA do card
      // é de `entradaJaGasta`; tirar o subselect da consulta deixa o card parado
      // rearmando o roteiro, e o caso novo fica VERMELHO.
      const pedeNascimento = /max\(e\.started_at\)/.test(sql);
      const nascimentos = (opts.enrollments ?? [])
        .filter((e) => e.pointer_id === 'ptr-1')
        .map((e) => e.started_at)
        .sort();
      return {
        rows: [
          {
            id: 'ptr-1',
            nome: 'Cadastro',
            graph: GRAFO_DO_BANCO,
            ja_concluiu: false,
            ultimo_nascimento: pedeNascimento ? (nascimentos.at(-1) ?? null) : null,
          },
        ],
        rowCount: 1,
      };
    }
    if (/select p\.active_version_id, v\.graph/.test(sql)) {
      return { rows: [{ active_version_id: 'ver-1', graph: GRAFO_DO_BANCO }], rowCount: 1 };
    }
    if (/insert into followup_enrollments/.test(sql)) {
      existe = true;
      return { rows: [{ id: 'enr-1' }], rowCount: 1 };
    }
    if (/event_type in \('roteiro_tentativa', 'roteiro_pergunta_feita'\)/.test(sql)) {
      const linhas = [
        ...(opts.perguntasFeitas ?? []).map((campo) => ({ tipo: 'roteiro_pergunta_feita', campo, n: 1 })),
        ...Object.entries(opts.tentativas ?? {}).map(([campo, n]) => ({ tipo: 'roteiro_tentativa', campo, n })),
      ];
      return { rows: linhas, rowCount: linhas.length };
    }
    if (/from messages/.test(sql)) {
      const linhas = opts.lote ?? (mensagem === null ? [] : [{ id: 'msg-1', ...mensagem }]);
      return { rows: linhas, rowCount: linhas.length };
    }
    // #2630: a etapa do card do contato, que a entrada por etapa lê — com a
    // ENTRADA nela (#3253): o carimbo da 0071, e o `created_at` de reserva.
    if (/from crm_leads/.test(sql)) {
      const etapa = opts.etapaDoContato ?? null;
      // O card FECHADO não conta (#3253): o filtro de status é do SQL, e sem ele
      // o card ganho/perdido parado na etapa volta a ligar o roteiro.
      const soAberto = /status = 'open'/.test(sql);
      if (etapa === null || (soAberto && (opts.statusDoCard ?? 'open') !== 'open')) {
        return { rows: [], rowCount: 0 };
      }
      return {
        rows: [
          {
            stage_id: etapa,
            stage_changed_at: opts.etapaDoContatoDesde === undefined ? ENTRADA_DO_CARD : opts.etapaDoContatoDesde,
            created_at: opts.cardCriadoEm ?? ENTRADA_DO_CARD,
          },
        ],
        rowCount: 1,
      };
    }
    if (/select custom_fields from contacts/.test(sql)) return { rows: [{ custom_fields: {} }], rowCount: 1 };
    if (/insert into followup_enrollment_events|update /.test(sql)) return { rows: [], rowCount: 1 };
    return { rows: [], rowCount: 0 };
  });
  return { pool: { query } as unknown as pg.Pool, query, sqls };
}

function log() {
  return { info: vi.fn(), warn: vi.fn(), error: vi.fn(), debug: vi.fn() };
}

const turno = {
  organizationId: 'org',
  contactId: 'ct',
  conversationId: 'conv',
  texto: 'quero financiar, meu nome é Lia Mendes',
  messageId: 'msg-1',
  flowPointerDoRoteador: null,
  mensagens: [{ de: 'cliente' as const, texto: 'quero financiar, meu nome é Lia Mendes' }],
};

const semLeitura: ValidarResposta = async () => ({ resultado: 'nao_respondeu' });

describe('prepararRoteiroDoTurno', () => {
  it('chave desligada: null, sem uma consulta sequer — mesmo com roteiro em andamento', async () => {
    const b = banco({ roteiroJaExiste: true });
    const r = await prepararRoteiroDoTurno(
      { pool: b.pool, moduloLigado: async () => false, validar: semLeitura, log: log() as never },
      turno,
    );
    expect(r).toBeNull();
    expect(b.query).not.toHaveBeenCalled();
  });

  it('sem roteiro e sem palavra-gatilho: null, nada é criado', async () => {
    const b = banco({ mensagem: { body: 'oi, tudo bem?' } });
    const r = await prepararRoteiroDoTurno(
      { pool: b.pool, moduloLigado: async () => true, validar: semLeitura, log: log() as never },
      { ...turno, texto: 'oi, tudo bem?' },
    );
    expect(r).toBeNull();
    expect(b.sqls.some((s) => /insert into followup_enrollments/.test(s))).toBe(false);
  });

  it('palavra-gatilho começa o roteiro; sem leitura do validador, a frase de gatilho NÃO vira resposta', async () => {
    const b = banco();
    const r = await prepararRoteiroDoTurno(
      { pool: b.pool, moduloLigado: async () => true, validar: semLeitura, log: log() as never },
      turno,
    );
    expect(r?.iniciadoNesteTurno).toBe(true);
    expect(r?.estado.situacao.pendentes.map((n) => n.config.key)).toEqual(['nome_completo']);
    expect(r?.bloco).toContain('Roteiro de atendimento ativo — Cadastro');
    expect(b.sqls.some((s) => /update contacts/.test(s))).toBe(false);
  });

  it('no turno de início, a captura determinística não roda: "sim, quero financiar" não responde a CNH', async () => {
    const b = banco({ grafo: GRAFO_SIM_NAO, mensagem: { body: 'sim, quero financiar' } });
    const r = await prepararRoteiroDoTurno(
      { pool: b.pool, moduloLigado: async () => true, validar: semLeitura, log: log() as never },
      { ...turno, texto: 'sim, quero financiar' },
    );
    expect(r?.iniciadoNesteTurno).toBe(true);
    expect(b.sqls.some((s) => /update contacts/.test(s))).toBe(false);
    expect(r?.estado.situacao.pendentes.map((n) => n.config.key)).toEqual(['tem_cnh']);
  });

  it('no turno de início, o que o validador leu na mensagem já é gravado', async () => {
    const b = banco();
    const r = await prepararRoteiroDoTurno(
      {
        pool: b.pool,
        moduloLigado: async () => true,
        validar: async () => ({ resultado: 'respondeu', respostas: [{ campo: 'nome_completo', valor: 'Lia Mendes' }] }),
        log: log() as never,
      },
      turno,
    );
    expect(b.sqls.some((s) => /update contacts/.test(s))).toBe(true);
    expect(r?.estado.situacao.pendentes).toHaveLength(0);
  });

  it('o roteador começa o roteiro que a intenção aponta, sem olhar palavra-gatilho', async () => {
    const b = banco({ mensagem: { body: 'oi' } });
    const r = await prepararRoteiroDoTurno(
      { pool: b.pool, moduloLigado: async () => true, validar: semLeitura, log: log() as never },
      { ...turno, texto: 'oi', flowPointerDoRoteador: 'ptr-1' },
    );
    expect(r?.iniciadoNesteTurno).toBe(true);
    expect(b.sqls.some((s) => /select p\.id, p\.name as nome, v\.graph/.test(s))).toBe(false);
  });

  it('o log do turno nunca leva o texto do cliente nem o valor lido', async () => {
    const b = banco({ roteiroJaExiste: true, mensagem: { body: 'meu CPF é 529.982.247-25 e sou Lia Mendes' } });
    const l = log();
    await prepararRoteiroDoTurno(
      {
        pool: b.pool,
        moduloLigado: async () => true,
        validar: async () => ({ resultado: 'respondeu', respostas: [{ campo: 'nome_completo', valor: 'Lia Mendes' }] }),
        log: l as never,
      },
      { ...turno, texto: 'meu CPF é 529.982.247-25 e sou Lia Mendes' },
    );
    const tudo = JSON.stringify([...l.info.mock.calls, ...l.warn.mock.calls]);
    expect(l.info).toHaveBeenCalled();
    expect(tudo).not.toContain('529');
    expect(tudo).not.toContain('Lia');
  });

  it('banco que falha: o turno segue sem roteiro (null), com aviso', async () => {
    const l = log();
    const r = await prepararRoteiroDoTurno(
      {
        pool: { query: async () => { throw new Error('conexão caiu'); } } as unknown as pg.Pool,
        moduloLigado: async () => true,
        validar: semLeitura,
        log: l as never,
      },
      turno,
    );
    expect(r).toBeNull();
    expect(l.warn).toHaveBeenCalled();
  });
});

/**
 * #2630 — A ENTRADA POR ETAPA DO FUNIL.
 *
 * O defeito medido: um card que entra em "Proposta enviada" não dispara o
 * roteiro daquela etapa, porque as únicas entradas do turno eram o roteador e a
 * palavra-gatilho. O conserto é a terceira porta (`settings.etapas_do_funil`),
 * com a precedência preservada: roteador → palavra-gatilho → etapa.
 */
describe('prepararRoteiroDoTurno — entrada por etapa do funil (#2630)', () => {
  const semGatilho = { body: 'oi, tudo bem?' };

  /** A origem gravada no evento `roteiro_iniciado` da trilha. */
  function iniciadoCom(b: ReturnType<typeof banco>, origem: string): boolean {
    return b.query.mock.calls
      .filter(([sql]) => /insert into followup_enrollment_events/.test(String(sql)))
      .map(([, params]) => params as unknown[])
      .filter((p) => p[3] === 'roteiro_iniciado')
      .some((p) => (JSON.parse(String(p[4])) as { origem?: string }).origem === origem);
  }

  it('⭐ card na etapa e mensagem sem palavra-gatilho: o roteiro começa, com origem etapa na trilha', async () => {
    const b = banco({ grafo: GRAFO_POR_ETAPA, mensagem: semGatilho, etapaDoContato: ETAPA });
    const r = await prepararRoteiroDoTurno(
      { pool: b.pool, moduloLigado: async () => true, validar: semLeitura, log: log() as never },
      { ...turno, texto: 'oi, tudo bem?' },
    );
    expect(r?.iniciadoNesteTurno).toBe(true);
    expect(b.sqls.some((s) => /insert into followup_enrollments/.test(s))).toBe(true);
    expect(
      iniciadoCom(b, 'etapa'),
      'a trilha precisa dizer que quem armou foi a etapa, e não a palavra-gatilho',
    ).toBe(true);
  });

  it('card em OUTRA etapa: nada começa — quem liga é a etapa do roteiro', async () => {
    const b = banco({
      grafo: GRAFO_POR_ETAPA,
      mensagem: semGatilho,
      etapaDoContato: '22222222-2222-4222-8222-222222222222',
    });
    const r = await prepararRoteiroDoTurno(
      { pool: b.pool, moduloLigado: async () => true, validar: semLeitura, log: log() as never },
      { ...turno, texto: 'oi, tudo bem?' },
    );
    expect(r).toBeNull();
    expect(b.sqls.some((s) => /insert into followup_enrollments/.test(s))).toBe(false);
  });

  it('contato sem card: nenhuma etapa liga, e a busca de roteiros não é repetida à toa', async () => {
    const b = banco({ grafo: GRAFO_POR_ETAPA, mensagem: semGatilho });
    const r = await prepararRoteiroDoTurno(
      { pool: b.pool, moduloLigado: async () => true, validar: semLeitura, log: log() as never },
      { ...turno, texto: 'oi, tudo bem?' },
    );
    expect(r).toBeNull();
    // Uma leitura de roteiros (a da palavra-gatilho); a etapa não paga a dela,
    // porque não há etapa nenhuma no card.
    expect(b.sqls.filter((s) => /select p\.id, p\.name as nome, v\.graph/.test(s))).toHaveLength(1);
  });

  it('⭐ o ROTEADOR ganha da etapa: com roteiro apontado, a etapa nem é lida', async () => {
    const b = banco({ grafo: GRAFO_POR_ETAPA, mensagem: semGatilho, etapaDoContato: ETAPA });
    const r = await prepararRoteiroDoTurno(
      { pool: b.pool, moduloLigado: async () => true, validar: semLeitura, log: log() as never },
      { ...turno, texto: 'oi', flowPointerDoRoteador: 'ptr-1' },
    );
    expect(r?.iniciadoNesteTurno).toBe(true);
    expect(b.sqls.some((s) => /from crm_leads/.test(s))).toBe(false);
    expect(iniciadoCom(b, 'roteador')).toBe(true);
  });

  it('a palavra-gatilho ganha da etapa: o pedido explícito vence o contexto do card', async () => {
    const grafo: FlowGraph = {
      ...GRAFO_POR_ETAPA,
      settings: { max_tentativas_pergunta: 3, gatilhos: ['financiar'], etapas_do_funil: [ETAPA] },
    };
    const b = banco({ grafo, mensagem: { body: 'quero financiar' }, etapaDoContato: ETAPA });
    const r = await prepararRoteiroDoTurno(
      { pool: b.pool, moduloLigado: async () => true, validar: semLeitura, log: log() as never },
      { ...turno, texto: 'quero financiar' },
    );
    expect(r?.iniciadoNesteTurno).toBe(true);
    expect(b.sqls.some((s) => /from crm_leads/.test(s))).toBe(false);
    expect(iniciadoCom(b, 'gatilho')).toBe(true);
  });

  // ─── A ENTRADA, e não o estado (#3253, revisão do mantenedor) ──────────────
  // Dois turnos seguidos com o card PARADO na etapa não podem reiniciar: a etapa
  // vale uma vez por ENTRADA do card nela.

  it('⭐ card PARADO na etapa com a entrada já gasta: o turno seguinte NÃO recomeça', async () => {
    // O roteiro já nasceu depois da entrada (e não está vivo — venceu pelo prazo
    // e virou terminal, que o `ja_concluiu` não conta). O card segue na etapa.
    const b = banco({
      grafo: GRAFO_POR_ETAPA,
      mensagem: semGatilho,
      etapaDoContato: ETAPA,
      enrollments: [{ pointer_id: 'ptr-1', started_at: DEPOIS_DA_ENTRADA }],
    });
    const deps = { pool: b.pool, moduloLigado: async () => true, validar: semLeitura, log: log() as never };
    const primeira = await prepararRoteiroDoTurno(deps, { ...turno, texto: 'oi, tudo bem?' });
    const segunda = await prepararRoteiroDoTurno(deps, { ...turno, texto: 'oi de novo' });
    expect(primeira).toBeNull();
    expect(segunda).toBeNull();
    expect(b.sqls.some((s) => /insert into followup_enrollments/.test(s))).toBe(false);
  });

  it('entrada NOVA: o roteiro nascido antes de o card voltar à etapa não gasta esta entrada', async () => {
    const b = banco({
      grafo: GRAFO_POR_ETAPA,
      mensagem: semGatilho,
      etapaDoContato: ETAPA,
      enrollments: [{ pointer_id: 'ptr-1', started_at: ANTES_DA_ENTRADA }],
    });
    const r = await prepararRoteiroDoTurno(
      { pool: b.pool, moduloLigado: async () => true, validar: semLeitura, log: log() as never },
      { ...turno, texto: 'oi, tudo bem?' },
    );
    expect(r?.iniciadoNesteTurno).toBe(true);
    expect(iniciadoCom(b, 'etapa')).toBe(true);
  });

  it('⭐ card criado DIRETO na etapa, sem `stage_changed_at`: a entrada é o `created_at` do card', async () => {
    // Sem movimento não há carimbo (o caso que o mantenedor nomeou): a data
    // honesta da entrada é a criação do card.
    const b = banco({
      grafo: GRAFO_POR_ETAPA,
      mensagem: semGatilho,
      etapaDoContato: ETAPA,
      etapaDoContatoDesde: null,
      cardCriadoEm: ENTRADA_DO_CARD,
      enrollments: [{ pointer_id: 'ptr-1', started_at: DEPOIS_DA_ENTRADA }],
    });
    const r = await prepararRoteiroDoTurno(
      { pool: b.pool, moduloLigado: async () => true, validar: semLeitura, log: log() as never },
      { ...turno, texto: 'oi, tudo bem?' },
    );
    expect(r).toBeNull();
    expect(b.sqls.some((s) => /insert into followup_enrollments/.test(s))).toBe(false);
  });

  // ─── O CARD FECHADO E A LEITURA ÚNICA (#3253, detalhes do mantenedor) ───────

  it('⭐ card GANHO parado na etapa: o roteiro não começa por um negócio que já terminou', async () => {
    const b = banco({
      grafo: GRAFO_POR_ETAPA,
      mensagem: semGatilho,
      etapaDoContato: ETAPA,
      statusDoCard: 'won',
    });
    const r = await prepararRoteiroDoTurno(
      { pool: b.pool, moduloLigado: async () => true, validar: semLeitura, log: log() as never },
      { ...turno, texto: 'oi, tudo bem?' },
    );
    expect(r).toBeNull();
    expect(b.sqls.some((s) => /insert into followup_enrollments/.test(s))).toBe(false);
    // O card do contato é lido com o STATUS: o filtro é do SQL.
    expect(b.sqls.find((s) => /from crm_leads/.test(s)) ?? '').toMatch(/status = 'open'/);
  });

  it('⭐ palavra-gatilho que NÃO casa: a lista de roteiros ativos é lida UMA vez no turno', async () => {
    // Antes do arremate, a palavra-gatilho e a etapa pediam cada uma a sua
    // consulta de roteiros ativos: o turno pagava a lista duas vezes.
    const b = banco({ grafo: GRAFO_POR_ETAPA, mensagem: semGatilho, etapaDoContato: ETAPA });
    const r = await prepararRoteiroDoTurno(
      { pool: b.pool, moduloLigado: async () => true, validar: semLeitura, log: log() as never },
      { ...turno, texto: 'oi, tudo bem?' },
    );
    expect(r?.iniciadoNesteTurno).toBe(true);
    expect(iniciadoCom(b, 'etapa')).toBe(true);
    expect(b.sqls.filter((s) => /select p\.id, p\.name as nome, v\.graph/.test(s))).toHaveLength(1);
  });
});

describe('mídia e retry (achado 8 da prova; revisão do PR 1)', () => {
  it('figurinha ou áudio sem transcrição: nem tentativa, nem validador', async () => {
    const b = banco({ roteiroJaExiste: true, mensagem: { body: null, media_derived_text: null } });
    const validar = vi.fn(semLeitura);
    const r = await prepararRoteiroDoTurno(
      { pool: b.pool, moduloLigado: async () => true, validar, log: log() as never },
      { ...turno, texto: '[sticker]' },
    );
    expect(r).not.toBeNull();
    expect(validar).not.toHaveBeenCalled();
    // A mensagem é reivindicada (o retry não a relê), mas nada conta tentativa.
    const tipos = b.query.mock.calls
      .filter(([sql]) => /insert into followup_enrollment_events/.test(String(sql)))
      .map(([, params]) => (params as unknown[])[3]);
    expect(tipos).toEqual(['roteiro_mensagem']);
  });

  it('áudio TRANSCRITO: o roteiro lê a transcrição, sem o enquadramento do histórico', async () => {
    const b = banco({ roteiroJaExiste: true, mensagem: { body: null, media_derived_text: 'meu nome é Lia Mendes' } });
    const validar = vi.fn(semLeitura);
    await prepararRoteiroDoTurno(
      { pool: b.pool, moduloLigado: async () => true, validar, log: log() as never },
      { ...turno, texto: '[Mídia do cliente: ele enviou um áudio…]\nConteúdo: meu nome é Lia Mendes' },
    );
    expect(validar).toHaveBeenCalledWith(expect.objectContaining({ textoAtual: 'meu nome é Lia Mendes' }));
  });

  it('retry da mesma mensagem não chama o validador de novo', async () => {
    const b = banco({ roteiroJaExiste: true });
    const validar = vi.fn(semLeitura);
    const deps = { pool: b.pool, moduloLigado: async () => true, validar, log: log() as never };
    await prepararRoteiroDoTurno(deps, turno);
    await prepararRoteiroDoTurno(deps, turno);
    expect(validar).toHaveBeenCalledTimes(1);
  });
});

describe('revisão adversarial do PR 2 — dado inventado', () => {
  it('⭐ turno que COMEÇA o roteiro: "sim, quero financiar" não vira tem_cnh = true', async () => {
    const b = banco({ grafo: GRAFO_SIM_NAO, mensagem: { body: 'sim, quero financiar' } });
    const validar = vi.fn<ValidarResposta>(async () => ({ resultado: 'nao_respondeu' }));
    const r = await prepararRoteiroDoTurno(
      { pool: b.pool, moduloLigado: async () => true, validar, log: log() as never },
      { ...turno, texto: 'sim, quero financiar' },
    );
    // Nenhuma pergunta foi feita: não há "pergunta atual" para o validador.
    expect(validar).toHaveBeenCalledWith(expect.objectContaining({ perguntaAtual: null }));
    expect(b.sqls.some((s) => /update contacts/.test(s))).toBe(false);
    expect(r?.estado.situacao.pendentes.map((n) => n.config.key)).toEqual(['tem_cnh']);
  });

  it('pergunta já FEITA é a pergunta atual no turno seguinte', async () => {
    const b = banco({
      roteiroJaExiste: true,
      grafo: GRAFO_SIM_NAO,
      mensagem: { body: 'sim' },
      perguntasFeitas: ['tem_cnh'],
    });
    const validar = vi.fn<ValidarResposta>(async () => ({ resultado: 'nao_respondeu' }));
    await prepararRoteiroDoTurno(
      { pool: b.pool, moduloLigado: async () => true, validar, log: log() as never },
      { ...turno, texto: 'sim' },
    );
    expect(validar).toHaveBeenCalledWith(expect.objectContaining({ perguntaAtual: 'tem_cnh' }));
  });

  it('⭐ RESPOSTA TARDIA (#1130, @vgamkt): a pergunta esgotada vai ao validador, e o que ele leu é gravado', async () => {
    // Duas perguntas: `cidade` já bateu no teto (3 de 3) e está ENCERRADA; a
    // CNH segue pendente, então o roteiro continua vivo.
    const grafo: FlowGraph = {
      ...GRAFO,
      nodes: [
        GRAFO.nodes[0]!,
        {
          id: 'c1',
          type: 'collect',
          label: 'Cidade',
          position: { x: 0, y: 0 },
          config: { key: 'cidade', label: 'Cidade', type: 'text', required: true, permite_correcao: false },
        },
        {
          id: 'c2',
          type: 'collect',
          label: 'CNH',
          position: { x: 0, y: 0 },
          config: { key: 'tem_cnh', label: 'Tem CNH', type: 'boolean', required: true, permite_correcao: false },
        },
        GRAFO.nodes[2]!,
      ],
      edges: [
        { id: 'a', source: 't', target: 'c1', priority: 0, condition: { type: 'always' } },
        { id: 'b', source: 'c1', target: 'c2', priority: 0, condition: { type: 'always' } },
        { id: 'c', source: 'c2', target: 'e', priority: 0, condition: { type: 'always' } },
      ],
    };
    const b = banco({
      roteiroJaExiste: true,
      grafo,
      mensagem: { body: 'ah, eu moro em Campinas' },
      perguntasFeitas: ['cidade', 'tem_cnh'],
      tentativas: { cidade: 3 },
    });
    const validar = vi.fn<ValidarResposta>(async () => ({
      resultado: 'respondeu',
      respostas: [{ campo: 'cidade', valor: 'Campinas' }],
    }));
    await prepararRoteiroDoTurno(
      { pool: b.pool, moduloLigado: async () => true, validar, log: log() as never },
      { ...turno, texto: 'ah, eu moro em Campinas' },
    );

    const args = validar.mock.calls[0]![0];
    expect(args.perguntas.map((p) => p.key)).toEqual(['tem_cnh']);
    expect(args.esgotados?.map((p) => p.key)).toEqual(['cidade']);
    const gravacao = b.query.mock.calls.find(([sql]) => /update contacts/.test(sql));
    expect(gravacao, 'a resposta tardia precisa chegar ao contato').toBeDefined();
    expect(gravacao![1]).toEqual(expect.arrayContaining(['cidade', 'Campinas']));
  });

  it('⭐ rajada "oi" + "meu cpf é 529.982.247-25": o validador lê o LOTE, e o retry não relê', async () => {
    const b = banco({
      roteiroJaExiste: true,
      lote: [
        { id: 'm1', body: 'oi' },
        { id: 'm2', body: 'meu cpf é 529.982.247-25' },
      ],
    });
    const validar = vi.fn<ValidarResposta>(async () => ({ resultado: 'nao_respondeu' }));
    const deps = { pool: b.pool, moduloLigado: async () => true, validar, log: log() as never };
    await prepararRoteiroDoTurno(deps, { ...turno, messageId: 'm1', texto: 'oi' });
    expect(validar).toHaveBeenCalledWith(
      expect.objectContaining({ textoAtual: 'oi\nmeu cpf é 529.982.247-25' }),
    );
    await prepararRoteiroDoTurno(deps, { ...turno, messageId: 'm1', texto: 'oi' });
    expect(validar).toHaveBeenCalledTimes(1);
  });
});

describe('garantirPerguntaDoRoteiro', () => {
  async function roteiroAtivo() {
    const b = banco({ roteiroJaExiste: true });
    const r = await prepararRoteiroDoTurno(
      { pool: b.pool, moduloLigado: async () => true, validar: semLeitura, log: log() as never },
      { ...turno, messageId: null },
    );
    return { roteiro: r!, b };
  }

  it('o modelo já perguntou: o motor não manda de novo', async () => {
    const { roteiro, b } = await roteiroAtivo();
    const enviar = vi.fn(async () => true);
    await garantirPerguntaDoRoteiro(
      { pool: b.pool, log: log() as never },
      { organizationId: 'org', roteiro, corposEnviados: ['Legal! Qual é o seu nome completo?'], enviar },
    );
    expect(enviar).not.toHaveBeenCalled();
  });

  it('o modelo não perguntou: o motor manda a pergunta pela cadeia', async () => {
    const { roteiro, b } = await roteiroAtivo();
    const enviar = vi.fn(async () => true);
    await garantirPerguntaDoRoteiro(
      { pool: b.pool, log: log() as never },
      { organizationId: 'org', roteiro, corposEnviados: ['Temos várias opções de financiamento.'], enviar },
    );
    expect(enviar).toHaveBeenCalledWith('Nome completo?');
  });
});

describe('#1943 — turno descartado como resposta obsoleta não emite a pergunta do roteiro', () => {
  async function roteiroAtivoComDescarte(): Promise<{ roteiro: RoteiroDoTurno; b: ReturnType<typeof banco> }> {
    const b = banco({ roteiroJaExiste: true });
    const roteiro = await prepararRoteiroDoTurno(
      { pool: b.pool, moduloLigado: async () => true, validar: semLeitura, log: log() as never },
      { ...turno, messageId: null },
    );
    return { roteiro: roteiro!, b };
  }

  it('perguntaDoRoteiroPodeSair: turno descartado veta, teto de envios segue valendo', () => {
    expect(perguntaDoRoteiroPodeSair({ turnoDescartado: true, seq: 0, maxSendsPerTurn: 5 })).toBe(false);
    expect(perguntaDoRoteiroPodeSair({ turnoDescartado: true, seq: 4, maxSendsPerTurn: 5 })).toBe(false);
    // Sem descarte, o teto de mensagens físicas (F2-15b) continua sendo o guard.
    expect(perguntaDoRoteiroPodeSair({ turnoDescartado: false, seq: 0, maxSendsPerTurn: 5 })).toBe(true);
    expect(perguntaDoRoteiroPodeSair({ turnoDescartado: false, seq: 5, maxSendsPerTurn: 5 })).toBe(false);
  });

  it('⭐ num turno descartado, garantirPerguntaDoRoteiro NÃO manda a pergunta nem registra roteiro_pergunta_feita', async () => {
    const { roteiro, b } = await roteiroAtivoComDescarte();
    // O `enviar` do turno é quem leva o descarte (ver `perguntaDoRoteiroPodeSair`
    // no inbound-turn.ts): no turno obsoleto ele devolve false, então a trava
    // "a pergunta saiu?" não envia nada e o evento não é registrado — a pergunta
    // segue pendente para o turno da mensagem nova.
    const enviar = vi.fn(async () =>
      perguntaDoRoteiroPodeSair({ turnoDescartado: true, seq: 0, maxSendsPerTurn: 5 }),
    );
    await garantirPerguntaDoRoteiro(
      { pool: b.pool, log: log() as never },
      { organizationId: 'org', roteiro, corposEnviados: [], enviar },
    );
    expect(enviar).toHaveBeenCalledTimes(1);
    expect(await enviar.mock.results[0]!.value).toBe(false);
    const eventos = b.query.mock.calls
      .filter(([sql]) => /insert into followup_enrollment_events/.test(String(sql)))
      .map(([, params]) => (params as unknown[])[3]);
    expect(eventos).not.toContain('roteiro_pergunta_feita');
    // A pergunta continua pendente: roteiro_pergunta_feita NÃO foi registrado.
    expect(roteiro.estado.situacao.pendentes.map((n) => n.config.key)).toEqual(['nome_completo']);
  });

  it('fiado no turno: a guarda liga o descarte e o enviar do roteiro veta por ele', () => {
    const src = readFileSync(join(process.cwd(), 'lib/agent-engine/agent/inbound-turn.ts'), 'utf8');
    const guarda = src.indexOf("code: 'resposta_obsoleta'");
    // Uso no `enviar` do roteiro — o import (com `} perguntaDoRoteiroPodeSair,`)
    // vem antes da guarda e enganaria um `indexOf` simples no nome.
    const enviaRoteiro = src.indexOf('!perguntaDoRoteiroPodeSair(');
    const marcaDescarte = src.indexOf('turnoDescartado = true');
    expect(marcaDescarte).toBeGreaterThan(0);
    // Uma só atribuição, e DENTRO da guarda: depois da chamada da régua.
    expect(src.split('turnoDescartado = true').length - 1).toBe(1);
    expect(marcaDescarte).toBeGreaterThan(src.indexOf('await respostaFicouObsoleta('));
    expect(marcaDescarte).toBeLessThan(guarda); // liga ANTES de devolver resposta_obsoleta
    expect(enviaRoteiro).toBeGreaterThan(guarda); // o enviar do roteiro lê o descarte
    const tornoDoEnviar = src.slice(enviaRoteiro, enviaRoteiro + 200);
    expect(tornoDoEnviar).toContain('turnoDescartado');
  });
});

describe('ordem no turno (achado 5 da prova do #1130)', () => {
  const src = readFileSync(join(process.cwd(), 'lib/agent-engine/agent/inbound-turn.ts'), 'utf8');
  const chamada = src.indexOf('await prepararRoteiroDoTurno(');

  it('o roteiro entra DEPOIS da pausa, do pedido de humano e do opt-out ambíguo', () => {
    expect(chamada).toBeGreaterThan(0);
    for (const trava of [
      'agentConfig.pausedAt',
      'detectHumanHandoffRequest(texto)',
      'detectAmbiguousOptOut(texto)',
      // O 4º argumento (`conversationId`, #4146) entra depois: o que se pina aqui
      // é a CHAMADA, não a lista de parâmetros.
      'isLeadInHandoff(pool, tenantId, leadId,',
    ]) {
      const posicao = src.indexOf(trava);
      expect(posicao, trava).toBeGreaterThan(0);
      expect(posicao, trava).toBeLessThan(chamada);
    }
  });

  it('e nunca para contato bloqueado, nem fora do turno de inbound', () => {
    const guarda = src.slice(src.lastIndexOf('const roteiro =', chamada), chamada);
    expect(guarda).toContain("liveJob().kind === 'inbound_turn'");
    expect(guarda).toContain('!optedOutThisTurn');
    expect(guarda).toContain('!preview');
  });
});
