/**
 * Org admin auth + requireOrgRole integration tests (ADR-0021).
 */
import Fastify from "fastify";
import { afterAll, beforeEach, describe, expect, it, vi } from "vitest";
import { registerAdminPlugin } from "../../src/admin/plugin.js";
import {
  mintSessionToken,
  sessionCookieHeader,
} from "../../src/admin/routes/authRoutes.js";
import { OAUTH_STATE_COOKIE_NAME, signOAuthState } from "../../src/admin/auth/session.js";
import { DEFAULT_ORG_ID, prisma, resetDb, seedTestOrganization, testSecretEnvelope } from "./helpers.js";
import { FeishuApplicationConnectionService } from "../../src/connections/feishuApplicationConnections.js";
import { upsertScopedFeishuIdentity } from "../../src/feishu/identityNamespace.js";

const SESSION_SECRET = "integration-test-session-secret";
const PUBLIC_BASE = "http://127.0.0.1:8788";

async function buildApp(fetchImpl?: typeof fetch, allowLegacyFeishuOAuth = true) {
  const app = Fastify({ logger: false });
  await registerAdminPlugin(app, {
    prisma,
    sessionSecret: SESSION_SECRET,
    publicBaseUrl: PUBLIC_BASE,
    feishuAppId: "cli_test",
    feishuAppSecret: "secret_test",
    projectWorkspaceRoot: "/tmp/cph-test-workspaces",
    secretEnvelope: testSecretEnvelope,
    allowLegacyFeishuOAuth,
    cookieSecure: false,
    ...(fetchImpl !== undefined ? { fetchImpl } : {}),
  });
  await app.ready();
  return app;
}

async function seedUser(
  id: string,
  openId: string,
  role: "OWNER" | "ADMIN" | "MEMBER" = "ADMIN",
  orgId: string = DEFAULT_ORG_ID,
): Promise<void> {
  await prisma.user.create({
    data: { id, feishuOpenId: openId, displayName: id },
  });
  await prisma.organizationMembership.create({
    data: { organizationId: orgId, userId: id, role },
  });
}

