/**
 * `resolveAuthDual` — sessão de navegador OU token de servidor.
 *
 * O que esta suíte prova, e é o que não pode regredir:
 *
 *  1. No ramo do token, `organization_id` sai da LINHA DO TOKEN. Nunca do
 *     corpo, nunca da query. É o que impede um Bearer de uma org escrever
 *     noutra.
 *  2. Escrita exige `mcp:write`. Um token de leitura (`mcp:read`) é recusado
 *     com 403, não aceito com poderes a mais.
 *  3. Sem header `Authorization`, cai na sessão, com o mesmo gate do resto de
 *     `/api/v1/*`.
 *  4. Os caminhos novos estão em `public-paths`. Sem isso o proxy responde 401
 *     antes de qualquer uma das regras acima ser consultada, e a rota fica
 *     inalcançável por token mesmo estando correta.
 */
import { NextRequest } from "next/server";
import { beforeEach, describe, expect, it, vi } from "vitest";

import { isPublicPath } from "@/lib/auth/public-paths";
import { requireRole } from "@/lib/auth/require-role";
import type { AuthUser } from "@/lib/auth/types";
import { McpAuthError } from "@/lib/mcp/auth";

import { resolveAuthDual } from "./auth-dual";

vi.mock("@/lib/auth/require-role", () => ({ requireRole: vi.fn() }));
vi.mock("@/lib/supabase/server", () => ({ createClient: vi.fn(async () => ({ session: true })) }));
vi.mock("@/lib/supabase/admin", () => ({ createAdminClient: vi.fn(() => ({ admin: true })) }));

vi.mock("@/lib/mcp/auth", async () => {
  const actual = await vi.importActual<typeof import("@/lib/mcp/auth")>("@/lib/mcp/auth");
  return { ...actual, validateBearerToken: vi.fn() };
});
const { validateBearerToken } = await import("@/lib/mcp/auth");

const ORG_DO_TOKEN = "22222222-2222-4222-8222-222222222222";
const ORG_DA_SESSAO = "33333333-3333-4333-8333-333333333333";
const USER_ID = "11111111-1111-4111-8111-111111111111";

const OPCOES = {
  requestId: "req-1",
  resource: "messages",
  role: "agent" as const,
  scope: "mcp:write",
};

function req(headers?: HeadersInit) {
  return new NextRequest("http://localhost/api/v1/messages", { method: "POST", headers });
}

function sessaoOk(): void {
  const user: AuthUser = {
    id: USER_ID,
    email: "a@example.com",
    full_name: null,
    avatar_url: null,
    is_platform_admin: false,
    idioma: "pt-BR" as const,
    organizations: [
      { organization_id: ORG_DA_SESSAO, organization_name: "Org", role: "agent" },
    ],
  } as AuthUser;
  vi.mocked(requireRole).mockResolvedValue({
    ok: true,
    user,
    org: { orgId: ORG_DA_SESSAO, role: "agent" },
  } as never);
}

beforeEach(() => {
  vi.clearAllMocks();
});

describe("resolveAuthDual", () => {
  it("no ramo do token, a org vem do token e não do corpo", async () => {
    vi.mocked(validateBearerToken).mockResolvedValue({
      organizationId: ORG_DO_TOKEN,
      scopes: ["mcp:read", "mcp:write"],
      role: "agent",
      actor: { type: "api_token", id: "tok-1" },
    } as never);

    const r = await resolveAuthDual(req({ authorization: "Bearer dsk_abc" }), OPCOES);

    expect(r.ok).toBe(true);
    if (!r.ok) return;
    expect(r.organizationId).toBe(ORG_DO_TOKEN);
    expect(r.via).toBe("token");
    // requireRole nem chega a ser consultado quando há Bearer.
    expect(requireRole).not.toHaveBeenCalled();
  });

  it("recusa token de leitura numa rota de escrita", async () => {
    vi.mocked(validateBearerToken).mockResolvedValue({
      organizationId: ORG_DO_TOKEN,
      scopes: ["mcp:read"],
      role: "agent",
      actor: { type: "api_token", id: "tok-1" },
    } as never);

    const r = await resolveAuthDual(req({ authorization: "Bearer dsk_abc" }), OPCOES);

    expect(r.ok).toBe(false);
    if (r.ok) return;
    expect(r.response.status).toBe(403);
  });

  it("token inválido fecha em 401 e não cai na sessão", async () => {
    vi.mocked(validateBearerToken).mockRejectedValue(
      new McpAuthError(-32001, 401, "Token inválido."),
    );

    const r = await resolveAuthDual(req({ authorization: "Bearer dsk_ruim" }), OPCOES);

    expect(r.ok).toBe(false);
    if (r.ok) return;
    expect(r.response.status).toBe(401);
    expect(requireRole).not.toHaveBeenCalled();
  });

  it("token de org suspensa responde 403 org_suspended", async () => {
    vi.mocked(validateBearerToken).mockRejectedValue(new McpAuthError(-32002, 403, "Organization suspended.", "org_suspended"));
    const r = await resolveAuthDual(req({ authorization: "Bearer dsk_x_y" }), OPCOES);
    expect(r.ok).toBe(false);
    if (!r.ok) {
      expect(r.response.status).toBe(403);
      expect((await r.response.json()).error.code).toBe("org_suspended");
    }
  });

  it("sem Authorization, usa a sessão", async () => {
    sessaoOk();

    const r = await resolveAuthDual(req(), OPCOES);

    expect(r.ok).toBe(true);
    if (!r.ok) return;
    expect(r.organizationId).toBe(ORG_DA_SESSAO);
    expect(r.via).toBe("session");
    expect(validateBearerToken).not.toHaveBeenCalled();
  });

  // #1613: o gate de `messages:on_behalf` na rota lê `scopes` daqui. Se o ramo
  // do token não os devolver, a rota recusa todo "em nome de" com 403 — e os
  // testes da rota não veem, porque mockam `resolveAuthDual` já com `scopes`.
  it("no ramo do token, devolve os scopes e o id da linha do token", async () => {
    vi.mocked(validateBearerToken).mockResolvedValue({
      organizationId: ORG_DO_TOKEN,
      scopes: ["mcp:write", "messages:on_behalf"],
      role: "agent",
      apiTokenId: "tok-1",
      actor: { type: "api_token", id: "tok-1" },
    } as never);

    const r = await resolveAuthDual(req({ authorization: "Bearer dsk_abc" }), OPCOES);

    expect(r.ok).toBe(true);
    if (!r.ok) return;
    expect(r.scopes).toContain("messages:on_behalf");
    expect(r.apiTokenId).toBe("tok-1");
  });

  it("a sessão não carrega scopes: campo condicionado a escopo fica fechado para a tela", async () => {
    sessaoOk();

    const r = await resolveAuthDual(req(), OPCOES);

    expect(r.ok).toBe(true);
    if (!r.ok) return;
    expect(r.scopes).toBeUndefined();
    expect(r.apiTokenId).toBeUndefined();
  });
});

