/**
 * A aba do papel OPERADOR na tela do agente (spec 16 §6).
 *
 * ═══ O QUE ESTE SPEC PROVA QUE O TESTE DE COMPONENTE NÃO PROVA ═══
 *
 * `tests/unit/painel-do-operador.test.tsx` monta o componente com props e
 * verifica o que ele renderiza. Isso mede o componente, não o produto: entre a
 * escolha do usuário e o banco existem o formulário, a server action, o Zod, o
 * `VERSION_COLUMNS` de seis arquivos e o `SELECT` que relê tudo. Cada um deles
 * pode perder um campo sem quebrar nenhum teste de unidade.
 *
 * O caso `salvar → RECARREGAR → conferir` é o coração daqui, e não é zelo
 * genérico: é o sintoma exato que `tests/unit/agent-version-columns-drift.test.ts`
 * descreve — *"um campo que se desmarca sozinho depois do refresh, e o save
 * seguinte grava o valor errado por cima"*. Aconteceu com `cases_enabled`
 * (entrou em 2 dos 7 arquivos) e eu repeti o padrão com `operator_*` nesta
 * mesma série, com 1 de 6. O teste que teria pego é este.
 *
 * ⚠️ Roda contra o Supabase LOCAL. O `playwright.config.ts` injeta o `.env.e2e`
 * e recusa subir sem ele — antes disso, esta suíte escrevia em produção.
 */
import { execFileSync } from "node:child_process";
import { mkdirSync } from "node:fs";

import { test, expect, esperarARevelacaoNasCargas, type Browser, type Page } from "./helpers/test";

import { loginComoAdmin, lerCreds, type CredsE2E } from "./helpers/login-admin";

import { CONFERENCIAS_DE_SAIDA, CONFERENCIA_DE_ENTRADA } from "@/lib/ai/guardrails/lista-de-conferencia";
let creds: CredsE2E = lerCreds();

test.use({ locale: "pt-BR" });

// Mesmo orçamento dos vizinhos: o login pode disparar uma re-semeadura de
// credenciais quando outra sessão rotaciona o fator TOTP deste banco.
test.describe.configure({ timeout: 240_000 });

/**
 * A tela de papéis é do agente `mcp_agent`. O `default_agent_id` do seed de
 * credenciais é um `rag_bot` — outro tipo, outra tela — e apontar para ele fazia
 * o spec falhar como se a aba não existisse. Medido ao escrever este arquivo.
 *
 * A precondição é semeada AQUI, e não pressuposta: depender de um `mcp_agent`
 * que outra spec deixou é depender da ordem alfabética dos arquivos, que já
 * mordeu este repo antes (ver o comentário do `.github/workflows/e2e.yml` sobre
 * `agente-novo-e-uso` vir antes de `followup-builder`).
 */
test.beforeAll(() => {
  execFileSync("npx", ["tsx", "scripts/seed-e2e-capacidades.ts"], { stdio: "inherit" });
  creds = lerCreds();
});

/**
 * UM login para a bateria inteira, numa página compartilhada.
 *
 * O padrão `beforeEach` + login custava 7 logins para exercitar uma aba, e o
 * produto tem teto de 60 logins por IP a cada 5 minutos (o mesmo que protege a
 * conta de um cliente real). Medido: com um login por teste, a bateria estourava
 * o teto no 3º caso e a falha aparecia como "campo de MFA não apareceu" —
 * parecendo defeito de produto onde havia limite de ambiente.
 *
 * `mode: "serial"` porque os casos passam a compartilhar estado de navegação:
 * um deles salva rascunho, e rodar em paralelo faria um pisar no outro.
 */
test.describe.configure({ mode: "serial" });

let pagina: Page;

test.beforeAll(async ({ browser }: { browser: Browser }) => {
  /**
   * Contexto PRÓPRIO escapa das fixtures de `helpers/test.ts` — elas embrulham
   * a navegação das páginas que ELAS criam, e o `contexto` daqui não é o delas.
   * Sem esta linha o `goto` volta com a cópia do servidor ainda dentro de
   * `<div hidden id="S:N">` (a caixa do streaming SSR, #1374): o mesmo test id
   * aparece 2x no mesmo instante e o modo estrito do Playwright reprova o
   * `click` do `papel-operacao` — de forma INTERMITENTE, porque depende de
   * quem chega primeiro, o script que revela a página ou o evento `load`.
   *
   * É a mesma espera da fixture (não uma cópia da lógica): `esperarARevelacaoNasCargas`
   * é o que `helpers/test.ts` registra em `context.on("page", …)`, e aqui é
   * registrado neste contexto porque é ELE que cria a página usada pela bateria.
   * O conserto irmão, em `acervo-de-conhecimento.spec.ts`, é esta mesma linha.
   */
  const contexto = await browser.newContext({ locale: "pt-BR" });
  contexto.on("page", esperarARevelacaoNasCargas);
  pagina = await contexto.newPage();
  creds = await loginComoAdmin(pagina, creds);
});

