import { beforeEach, describe, expect, it, vi } from "vitest";

import {
  GET,
  MAX_LOTES,
  type PodaDb,
  TAMANHO_DO_LOTE,
  houveEfeito,
  podarHistorico,
} from "@/app/api/v1/cron/data-retention/route";

import {
  RETENCAO_AUDITORIA_DIAS_PADRAO,
  RETENCAO_AUDITORIA_DIAS_PISO,
  RETENCAO_CANDIDATOS_GOLDEN_DIAS_PADRAO,
  RETENCAO_CANDIDATOS_GOLDEN_DIAS_PISO,
  RETENCAO_AVISO_DE_CASO_DIAS_PADRAO,
  RETENCAO_CONVERSA_DO_CASO_DIAS_PADRAO,
  RETENCAO_ESPELHO_AGENDA_DIAS_PADRAO,
  RETENCAO_ESPELHO_AGENDA_DIAS_PISO,
  RETENCAO_FILA_DIAS_PADRAO,
  RETENCAO_PASSAGEM_DIAS_PADRAO,
  RETENCAO_FILA_DIAS_PISO,
  RETENCAO_MIDIA_DIAS_PISO,
  RETENCAO_OBSERVACOES_DO_JEV_DIAS_PADRAO,
  RETENCAO_OBSERVACOES_DO_JEV_DIAS_PISO,
  RETENCAO_PROSPECCAO_DIAS_PADRAO,
  RETENCAO_PROSPECCAO_DIAS_PISO,
  RETENCAO_RASCUNHO_DIAS_PADRAO,
  RETENCAO_RASCUNHO_DIAS_PISO,
  RETENCAO_TETO_DIAS,
  interpretarRetencao,
} from "@/lib/retencao/politica";

vi.mock("@/lib/env", () => ({
  env: {
    INTERNAL_CRON_SECRET: "segredo",
    INTERNAL_SECRET: "",
    JOB_QUEUE_RETENTION_DAYS: "",
    AUDIT_LOG_RETENTION_DAYS: "",
  },
}));
const auditou = vi.fn();
vi.mock("@/lib/audit", () => ({ audit: (...args: unknown[]) => auditou(...args) }));

/** O que o `rpc` do admin client devolve nesta rodada (o handler HTTP usa isto). */
let respostaRpc: { data: number | null; error: { message: string } | null } = {
  data: 0,
  error: null,
};
/**
 * As linhas que a varredura de anonimização enxerga nesta rodada. Vazio por
 * padrão: os casos deste arquivo medem a PODA, e uma varredura com trabalho a
 * fazer acrescentaria linhas de auditoria que confundiriam a contagem — o que a
 * varredura faz é medido em `lgpd-varredura-completa-a-cascata.test.ts`.
 */
let contatosAnonimizados: Array<{ id: string; organization_id: string }> = [];
/**
 * As tabelas que o handler LEU nesta rodada, em ordem (#2508).
 *
 * É a prova de que a varredura de anonimização RODOU mesmo com poda parcial: a
 * primeira leitura dela é em `contacts`, e a poda sozinha só toca
 * `conversation_drafts` (o DELETE da décima poda) — sem esta lista, "a
 * varredura não foi pulada" seria indistinguível de "não sei".
 */
let tabelasLidas: string[] = [];
/**
 * As linhas que o DELETE da décima poda (`conversation_drafts`) devolve nesta
 * rodada. Vazio por padrão: os casos deste arquivo medem a PODA das irmãs, e um
 * expurgo com trabalho a fazer mudaria a contagem de auditoria. O caso em que
 * ele apaga está no fim deste arquivo — é o que prova `houveEfeito` contando.
 */
let rascunhosApagados: Array<{ id: string }> = [];
vi.mock("@/lib/supabase/admin", () => ({
  createAdminClient: () => ({
    rpc: async () => respostaRpc,
    from: (tabela: string) => {
      tabelasLidas.push(tabela);
      const q: Record<string, unknown> = {
        eq: () => q,
        in: () => q,
        limit: () => q,
        then: (r: (v: unknown) => unknown) =>
          Promise.resolve({ data: contatosAnonimizados, error: null }).then(r),
      };
      // A superfície do DELETE da décima poda: `.delete().lt().select().order().limit()`.
      // O dublê recusa `limit` sem `order` antes, como o PostgREST 12.2 recusa
      // (400 PGRST109): tirar o `.order()` do handler reprova este arquivo.
      let ordenado = false;
      const apagando: Record<string, unknown> = {
        lt: () => apagando,
        select: () => apagando,
        order: () => {
          ordenado = true;
          return apagando;
        },
        limit: () => {
          if (!ordenado) throw new Error("PGRST109: A 'limit' was applied without an explicit 'order'");
          return apagando;
        },
        then: (r: (v: unknown) => unknown) =>
          Promise.resolve({ data: rascunhosApagados, error: null }).then(r),
      };
      return { select: () => q, update: () => q, delete: () => apagando };
    },
  }),
}));

/**
 * O LAÇO DE LOTES DA PODA — issue #261.
 *
 * O DELETE em lotes não é preciosismo: um DELETE único num banco de cliente com
 * anos de histórico segura a tabela pelo tempo inteiro da varredura, e o produto
 * é instalado em VPS sem janela de manutenção. Três propriedades sustentam isso,
 * e as três são invisíveis a olho nu:
 *
 *   1. o laço PARA no primeiro lote incompleto (senão gasta uma ida ao banco a
 *      cada rodada só para ouvir zero, 1×/dia, para sempre);
 *   2. o laço tem TETO por invocação (senão a primeira rodada de uma instalação
 *      antiga segura o `curl` do cron até o timeout de 120 s e deixa a última
 *      transação para o servidor abortar sozinho);
 *   3. quando o teto é atingido, o resultado DIZ que sobrou trabalho — silêncio
 *      aqui seria indistinguível de "acabou".
 *
 * A régua deste arquivo é a REGRA (o laço, o teto, a interpretação do knob). O
 * que o banco de fato apaga — e o que ele se recusa a apagar — é medido contra
 * um Postgres real em `tests/invariants/retencao-poda-e-expurgo.test.ts`.
 */
