import { cache } from "react";
import { combinarInterfaces } from "@/lib/navigation/interface";
/**
 * Server-side auth helpers — load AuthUser, resolve active org, gate routes.
 *
 * Uses the service-role admin client to read tenant-scoped tables
 * (`user_organizations`, `platform_admins`, `organizations`) — RLS bypass is
 * intentional here because we resolve the user from the validated JWT first
 * and then filter by `user_id` (a trusted source).
 */
import type { SupportContext } from "@/lib/impersonate/support";
import { readSupportContext } from "@/lib/impersonate/support";
import {
  comCacheCurto,
  etiquetaDeOrg,
  etiquetaDeSessao,
  etiquetaDeUsuario,
  sessaoDosCookies,
  type SessaoDosCookies,
} from "@/lib/auth/cache-curto";
import { cookies, headers } from "next/headers";
import { agindoPeloPoderDePlataforma } from "@/lib/auth/poder-de-plataforma";
import { redirect } from "next/navigation";
import { safeNext } from "@/lib/auth/safe-next";
import { logger } from "@/lib/logger";
import { createClient } from "@/lib/supabase/server";
import { createAdminClient } from "@/lib/supabase/admin";
import {
  empresaExigeMfa,
  avaliaPoliticaDeMfa,
  politicaDaEmpresa,
  type ExigenciaDeMfa,
  type PapelMinimoDeMfa,
} from "@/lib/auth/politica-mfa";
import { normalizarIdioma } from "@/lib/i18n/idiomas";
import { STATUS_OPERANTE, ehOperante } from "@/lib/organizacao/operante";
import type { AuthUser, Role, UserOrgMembership, ActiveOrg } from "./types";

const ACTIVE_ORG_COOKIE = "active_org";

interface RawMembershipRow {
  interface_settings?: unknown;
  organization_id: string;
  role: string;
  /** Só para ORDENAR — a lista decide qual organização fica ativa sem cookie. */
  accepted_at?: string | null;
  organizations: OrgJoin | OrgJoin[] | null;
  /**
   * As portas da EMPRESA, por embed PRÓPRIO (`organizations.interface_settings`,
   * migration 0367). Ficam fora de `organizations(display_name, locale)` de
   * propósito: aquele embed é o que a membership SEMPRE trouxe — nome e IDIOMA da
   * empresa, lidos em toda navegação — e um jsonb novo ali faria a escolha de menu
   * mexer no caminho de quem só precisa saber em que língua desenhar a tela.
   */
  interface_da_empresa: OrgJoinEmpresa | OrgJoinEmpresa[] | null;
}

interface OrgJoin {
  display_name: string;
  locale: string | null;
  timezone: string | null;
  currency: string | null;
  country: string | null;
  status?: string;
  suspended_kind?: string | null;
}

/** O mesmo `organizations`, alcançado por outro embed: só as portas da EMPRESA. */
interface OrgJoinEmpresa {
  interface_settings?: unknown;
}

/**
 * O idioma padrão da organização ativa — `null` se não há organização.
 */
async function localeDaOrgAtiva(memberships: UserOrgMembership[]): Promise<string | null> {
  if (memberships.length === 0) return null;
  const store = await cookies();
  return escolherMembroAtivo(memberships, store.get(ACTIVE_ORG_COOKIE)?.value)?.locale ?? null;
}

/**
 * Qual organização está ativa, dado o cookie.
 *
 * Extraída porque DUAS coisas precisam da mesma resposta e não podem divergir:
 * `resolveActiveOrg`, que decide o escopo dos dados, e a resolução do idioma
 * dentro de `loadAuthUser`. Se cada uma escolhesse por conta, dava para ver os
 * dados de uma empresa com a interface no idioma de outra.
 *
 * ⚠️ O `memberships[0]` do fim só é uma ESCOLHA porque quem monta `memberships`
 * ordena a consulta (`accepted_at`, depois `organization_id`). Sem aquele
 * `ORDER BY`, isto aqui é um sorteio — e desde que o idioma passou a vir junto
 * da organização ativa, o sorteio decide TAMBÉM em que língua o sistema abre.
 * As duas coisas andam juntas: não tire a ordenação de lá sem resolver isto.
 */
function escolherMembroAtivo(
  memberships: UserOrgMembership[],
  cookieOrg: string | undefined,
): UserOrgMembership | null {
  if (memberships.length === 0) return null;
  if (cookieOrg) {
    // Com cookie, MANTÉM mesmo a suspensa: é por ela que a pessoa chega ao hub
    // `/account-suspended` para pagar, pedir LGPD ou trocar de empresa.
    const achado = memberships.find((o) => o.organization_id === cookieOrg);
    if (achado) return achado;
  }
  // Sem cookie, a primeira OPERANTE na mesma ordem (`accepted_at`,
  // `organization_id`); a primeira de todas só quando nenhuma opera.
  return memberships.find((o) => ehOperante(o.org_status)) ?? memberships[0] ?? null;
}