test.afterAll(async () => {
  await pagina?.context().close();
});

/**
 * O agente da organização de teste. Falha ALTO se o seed não o deixou: sem isto
 * a navegação iria para `/app/ai/agents/undefined` e o erro apareceria como
 * "elemento não encontrado", mandando quem lê procurar um defeito de UI que não
 * existe.
 */
function agenteDeTeste(): string {
  const id = creds.capacidades?.agent_id;
  if (id === undefined || id === "") {
    throw new Error(
      "`.e2e-creds.json` sem `capacidades.agent_id` — o beforeAll deveria ter semeado. " +
        "Rode `pnpm exec tsx scripts/seed-e2e-capacidades.ts` com o ambiente do e2e.",
    );
  }
  return id;
}

/** Abre o agente semeado e vai para a aba do papel que organiza o sistema. */
async function abrirAbaDoOperador(): Promise<void> {
  await pagina.goto(`/app/ai/agents/${agenteDeTeste()}`);

  /**
   * A prova da ESPERA, e não a esperança dela: o `goto` daqui só devolve depois
   * que a revelação terminou (a linha do `beforeAll`), então neste instante não
   * pode sobrar caixa do streaming SSR. Medido antes do conserto: a caixa
   * `S:` fica no DOM ~400 ms depois do `load` em `next start`, e quem clicasse
   * nessa janela via o mesmo test id duas vezes —"resolved to 2 elements".
   *
   * A leitura é UM retrato, sem retry: `toHaveCount(1)` esperaria a duplicata
   * sumir e transformaria o defeito em verde por tempo, que é exatamente o que
   * esta issue existe para não fazer.
   */
  const caixasPendentes = await pagina.evaluate(
    () => document.querySelectorAll('div[hidden][id^="S:"]').length,
  );
  expect(
    caixasPendentes,
    "a caixa do streaming SSR sobrou depois do goto — a espera da revelação não valeu",
  ).toBe(0);

  await pagina.getByTestId("papel-operacao").click();
}