function bancoQueDevolve(sequencias: {
  fila: number[];
  auditoria: number[];
  /** A décima poda (issue #1686) — um lote por posição, como as irmãs. */
  rascunhos?: number[];
  /** A décima segunda poda (#1534) — um lote por posição, em JSONB. */
  midia?: Array<{ vencidas: number; orfas?: number; expurgadas?: number }>;
  /**
   * Nomes de `rpc` que FALHAM nesta rodada (#2508) — o que o banco devolve é o
   * erro do PostgREST, e é ele que a poda tem de NOMEAR sem derrubar as irmãs.
   */
  erroEm?: string[];
  /**
   * O dreno que falha DEPOIS de N lotes bons (sugestão 1 do #2645): o parcial
   * dos lotes já apagados tem de voltar no relatório.
   */
  falhaDepoisDe?: { nome: string; lotes: number };
  /** A décima poda falha nesta rodada (erro do DELETE, não do rpc). */
  erroRascunhos?: boolean;
  /** A poda de mídia falha nesta rodada (JSONB com erro). */
  erroMidia?: boolean;
}): {
  db: PodaDb;
  chamadas: { nome: string; dias: number; limite: number }[];
  /** Os cortes que `apagarRascunhos` recebeu, em ordem — é a régua do relógio. */
  cortes: string[];
  /** O `p_limite` de CADA chamada de `enfileirarMidia`, em ordem. */
  lotesDeMidia: number[];
} {
  const chamadas: { nome: string; dias: number; limite: number }[] = [];
  const chamadasPorNome = new Map<string, number>();
  const cortes: string[] = [];
  const lotesDeMidia: number[] = [];
  const restante = {
    fila: [...sequencias.fila],
    auditoria: [...sequencias.auditoria],
    rascunhos: [...(sequencias.rascunhos ?? [0])],
    midia: [...(sequencias.midia ?? [{ vencidas: 0, orfas: 0 }])],
  };
  const db: PodaDb = {
    async rpc(nome, args) {
      const numero = (chamadasPorNome.get(nome) ?? 0) + 1;
      chamadasPorNome.set(nome, numero);
      chamadas.push({ nome, dias: args.p_retencao_dias, limite: args.p_limite });
      if (sequencias.erroEm?.includes(nome)) {
        // A mensagem do banco NÃO nomeia a função (o nome é do prefixo da poda):
        // é o que permite medir o prefixo duplicado (#2645).
        return { data: null, error: { message: "permission denied (dublê)" } };
      }
      if (sequencias.falhaDepoisDe?.nome === nome && numero > sequencias.falhaDepoisDe.lotes) {
        return { data: null, error: { message: `timeout ao drenar ${nome}` } };
      }
      const balde = nome === "fn_podar_fila_de_jobs" ? restante.fila : restante.auditoria;
      return { data: balde.shift() ?? 0, error: null };
    },
    async apagarRascunhos(corte) {
      cortes.push(corte);
      if (sequencias.erroRascunhos) {
        return { data: null, error: { message: "permission denied for table conversation_drafts" } };
      }
      return { data: restante.rascunhos.shift() ?? 0, error: null };
    },
    async enfileirarMidia(lote) {
      lotesDeMidia.push(lote);
      if (sequencias.erroMidia) {
        return { data: null, error: { message: "permission denied for function fn_enfileirar_midia_vencida" } };
      }
      return { data: restante.midia.shift() ?? { vencidas: 0, orfas: 0 }, error: null };
    },
  };
  return { db, chamadas, cortes, lotesDeMidia };
}

describe("interpretarRetencao — o knob nunca derruba o produto", () => {
  it("ausente ou vazio devolve o padrão, sem aviso", () => {
    // É o caminho de toda instalação que nunca editou `.env` — a doutrina de
    // packaging exige que ele funcione sem edição manual de arquivo.
    for (const bruto of [undefined, "", "   "]) {
      const r = interpretarRetencao(bruto, { chave: "K", padrao: 90, piso: 7 });
      expect(r).toEqual({ dias: 90, aviso: null });
    }
  });

  it("lixo devolve o padrão COM aviso — nunca a frase tranquilizadora", () => {
    for (const bruto of ["noventa", "90d", "-1", "0", "1.5", "NaN"]) {
      const r = interpretarRetencao(bruto, { chave: "K", padrao: 90, piso: 7 });
      expect(r.dias, `entrada ${bruto}`).toBe(90);
      expect(r.aviso, `entrada ${bruto} sem aviso`).toContain("K=");
    }
  });

  it("valor abaixo do piso é ELEVADO, com aviso", () => {
    const r = interpretarRetencao("2", { chave: "K", padrao: 90, piso: 7 });
    expect(r.dias).toBe(7);
    expect(r.aviso).toContain("piso");
  });

  it("valor acima do teto é REDUZIDO ao teto, com aviso no MESMO formato do piso", () => {
    // `AUDIT_LOG_RETENTION_DAYS=9999999` chegaria ao Postgres como
    // `now() - make_interval(days => 9999999)` — antes do mínimo de
    // `timestamptz` (4713 a.C.) → `timestamp out of range`, o cron
    // `data-retention` lançava a cada rodada, e aquela tabela e as que vêm
    // depois dela paravam de ser podadas (#2509). O formato do aviso espelha o
    // do piso:
    // `chave=valor está <preposição> do <limite> de N dias — usando N.`
    const r = interpretarRetencao("9999999", {
      chave: "AUDIT_LOG_RETENTION_DAYS",
      padrao: 90,
      piso: 7,
    });
    expect(r.dias).toBe(RETENCAO_TETO_DIAS);
    expect(r.aviso).toBe(
      `AUDIT_LOG_RETENTION_DAYS=9999999 está acima do teto de ${RETENCAO_TETO_DIAS} dias — ` +
        `usando ${RETENCAO_TETO_DIAS}.`,
    );
  });

  it("valor NO teto passa intacto — o teto é inclusivo", () => {
    expect(
      interpretarRetencao(String(RETENCAO_TETO_DIAS), { chave: "K", padrao: 90, piso: 7 }),
    ).toEqual({ dias: RETENCAO_TETO_DIAS, aviso: null });
  });

  it("valor válido passa inteiro, sem aviso", () => {
    expect(interpretarRetencao("400", { chave: "K", padrao: 90, piso: 7 })).toEqual({
      dias: 400,
      aviso: null,
    });
  });
});