/**
 * Loads the AuthUser for the current request. Returns null if unauthenticated.
 * Use only in Server Components / Route Handlers / Server Actions.
 *
 * Uses the user-scoped server client (cookie session). RLS policies allow:
 * - user_organizations: user_id = auth.uid() (user_orgs_select)
 * - organizations: id IN fn_user_org_ids()  (orgs_select)
 * - platform_admins: only platform admins read (so non-admins get null — correct)
 */
/**
 * "Não havia sessão nenhuma" — o estado NORMAL, não um incidente.
 *
 * `getUser()` não devolve `error: null` para quem não está logado. Sem cookie de
 * sessão, `GoTrueClient._getUser` corta antes de falar com o GoTrue e devolve
 * `{ data: { user: null }, error: new AuthSessionMissingError() }` (status 400).
 * Medido em `@supabase/auth-js` 2.112.1, com um cookie store vazio:
 *
 *     user  = null
 *     error = AuthSessionMissingError: Auth session missing! (status=400)
 *
 * Isso importa porque **não há `middleware.ts` neste projeto**: o gate de
 * `/app/*` é o próprio `app/app/layout.tsx`, que chama `loadAuthUser()` e só
 * então redireciona. Ou seja, todo visitante deslogado — e todo crawler — passa
 * por aqui sem sessão. Logar esse caso como erro reintroduziria, do lado do log,
 * exatamente a ambiguidade que o bloco acima existe para desfazer: o estado
 * normal e a falha transitória voltariam a ser a mesma linha, agora afogadas em
 * volume de tráfego anônimo.
 *
 * Compara por `name` e não por `instanceof`: há DUAS cópias de `@supabase/auth-js`
 * na árvore (2.111.0 e 2.112.1 em `node_modules/.pnpm`), e `instanceof` só acerta
 * quando o erro vem da mesma cópia que o teste importou.
 */
export function ehSessaoAusente(error: { name?: string } | null | undefined): boolean {
  return error?.name === "AuthSessionMissingError";
}

/**
 * A sessão que os cookies da requisição identificam — `null` quando não há
 * requisição (script, job) ou quando o cookie não é reconhecível. Nesses casos a
 * requisição segue o caminho direto, sem chave de cache.
 */
async function sessaoDosCookiesDaIrequisicao(): Promise<SessaoDosCookies | null> {
  try {
    return sessaoDosCookies((await cookies()).getAll());
  } catch {
    // `cookies()` lança fora de escopo de requisição. Antes do #3696 este
    // caminho nem tocava o cookie de sessão; quem chama `loadAuthUser` de um
    // script continua funcionando, só não reaproveita nada.
    return null;
  }
}

const sessaoDaRequisicao = cache(async () => {
  const supabase = await createClient();
  const sessao = await sessaoDosCookiesDaIrequisicao();
  /**
   * CACHE ENTRE REQUISIÇÕES DO `getUser()` — é a maior linha do log do
   * Supabase na issue #3696 (18,6 mil `/auth/v1/user` em ~17 h), porque toda
   * navegação valida o mesmo token de novo.
   *
   * Duas travas, ambas de segurança:
   *  - a chave é o HASH dos cookies de sessão: token rotacionado, chave nova.
   *    Sem cookie, não há chave e a requisição segue o caminho direto;
   *  - o `exp` do JWT é o teto (`ate`), com margem. Servir um usuário em cache
   *    para um token vencido faria as consultas RLS seguintes levarem 401 — o
   *    cache devolveria "autenticado" com credencial morta. Sem `exp` legível,
   *    NÃO se cacheia (fail-open para o caminho de hoje).
   *
   * Só o SUCESSO é guardado: `error` é incidente, não estado — e `user: null`
   * com erro é justamente o caso que não pode virar resposta reaproveitada.
   */
  const resposta =
    sessao && sessao.expiraEm !== null
      ? await comCacheCurto({
          chave: `sessao:${sessao.chave}`,
          etiquetas: [etiquetaDeSessao(sessao.chave)],
          // A identidade também derruba a sessão: `invalidarUsuario` (sair de
          // todos os aparelhos, MFA novo, revogação) alcança as OUTRAS sessões
          // da pessoa, cujo cookie esta requisição não conhece.
          etiquetasDoValor: (r) => (r.data?.user ? [etiquetaDeUsuario(r.data.user.id)] : []),
          ate: sessao.expiraEm,
          guardar: (r) => !r.error && !!r.data?.user,
          carregar: () => supabase.auth.getUser(),
        })
      : await supabase.auth.getUser();
  return { supabase, resposta, sessao };
});

interface PermissoesResolvidas {
  paRow: { user_id: string; scope: string | null } | null;
  memberships: UserOrgMembership[];
}

/**
 * As duas consultas de permissão mais a normalização das memberships — o
 * corpo que `loadAuthUser` sempre teve, extraído para caber inteiro atrás do
 * cache curto.
 *
 * A lista de memberships decide QUAL ORGANIZAÇÃO FICA ATIVA para quem não tem
 * o cookie `active_org` — `resolveActiveOrg` pega `organizations[0]` —, e por
 * isso a ordenação não é enfeite: ver o comentário do `order` abaixo.
 */