describe("os caminhos de envio passam pelo proxy", () => {
  // Sem estas entradas o proxy responde 401 antes do handler, e a dualidade
  // acima fica inalcançável por token mesmo estando implementada.
  it("libera as tres rotas de envio", () => {
    expect(isPublicPath("/api/v1/messages")).toBe(true);
    expect(isPublicPath("/api/v1/conversations/open-with-contact")).toBe(true);
    // Mídia: sem ela o cartão de fidelidade não sai depois do corte.
    expect(isPublicPath("/api/v1/conversations/abc-123/media")).toBe(true);
  });

  it("libera as rotas de configuração de IA/follow-up/agenda (issue #1875)", () => {
    expect(isPublicPath("/api/v1/ai/followup-flows")).toBe(true);
    expect(isPublicPath("/api/v1/ai/followup-flows/from-model")).toBe(true);
    expect(isPublicPath("/api/v1/ai/agents")).toBe(true);
    expect(
      isPublicPath("/api/v1/ai/agents/11111111-1111-4111-8111-111111111111/versions"),
    ).toBe(true);
    expect(isPublicPath("/api/v1/prospecting")).toBe(true);
    expect(isPublicPath("/api/v1/agenda/tipos")).toBe(true);
  });

  it("libera versão, publicação e teste do agente (issue #2052)", () => {
    const AGENTE = "11111111-1111-4111-8111-111111111111";
    const VERSAO = "22222222-2222-4222-8222-222222222222";
    expect(isPublicPath(`/api/v1/ai/agents/${AGENTE}`)).toBe(true);
    expect(isPublicPath(`/api/v1/ai/agents/${AGENTE}/publish`)).toBe(true);
    expect(isPublicPath(`/api/v1/ai/agents/${AGENTE}/versions/${VERSAO}`)).toBe(true);
    expect(isPublicPath(`/api/v1/ai/agents/${AGENTE}/versions/${VERSAO}/test`)).toBe(true);
    // E NÃO dá carona: os irmãos seguem só-sessão, e um segmento que não é
    // UUID (ou um sufixo qualquer) não entra de carona em cima de um que entra.
    expect(isPublicPath("/api/v1/ai/agents/assignable")).toBe(false);
    expect(isPublicPath("/api/v1/ai/agents/abc")).toBe(false);
    expect(isPublicPath(`/api/v1/ai/agents/${AGENTE}/pause`)).toBe(false);
    expect(isPublicPath(`/api/v1/ai/agents/${AGENTE}/versions/${VERSAO}/rollback`)).toBe(false);
    expect(isPublicPath(`/api/v1/ai/agents/${AGENTE}/versions/`)).toBe(false);
  });

  it("não dá carona aos irmãos que seguem só-sessão (issue #1875)", () => {
    // followup-flows: publicar, duplicar e [id] continuam exigindo a tela.
    expect(isPublicPath("/api/v1/ai/followup-flows/123/publish")).toBe(false);
    expect(isPublicPath("/api/v1/ai/followup-flows/123/duplicate")).toBe(false);
    expect(isPublicPath("/api/v1/ai/followup-flows/123")).toBe(false);
    expect(isPublicPath("/api/v1/ai/followup-flows/xpto")).toBe(false);
    // agents: assignable segue só-sessão; o segmento da versão é UUID.
    expect(isPublicPath("/api/v1/ai/agents/assignable")).toBe(false);
    expect(isPublicPath("/api/v1/ai/agents/abc/versions")).toBe(false);
    // prospecting: só a raiz; os irmãos de /agents (chat, prepare, session) seguem só-sessão.
    expect(isPublicPath("/api/v1/prospecting/agents")).toBe(false);
    expect(isPublicPath("/api/v1/prospecting/agents/chat")).toBe(false);
    // agenda/tipos: o irmão /reativar segue só-sessão.
    expect(isPublicPath("/api/v1/agenda/tipos/reativar")).toBe(false);
  });

  it("não dá carona a sub-paths que não têm suporte a Bearer", () => {
    expect(isPublicPath("/api/v1/messages/alguma-mensagem")).toBe(false);
    expect(isPublicPath("/api/v1/conversations/alguma-conversa")).toBe(false);
    // A entrada de mídia é ancorada: não libera irmãs como /notes ou /claim.
    expect(isPublicPath("/api/v1/conversations/abc-123/notes")).toBe(false);
    expect(isPublicPath("/api/v1/conversations/abc-123/media/extra")).toBe(false);
  });
});