describe("podarHistorico — o laço de lotes", () => {
  it("para no primeiro lote incompleto (não gasta uma ida a mais)", async () => {
    const { db, chamadas } = bancoQueDevolve({
      fila: [TAMANHO_DO_LOTE, 7],
      auditoria: [0],
    });
    const r = await podarHistorico(db, {});
    expect(r.jobs_apagados).toBe(TAMANHO_DO_LOTE + 7);
    expect(r.lotes_fila).toBe(2);
    expect(r.fila_tem_resto).toBe(false);
    expect(r.lotes_auditoria).toBe(1);
    expect(chamadas.filter((c) => c.nome === "fn_podar_fila_de_jobs")).toHaveLength(2);
  });

  it("respeita o teto por invocação e DECLARA que sobrou trabalho", async () => {
    // Previsão feita ANTES: com todo lote cheio, são exatamente MAX_LOTES
    // chamadas e `fila_tem_resto` verdadeiro.
    const { db, chamadas } = bancoQueDevolve({
      fila: Array.from({ length: MAX_LOTES + 5 }, () => TAMANHO_DO_LOTE),
      auditoria: [0],
    });
    const r = await podarHistorico(db, {});
    expect(chamadas.filter((c) => c.nome === "fn_podar_fila_de_jobs")).toHaveLength(MAX_LOTES);
    expect(r.jobs_apagados).toBe(MAX_LOTES * TAMANHO_DO_LOTE);
    expect(r.fila_tem_resto).toBe(true);
  });

  it("pede ao banco os dias do padrão quando o .env está intocado", async () => {
    const { db, chamadas } = bancoQueDevolve({ fila: [0], auditoria: [0] });
    const r = await podarHistorico(db, {});
    expect(chamadas[0]).toEqual({
      nome: "fn_podar_fila_de_jobs",
      dias: RETENCAO_FILA_DIAS_PADRAO,
      limite: TAMANHO_DO_LOTE,
    });
    expect(chamadas[1]).toEqual({
      nome: "fn_expurgar_auditoria_vencida",
      dias: RETENCAO_AUDITORIA_DIAS_PADRAO,
      limite: TAMANHO_DO_LOTE,
    });
    expect(r.avisos).toEqual([]);
  });

  it("eleva ao piso o knob abaixo dele e devolve o aviso", async () => {
    const { db, chamadas } = bancoQueDevolve({ fila: [0], auditoria: [0] });
    const r = await podarHistorico(db, {
      JOB_QUEUE_RETENTION_DAYS: "1",
      AUDIT_LOG_RETENTION_DAYS: "0",
    });
    expect(chamadas[0]?.dias).toBe(RETENCAO_FILA_DIAS_PISO);
    // "0" é lixo (não-positivo), então cai no PADRÃO, não no piso — é a
    // diferença entre "escolheu pouco" e "escreveu bobagem".
    expect(chamadas[1]?.dias).toBe(RETENCAO_AUDITORIA_DIAS_PADRAO);
    expect(r.avisos).toHaveLength(2);
  });

  it("drena a prospecção com o padrão 365 e eleva o knob de 5 ao piso 90", async () => {
    // A oitava poda entra no MESMO commit da migration (0408): o knob abaixo
    // do piso é ELEVADO, como todas as irmãs — e o aviso acompanha.
    const { db, chamadas } = bancoQueDevolve({ fila: [0], auditoria: [0] });
    const r = await podarHistorico(db, { PROSPECCAO_RETENTION_DAYS: "5" });
    // Pelo NOME, e não pela posição: a nona poda (0421) entrou depois dela.
    const daProspeccao = chamadas.find((c) => c.nome === "fn_expurgar_prospeccao_vencida");
    expect(daProspeccao?.dias).toBe(RETENCAO_PROSPECCAO_DIAS_PISO);
    expect(r.retencao_prospeccao_dias).toBe(RETENCAO_PROSPECCAO_DIAS_PISO);
    expect(r.avisos).toHaveLength(1);
    expect(r.avisos[0]).toContain("PROSPECCAO_RETENTION_DAYS");
  });

  it("drena as observações do Jev com o padrão 90 e eleva o knob de 7 ao piso 30 (0421)", async () => {
    const semKnob = bancoQueDevolve({ fila: [0], auditoria: [0] });
    await podarHistorico(semKnob.db, {});
    expect(semKnob.chamadas.find((c) => c.nome === "fn_expurgar_observacoes_do_jev")?.dias).toBe(
      RETENCAO_OBSERVACOES_DO_JEV_DIAS_PADRAO,
    );

    const { db, chamadas } = bancoQueDevolve({ fila: [0], auditoria: [0] });
    const r = await podarHistorico(db, { JEV_OBSERVACOES_RETENTION_DAYS: "7" });
    expect(chamadas.find((c) => c.nome === "fn_expurgar_observacoes_do_jev")?.dias).toBe(
      RETENCAO_OBSERVACOES_DO_JEV_DIAS_PISO,
    );
    expect(r.retencao_observacoes_do_jev_dias).toBe(RETENCAO_OBSERVACOES_DO_JEV_DIAS_PISO);
    expect(r.avisos).toEqual([expect.stringContaining("JEV_OBSERVACOES_RETENTION_DAYS")]);
  });

  it("um dreno que falha é NOMEADO e as irmãs seguem — a rodada não é tudo-ou-nada (#2508)", async () => {
    // Antes, o primeiro erro abortava a rodada: as podas seguintes não rodavam,
    // a retomada de anonimização era pulada e o que já tinha sido apagado sumia
    // da trilha. Agora a falha vira linha do relatório e o laço continua — um
    // grant que não veio num clone derruba UMA tabela, não o dia.
    const { db, chamadas } = bancoQueDevolve({
      fila: [5],
      auditoria: [0],
      erroEm: ["fn_expurgar_auditoria_vencida"],
    });
    const r = await podarHistorico(db, {});

    expect(r.jobs_apagados, "a poda que rodou ANTES da falha perdeu a contagem").toBe(5);
    expect(r.auditoria_apagada).toBe(0);
    expect(r.falhas).toEqual([expect.stringContaining("fn_expurgar_auditoria_vencida")]);
    // O dreno que vem DEPOIS do que falhou continua sendo chamado.
    expect(chamadas.some((c) => c.nome === "fn_expurgar_espelho_da_agenda")).toBe(true);
  });

  it("erro em TODOS os drenos: as do rpc + mídia + rascunhos entram nomeadas, sem lançar", async () => {
    const db: PodaDb = {
      async rpc(nome) {
        return { data: null, error: { message: `permission denied for function ${nome}` } };
      },
      async apagarRascunhos() {
        return { data: null, error: { message: "permission denied for table conversation_drafts" } };
      },
      async enfileirarMidia() {
        return { data: null, error: { message: "permission denied for function fn_enfileirar_midia_vencida" } };
      },
    };
    const r = await podarHistorico(db, {});

    // 18 drenos: 16 pelo rpc (o 15º é o aviso de agendamento, 0753; o 16º é o
    // traço do turno, 0778) + a mídia + a décima poda (o DELETE do admin).
    expect(r.falhas).toHaveLength(18);
    expect(r.falhas.some((f) => f.includes("conversation_drafts"))).toBe(true);
    expect(r.falhas.some((f) => f.includes("fn_enfileirar_midia_vencida"))).toBe(true);
    expect(r.jobs_apagados).toBe(0);
  });

  it("⭐ dreno interrompido no meio: os lotes que JÁ passaram ficam no relatório (#2645)", async () => {
    // O banco falha no TERCEIRO lote da auditoria, com dois lotes cheios já
    // apagados. Antes, o parcial sumia e o relatório registrava 0 — no expurgo
    // da auditoria isso é apagar linhas e contá-las como zero NA PRÓPRIA TRILHA.
    const { db } = bancoQueDevolve({
      fila: [3],
      auditoria: [TAMANHO_DO_LOTE, TAMANHO_DO_LOTE, TAMANHO_DO_LOTE],
      falhaDepoisDe: { nome: "fn_expurgar_auditoria_vencida", lotes: 2 },
    });
    const r = await podarHistorico(db, {});

    expect(r.auditoria_apagada, "o parcial dos lotes que passaram sumiu do relatório").toBe(
      TAMANHO_DO_LOTE * 2,
    );
    expect(r.lotes_auditoria).toBe(2);
    expect(r.falhas).toEqual([expect.stringContaining("fn_expurgar_auditoria_vencida")]);
    // As irmãs seguem — inclusive as que vêm depois da que falhou.
    expect(r.jobs_apagados).toBe(3);
    expect(r.lotes_checkpoints).toBe(1);
  });

  it("o nome do dreno sai UMA vez em `falhas` — sem o prefixo duplicado (#2645)", async () => {
    const { db } = bancoQueDevolve({
      fila: [0],
      auditoria: [0],
      erroEm: ["fn_expurgar_auditoria_vencida"],
    });
    const r = await podarHistorico(db, {});

    const linha = r.falhas.find((f) => f.includes("fn_expurgar_auditoria_vencida"))!;
    expect(linha.startsWith("fn_expurgar_auditoria_vencida: ")).toBe(true);
    expect(linha.match(/fn_expurgar_auditoria_vencida/g), "o nome saiu duplicado").toHaveLength(1);
  });

  it("mensagem de erro comprida é cortada em 300, como no caminho antigo (#2645)", async () => {
    const db: PodaDb = {
      async rpc() {
        return { data: null, error: { message: "x".repeat(2000) } };
      },
      async apagarRascunhos() {
        return { data: 0, error: null };
      },
      async enfileirarMidia() {
        return { data: { vencidas: 0, orfas: 0 }, error: null };
      },
    };
    const r = await podarHistorico(db, {});

    // Só os 16 drenos do rpc falham nesta rodada (mídia e rascunhos vão bem).
    expect(r.falhas).toHaveLength(16);
    for (const f of r.falhas) expect(f.length).toBeLessThanOrEqual(300);
  });
});