async function lerPermissoes(
  supabase: Awaited<ReturnType<typeof createClient>>,
  userId: string,
): Promise<PermissoesResolvidas> {
  // Platform admin e Org memberships consultados em paralelo no Supabase:
  // elimina round-trip sequencial a cada requisição.
  const [
    { data: paRow, error: paErro },
    { data: rawMemberships, error: membErro },
  ] = await Promise.all([
      supabase
        .from("platform_admins")
        .select("user_id, scope, revoked_at")
        .eq("user_id", userId)
        .is("revoked_at", null)
        .maybeSingle(),
      supabase
        .from("user_organizations")
        .select(
          // Dois embeds do MESMO `organizations`, como manda o PostgREST quando a
          // mesma relação aparece duas vezes: `organizations(...)` continua sendo
          // o que a membership sempre trouxe (nome, IDIOMA e FUSO da empresa — o
          // idioma decide a tela inteira e não pode depender de um embed que a
          // issue #1341 acabou de engordar), e o alias traz só as portas da EMPRESA.
          // `timezone` veio do main (fuso da organização nas listas, #1290) e convive
          // com o alias: um embed por relação, sem renomear o que já existia.
          "organization_id, role, interface_settings, accepted_at, organizations!user_organizations_organization_id_fkey(display_name, locale, timezone, currency, country, status, suspended_kind), interface_da_empresa:organizations!user_organizations_organization_id_fkey(interface_settings)",
        )
        .eq("user_id", userId)
        .is("revoked_at", null)
        .order("accepted_at", { ascending: true, nullsFirst: true })
        .order("organization_id", { ascending: true }),
    ]);

  /**
   * FALHA ALTO, não baixo.
   *
   * Antes, o erro destas duas queries era descartado e `rawMemberships` nulo virava
   * `[]` — ou seja, "usuário sem organização". O resultado é que uma instabilidade do
   * banco chega ao operador como **"você não pertence a nenhuma organização"**: as
   * telas de admin somem, as rotas devolvem 403, e nada indica que a causa é
   * infraestrutura.
   *
   * Medido em 2026-07-30: com o PostgREST devolvendo `name resolution failed` depois
   * de um restart do Docker, TODOS os cards de admin sumiram do hub de configurações.
   * Custou seis diagnósticos errados — build velho, processo velho, cache, filtro de
   * papel — antes de alguém olhar a causa real.
   *
   * Degradar permissão em silêncio é o pior desfecho possível num caminho de auth:
   * parece uma decisão de autorização e é um defeito de infra. Melhor estourar e
   * mostrar erro do que renderizar uma UI mentirosa.
   *
   * ⚠️ E é por isso que este `throw` fica DENTRO do cache curto e não fora dele: o
   * `comCacheCurto` não guarda o que estourou. Um erro cacheado por 30 s viraria 30 s
   * de "todas as telas de admin sumiram" — o mesmo incidente, agora com prazo de
   * validade.
   */
  if (paErro || membErro) {
    const detalhe = (paErro ?? membErro)!;
    logger.error("[auth] não foi possível resolver permissões do usuário", {
      user_id: userId,
      onde: paErro ? "platform_admins" : "user_organizations",
      code: detalhe.code,
      message: detalhe.message,
    });
    throw new Error(
      `auth_permissions_unavailable: ${detalhe.message} — permissões não puderam ser ` +
        `resolvidas; a sessão NÃO foi rebaixada por decisão de autorização.`,
    );
  }

  const rows = (rawMemberships ?? []) as RawMembershipRow[];
  const memberships: UserOrgMembership[] = rows.map((row) => {
    const orgs = row.organizations;
    const org = Array.isArray(orgs) ? (orgs[0] ?? null) : orgs;
    const empresas = row.interface_da_empresa;
    const empresa = Array.isArray(empresas) ? (empresas[0] ?? null) : empresas;
    return {
      organization_id: row.organization_id,
      organization_name: org?.display_name ?? "—",
      role: row.role as Role,
      // EMPRESA ∩ VÍNCULO (migration 0367): a empresa escolhe o universo de
      // portas da instalação, o vínculo escolhe menos dentro dele. Até aqui o
      // vínculo decidia sozinho, então a escolha da empresa não existia.
      interface_settings: combinarInterfaces(empresa?.interface_settings, row.interface_settings),
      locale: org?.locale ?? null,
      timezone: org?.timezone ?? null,
      currency: org?.currency ?? null,
      country: org?.country ?? null,
      org_status: org?.status ?? null,
      suspended_kind: org?.suspended_kind ?? null,
    };
  });

  return { paRow: (paRow ?? null) as PermissoesResolvidas["paRow"], memberships };
}