test.describe("A aba do papel que organiza o sistema", () => {
  test("os TRÊS papéis aparecem, e os nomes dizem o que cada um faz", async () => {
    await pagina.goto(`/app/ai/agents/${agenteDeTeste()}`);

    // Os rótulos são o contrato com quem configura. "Conversador"/"Operador" é
    // vocabulário nosso; quem tem uma clínica pensa em quem fala com o cliente
    // e em quem mantém a casa em ordem.
    await expect(pagina.getByTestId("papel-conversa")).toHaveText(/conversa com o cliente/i);
    await expect(pagina.getByTestId("papel-operacao")).toHaveText(/organiza o sistema/i);
    // O TERCEIRO. O épico se chama "os três papéis" e foi entregue com dois na
    // tela — a cadeia de conferências rodava e ninguém que configura sabia.
    await expect(pagina.getByTestId("papel-seguranca")).toHaveText(/confere antes de enviar/i);
  });

  test("o papel que confere mostra a lista inteira, aberta, e sem interruptor decorativo", async () => {
    await pagina.goto(`/app/ai/agents/${agenteDeTeste()}`);
    await pagina.getByTestId("papel-seguranca").click();

    const painel = pagina.getByTestId("painel-de-seguranca");
    await expect(painel).toBeVisible();

    // MEDIDA POR FERRAMENTA, não a olho: o defeito que este painel substitui era
    // um `<details>` FECHADO dentro do resultado de um teste — presente no DOM e
    // invisível na prática. Altura real > 0 é o que separa "existe" de "aparece".
    const altura = await painel.evaluate((el) => el.getBoundingClientRect().height);
    expect(altura, "o painel está no DOM e não ocupa espaço na tela").toBeGreaterThan(100);

    // Todas as conferências, não uma amostra.
    //
    // O NÚMERO É DERIVADO, NÃO ESCRITO. O painel renderiza as conferências de
    // saída MAIS a de entrada, que é uma só e mora fora do array porque roda
    // antes das outras, sobre o que CHEGA — `PainelDeSeguranca.tsx` a monta
    // assim, e esta é a mesma expressão que `painel-de-seguranca.test.tsx` usa.
    //
    // Era o literal `12` até este conserto, e o literal envelheceu em uma semana:
    // o PR #420 (@automatikpg-ux) acrescentou `agenda_stall`, atualizou o unit —
    // que reprova na hora — e não tinha como saber deste, que só reprova no
    // `e2e`, minutos depois e noutro job. A `main` ficou vermelha por isso.
    // Número escrito à mão envelhece calado; a expressão acompanha a lista.
    const itens = painel.locator('[data-testid^="item-conferencia-"]');
    await expect(itens).toHaveCount([...CONFERENCIAS_DE_SAIDA, CONFERENCIA_DE_ENTRADA].length);

    // Um interruptor de CONFERÊNCIA por item que tem ESCOLHA na lista — e só
    // esses. O número também é DERIVADO: era o literal `2` (as duas camadas que
    // consultam modelo e custam por mensagem), e o #2321 (@hampikamayuq) trouxe
    // a terceira escolha legítima, a afirmação clínica, que não custa nada e vem
    // DESLIGADA — ligar acrescenta proteção, não tira. O literal reprovou no
    // e2e, noutro job, como o `12` dos itens já tinha reprovado (#420).
    //
    // A guarda que o literal fazia continua, agora na lista: um item com
    // `escolha: null` é o que "não se desliga", e a tela não pode desenhar
    // interruptor nele. E as camadas PAGAS seguem sendo exatamente as que se
    // declaram `consultaModelo` — quem quiser oferecer desligar uma proteção
    // fixa tem de mudar a lista, à vista, em vez de só o número daqui.
    const comEscolha = [...CONFERENCIAS_DE_SAIDA, CONFERENCIA_DE_ENTRADA].filter((c) => c.escolha !== null);
    expect(
      comEscolha.filter((c) => c.escolha?.consultaModelo).length,
      "as camadas pagas (que consultam modelo) continuam sendo duas",
    ).toBe(2);
    await expect(painel.locator('[data-testid^="conferencia-"][role="switch"]')).toHaveCount(comEscolha.length);

    // E a contagem TOTAL continua cercada, por categoria em vez de por número.
    // Ancorar só nas conferências abriria a porta que este caso existe para
    // fechar: um interruptor solto no painel, de categoria nenhuma, passaria
    // despercebido. Então todo `role="switch"` do painel tem de ser OU uma
    // conferência OU morar num cartão que se declara — e cartão novo entra aqui,
    // conscientemente, em vez de o número virar 3 sem ninguém olhar.
    //
    // O `ajustes-de-estilo` é troca determinística de pontuação, sem chamada de
    // modelo: não é camada paga, e por isso não entra na contagem acima.
    const CARTOES_DECLARADOS = ["ajustes-de-estilo"];
    const soltos = await painel.evaluate(
      (el, cartoes) =>
        Array.from(el.querySelectorAll('[role="switch"]'))
          // O rótulo é do PRÓPRIO interruptor, nunca de um ancestral: a primeira
          // versão desta linha perguntava pelo `closest`, e aí um interruptor
          // embrulhado num cartão `conferencia-*` sem carregar o rótulo escapava
          // das DUAS asserções — nem contava como conferência, nem aparecia como
          // solto. Medido em bancada antes de entrar.
          .filter((s) => !s.getAttribute("data-testid")?.startsWith("conferencia-"))
          .filter((s) => !cartoes.some((c) => s.closest(`[data-testid="${c}"]`)))
          .map((s) => s.getAttribute("data-testid") ?? s.outerHTML.slice(0, 80)),
      CARTOES_DECLARADOS,
    );
    expect(soltos, "interruptor no painel de segurança sem categoria declarada").toEqual([]);

    const registro = painel.getByTestId("item-conferencia-registro_sem_efeito");
    await expect(registro).toBeVisible();
    await expect(registro.getByText("Conferir a escrita antes de afirmar registro", { exact: true })).toBeVisible();
    await expect(registro).toContainText("nenhuma escrita neste turno");
    const fixa = registro.getByTestId("conferencia-registro_sem_efeito-fixa");
    await expect(fixa).toBeVisible();
    await expect(fixa).toContainText("Isto não se desliga.");
    await expect(fixa).toContainText("Regra fixa, sem custo.");
    await expect(registro.getByRole("switch")).toHaveCount(0);
    mkdirSync("evidence/registro-no-envio", { recursive: true });
    await registro.screenshot({ path: "evidence/registro-no-envio/linha-fixa.png" });
  });

  test("desligado, a tela diz o que CONTINUA acontecendo — não só o que para", async () => {
    await abrirAbaDoOperador();

    const aviso = pagina.getByTestId("operador-consequencia");
    await expect(aviso).toBeVisible();
    // Sem a primeira metade, o usuário conclui que desligar deixa o sistema
    // cego, e liga por medo em vez de escolha. A decisão da spec 16 §2.1 é que
    // o registro básico continua por código determinístico.
    await expect(aviso).toContainText(/continua atendendo/i);
    await expect(aviso).toContainText(/registrado sozinho/i);
    await expect(aviso).toContainText(/decidir sobre a operação/i);
  });

  test("desligado, não oferece configuração que não vai valer", async () => {
    await abrirAbaDoOperador();
    // Convidar alguém a escolher capacidades de um papel desligado produz a
    // conclusão de que o produto quebrou quando nada acontece.
    await expect(pagina.getByTestId("operador-capacidades")).toBeHidden();
  });

  test("ligar revela as escolhas do papel, e elas são SÓ dele", async () => {
    await abrirAbaDoOperador();
    await pagina.getByTestId("operador-liga").click();

    await expect(pagina.getByTestId("operador-capacidades")).toBeVisible();
    // A frase que impede o modelo mental errado: esta lista não é a mesma da
    // aba de conversa.
    await expect(pagina.getByText(/só deste papel/i)).toBeVisible();
    // Sem nada marcado, o papel ainda tem função — e a tela diz qual, senão o
    // usuário liga, não marca nada e conclui que quebrou.
    await expect(pagina.getByTestId("operador-sem-capacidade")).toContainText(/prometer algo/i);
  });

  test("a escolha SOBREVIVE ao recarregar — o caso que pega o campo perdido no meio do caminho", async () => {
    await abrirAbaDoOperador();

    // Estado inicial: desligado (default do banco — migration não liga sozinha
    // um papel que gasta modelo na chave do self-hoster).
    await expect(pagina.getByTestId("operador-consequencia")).toBeVisible();

    await pagina.getByTestId("operador-liga").click();
    await expect(pagina.getByTestId("operador-capacidades")).toBeVisible();

    await pagina.getByRole("button", { name: /salvar rascunho/i }).click();
    await expect(pagina.getByText(/rascunho v\d+ salvo/i)).toBeVisible({ timeout: 20_000 });

    // O RECARREGAMENTO é o teste. Tudo até aqui vive em estado de React; só
    // depois desta linha a resposta vem do banco, atravessando a server action,
    // o Zod, o VERSION_COLUMNS e o SELECT que relê. É onde um campo que ninguém
    // propagou some em silêncio.
    await pagina.reload();
    await pagina.getByTestId("papel-operacao").click();

    await expect(
      pagina.getByTestId("operador-capacidades"),
      "o papel voltou desligado depois do refresh — algum ponto do caminho não " +
        "carrega operator_enabled (ver agent-version-columns-drift.test.ts)",
    ).toBeVisible();
    await expect(pagina.getByTestId("operador-consequencia")).toBeHidden();
  });

  test("o modelo do papel volta a herdar, e isso também sobrevive ao refresh", async () => {
    await abrirAbaDoOperador();
    await pagina.getByTestId("operador-liga").click();

    // Com o papel recém-ligado e sem modelo escolhido, o caminho de volta não
    // faz sentido — não há para onde voltar.
    await expect(pagina.getByTestId("operador-modelo-herdar")).toBeHidden();

    await pagina.getByRole("button", { name: /salvar rascunho/i }).click();
    await expect(pagina.getByText(/rascunho v\d+ salvo/i)).toBeVisible({ timeout: 20_000 });

    await pagina.reload();
    await pagina.getByTestId("papel-operacao").click();
    // `operator_model` NULL = herda o do Conversador. Se o refresh trouxesse
    // uma string vazia virando "modelo escolhido", o botão apareceria — e o
    // usuário veria uma escolha que ele não fez.
    await expect(pagina.getByTestId("operador-modelo-herdar")).toBeHidden();
  });

  test("a tela do papel não fala a NOSSA língua", async () => {
    await abrirAbaDoOperador();
    await pagina.getByTestId("operador-liga").click();

    // O mesmo defeito que a spec 16 existe para matar, um nível acima: se o
    // vocabulário interno vaza para quem CONFIGURA, ele vaza de novo para quem
    // é atendido — o dono do negócio copia o que lê.
    const corpo = (await pagina.locator("main").innerText()).toLowerCase();
    for (const jargao of ["operator_", "tool_ids", "mcp", "payload", "job_queue"]) {
      expect(corpo, `vocabulário interno na tela: ${jargao}`).not.toContain(jargao);
    }
  });
});