describe("a décima poda — o rascunho sugerido vencido (issue #1686)", () => {
  it("sem knob, corta 30 dias atrás do VENCIMENTO e reporta o prazo", async () => {
    // O relógio é `expires_at`, e isto se mede pelo CORTE: ele tem de estar ~30
    // dias para trás, não "agora" (que seria cortar por `created_at` e apagar
    // rascunho cuja janela ainda está aberta).
    const { db, cortes } = bancoQueDevolve({ fila: [0], auditoria: [0] });
    const antes = Date.now();
    const r = await podarHistorico(db, {});

    expect(cortes).toHaveLength(1);
    const corte = Date.parse(cortes[0] as string);
    const esperado = antes - RETENCAO_RASCUNHO_DIAS_PADRAO * 86_400_000;
    expect(Math.abs(corte - esperado)).toBeLessThan(10_000);
    expect(corte).toBeLessThan(antes - 29 * 86_400_000);

    expect(r.retencao_rascunho_dias).toBe(RETENCAO_RASCUNHO_DIAS_PADRAO);
    expect(r.rascunhos_apagados).toBe(0);
    expect(r.lotes_rascunhos).toBe(1);
    expect(r.rascunhos_tem_resto).toBe(false);
    // `.env` intocado = caminho padrão de toda instalação: sem aviso.
    expect(r.avisos).toEqual([]);
  });

  it("knob abaixo do piso é ELEVADO para 7, com aviso; lixo cai no padrão", async () => {
    const baixo = bancoQueDevolve({ fila: [0], auditoria: [0] });
    const elevado = await podarHistorico(baixo.db, { DRAFT_RETENTION_DAYS: "1" });
    const corteElevado = Date.parse(baixo.cortes[0] as string);
    expect(Math.abs(corteElevado - (Date.now() - RETENCAO_RASCUNHO_DIAS_PISO * 86_400_000))).toBeLessThan(
      10_000,
    );
    expect(elevado.retencao_rascunho_dias).toBe(RETENCAO_RASCUNHO_DIAS_PISO);
    expect(elevado.avisos).toEqual([expect.stringContaining("DRAFT_RETENTION_DAYS")]);

    // "trezentos" é lixo, não escolha: cai no PADRÃO com aviso, nunca num
    // número que o operador não escreveu.
    const lixo = bancoQueDevolve({ fila: [0], auditoria: [0] });
    const r = await podarHistorico(lixo.db, { DRAFT_RETENTION_DAYS: "trezentos" });
    expect(r.retencao_rascunho_dias).toBe(RETENCAO_RASCUNHO_DIAS_PADRAO);
    expect(r.avisos).toEqual([expect.stringContaining("DRAFT_RETENTION_DAYS")]);
  });

  it("para no lote incompleto e DECLARA resto quando o teto fecha", async () => {
    const cheio = bancoQueDevolve({
      fila: [0],
      auditoria: [0],
      rascunhos: Array.from({ length: MAX_LOTES + 3 }, () => TAMANHO_DO_LOTE),
    });
    const r = await podarHistorico(cheio.db, {});
    expect(cheio.cortes).toHaveLength(MAX_LOTES);
    expect(r.rascunhos_apagados).toBe(MAX_LOTES * TAMANHO_DO_LOTE);
    expect(r.rascunhos_tem_resto).toBe(true);

    const parcial = bancoQueDevolve({
      fila: [0],
      auditoria: [0],
      rascunhos: [TAMANHO_DO_LOTE, 12],
    });
    const r2 = await podarHistorico(parcial.db, {});
    expect(parcial.cortes).toHaveLength(2);
    expect(r2.rascunhos_apagados).toBe(TAMANHO_DO_LOTE + 12);
    expect(r2.rascunhos_tem_resto).toBe(false);
    expect(r2.lotes_rascunhos).toBe(2);
  });

  it("erro na décima poda é NOMEADO e as irmãs seguem (#2508)", async () => {
    // Mesmo contrato das irmãs: falha ABERTA na ação e ABERTA na informação —
    // uma poda que falha em silêncio vira "o rascunho não some e ninguém sabe
    // por quê" seis meses depois. O que mudou é que ela não derruba o dia.
    const db: PodaDb = {
      async rpc() {
        return { data: 0, error: null };
      },
      async apagarRascunhos() {
        return { data: null, error: { message: "permission denied for table conversation_drafts" } };
      },
      async enfileirarMidia() {
        return { data: { vencidas: 0, orfas: 0 }, error: null };
      },
    };
    const r = await podarHistorico(db, {});

    expect(r.falhas).toEqual([expect.stringContaining("conversation_drafts")]);
    expect(r.rascunhos_apagados).toBe(0);
    // As irmãs que vêm ANTES/DEPOIS continuam no relatório.
    expect(r.lotes_fila).toBe(1);
    expect(r.lotes_checkpoints).toBe(1);
  });

  it("erro na poda de mídia (#1534) é NOMEADO e as irmãs seguem (#2508)", async () => {
    const db: PodaDb = {
      async rpc() {
        return { data: 0, error: null };
      },
      async apagarRascunhos() {
        return { data: 0, error: null };
      },
      async enfileirarMidia() {
        return { data: null, error: { message: "permission denied for function fn_enfileirar_midia_vencida" } };
      },
    };
    const r = await podarHistorico(db, {});

    expect(r.falhas).toEqual([expect.stringContaining("fn_enfileirar_midia_vencida")]);
    expect(r.midia_enfileirada).toBe(0);
    expect(r.lotes_checkpoints).toBe(1);
  });
});