/**
 * O snapshot de PERMISSÕES da pessoa atravessando requisições por até 30 s
 * (#3696): `platform_admins` + `user_organizations` eram ~14 mil consultas em
 * ~17 h de uso, repetidas a cada navegação.
 *
 * ⚠️ A INVALIDAÇÃO É A CONDIÇÃO DE EXISTIR. Toda escrita de vínculo (revogar,
 * reativar, trocar papel, mexer na interface do membro, aceitar convite), toda
 * mudança de estado da organização (suspender/reativar) e toda sessão de
 * acompanhamento começada ou encerrada chamam `invalidarUsuario`/
 * `invalidarOrg` no mesmo gesto. Quem escrever um caminho NOVO de vínculo
 * precisa fazer o mesmo — a cerca
 * `tests/unit/acesso-revogado-nao-espera-o-cache.test.ts` percorre estes
 * arquivos e reprova quem esquecer.
 *
 * ⚠️ E O PODER DE PLATAFORMA POSITIVO NÃO É CACHEADO (`guardar`). Um grant só
 * nasce e só morre por SQL direto — a rota de `platform_admins` é 405 por
 * projeto (Spec 01 §3.4 T-04) —, logo não existe invalidação confiável para
 * ele. Guardar a AUSÊNCIA é seguro por construção: um "não é admin" obsoleto
 * só pode NEGAR, nunca conceder. O preço é o platform admin pagar as duas
 * consultas a cada requisição; o ganho é não existir janela em que um grant
 * revogado por DBA continue valendo.
 */
const permissoesDoUsuario = cache(
  async (
    userId: string,
    supabase: Awaited<ReturnType<typeof createClient>>,
  ): Promise<PermissoesResolvidas> =>
    comCacheCurto({
      chave: `permissoes:${userId}`,
      etiquetas: [etiquetaDeUsuario(userId)],
      // As orgs só se conhecem DEPOIS da consulta, e é por elas que a
      // suspensão de uma empresa (que muda `org_status` de todos os membros)
      // derruba o snapshot de cada um com um `invalidarOrg` só.
      etiquetasDoValor: (p) => p.memberships.map((m) => etiquetaDeOrg(m.organization_id)),
      guardar: (p) => p.paRow === null,
      carregar: () => lerPermissoes(supabase, userId),
    }),
);

/**
 * O contexto de acompanhamento administrativo (`fn_support_context`), também
 * curto — 6,4 mil chamadas em ~17 h no relato da issue.
 *
 * A chave leva a SESSÃO junto do usuário, e não só o usuário: a RPC resolve
 * `auth.session_id`, então o mesmo admin em duas sessões (uma acompanhando,
 * outra não) tem dois contextos diferentes. Sem a chave de sessão, o cache
 * misturaria os dois — exatamente o tipo de vazamento que um cache de
 * autorização não pode ter.
 *
 * Invalidado por identidade (`invalidarUsuario`, no início e no fim do
 * acompanhamento) e por sessão (`invalidarSessao`, na saída).
 */
const suporteDaSessao = cache(
  async (
    userId: string,
    sessao: SessaoDosCookies | null,
    supabase: Awaited<ReturnType<typeof createClient>>,
  ): Promise<SupportContext | null> => {
    // Sem cookie de sessão reconhecível não há chave — e sem chave não se
    // cacheia nada. Caminho direto, como antes do #3696.
    if (!sessao) return readSupportContext(supabase);
    return comCacheCurto({
      chave: `suporte:${userId}:${sessao.chave}`,
      etiquetas: [etiquetaDeUsuario(userId), etiquetaDeSessao(sessao.chave)],
      // Só a AUSÊNCIA, pela mesma regra do poder de plataforma: um acompanhamento
      // ativo pode vencer pelo relógio ou cair por SQL, sem invalidação possível.
      guardar: (contexto) => contexto === null,
      carregar: () => readSupportContext(supabase),
    });
  },
);

