/**
 * E2E do ciclo de vida do CONVITE, ponta a ponta + adversarial (Playwright).
 *
 * Cobre o que a pergunta do dono pediu — convidar → aceitar → entrar → ver só o
 * permitido → agir dentro da permissão — e stressa os cantos:
 *   1. Ciclo feliz: admin convida → convidado loga e aceita → vira membership agent → cai no inbox
 *   2. Escopo pós-aceite: o agent vê inbox/kanban, é bloqueado (403) em billing/api-tokens
 *   3. Permissão pós-aceite: o agent NÃO consegue convidar (invite é admin-only → 403)
 *   4. Reuso do token: aceitar o MESMO token 2x é idempotente (sem membership duplicada)
 *   5. already_member: reconvidar quem já é membro → failed:[{reason: already_member}]
 *   6. Token expirado → "Convite inválido ou expirado"
 *   7. Token adulterado (HMAC quebrado) → idem
 *   8. Email não corresponde: logado com OUTRA conta → "Email não corresponde"
 *   9. Não autenticado → cadastro em destaque e login secundário
 *
 * Pré-req: npx tsx scripts/seed-e2e-invite.ts (o spec roda sozinho se faltar).
 * Contra o Supabase do env (local recomendado — precisa das migrations de RLS
 * 0035/0036/0042/0044 pro escopo do agent ser real).
 */
import { execFileSync } from "node:child_process";
import { randomUUID } from "node:crypto";
import * as fs from "node:fs";
import * as path from "node:path";

import { esperarARevelacaoNasCargas, test, expect, type Page } from "./helpers/test";
import { createClient } from "@supabase/supabase-js";

import { waitForEmail, extractAuthConfirmLink, seguirLinkDeAcesso, uniqueEmail } from "./helpers/auth";

import { signInviteToken } from "../../lib/auth/invite-token";
import { generateTotp, msUntilNextTotpWindow } from "./utils/totp";

// ---- creds do seed base (.e2e-creds.json) + do convite (.e2e-invite.json) ----
interface BaseCreds {
  password: string;
  users: Record<string, { id: string; email: string; role: string }>;
  admin_totp?: { factor_id: string; secret: string };
}
interface InviteCreds {
  org_id: string;
  invitee_email: string;
  invitee_id: string;
}

const CREDS_PATH = path.join(process.cwd(), ".e2e-creds.json");
const INVITE_PATH = path.join(process.cwd(), ".e2e-invite.json");

function load(): { base: BaseCreds; inv: InviteCreds } {
  if (!fs.existsSync(INVITE_PATH) || !fs.existsSync(CREDS_PATH)) {
    execFileSync("npx", ["tsx", "scripts/seed-e2e-invite.ts"], { stdio: "inherit" });
  }
  return {
    base: JSON.parse(fs.readFileSync(CREDS_PATH, "utf8")) as BaseCreds,
    inv: JSON.parse(fs.readFileSync(INVITE_PATH, "utf8")) as InviteCreds,
  };
}

const { base, inv } = load();

// service-role client (mesmo env do dev) — pra provar estado no banco e resetar
const svc = createClient(
  process.env.NEXT_PUBLIC_SUPABASE_URL!,
  process.env.SUPABASE_SERVICE_ROLE_KEY!,
  { auth: { persistSession: false } },
);

async function membershipCount(): Promise<number> {
  const { count } = await svc
    .from("user_organizations")
    .select("id", { count: "exact", head: true })
    .eq("user_id", inv.invitee_id)
    .eq("organization_id", inv.org_id)
    .is("revoked_at", null);
  return count ?? 0;
}

async function membershipRole(): Promise<string | null> {
  const { data } = await svc
    .from("user_organizations")
    .select("role")
    .eq("user_id", inv.invitee_id)
    .eq("organization_id", inv.org_id)
    .is("revoked_at", null)
    .maybeSingle();
  return (data as { role: string } | null)?.role ?? null;
}

async function resetInvitee(): Promise<void> {
  await svc.from("user_organizations").delete().eq("user_id", inv.invitee_id).eq("organization_id", inv.org_id);
}