describe("houveEfeito — as duas direções", () => {
  const base = {
    jobs_apagados: 0,
    auditoria_apagada: 0,
    lotes_fila: 1,
    lotes_auditoria: 1,
    fila_tem_resto: false,
    auditoria_tem_resto: false,
    espelho_apagado: 0,
    // Quarta poda (migration 0190): os nonces de OAuth do Google já queimados.
    nonces_apagados: 0,
    lotes_espelho: 0,
    espelho_tem_resto: false,
    retencao_fila_dias: RETENCAO_FILA_DIAS_PADRAO,
    retencao_auditoria_dias: RETENCAO_AUDITORIA_DIAS_PADRAO,
    retencao_espelho_dias: RETENCAO_ESPELHO_AGENDA_DIAS_PADRAO,
    // Quinta poda (migration 0281): a conversa da equipe com a IA sobre um caso.
    conversa_do_caso_apagada: 0,
    lotes_conversa_do_caso: 0,
    conversa_do_caso_tem_resto: false,
    retencao_conversa_do_caso_dias: RETENCAO_CONVERSA_DO_CASO_DIAS_PADRAO,
    // Sexta poda (migration 0291): o registro da passagem para uma pessoa.
    passagens_apagadas: 0,
    lotes_passagens: 0,
    passagens_tem_resto: false,
    retencao_passagem_dias: RETENCAO_PASSAGEM_DIAS_PADRAO,
    // Sétima poda (migration 0292): o registro de entrega do aviso de caso.
    avisos_de_caso_apagados: 0,
    lotes_avisos_de_caso: 0,
    avisos_de_caso_tem_resto: false,
    avisos_de_agendamento_apagados: 0,
    lotes_avisos_de_agendamento: 0,
    avisos_de_agendamento_tem_resto: false,
    retencao_aviso_de_caso_dias: RETENCAO_AVISO_DE_CASO_DIAS_PADRAO,
    // Oitava poda (migration 0408): o candidato de prospecção vencido.
    prospeccao_apagada: 0,
    lotes_prospeccao: 0,
    prospeccao_tem_resto: false,
    retencao_prospeccao_dias: RETENCAO_PROSPECCAO_DIAS_PADRAO,
    // Nona poda (migration 0421): as observações do Jev.
    observacoes_do_jev_apagadas: 0,
    lotes_observacoes_do_jev: 0,
    observacoes_do_jev_tem_resto: false,
    retencao_observacoes_do_jev_dias: RETENCAO_OBSERVACOES_DO_JEV_DIAS_PADRAO,
    // Décima poda (issue #1686): o rascunho sugerido por integração vencido.
    rascunhos_apagados: 0,
    lotes_rascunhos: 0,
    rascunhos_tem_resto: false,
    retencao_rascunho_dias: RETENCAO_RASCUNHO_DIAS_PADRAO,
    // Décima primeira poda (migration 0428, issue #1695): o candidato ao golden set.
    candidatos_do_golden_apagados: 0,
    lotes_candidatos_do_golden: 0,
    candidatos_do_golden_tem_resto: false,
    retencao_candidatos_do_golden_dias: RETENCAO_CANDIDATOS_GOLDEN_DIAS_PADRAO,
    // Décima segunda poda (migration 0557, issue #1534): a retenção de mídia.
    midia_enfileirada: 0,
    midia_expurgada: 0,
    lotes_midia: 0,
    midia_tem_resto: false,
    retencao_midia_dias: RETENCAO_MIDIA_DIAS_PISO,
    // Da décima terceira à décima sexta (migration 0587): as tabelas da IA. O
    // que elas apagam é medido em `retencao-das-tabelas-da-ia.test.ts`.
    telemetria_de_ia_apagada: 0,
    lotes_telemetria_de_ia: 0,
    telemetria_de_ia_tem_resto: false,
    retencao_telemetria_de_ia_dias: 400,
    ritmo_de_envio_apagado: 0,
    lotes_ritmo_de_envio: 0,
    ritmo_de_envio_tem_resto: false,
    retencao_ritmo_de_envio_dias: 2,
    copias_enviadas_apagadas: 0,
    lotes_copias_enviadas: 0,
    copias_enviadas_tem_resto: false,
    retencao_copias_enviadas_dias: 30,
    checkpoints_apagados: 0,
    lotes_checkpoints: 0,
    checkpoints_tem_resto: false,
    retencao_checkpoints_dias: 180,
    // Décima sétima poda (migration 0778, issue #3201): o traço do turno.
    tracos_apagados: 0,
    lotes_tracos: 0,
    tracos_tem_resto: false,
    retencao_traco_dias: 400,
    avisos: [] as string[],
    falhas: [] as string[],
  };

  it("rodada que não apagou nada NÃO ocupa linha de auditoria", () => {
    expect(houveEfeito(base)).toBe(false);
  });

  it("apagou nonce → audita, pela mesma razão das outras três", () => {
    // Sem esta linha em `houveEfeito`, uma rodada que só podou nonces apagaria
    // linhas e não deixaria registro. O caso entrou porque quem acrescentou a
    // quarta poda (eu) a ligou ao laço e ao retorno e esqueceu do predicado —
    // um parágrafo abaixo do comentário que descreve exatamente esse defeito.
    expect(houveEfeito({ ...base, nonces_apagados: 1 })).toBe(true);
  });

  it("...e apagou candidato de prospecção vencido → TAMBÉM audita (0408)", () => {
    // A oitava poda entra em `houveEfeito` no MESMO commit em que entra no
    // laço — é a lição da quarta e da quinta. E esta é a única poda da casa
    // que apaga dado de uma pessoa que NUNCA falou com a empresa: silenciar
    // aqui seria apagar dado sensível sem trilha.
    expect(houveEfeito({ ...base, prospeccao_apagada: 1 })).toBe(true);
  });

  it("...e apagou observação do Jev vencida → TAMBÉM audita (0421)", () => {
    expect(houveEfeito({ ...base, observacoes_do_jev_apagadas: 1 })).toBe(true);
  });

  it("...e apagou rascunho vencido → TAMBÉM audita (issue #1686)", () => {
    // A décima poda entra em `houveEfeito` NO MESMO commit em que entra no
    // laço — a lição da quarta, da quinta e das demais. E é a única que apaga
    // TEXTO escrito para uma pessoa: apagaria dado pessoal sem trilha.
    expect(houveEfeito({ ...base, rascunhos_apagados: 1 })).toBe(true);
  });

  it("...e apagou traço do turno vencido → TAMBÉM audita (0778, #3201)", () => {
    // A mesma lição das anteriores: a poda entra no laço e em `houveEfeito` no
    // MESMO commit. O traço é metadado do turno (não é texto de cliente), mas é
    // a trilha do que o agente FEZ — apagar sem registro é encolhimento.
    expect(houveEfeito({ ...base, tracos_apagados: 1 })).toBe(true);
  });

  it("...e apagou candidato ao golden set vencido → TAMBÉM audita (0428)", () => {
    // A décima primeira poda entra em `houveEfeito` no MESMO commit em que
    // entra no laço — a mesma lição das dez anteriores: o predicado esquecido
    // é mudo.
    expect(houveEfeito({ ...base, candidatos_do_golden_apagados: 1 })).toBe(true);
  });

  it("apagou job → audita; apagou auditoria → audita", () => {
    // A segunda é a que não pode se perder: é ela que faz o expurgo do audit
    // deixar rastro em vez de encolher a trilha em silêncio.
    expect(houveEfeito({ ...base, jobs_apagados: 1 })).toBe(true);
    expect(houveEfeito({ ...base, auditoria_apagada: 1 })).toBe(true);
  });

  it("...e apagou conversa do caso → TAMBÉM audita (migration 0281)", () => {
    // A quinta poda entra em `houveEfeito` no MESMO commit em que entra no
    // laço: as quatro anteriores mostram que esquecer o predicado é o modo de
    // falha natural aqui, e ele é mudo — a rodada apaga e não deixa registro.
    expect(houveEfeito({ ...base, conversa_do_caso_apagada: 1 })).toBe(true);
  });

  it("...e apagou passagem vencida → TAMBÉM audita (migration 0291)", () => {
    // A sexta poda entra em `houveEfeito` no MESMO commit em que entra no laço.
    // As cinco anteriores já mostram que esquecer o predicado é o modo de falha
    // natural aqui — e ele é mudo: a rodada apaga e não deixa registro.
    expect(houveEfeito({ ...base, passagens_apagadas: 1 })).toBe(true);
  });

  it("...e apagou entrega de aviso vencida → TAMBÉM audita (migration 0292)", () => {
    // A sétima poda entra em `houveEfeito` no MESMO commit em que entra no laço.
    // As seis anteriores já mostram que esquecer o predicado é o modo de falha
    // natural aqui — e ele é mudo: a rodada apaga e não deixa registro.
    expect(houveEfeito({ ...base, avisos_de_caso_apagados: 1 })).toBe(true);
    expect(houveEfeito({ ...base, avisos_de_agendamento_apagados: 1 })).toBe(true);
  });

  it("...e apagou espelho da agenda → TAMBÉM audita (migration 0187)", () => {
    // Sem este caso, uma rodada que só podou o espelho apagaria linhas e não
    // deixaria registro. A doutrina do repo é auditar QUANDO HÁ EFEITO — nunca
    // parar de auditar —, e um efeito novo que não entra em `houveEfeito` é
    // exatamente o silêncio que ela proíbe.
    expect(houveEfeito({ ...base, espelho_apagado: 1 })).toBe(true);
  });
});