export const loadAuthUser = cache(async (): Promise<AuthUser | null> => {
  const {
    supabase,
    resposta: {
      data: { user },
      error,
    },
    sessao,
  } = await sessaoDaRequisicao();
  // ⚠️ O `error` era DESCARTADO — nem chegava a ser desestruturado —, e aqui
  // `user: null` é tão ambíguo quanto o `data: null` que a query logo abaixo
  // trata com todo o cuidado: significa "não está logado" (estado normal) E
  // "não deu para perguntar" (rede, GoTrue fora do ar, token ilegível).
  //
  // A ação não muda, e isso é deliberado: sem usuário confirmado, devolver
  // `null` — e portanto redirecionar para o login — é o desfecho seguro.
  // Falhar FECHADO na ação continua certo. O que estava errado era falhar
  // fechado também na INFORMAÇÃO: quem investigasse depois via só uma pessoa
  // "deslogada", sem nada distinguindo isso de uma falha transitória.
  //
  // Custou caro uma vez: um vermelho de e2e em que a barra lateral "perdeu o
  // logo" foi, por eliminação, uma casca de app que não era a casca do app —
  // e a hipótese nº 1 é justamente um redirect nascido aqui. Com este log, a
  // próxima ocorrência se explica sozinha em vez de custar uma investigação.
  //
  // ⚠️ MAS NEM TODO `error` AQUI É INCIDENTE — e é por isso que `ehSessaoAusente`
  // existe. Ver o comentário dela: sem esse filtro, este log dispara em todo
  // visitante deslogado e refaz, do lado do log, a mesma fusão que este bloco
  // existe para desfazer.
  if (error && !ehSessaoAusente(error)) {
    // As chaves são as MESMAS do outro `logger.error` desta função (linha ~134):
    // `code` e `message`. Dois nomes para o mesmo conceito, dentro da mesma
    // função, obrigariam quem consulta o agregador a escrever duas buscas — num
    // conserto cujo objeto é diagnóstico.
    //
    // `name` viaja junto porque é o que separa as CLASSES: `AuthRetryableFetchError`
    // (rede, GoTrue fora do ar) de `AuthApiError` (token ilegível). Ambas podem
    // chegar com o mesmo `status`, e a mensagem vem em inglês do upstream — sem o
    // nome, distinguir as duas viraria regex sobre texto que muda entre versões.
    logger.error("[auth] getUser falhou — tratando como não autenticado", {
      name: error.name,
      code: error.code ?? null,
      status: error.status ?? null,
      message: error.message,
    });
  }
  if (!user) return null;

  // Permissões e contexto de acompanhamento em PARALELO, como estavam no lote
  // de antes — o que mudou (#3696) é que cada um deles agora atravessa a
  // requisição: ver `permissoesDoUsuario` e `suporteDaSessao`.
  //
  // A precedência dos erros é a MESMA: falha de permissão estoura primeiro
  // (vem do `Promise.all`) e a do acompanhamento é relançada logo abaixo.
  const [permissoes, leituraDoSuporte] = await Promise.all([
    permissoesDoUsuario(user.id, supabase),
    suporteDaSessao(user.id, sessao, supabase).then(
      (contexto) => ({ ok: true as const, contexto }),
      (erro: unknown) => ({ ok: false as const, erro }),
    ),
  ]);

  const { paRow, memberships } = permissoes;
  if (!leituraDoSuporte.ok) throw leituraDoSuporte.erro;
  const support = leituraDoSuporte.contexto;
  const fullName = (user.user_metadata?.full_name as string | undefined) ?? null;
  const avatarUrl = (user.user_metadata?.avatar_url as string | undefined) ?? null;
  const locale = (user.user_metadata?.locale as string | undefined) ?? null;
  // A cadeia inteira num lugar só: pessoa → organização ativa → padrão. Quem
  // consome pede `idioma` e não precisa saber que existe uma ordem.
  //
  // O cookie só é lido quando a resposta DEPENDE dele: quem já tem preferência
  // própria, e quem não pertence a organização nenhuma, não têm o que resolver.
  // Ler assim mesmo faria toda tela do produto tocar o cookie para descartar o
  // valor em seguida.
  const idioma = normalizarIdioma(
    locale ?? support?.locale ?? (await localeDaOrgAtiva(memberships)),
  );
  const timezone = (user.user_metadata?.timezone as string | undefined) ?? null;

  return {
    id: user.id,
    email: user.email ?? "",
    full_name: fullName,
    avatar_url: avatarUrl,
    is_platform_admin: !!paRow,
    platform_admin_scope: paRow?.scope ?? null,
    locale,
    idioma,
    timezone,
    organizations: memberships,
    support,
  };
});

/**
 * O `user_metadata` CRU da sessão desta requisição — `{}` quando não há.
 *
 * ─── Por que não alargar `AuthUser`, e por que não um `getUser` novo ──────
 *
 * `AuthUser` é o recorte que TODA tela usa, e ele viaja para o cliente em
 * `app/app/layout.tsx` (`<AuthProvider user={user}>`). Espalhar o metadata
 * inteiro ali mandaria ao navegador chaves que não são da tela — o
 * `invite_token` do cadastro, por exemplo. Quem precisa de uma preferência
 * específica lê esta função e extrai a chave dela, como
 * `app/get-started/page.tsx` já faz com uma consulta própria.
 *
 * A consulta própria seria um SEGUNDO `/auth/v1/user` por carregamento — a
 * linha que a #3696 mediu 18,6 mil vezes em 17 h. Aqui não há ida nova ao
 * GoTrue: é o resultado de `sessaoDaRequisicao`, que `loadAuthUser` já pediu e
 * que o `cache()` do React reaproveita dentro da mesma requisição.
 */
export const metadadosDaSessao = cache(async (): Promise<Record<string, unknown>> => {
  const {
    resposta: {
      data: { user },
    },
  } = await sessaoDaRequisicao();
  const metadata = user?.user_metadata;
  return typeof metadata === "object" && metadata !== null
    ? (metadata as Record<string, unknown>)
    : {};
});