describe("admin auth + org API guards", () => {
  beforeEach(async () => {
    await resetDb();
  });

  afterAll(async () => {
    await prisma.$disconnect();
  });

  it("GET /api/me returns 401 without session", async () => {
    const app = await buildApp();
    try {
      const res = await app.inject({ method: "GET", url: "/api/me" });
      expect(res.statusCode).toBe(401);
      expect(res.json().error.code).toBe("unauthenticated");
    } finally {
      await app.close();
    }
  });

  it("GET /api/me returns user and memberships with valid session", async () => {
    await seedUser("u-admin", "ou_admin", "ADMIN");
    const app = await buildApp();
    try {
      const token = mintSessionToken(
        { userId: "u-admin", feishuOpenId: "ou_admin" },
        SESSION_SECRET,
      );
      const res = await app.inject({
        method: "GET",
        url: "/api/me",
        headers: { cookie: sessionCookieHeader(token) },
      });
      expect(res.statusCode).toBe(200);
      const body = res.json() as {
        user: { id: string; feishuOpenId: string };
        organizations: Array<{ slug: string; role: string }>;
      };
      expect(body.user.id).toBe("u-admin");
      expect(body.user.feishuOpenId).toBe("ou_admin");
      expect(body.organizations).toEqual([
        expect.objectContaining({ slug: "test-default", role: "ADMIN" }),
      ]);
    } finally {
      await app.close();
    }
  });

  it("org summary requires OWNER or ADMIN", async () => {
    await seedUser("u-member", "ou_member", "MEMBER");
    await seedUser("u-admin", "ou_admin", "ADMIN");
    const app = await buildApp();
    try {
      const memberToken = mintSessionToken(
        { userId: "u-member", feishuOpenId: "ou_member" },
        SESSION_SECRET,
      );
      const memberRes = await app.inject({
        method: "GET",
        url: "/api/org/test-default",
        headers: { cookie: sessionCookieHeader(memberToken) },
      });
      expect(memberRes.statusCode).toBe(403);

      const adminToken = mintSessionToken(
        { userId: "u-admin", feishuOpenId: "ou_admin" },
        SESSION_SECRET,
      );
      const adminRes = await app.inject({
        method: "GET",
        url: "/api/org/test-default",
        headers: { cookie: sessionCookieHeader(adminToken) },
      });
      expect(adminRes.statusCode).toBe(200);
      expect(adminRes.json()).toEqual({
        organization: expect.objectContaining({
          id: DEFAULT_ORG_ID,
          slug: "test-default",
        }),
        actorRole: "ADMIN",
      });
    } finally {
      await app.close();
    }
  });

  it("PATCH settings updates membersCanCreateProjects", async () => {
    await seedUser("u-owner", "ou_owner", "OWNER");
    const app = await buildApp();
    try {
      const token = mintSessionToken(
        { userId: "u-owner", feishuOpenId: "ou_owner" },
        SESSION_SECRET,
      );
      const res = await app.inject({
        method: "PATCH",
        url: "/api/org/test-default/settings",
        headers: {
          cookie: sessionCookieHeader(token),
          "content-type": "application/json",
        },
        payload: { membersCanCreateProjects: false },
      });
      expect(res.statusCode).toBe(200);
      expect(res.json()).toEqual({ membersCanCreateProjects: false });

      const getRes = await app.inject({
        method: "GET",
        url: "/api/org/test-default/settings",
        headers: { cookie: sessionCookieHeader(token) },
      });
      expect(getRes.json()).toEqual({ membersCanCreateProjects: false });
    } finally {
      await app.close();
    }
  });

  it("GET /auth/feishu redirects to Feishu authorize URL", async () => {
    const app = await buildApp();
    try {
      const res = await app.inject({
        method: "GET",
        url: "/auth/feishu?returnTo=/admin",
      });
      expect(res.statusCode).toBe(302);
      const location = res.headers.location;
      expect(location).toBeTypeOf("string");
      const url = new URL(location as string);
      expect(url.hostname).toBe("accounts.feishu.cn");
      expect(url.searchParams.get("client_id")).toBe("cli_test");
      expect(url.searchParams.get("redirect_uri")).toBe(
        `${PUBLIC_BASE}/auth/feishu/callback`,
      );
      expect(url.searchParams.get("state")).toBeTruthy();
      const setCookie = res.headers["set-cookie"];
      expect(JSON.stringify(setCookie)).toContain(OAUTH_STATE_COOKIE_NAME);
    } finally {
      await app.close();
    }
  });

  it("does not expose unscoped OAuth when the explicit compatibility switch is off", async () => {
    const app = await buildApp(undefined, false);
    try {
      const start = await app.inject({ method: "GET", url: "/auth/feishu" });
      expect(start.statusCode).toBe(404);
      const nonce = "legacy-disabled";
      const state = signOAuthState({ nonce, returnTo: "/admin" }, SESSION_SECRET);
      const callback = await app.inject({
        method: "GET",
        url: `/auth/feishu/callback?code=ok&state=${encodeURIComponent(state)}`,
        headers: { cookie: `${OAUTH_STATE_COOKIE_NAME}=${nonce}` },
      });
      expect(callback.statusCode).toBe(302);
      expect(callback.headers.location).toContain("oauth_failed");
    } finally {
      await app.close();
    }
  });

  it("OAuth callback upserts user and sets session cookie", async () => {
    const fetchImpl = vi.fn(async (input: RequestInfo | URL) => {
      const url = String(input);
      if (url.includes("/oauth/token")) {
        return new Response(
          JSON.stringify({ code: 0, access_token: "u-tok", token_type: "Bearer", expires_in: 7200 }),
          { status: 200, headers: { "Content-Type": "application/json" } },
        );
      }
      if (url.includes("/user_info")) {
        return new Response(
          JSON.stringify({
            code: 0,
            data: { open_id: "ou_new", name: "New User", avatar_url: null },
          }),
          { status: 200, headers: { "Content-Type": "application/json" } },
        );
      }
      throw new Error(`unexpected ${url}`);
    });

    const app = await buildApp(fetchImpl as unknown as typeof fetch);
    try {
      const nonce = "nonce-test-1";
      const state = signOAuthState(
        { nonce, returnTo: "/admin" },
        SESSION_SECRET,
      );
      const res = await app.inject({
        method: "GET",
        url: `/auth/feishu/callback?code=ok&state=${encodeURIComponent(state)}`,
        headers: { cookie: `${OAUTH_STATE_COOKIE_NAME}=${nonce}` },
      });
      expect(res.statusCode).toBe(302);
      expect(res.headers.location).toBe("/admin");
      expect(JSON.stringify(res.headers["set-cookie"])).toContain("cph_session=");

      const user = await prisma.user.findUnique({ where: { feishuOpenId: "ou_new" } });
      expect(user?.displayName).toBe("New User");
    } finally {
      await app.close();
    }
  });

  it("binds Organization OAuth state, identity, and session to the intended connection", async () => {
    await seedUser("scoped-owner", "legacy_scoped_owner", "OWNER");
    const connections = new FeishuApplicationConnectionService(prisma, testSecretEnvelope, async () => {});
    const connection = await connections.rotateCustomerApplication({
      organizationId: DEFAULT_ORG_ID,
      actorUserId: "scoped-owner",
      appId: "cli_customer_oauth",
      appSecret: "customer-oauth-secret",
      botOpenId: "ou_customer_bot",
    });
    const identity = await upsertScopedFeishuIdentity(prisma, {
      connectionId: connection.id,
      openId: "ou_scoped_user",
      displayName: "Invited User",
    });
    await seedTestOrganization("org_scoped_other", "scoped-other");
    await prisma.organizationMembership.create({
      data: { organizationId: "org_scoped_other", userId: identity.userId, role: "ADMIN" },
    });
    const fetchImpl = vi.fn(async (input: RequestInfo | URL, init?: RequestInit) => {
      const url = String(input);
      if (url.includes("/oauth/token")) {
        expect(JSON.parse(String(init?.body))).toMatchObject({ client_id: "cli_customer_oauth" });
        return Response.json({ code: 0, access_token: "scoped-user-token" });
      }
      if (url.includes("/user_info")) {
        return Response.json({
          code: 0,
          data: {
            open_id: "ou_scoped_user",
            union_id: "on_scoped_union",
            name: "Scoped User",
          },
        });
      }
      throw new Error(`unexpected ${url}`);
    });
    const app = await buildApp(fetchImpl as unknown as typeof fetch);
    try {
      const start = await app.inject({
        method: "GET",
        url: "/auth/feishu/test-default?returnTo=/admin/settings",
      });
      expect(start.statusCode).toBe(302);
      const authorize = new URL(String(start.headers.location));
      expect(authorize.searchParams.get("client_id")).toBe("cli_customer_oauth");
      const state = authorize.searchParams.get("state");
      expect(state).not.toBeNull();
      const nonceCookie = cookiePair(start.headers["set-cookie"], OAUTH_STATE_COOKIE_NAME);

      const callback = await app.inject({
        method: "GET",
        url: `/auth/feishu/callback?code=ok&state=${encodeURIComponent(state!)}`,
        headers: { cookie: nonceCookie },
      });
      expect(callback.statusCode).toBe(302);
      expect(callback.headers.location).toBe("/admin/settings");
      const sessionCookie = cookiePair(callback.headers["set-cookie"], "cph_session");
      const me = await app.inject({ method: "GET", url: "/api/me", headers: { cookie: sessionCookie } });
      expect(me.statusCode).toBe(200);
      expect(me.json()).toMatchObject({
        user: { id: identity.userId, displayName: "Scoped User" },
        organizations: [expect.objectContaining({ slug: "test-default", role: "MEMBER" })],
      });
      expect((me.json() as { organizations: unknown[] }).organizations).toHaveLength(1);
      const crossOrganization = await app.inject({
        method: "GET",
        url: "/api/org/scoped-other",
        headers: { cookie: sessionCookie },
      });
      expect(crossOrganization.statusCode).toBe(403);
      expect(crossOrganization.json()).toMatchObject({ error: { code: "forbidden" } });
      await expect(prisma.feishuUserIdentity.findUniqueOrThrow({
        where: { id: identity.identityId },
        select: { unionId: true },
      })).resolves.toEqual({ unionId: "on_scoped_union" });
      await expect(prisma.auditEntry.count({
        where: {
          organizationId: DEFAULT_ORG_ID,
          actorUserId: identity.userId,
          action: "organization_member.oauth_auto_joined",
        },
      })).resolves.toBe(1);

      const defaultStart = await app.inject({ method: "GET", url: "/auth/feishu/test-default" });
      const defaultAuthorize = new URL(String(defaultStart.headers.location));
      const defaultState = defaultAuthorize.searchParams.get("state");
      expect(defaultState).not.toBeNull();
      const defaultCallback = await app.inject({
        method: "GET",
        url: `/auth/feishu/callback?code=ok&state=${encodeURIComponent(defaultState!)}`,
        headers: { cookie: cookiePair(defaultStart.headers["set-cookie"], OAUTH_STATE_COOKIE_NAME) },
      });
      expect(defaultCallback.statusCode).toBe(302);
      expect(defaultCallback.headers.location).toBe("/admin");

      await connections.disable({ organizationId: DEFAULT_ORG_ID, actorUserId: "scoped-owner" });
      const revoked = await app.inject({ method: "GET", url: "/api/me", headers: { cookie: sessionCookie } });
      expect(revoked.statusCode).toBe(401);
    } finally {
      await app.close();
    }
  });

  it("does not restore a revoked Organization membership during scoped OAuth", async () => {
    await seedUser("revoked-owner", "legacy_revoked_owner", "OWNER");
    const connections = new FeishuApplicationConnectionService(prisma, testSecretEnvelope, async () => {});
    const connection = await connections.rotateCustomerApplication({
      organizationId: DEFAULT_ORG_ID,
      actorUserId: "revoked-owner",
      appId: "cli_revoked_oauth",
      appSecret: "revoked-oauth-secret",
      botOpenId: "ou_revoked_bot",
    });
    const identity = await upsertScopedFeishuIdentity(prisma, {
      connectionId: connection.id,
      openId: "ou_revoked_user",
      displayName: "Revoked User",
    });
    await prisma.organizationMembership.create({
      data: {
        organizationId: DEFAULT_ORG_ID,
        userId: identity.userId,
        role: "MEMBER",
        revokedAt: new Date(),
      },
    });
    const fetchImpl = vi.fn(async (input: RequestInfo | URL) => {
      const url = String(input);
      if (url.includes("/oauth/token")) {
        return Response.json({ code: 0, access_token: "revoked-user-token" });
      }
      if (url.includes("/user_info")) {
        return Response.json({
          code: 0,
          data: { open_id: "ou_revoked_user", name: "Revoked User" },
        });
      }
      throw new Error(`unexpected ${url}`);
    });
    const app = await buildApp(fetchImpl as unknown as typeof fetch);
    try {
      const start = await app.inject({ method: "GET", url: "/auth/feishu/test-default" });
      const authorize = new URL(String(start.headers.location));
      const state = authorize.searchParams.get("state");
      expect(state).not.toBeNull();
      const callback = await app.inject({
        method: "GET",
        url: `/auth/feishu/callback?code=ok&state=${encodeURIComponent(state!)}`,
        headers: { cookie: cookiePair(start.headers["set-cookie"], OAUTH_STATE_COOKIE_NAME) },
      });
      expect(callback.statusCode).toBe(302);
      await expect(prisma.organizationMembership.count({
        where: { organizationId: DEFAULT_ORG_ID, userId: identity.userId, revokedAt: null },
      })).resolves.toBe(0);
      await expect(prisma.auditEntry.count({
        where: { action: "organization_member.oauth_auto_joined", actorUserId: identity.userId },
      })).resolves.toBe(0);
    } finally {
      await app.close();
    }
  });

  it("unknown org slug returns 404 for admin", async () => {
    await seedUser("u-admin", "ou_admin", "ADMIN");
    const app = await buildApp();
    try {
      const token = mintSessionToken(
        { userId: "u-admin", feishuOpenId: "ou_admin" },
        SESSION_SECRET,
      );
      const res = await app.inject({
        method: "GET",
        url: "/api/org/does-not-exist",
        headers: { cookie: sessionCookieHeader(token) },
      });
      expect(res.statusCode).toBe(404);
    } finally {
      await app.close();
    }
  });
});

function cookiePair(header: string | string[] | undefined, name: string): string {
  const values = Array.isArray(header) ? header : [header ?? ""];
  const cookie = values.find((value) => value.startsWith(`${name}=`));
  if (cookie === undefined) throw new Error(`missing cookie: ${name}`);
  return cookie.split(";", 1)[0]!;
}