describe("os pisos do TypeScript e os do SQL são os mesmos números", () => {
  it("os quatro valores da política aparecem literalmente no baseline.sql", async () => {
    // Duas cópias de um piso é como um piso vira decorativo: o `.env.example`
    // documenta um número, a função do banco aplica outro, e ninguém percebe
    // porque os dois lados continuam "funcionando".
    const { readFileSync } = await import("node:fs");
    const { join } = await import("node:path");
    const sql = readFileSync(join(__dirname, "..", "..", "supabase", "baseline.sql"), "utf8");
    const bloco = sql.slice(sql.indexOf("-- ---- poda da fila e expurgo do audit (migration 0167)"));
    expect(bloco.length).toBeGreaterThan(500);
    expect(bloco).toContain(`greatest(coalesce(p_retencao_dias, ${RETENCAO_FILA_DIAS_PADRAO}), ${RETENCAO_FILA_DIAS_PISO})`);
    expect(bloco).toContain(
      `greatest(coalesce(p_retencao_dias, ${RETENCAO_AUDITORIA_DIAS_PADRAO}), ${RETENCAO_AUDITORIA_DIAS_PISO})`,
    );
  });

  it("...e o do espelho da agenda também (migration 0187)", async () => {
    // A lista deste describe era FIXA em dois pares, e o terceiro nasceria fora
    // dela sem ninguém ser avisado — o mesmo eixo de completude que já mordeu
    // nesta entrega. Um piso que só existe no TypeScript é decorativo.
    const { readFileSync } = await import("node:fs");
    const { join } = await import("node:path");
    const sql = readFileSync(join(__dirname, "..", "..", "supabase", "baseline.sql"), "utf8");
    // O rótulo aponta para o bloco da FUNÇÃO, não para o do `comment on table`.
    // A 0187 está PARTIDA em dois no baseline — a função antes da varredura anon,
    // o resto no fim —, e o `greatest` mora só na primeira metade. Apontar para o
    // rótulo errado dava vermelho num conserto que estava certo.
    const bloco = sql.slice(sql.indexOf("-- ---- o espelho do Google é cache com prazo: função (migration 0187)"));
    expect(bloco.length).toBeGreaterThan(500);
    expect(bloco).toContain(
      `greatest(coalesce(p_retencao_dias, ${RETENCAO_ESPELHO_AGENDA_DIAS_PADRAO}), ${RETENCAO_ESPELHO_AGENDA_DIAS_PISO})`,
    );
  });

  it("...e o dos candidatos ao golden set também (migration 0428)", async () => {
    // Mesma régua das três acima: piso que só existe no TypeScript é decorativo.
    // O apêndice do baseline é o que quem instalou numa VPS aplica — se o número
    // divergir lá, a instalação inteira poda com outro prazo que o `.env.example`
    // promete.
    const { readFileSync } = await import("node:fs");
    const { join } = await import("node:path");
    const sql = readFileSync(join(__dirname, "..", "..", "supabase", "baseline.sql"), "utf8");
    const bloco = sql.slice(
      sql.indexOf("-- ---- os candidatos ao golden set viram linha de rótulo (migration 0428) ----"),
    );
    expect(bloco.length).toBeGreaterThan(500);
    expect(bloco).toContain(
      `greatest(coalesce(p_retencao_dias, ${RETENCAO_CANDIDATOS_GOLDEN_DIAS_PADRAO}), ${RETENCAO_CANDIDATOS_GOLDEN_DIAS_PISO})`,
    );
  });
});