/**
 * A organização ativa SEM o portão de suspensão — o corpo que `resolveActiveOrg`
 * tinha até a spec da cobrança (§4, item 3).
 *
 * Só para quem PRECISA enxergar a org parada: `requireRole` (responde 403
 * `org_suspended` em JSON, não 307), o hub `/account-suspended`, leitura que não
 * pode sumir para o suspenso (`lib/legal/operador.ts`) e o início de um
 * acompanhamento, que só guarda para onde voltar (`admin/tenants/[id]/impersonate`).
 * Rota de API usa `orgAtivaDaApi` (lib/auth/require-role.ts: 403 JSON); o resto,
 * `resolveActiveOrg`.
 */
export const orgAtivaSemPortao = cache(async (authUser: AuthUser): Promise<ActiveOrg | null> => {
  if (authUser.support) {
    if (authUser.support.status !== "active") redirect("/support-ended");
    // Acompanhamento não tem membership, e era por isso que este caminho
    // devolvia a organização PELADA: sem fuso, e agora sem moeda nem país. A
    // tela então caía nos padrões e mostrava `R$` dentro de uma empresa em
    // euro — o mesmo defeito que este conserto ataca, por outra porta. Uma
    // leitura por id, só nas sessões de acompanhamento; falha degrada para o
    // que havia antes, porque perder o acesso de suporte é pior que um símbolo
    // errado.
    const { data: orgDoSuporte } = await createAdminClient()
      .from("organizations")
      .select("timezone, currency, country")
      .eq("id", authUser.support.organization_id)
      .maybeSingle();
    return {
      orgId: authUser.support.organization_id,
      name: authUser.support.name,
      role: authUser.support.access_mode === "full" ? "admin" : "viewer",
      timezone: orgDoSuporte?.timezone ?? null,
      currency: orgDoSuporte?.currency ?? null,
      country: orgDoSuporte?.country ?? null,
      // `fn_support_context` só devolve status 'active' com a org em 'active'
      // (`o.status <> 'active'` vira 'revoked'), e a linha acima já saiu.
      org_status: STATUS_OPERANTE,
      suspended_kind: null,
    };
  }
  const store = await cookies();
  const ativo = escolherMembroAtivo(authUser.organizations, store.get(ACTIVE_ORG_COOKIE)?.value);
  if (!ativo) return null;
  return {
    orgId: ativo.organization_id,
    name: ativo.organization_name,
    role: ativo.role,
    interface_settings: ativo.interface_settings,
    timezone: ativo.timezone ?? null,
    currency: ativo.currency ?? null,
    country: ativo.country ?? null,
    org_status: ativo.org_status ?? null,
    suspended_kind: ativo.suspended_kind ?? null,
  };
});

/**
 * Resolves the active organization for the current request.
 * Priority: cookie `active_org` (if member of) → first OPERANT membership → first.
 * Returns null if user has zero memberships.
 *
 * Org NÃO operante redireciona para `/account-suspended` (mesmo precedente do
 * `/support-ended`). É isto que fecha páginas, layouts e server actions de uma vez.
 * NUNCA em rota de API (`app/api/**`): o `fetch` seguiria o 307 para HTML — lá
 * é `orgAtivaDaApi` (cerca `tests/unit/api-nao-redireciona-org-suspensa.test.ts`).
 */
export const resolveActiveOrg = cache(async (authUser: AuthUser): Promise<ActiveOrg | null> => {
  const org = await orgAtivaSemPortao(authUser);
  if (org && !ehOperante(org.org_status)) redirect("/account-suspended");
  return org;
});

/**
 * For Server Components / Server Actions in /app/(app)/* routes — guarantees
 * an authenticated user. Redirects to /login if not.
 */
export async function requireAuth(): Promise<AuthUser> {
  const user = await loadAuthUser();
  if (!user) redirect("/login");
  return user;
}

/**
 * Returns true if the current session has at least one verified TOTP factor.
 * Use only in Server Components / Server Actions (cookie session).
 *
 * LANÇA quando não conseguiu ler os fatores. O `getUser()` do auth-js não
 * lança: se falhar, ele DEVOLVE `{ data: { user: null }, error }`. Ler só
 * `data` transformava essa falha em "não tem fator" — e `mfaEmDivida` liberava
 * a sessão `aal1` de quem TEM fator. Uma leitura que não aconteceu não pode
 * virar resposta: quem decide acesso falha fechado (a exceção vira 500 na rota,
 * nunca 200).
 *
 * Lê `user.factors` da MESMA ida ao GoTrue que `loadAuthUser` já fez
 * (`sessaoDaRequisicao`), com a regra do `listFactors()` do auth-js 2.117:
 * fator `totp` com `status === "verified"`.
 */
export const isMfaEnrolled = cache(async (): Promise<boolean> => {
  const {
    resposta: { data, error },
  } = await sessaoDaRequisicao();
  if (error) throw error;
  return !!data.user?.factors?.some((f) => f.factor_type === "totp" && f.status === "verified");
});