async function login(page: Page, email: string): Promise<void> {
  await page.goto("/login");
  await page.locator("#email").fill(email);
  await page.locator("#password").fill(base.password);
  await page.getByRole("button", { name: "Entrar", exact: true }).click();
  await page.waitForURL(/\/app\//);
}

async function loginAdminTotp(page: Page): Promise<void> {
  const secret = base.admin_totp!.secret;
  await page.goto("/login");
  await page.locator("#email").fill(base.users.admin!.email);
  await page.locator("#password").fill(base.password);
  await page.getByRole("button", { name: "Entrar", exact: true }).click();
  await page.waitForURL(/\/login\/mfa/);
  for (let attempt = 0; attempt < 3; attempt++) {
    // não digitar em cima da virada da janela TOTP (o código expiraria no envio)
    if (msUntilNextTotpWindow() < 4_000) await page.waitForTimeout(msUntilNextTotpWindow() + 300);
    await page.locator('input[aria-label="Dígito 1"]').click();
    await page.keyboard.type(generateTotp(secret), { delay: 40 });
    try {
      // 1ª compilação de /app no dev pode ser lenta → timeout generoso
      await page.waitForURL(/\/app\//, { timeout: 30_000 });
      return;
    } catch {
      if (/\/app\//.test(page.url())) return; // navegou; só passou do timeout
      if (!/\/login\/mfa/.test(page.url())) throw new Error(`MFA em estado inesperado: ${page.url()}`);
      await page.waitForTimeout(msUntilNextTotpWindow() + 300); // código recusado → nova janela
    }
  }
  throw new Error("admin MFA falhou após 3 tentativas");
}

// admin convida o convidado como agent → devolve o accept_url (token stateless)
async function issueInvite(page: Page, email: string, role: string): Promise<{ acceptUrl: string; failed: Array<{ email: string; reason: string }> }> {
  const res = await page.request.post("/api/v1/team/invite", {
    data: { invitations: [{ email, role }] },
  });
  expect(res.status(), await res.text()).toBe(201);
  const json = (await res.json()) as { data: { sent: Array<{ email: string; accept_url: string }>; failed: Array<{ email: string; reason: string }> } };
  return { acceptUrl: json.data.sent[0]?.accept_url ?? "", failed: json.data.failed };
}

function tokenOf(acceptUrl: string): string {
  return acceptUrl.split("/team/accept-invite/")[1] ?? "";
}

test.describe.configure({ mode: "serial", timeout: 180_000 });

test.describe("ciclo de vida do convite (ponta a ponta + adversarial)", () => {
  test.beforeAll(async ({ browser }) => {
    // Dev (webpack) compila cada rota na 1ª visita — 40-80s cada. Aquecemos TODAS
    // as rotas que a suíte toca aqui (orçamento grande, uma vez); com o dev
    // persistente, os testes depois rodam rápido.
    test.setTimeout(600_000);
    await resetInvitee(); // convidado começa SEM acesso

    // (a) telas autenticadas de /app + endpoints — como agent (membro, sem MFA)
    const ctx = await browser.newContext();
    ctx.on("page", esperarARevelacaoNasCargas);
    const page = await ctx.newPage();
    await page.goto("/login");
    await page.locator("#email").fill(base.users.agent!.email);
    await page.locator("#password").fill(base.password);
    await page.getByRole("button", { name: "Entrar", exact: true }).click();
    await page.waitForURL(/\/app\//, { timeout: 150_000 }).catch(() => {});
    for (const r of ["/app/inbox", "/app/kanban", "/app/contacts", "/app/settings/billing", "/app/settings/api-tokens"]) {
      await page.goto(r).catch(() => {});
    }
    // compila o endpoint de convite (agent → 403, mas compila a rota)
    await page.request
      .post("/api/v1/team/invite", { data: { invitations: [{ email: "warm@deskcomm.test", role: "agent" }] } })
      .catch(() => {});
    // compila a tela de aceite (token dummy → inválido, mas compila a página)
    await page.goto("/team/accept-invite/warmup").catch(() => {});
    await ctx.close();

    // (b) /login/mfa — login de senha do admin, sem completar o TOTP
    const mctx = await browser.newContext();
    mctx.on("page", esperarARevelacaoNasCargas);
    const mp = await mctx.newPage();
    await mp.goto("/login");
    await mp.locator("#email").fill(base.users.admin!.email);
    await mp.locator("#password").fill(base.password);
    await mp.getByRole("button", { name: "Entrar", exact: true }).click();
    await mp.waitForURL(/\/login\/mfa/, { timeout: 150_000 }).catch(() => {});
    await mctx.close();
  });

  test("1. ciclo feliz: convidar → aceitar → vira agent → cai no inbox", async ({ browser }) => {
    // admin convida
    const adminCtx = await browser.newContext();
    adminCtx.on("page", esperarARevelacaoNasCargas);
    const adminPage = await adminCtx.newPage();
    await loginAdminTotp(adminPage);
    const { acceptUrl, failed } = await issueInvite(adminPage, inv.invitee_email, "agent");
    expect(failed).toEqual([]);
    expect(acceptUrl).toContain("/team/accept-invite/");
    await adminCtx.close();

    // antes do aceite: convidado NÃO tem acesso
    expect(await membershipCount()).toBe(0);

    // convidado loga e aceita
    const inviteeCtx = await browser.newContext();
    inviteeCtx.on("page", esperarARevelacaoNasCargas);
    const page = await inviteeCtx.newPage();
    await page.goto("/login");
    await page.locator("#email").fill(inv.invitee_email);
    await page.locator("#password").fill(base.password);
    await page.getByRole("button", { name: "Entrar", exact: true }).click();
    // Sem membership ainda: o login não cai em /app. Esperamos a SESSÃO (cookie)
    // se firmar antes de ir ao accept_url — senão a página cai no estado não-logado.
    await expect
      .poll(async () => (await inviteeCtx.cookies()).some((c) => c.name.startsWith("sb-deskcomm-auth")), {
        timeout: 40_000, // 1º login no dev (webpack) compila signInWithPassword — pode levar ~16s
      })
      .toBe(true);
    // Vamos direto ao accept_url (fluxo real do link do email).
    await page.goto(tokenPath(acceptUrl));
    await expect(page.getByRole("heading", { name: /Aceitar convite/i })).toBeVisible();
    await page.getByRole("button", { name: /Aceitar convite/i }).click();
    await page.waitForURL(/\/app\/inbox/);

    // depois do aceite: membership agent criada
    expect(await membershipCount()).toBe(1);
    expect(await membershipRole()).toBe("agent");
    await inviteeCtx.close();
  });

  test("2. escopo pós-aceite: agent vê inbox/kanban, bloqueado em billing/api-tokens", async ({ page }) => {
    await login(page, inv.invitee_email);

    await page.goto("/app/settings/billing");
    await page.waitForURL(/\/403/);
    await expect(page.getByRole("heading", { name: /403/ })).toBeVisible();

    await page.goto("/app/settings/api-tokens");
    await page.waitForURL(/\/403/);

    await page.goto("/app/inbox");
    await expect(page.getByText("Selecione uma conversa", { exact: true })).toBeVisible();

    await page.goto("/app/kanban");
    await expect(page.getByRole("heading", { name: "Funis" })).toBeVisible();
  });

  test("3. permissão pós-aceite: agent NÃO consegue convidar (403)", async ({ page }) => {
    await login(page, inv.invitee_email);
    const res = await page.request.post("/api/v1/team/invite", {
      data: { invitations: [{ email: "outro.invite@deskcomm.test", role: "agent" }] },
    });
    expect(res.status()).toBe(403);
  });

  test("4. reuso do token: aceitar 2x é idempotente (sem membership duplicada)", async ({ browser }) => {
    // Token válido mintado direto: o convidado já é membro (testes anteriores), então
    // reconvidá-lo seria — corretamente — barrado por already_member. Aqui testamos só
    // a IDEMPOTÊNCIA do aceite em si (aceitar o mesmo token 2x não duplica membership).
    const token = signInviteToken({
      invite_id: randomUUID(),
      email: inv.invitee_email,
      organization_id: inv.org_id,
      role: "agent",
      exp: Math.floor(Date.now() / 1000) + 3600,
    });
    const ctx = await browser.newContext();
    ctx.on("page", esperarARevelacaoNasCargas);
    const page = await ctx.newPage();
    await login(page, inv.invitee_email); // já é membro → cai no /app
    // 1º aceite (reaplica)
    await page.goto(`/team/accept-invite/${token}`);
    await page.getByRole("button", { name: /Aceitar convite/i }).click();
    await page.waitForURL(/\/app\/inbox/);
    // 2º aceite do MESMO token
    await page.goto(`/team/accept-invite/${token}`);
    await page.getByRole("button", { name: /Aceitar convite/i }).click();
    await page.waitForURL(/\/app\/inbox/);
    // sem duplicar membership
    expect(await membershipCount()).toBe(1);
    await ctx.close();
  });

  test("5. already_member: reconvidar quem já é membro → failed already_member", async ({ browser }) => {
    const ctx = await browser.newContext();
    ctx.on("page", esperarARevelacaoNasCargas);
    const page = await ctx.newPage();
    await loginAdminTotp(page);
    const { failed } = await issueInvite(page, inv.invitee_email, "agent");
    expect(failed.some((f) => f.email === inv.invitee_email && f.reason === "already_member")).toBe(true);
    await ctx.close();
  });

  test("6. token expirado → convite inválido ou expirado", async ({ page }) => {
    const expired = signInviteToken({
      invite_id: randomUUID(),
      email: inv.invitee_email,
      organization_id: inv.org_id,
      role: "agent",
      exp: Math.floor(Date.now() / 1000) - 60, // 1min no passado
    });
    await page.goto(`/team/accept-invite/${expired}`);
    await expect(page.getByRole("heading", { name: /inválido ou expirado/i })).toBeVisible();
  });

  test("7. token adulterado (HMAC quebrado) → inválido", async ({ page }) => {
    const valid = signInviteToken({
      invite_id: randomUUID(),
      email: inv.invitee_email,
      organization_id: inv.org_id,
      role: "agent",
      exp: Math.floor(Date.now() / 1000) + 3600,
    });
    // vira a assinatura: troca o último char por outro
    const flipped = valid.slice(0, -1) + (valid.slice(-1) === "A" ? "B" : "A");
    await page.goto(`/team/accept-invite/${flipped}`);
    await expect(page.getByRole("heading", { name: /inválido ou expirado/i })).toBeVisible();
  });

  test("8. email não corresponde: logado com outra conta → mismatch", async ({ page }) => {
    const valid = signInviteToken({
      invite_id: randomUUID(),
      email: inv.invitee_email, // convite pro convidado
      organization_id: inv.org_id,
      role: "agent",
      exp: Math.floor(Date.now() / 1000) + 3600,
    });
    // mas logamos como o AGENT do seed base (email diferente)
    await login(page, base.users.agent!.email);
    await page.goto(`/team/accept-invite/${valid}`);
    await expect(page.getByRole("heading", { name: /não corresponde/i })).toBeVisible();
  });

  test("9. não autenticado → CTA de login, não o formulário de aceite", async ({ page }) => {
    const valid = signInviteToken({
      invite_id: randomUUID(),
      email: inv.invitee_email,
      organization_id: inv.org_id,
      role: "agent",
      exp: Math.floor(Date.now() / 1000) + 3600,
    });
    await page.goto(`/team/accept-invite/${valid}`);
    await expect(page.getByRole("heading", { name: /Você foi convidado/i })).toBeVisible();
    await expect(page.getByRole("link", { name: "Já tenho conta? Entrar", exact: true })).toBeVisible();
  });

  /**
   * Quem é convidado e ainda NÃO tem conta.
   *
   * Até aqui a tela oferecia só "Fazer login", então essa pessoa criava conta
   * pelo caminho comum — e o provisionamento, sem encontrar vínculo nenhum,
   * abria uma organização e a tornava ADMIN dela. Ela terminava com uma empresa
   * fantasma, um wizard que não era dela e o gate de MFA de administrador; só
   * depois, voltando ao link dentro das 24h, entrava na empresa certa, ficando
   * nas duas — com a organização ativa decidida por sorteio.
   *
   * O suíte inteiro não exercitava esse estado: o seed CRIA a conta do
   * convidado de propósito, que é justamente o que esconde o defeito.
   */
  test("10. sem conta → o caminho existe e leva o convite junto", async ({ page }) => {
    const valid = signInviteToken({
      invite_id: randomUUID(),
      email: inv.invitee_email,
      organization_id: inv.org_id,
      role: "agent",
      exp: Math.floor(Date.now() / 1000) + 3600,
    });

    await page.goto(`/team/accept-invite/${valid}`);
    await page.getByRole("link", { name: "Criar minha conta", exact: true }).click();

    // O token viaja: é ele que faz a conta nova nascer amarrada a este convite
    // em vez de ganhar uma organização própria.
    await expect(page).toHaveURL(new RegExp(`/signup\\?invite=${encodeURIComponent(valid)}`));
    await page.getByRole("link", { name: "Entrar", exact: true }).click();
    await expect(page).toHaveURL(`/login?next=${encodeURIComponent(`/team/accept-invite/${valid}`)}`);
    await expect(page.getByLabel("Email")).toHaveValue(inv.invitee_email);
    await page.getByRole("link", { name: "Criar conta", exact: true }).click();
    await expect(page).toHaveURL(new RegExp(`/signup\\?invite=${encodeURIComponent(valid)}`));
    await expect(page.getByLabel("Email")).toHaveValue(inv.invitee_email);
  });

  test("11. o signup com convite não pede empresa, e trava o e-mail", async ({ page }) => {
    // Pedir "Nome da empresa" aqui seria mandar a pessoa batizar a organização
    // de outra gente; deixar o e-mail editável levaria a preencher tudo e
    // receber divergência no fim.
    const valid = signInviteToken({
      invite_id: randomUUID(),
      email: inv.invitee_email,
      organization_id: inv.org_id,
      role: "agent",
      exp: Math.floor(Date.now() / 1000) + 3600,
    });

    await page.goto(`/signup?invite=${encodeURIComponent(valid)}`);
    await expect(page.getByLabel("Nome da empresa")).toHaveCount(0);
    const email = page.getByLabel("Email");
    await expect(email).toHaveValue(inv.invitee_email);
    await expect(email).toHaveAttribute("readonly", /.*/);
  });

  test("12. convite expirado no signup avisa, em vez de abrir empresa nova", async ({ page }) => {
    const expirado = signInviteToken({
      invite_id: randomUUID(),
      email: inv.invitee_email,
      organization_id: inv.org_id,
      role: "agent",
      exp: Math.floor(Date.now() / 1000) - 60,
    });

    await page.goto(`/signup?invite=${encodeURIComponent(expirado)}`);
    // `.first()`: o Next monta um `<div role="alert" id="__next-route-announcer__">`
    // vazio em toda navegação de cliente, então `getByRole("alert")` sozinho casa
    // DOIS elementos e o strict mode reprova — sobre uma tela que está correta.
    await expect(page.getByRole("alert").first()).toContainText(/expirou|não é mais válido/i);
    // E cai no signup COMUM só depois do aviso — nunca em silêncio.
    await expect(page.getByLabel("Nome da empresa")).toBeVisible();
  });

  /**
   * ─── A LISTA DE CONVITES na aba Membros (migration 0238) ──────────────────
   *
   * Até aqui o convite pendente só existia numa lista efêmera dentro do modal
   * "Convidar membros". Estes casos provam pela tela: o convite aparece com
   * status, o e-mail que não saiu vira aviso + link copiável, e reenviar /
   * revogar funcionam — com a revogação barrando o token ainda dentro do prazo.
   */
  const FRESH_EMAIL = `convite.pendente.${randomUUID().slice(0, 8)}@deskcomm.test`;

  test.beforeAll(async () => {
    // Conta para o convidado do caso 16 (aceite do link revogado). Sufixo
    // aleatório + banco fresco no CI = sem colisão; em rerun local, ignora o
    // "já registrado".
    const { error } = await svc.auth.admin.createUser({
      email: FRESH_EMAIL,
      password: base.password,
      email_confirm: true,
    });
    if (error && !/already/i.test(error.message)) throw error;
  });

  test("13. convite pendente aparece na aba Membros, com status e quem convidou", async ({
    browser,
  }) => {
    const ctx = await browser.newContext();
    ctx.on("page", esperarARevelacaoNasCargas);
    const page = await ctx.newPage();
    await loginAdminTotp(page);
    const { acceptUrl } = await issueInvite(page, FRESH_EMAIL, "agent");
    expect(acceptUrl).toContain("/team/accept-invite/");

    await page.goto("/app/team");
    const row = page.getByRole("row", { name: new RegExp(FRESH_EMAIL, "i") });
    await expect(row).toBeVisible();
    // ⚠️ `exact: true`, e a razão está no endereço logo acima: o e-mail semeado
    // CONTÉM a palavra "pendente" (`convite.pendente.<uuid>@…`). Sem `exact`, o
    // `getByText` é substring e casa DUAS coisas na mesma linha — a célula do
    // e-mail e o selo de status —, e o Playwright reprova por strict mode.
    // Medido no CI em 11/09: `resolved to 2 elements`, com a célula do e-mail
    // como a primeira. O selo diz exatamente "Pendente"; o endereço, não.
    await expect(row.getByText("Pendente", { exact: true })).toBeVisible();
    // CI não configura Resend → o e-mail não sai, e a tela diz isso.
    await expect(row.getByText(/Não saiu/i)).toBeVisible();
    await expect(row.getByRole("button", { name: /Copiar link/i }).first()).toBeVisible();
    await ctx.close();
  });

  test("14. admin reenvia pelo menu da linha", async ({ browser }) => {
    const ctx = await browser.newContext();
    ctx.on("page", esperarARevelacaoNasCargas);
    const page = await ctx.newPage();
    await loginAdminTotp(page);
    await page.goto("/app/team");
    const row = page.getByRole("row", { name: new RegExp(FRESH_EMAIL, "i") });
    await row.getByRole("button", { name: /Ações/i }).click();
    await page.getByRole("menuitem", { name: /Reenviar/i }).click();
    await expect(page.getByText(/Convite reenviado/i)).toBeVisible();

    const { data } = await svc
      .from("team_invites")
      .select("resend_count")
      .eq("organization_id", inv.org_id)
      .eq("email", FRESH_EMAIL)
      .single();
    expect((data as { resend_count: number }).resend_count).toBeGreaterThanOrEqual(1);
    await ctx.close();
  });

  test("15. admin revoga; o token deixa de valer mesmo dentro do prazo", async ({ browser }) => {
    const adminCtx = await browser.newContext();
    adminCtx.on("page", esperarARevelacaoNasCargas);
    const adminPage = await adminCtx.newPage();
    await loginAdminTotp(adminPage);

    // captura o link ANTES de revogar (a API de listagem devolve o accept_url)
    const listRes = await adminPage.request.get("/api/v1/team/invites");
    const lista = (await listRes.json()) as {
      data: Array<{ email: string; accept_url: string | null }>;
    };
    const linkAntes = lista.data.find((i) => i.email === FRESH_EMAIL)?.accept_url ?? "";
    expect(linkAntes).toContain("/team/accept-invite/");

    await adminPage.goto("/app/team");
    const row = adminPage.getByRole("row", { name: new RegExp(FRESH_EMAIL, "i") });
    await row.getByRole("button", { name: /Ações/i }).click();
    await adminPage.getByRole("menuitem", { name: /Revogar/i }).click();
    await adminPage.getByRole("button", { name: /^Revogar$/ }).click();
    await expect(adminPage.getByText(/Convite revogado/i)).toBeVisible();

    const { data } = await svc
      .from("team_invites")
      .select("revoked_at")
      .eq("organization_id", inv.org_id)
      .eq("email", FRESH_EMAIL)
      .single();
    expect((data as { revoked_at: string | null }).revoked_at).not.toBeNull();
    await adminCtx.close();

    // o convidado tenta aceitar o link antigo → recusado, apesar de assinado e no prazo
    const inviteeCtx = await browser.newContext();
    inviteeCtx.on("page", esperarARevelacaoNasCargas);
    const ip = await inviteeCtx.newPage();
    await ip.goto("/login");
    await ip.locator("#email").fill(FRESH_EMAIL);
    await ip.locator("#password").fill(base.password);
    await ip.getByRole("button", { name: "Entrar", exact: true }).click();
    await expect
      .poll(
        async () =>
          (await inviteeCtx.cookies()).some((c) => c.name.startsWith("sb-deskcomm-auth")),
        { timeout: 40_000 },
      )
      .toBe(true);
    await ip.goto(new URL(linkAntes).pathname);
    await ip.getByRole("button", { name: /Aceitar convite/i }).click();

    // ⚠️ ALERTA, não HEADING — e a diferença não é de estilo, é de QUEM recusa.
    //
    // O heading "Convite inválido ou expirado" é do SERVIDOR, e sai só quando
    // `verifyInviteToken` devolve `null`: token adulterado ou vencido. Aqui o
    // token está íntegro e dentro das 24h — o que o invalidou foi a LINHA
    // (`team_invites.revoked_at`), e essa recusa acontece na server action, já
    // com a página desenhada. Ela chega pelo `<p role="alert">` do
    // `AcceptInviteForm`.
    //
    // Pedir o heading fazia o caso não poder passar nunca. Ele não chegou a
    // reprovar antes porque, na execução anterior, o caso 13 falhou e este foi
    // PULADO — a primeira vez que ele rodou de verdade foi a segunda.
    // `p[role="alert"]`, e não `getByRole("alert")`: o Next injeta o PRÓPRIO
    // anunciador de rota — `<div role="alert" aria-live="assertive"
    // id="__next-route-announcer__">`, vazio — em toda página, e um
    // `getByRole("alert")` casa os dois e reprova por strict mode. Medido no
    // CI; o nosso é o `<p>` do `AcceptInviteForm`.
    await expect(
      ip.locator('p[role="alert"]'),
      "a tela não disse nada ao convidado: o aceite foi recusado em silêncio",
    ).toContainText(/revogado|vencido|não foi possível/i);

    // E o outro lado, que é o que o caso existe para provar: NÃO entrou.
    // Sem isto, um alerta visível junto com um aceite bem-sucedido passaria.
    await expect(ip).toHaveURL(/\/team\/accept-invite\//);
    expect(
      (
        await svc
          .from("user_organizations")
          .select("user_id")
          .eq("organization_id", inv.org_id)
          .eq("user_id", (await svc.auth.admin.listUsers()).data.users.find(
            (u) => (u.email ?? "").toLowerCase() === FRESH_EMAIL.toLowerCase(),
          )?.id ?? "00000000-0000-4000-8000-000000000000")
      ).data ?? [],
      "o convite revogado criou vínculo mesmo assim",
    ).toEqual([]);
    await inviteeCtx.close();
  });
});

// helper: extrai só o path (relativo) do accept_url absoluto pro page.goto
function tokenPath(acceptUrl: string): string {
  const t = tokenOf(acceptUrl);
  return `/team/accept-invite/${t}`;
}


// Conta realmente inexistente: não substitui o cadastro por admin.createUser.
test("convite novo: alternar cadastro/login, confirmar no Mailpit e entrar só na equipe convidada", async ({ browser, baseURL }) => {
  test.setTimeout(180_000);
  const email = uniqueEmail("convite-lote");
  const senha = "SenhaForte!123";
  const antes = await svc.auth.admin.listUsers({ page: 1, perPage: 1000 });
  expect(antes.error).toBeNull();
  expect(antes.data.users.some((u) => u.email === email)).toBe(false);
  const adminCtx = await browser.newContext();
  adminCtx.on("page", esperarARevelacaoNasCargas);
  const adminPage = await adminCtx.newPage();
  const ctx = await browser.newContext();
  ctx.on("page", esperarARevelacaoNasCargas);
  const page = await ctx.newPage();
  let usuarioId: string | undefined;
  try {
    await loginAdminTotp(adminPage);
    const { acceptUrl, failed } = await issueInvite(adminPage, email, "agent");
    expect(failed).toEqual([]);
    const token = tokenOf(acceptUrl);
    expect(Boolean(token)).toBe(true);
    const adulterado = `${token.slice(0, -1)}${token.endsWith("A") ? "B" : "A"}`;
    // Query de e-mail não tem autoridade, assinatura adulterada não preenche.
    await page.goto(`/login?next=${encodeURIComponent(`/team/accept-invite/${adulterado}`)}&email=${encodeURIComponent(email)}`);
    await expect(page.getByLabel("Email")).toHaveValue("");

    await page.goto(`/team/accept-invite/${token}`);
    await page.getByRole("link", { name: "Criar minha conta", exact: true }).click();
    await expect(page).toHaveURL((url) => url.pathname === "/signup" && url.searchParams.get("invite") === token);
    await expect(page.getByLabel("Nome da empresa")).toHaveCount(0);
    await expect(page.getByLabel("Email")).toHaveValue(email);
    fs.mkdirSync("evidence/lote-bugfixes-01a178b3", { recursive: true });
    await page.screenshot({ path: "evidence/lote-bugfixes-01a178b3/convite-01-cadastro.png" });
    await page.getByRole("link", { name: "Entrar", exact: true }).click();
    // O e-mail é igual nas duas telas; só seu valor não espera a navegação.
    await expect(page).toHaveURL((url) => url.pathname === "/login" && url.searchParams.get("next") === `/team/accept-invite/${token}`);
    await expect(page.getByLabel("Email")).toHaveValue(email);
    expect(new URL(page.url()).searchParams.get("next") === `/team/accept-invite/${token}`).toBe(true);
    await page.screenshot({ path: "evidence/lote-bugfixes-01a178b3/convite-02-login-preenchido.png" });
    await page.getByRole("link", { name: "Criar conta", exact: true }).click();
    await expect(page).toHaveURL((url) => url.pathname === "/signup" && url.searchParams.get("invite") === token);
    expect(new URL(page.url()).searchParams.get("invite") === token).toBe(true);
    await page.getByLabel("Seu nome").fill("Pessoa convidada sintética");
    await page.getByLabel("Senha", { exact: true }).fill(senha);
    await page.getByLabel("Confirmar senha").fill(senha);
    await page.getByRole("button", { name: "Criar conta", exact: true }).click();
    await expect(page.getByText("Confirme seu e-mail")).toBeVisible();
    await page.screenshot({ path: "evidence/lote-bugfixes-01a178b3/convite-03-confirmar-email.png" });

    const html = await waitForEmail(email, "Confirme seu e-mail");
    const link = extractAuthConfirmLink(html, baseURL!);
    // Confirmação usa o token_hash real entregue ao endereço do convite.
    expect(Boolean(new URL(link).searchParams.get("token_hash"))).toBe(true);
    await seguirLinkDeAcesso(page, link);
    await expect(page).toHaveURL(/\/app\//);
    const depois = await svc.auth.admin.listUsers({ page: 1, perPage: 1000 });
    expect(depois.error).toBeNull();
    const usuario = depois.data.users.find((u) => u.email === email);
    expect(Boolean(usuario?.email_confirmed_at)).toBe(true);
    usuarioId = usuario!.id;
    const vinculos = await svc.from("user_organizations").select("organization_id,role").eq("user_id", usuarioId).is("revoked_at", null);
    expect(vinculos.error).toBeNull();
    expect(vinculos.data).toEqual([{ organization_id: inv.org_id, role: "agent" }]);
    const convite = await svc.from("team_invites").select("accepted_at,accepted_by").eq("organization_id", inv.org_id).eq("email", email).single();
    expect(convite.error).toBeNull();
    expect(Boolean(convite.data?.accepted_at)).toBe(true);
    expect(convite.data?.accepted_by).toBe(usuarioId);
    // Cookie real, revalidado por getUser() na rota autenticada.
    expect((await page.request.get("/api/v1/contacts?limit=1")).status()).toBe(200);
    await page.screenshot({ path: "evidence/lote-bugfixes-01a178b3/convite-04-equipe-certa.png" });
  } finally {
    // Se falhar depois do signup, acha só a conta sintética deste caso para limpar.
    if (!usuarioId) {
      const criados = await svc.auth.admin.listUsers({ page: 1, perPage: 1000 });
      usuarioId = criados.data.users.find((u) => u.email === email)?.id;
    }
    expect((await svc.from("team_invites").delete().eq("organization_id", inv.org_id).eq("email", email)).error).toBeNull();
    if (usuarioId) expect((await svc.auth.admin.deleteUser(usuarioId)).error).toBeNull();
    await ctx.close();
    await adminCtx.close();
  }
});