describe("o handler HTTP — a falha entra na trilha, o vazio não", () => {
  function requisicaoAutorizada(): Parameters<typeof GET>[0] {
    // O handler só lê `headers.get("authorization")`.
    return { headers: new Headers({ authorization: "Bearer segredo" }) } as never;
  }

  beforeEach(() => {
    auditou.mockClear();
    contatosAnonimizados = [];
    rascunhosApagados = [];
    tabelasLidas = [];
  });

  it("rodada que não apagou nada responde 200 e NÃO audita", async () => {
    respostaRpc = { data: 0, error: null };
    const resposta = await GET(requisicaoAutorizada());
    expect(resposta.status).toBe(200);
    expect(auditou).not.toHaveBeenCalled();
  });

  it("rodada que apagou AUDITA — o expurgo do audit deixa rastro", async () => {
    // Esta é a direção que não pode se perder: sem ela, o expurgo encolheria a
    // trilha em silêncio, que é exatamente o que a doutrina de append-only
    // existe para impedir.
    respostaRpc = { data: 7, error: null };
    await GET(requisicaoAutorizada());
    expect(auditou).toHaveBeenCalledTimes(1);
    expect(auditou.mock.calls[0]?.[0]).toMatchObject({
      action: "retention.sweep_run",
      metadata: { jobs_apagados: 7 },
    });
  });

  it("rodada que só apagou RASCUNHO vencido também AUDITA (issue #1686)", async () => {
    // A décima poda entra no laço e no relatório; sem ela nesta asserção, uma
    // rodada que só expurgasse rascunho apagaria TEXTO de uma pessoa sem deixar
    // registro — o silêncio que a doutrina proíbe.
    respostaRpc = { data: 0, error: null };
    rascunhosApagados = [{ id: "rascunho-1" }];
    const resposta = await GET(requisicaoAutorizada());
    expect(resposta.status).toBe(200);
    expect(auditou).toHaveBeenCalledTimes(1);
    expect(auditou.mock.calls[0]?.[0]).toMatchObject({
      action: "retention.sweep_run",
      metadata: { rascunhos_apagados: 1, jobs_apagados: 0 },
    });
  });

  it("rodada que FALHOU responde 500 e AUDITA a falha", async () => {
    // O laço de retorno: uma poda que parou de funcionar num clone (grants que
    // não vieram no `update.sh`) não pode ficar idêntica, na trilha, a uma poda
    // sem nada a fazer. Sem esta linha o único sinal viveria num `logger.error`
    // dentro do contêiner, atrás de um `curl` que manda tudo para /dev/null.
    respostaRpc = { data: null, error: { message: "permission denied for table api_audit_log" } };
    const resposta = await GET(requisicaoAutorizada());
    expect(resposta.status).toBe(500);
    expect(auditou).toHaveBeenCalledTimes(1);
    expect(auditou.mock.calls[0]?.[0]).toMatchObject({
      action: "retention.sweep_run",
      metadata: { falhou: true },
    });
  });

  it("⭐ poda parcial: a varredura de LGPD RODA mesmo assim, e a trilha leva as contagens do que foi apagado (#2508)", async () => {
    // O defeito que o caso mede, na ordem: o primeiro dreno que falhava
    // abortava o `try`, a retomada de anonimização (SLA D+15) era PULADA e a
    // trilha registrava só `{falhou, erro}` — o que já tinha sido apagado
    // sumia. Aqui a poda do rpc inteira falha E a décima poda (rascunhos)
    // apaga 1: a varredura tem de rodar (o dublê prova pela leitura de
    // `contacts`) e a linha de auditoria tem de carregar `rascunhos_apagados`.
    respostaRpc = { data: null, error: { message: "permission denied for table api_audit_log" } };
    rascunhosApagados = [{ id: "rascunho-1" }];
    const resposta = await GET(requisicaoAutorizada());

    expect(resposta.status, "poda parcial precisa continuar sinalizando 500").toBe(500);
    expect(tabelasLidas, "a varredura de LGPD foi pulada por causa da poda").toContain("contacts");

    expect(auditou).toHaveBeenCalledTimes(1);
    const metadata = (auditou.mock.calls[0]?.[0] as { metadata: Record<string, unknown> }).metadata;
    expect(metadata).toMatchObject({ falhou: true, rascunhos_apagados: 1 });
    expect(Array.isArray(metadata.falhas), "as falhas têm de ir NOMEADAS na trilha").toBe(true);
    expect((metadata.falhas as string[]).length).toBeGreaterThan(0);

    // O 500 leva o RELATÓRIO no detalhe (#2645, sugestão 3): a resposta não pode
    // perder o que a rodada conseguiu fazer — inclusive as contagens da
    // varredura de anonimização, que só existiam no corpo do 200.
    const corpo = (await resposta.json()) as { error: { details: Record<string, unknown> } };
    expect(corpo.error.details, "o 500 perdeu o relatório da rodada").toMatchObject({
      rascunhos_apagados: 1,
      anonimizacoes_examinadas: 0,
    });
    expect(Array.isArray(corpo.error.details.falhas)).toBe(true);
  });
});