/**
 * A política de CADASTRO completa para quem está na tela — as três saídas de
 * `avaliaPoliticaDeMfa` (`lib/auth/politica-mfa.ts`).
 *
 * As duas leituras vêm daqui porque o layout precisa delas de qualquer forma;
 * quem já tem a política em mãos deve chamar `avaliaPoliticaDeMfa` direto.
 *
 * A carência (`mfa_grace_days`) só é resolvida quando ela existe: a âncora é
 * `max(mfa_policy_changed_at, user_organizations.accepted_at)`, e buscar o
 * `accepted_at` de todo mundo custaria uma consulta em TODA requisição para
 * resolver um caso que a grande maioria das instalações não tem.
 */
export const exigenciaDeMfa = cache(
  async (
    role: Role | undefined,
    isPlatformAdmin: boolean,
    userId?: string,
    orgId?: string,
  ): Promise<ExigenciaDeMfa> => {
    const admin = createAdminClient();

    // As duas políticas são independentes — a da plataforma e a da empresa —, e
    // por isso são lidas JUNTAS. Em fila, o dono da instalação (que é platform
    // admin) pagava duas idas ao banco a cada tela de `/app`.
    const [linhaDaPlataforma, linhaDaEmpresa] = await Promise.all([
      isPlatformAdmin && userId
        ? admin
            .from("platform_admins")
            .select("mfa_required")
            .eq("user_id", userId)
            .is("revoked_at", null)
            .maybeSingle()
        : null,
      orgId ? admin.from("organizations").select("settings").eq("id", orgId).maybeSingle() : null,
    ]);

    let plataformaExige: boolean | null = null;
    if (linhaDaPlataforma) {
      plataformaExige = (linhaDaPlataforma.data?.mfa_required as boolean | undefined) ?? null;
    }

    let empresaExige = false;
    let papelMinimo: PapelMinimoDeMfa | null = null;
    let diasDeCarencia = 0;
    let mudouEm: Date | null = null;
    let aceitoEm: string | null = null;
    if (orgId && linhaDaEmpresa) {
      const { data } = linhaDaEmpresa;
      empresaExige = empresaExigeMfa(data?.settings);
      const cfg = politicaDaEmpresa(data?.settings);
      papelMinimo = cfg.papelMinimo;
      diasDeCarencia = cfg.diasDeCarencia;
      mudouEm = cfg.mudouEm;

      if (userId && diasDeCarencia > 0) {
        const { data: vinculo } = await admin
          .from("user_organizations")
          .select("accepted_at")
          .eq("organization_id", orgId)
          .eq("user_id", userId)
          .maybeSingle();
        aceitoEm = (vinculo?.accepted_at as string | null | undefined) ?? null;
      }
    }

    return avaliaPoliticaDeMfa({
      role,
      isPlatformAdmin,
      plataformaExige,
      empresaExige,
      papelMinimo,
      diasDeCarencia,
      mudouEm,
      aceitoEm,
    });
  },
);

/**
 * Quem é OBRIGADO A CADASTRAR a verificação em duas etapas.
 *
 * ⚠️ ISTO DEIXOU DE SER UMA CONSTANTE. A regra era
 * `isPlatformAdmin || role === "admin"`, sem opção —, e como o `install.sh`
 * cria o dono da instalação como platform admin, TODA instalação self-host
 * forçava TOTP antes de a pessoa usar o produto. Medido percorrendo o wizard: o
 * botão "Começar a usar" entregava o dono num bloqueador de tela cheia, um
 * sétimo passo que a barra de progresso nunca anunciou.
 *
 * Agora a resposta vem da POLÍTICA — `platform_admins.mfa_required` para o
 * platform admin, `organizations.settings.security.mfa_required` /
 * `.mfa_required_min_role` para o tenant —, e o padrão de ambos é não exigir. A
 * regra pura, com o porquê de cada ramo, vive em `lib/auth/politica-mfa.ts`.
 *
 * O booleano que este retorno devolve é o de BLOQUEIO (`bloqueia`), não o de
 * obrigação: é ele que o layout do `/app` transforma no `MfaEnrollGate` de tela
 * cheia, e durante a carência (`mfa_grace_days`) a pessoa já está obrigada mas a
 * tela ainda não pode travar. Quem precisa da distinção chama `exigenciaDeMfa`.
 */
export const requiresMfa = cache(
  async (
    role: Role | undefined,
    isPlatformAdmin: boolean,
    userId?: string,
    orgId?: string,
  ): Promise<boolean> => (await exigenciaDeMfa(role, isPlatformAdmin, userId, orgId)).bloqueia,
);

/**
 * Nível de garantia da SESSÃO atual: `aal2` = o segundo fator foi provado nesta
 * sessão; `aal1` = só a senha.
 *
 * Isto responde uma pergunta diferente de `isMfaEnrolled()`, e a diferença é o
 * achado que motivou este helper: "tem fator cadastrado" é política de
 * CADASTRO; "provou o fator agora" é política de SESSÃO. O produto só tinha a
 * primeira, então uma sessão `aal1` de um admin com TOTP cadastrado era
 * plenamente funcional — o cenário exato que a MFA existe para conter (senha
 * vazada/phishing).
 */
export async function sessionAal(): Promise<"aal1" | "aal2" | null> {
  const supabase = await createClient();
  const { data } = await supabase.auth.mfa.getAuthenticatorAssuranceLevel();
  // O tipo do SDK é `"aal1" | "aal2" | (string & {})` — aberto de propósito, para
  // aceitar níveis futuros. Estreitar aqui mantém a decisão em quem chama.
  const nivel: string | null | undefined = data?.currentLevel;
  return nivel === "aal1" || nivel === "aal2" ? nivel : null;
}

/**
 * A sessão está em dívida com a MFA? Verdadeiro só quando as TRÊS valem:
 * o papel exige MFA, o usuário JÁ cadastrou um fator, e a sessão é `aal1`.
 *
 * A condição do meio não é detalhe: sem ela, o admin que ainda não cadastrou
 * ficaria trancado do lado de fora — inclusive das rotas necessárias para
 * cadastrar. Quem ainda não tem fator continua sendo tratado pelo gate de
 * cadastro do `app/app/layout.tsx`.
 */
export async function mfaEmDivida(): Promise<boolean> {
  // ⚠️ NÃO PERGUNTA MAIS A POLÍTICA, e a mudança é o que impede o cadastro
  // opcional de virar um buraco: começava por `requiresMfa(...)`, então, com a
  // exigência desligada, quem ativasse a verificação POR VONTADE PRÓPRIA teria o
  // fator ignorado na sessão — o mesmo que não ter.
  //
  // Cadastrar e provar são perguntas diferentes. Quem TEM fator prova, sempre.
  if (!(await isMfaEnrolled())) return false;
  if ((await sessionAal()) === "aal2") return false;
  if (await agindoPeloPoderDePlataforma()) return true;
  // `aal1` com fator: só o banco sabe se esta sessão foi reconhecida por um
  // dispositivo confiável (`lib/auth/dispositivo-confiavel.ts`). Pergunta à
  // MESMA função que as definer cobram, para app e banco não divergirem — e
  // falha fechado: leitura que não aconteceu não vira "provada".
  const supabase = await createClient();
  const { data, error } = await supabase.rpc("fn_session_mfa_proven");
  if (error) throw error;
  return data !== true;
}

/**
 * A mesma dívida, SEM aceitar dispositivo confiável: quem tem fator precisa do
 * `aal2` do GoTrue, o código provado nesta sessão. É a régua das rotas da
 * PLATAFORMA cuja organização vem do path (`/admin/**`, extensões da
 * instalação), onde a organização ativa não diz nada sobre o alvo.
 */
export async function mfaEmDividaEstrita(): Promise<boolean> {
  if (!(await isMfaEnrolled())) return false;
  return (await sessionAal()) !== "aal2";
}

/**
 * O portão de SESSÃO das telas: quem tem fator e não o provou nesta sessão vai
 * ao desafio, de volta para onde estava. É a mesma pergunta das rotas
 * (`mfaEmDivida`), para que a tela e as rotas por trás dela respondam igual.
 *
 * A régua é a BRANDA de propósito: dispositivo confiável vale em `/app` — com
 * `mfaEmDividaEstrita` aqui, confiar no dispositivo não teria efeito nenhum. Quem
 * age pelo poder de plataforma segue devendo o código (`mfaEmDivida` decide), e o
 * `/admin` tem portão próprio, que exige o `aal2` (`requirePlatformAdmin`).
 *
 * Custo: sem fator, ou com `aal2`, nada de novo (`isMfaEnrolled` é `cache` da
 * requisição e o layout já a pediu; `sessionAal` lê o token). Sessão `aal1` COM
 * fator faz duas leituras a mais: a org ativa, via `agindoPeloPoderDePlataforma`,
 * que roda antes para todo platform admin, e o rpc `fn_session_mfa_proven`.
 * Falha ao ler os fatores LANÇA (o `isMfaEnrolled`), como nas rotas — nenhuma tela
 * renderiza; erro do rpc também lança.
 *
 * `regua`: a tela cujas ações usam a régua ESTRITA (as da instalação em `/app`:
 * extensões, servidor MCP externo) passa `mfaEmDividaEstrita`. Sem isso, quem
 * entrou pelo dispositivo confiável abria a tela e recebia `mfa_required` em
 * toda ação — e entrar de novo não pedia o código.
 */
export async function exigirProvaDeMfa(regua: () => Promise<boolean> = mfaEmDivida): Promise<void> {
  if (!(await regua())) return;
  // `x-pathname` vem do `proxy.ts`, gravado ANTES do `NextResponse.next` — só o
  // que existe ali chega aqui (tests/unit/proxy-encaminha-o-caminho.test.ts).
  const caminho = safeNext((await headers()).get("x-pathname"), "/app");
  redirect(`/login/mfa?next=${encodeURIComponent(caminho)}`);
}