import { describe, it, expect, vi, beforeEach, afterEach } from "vitest";

// user-oidc-utils imports the logger; stub it so importing stays side-effect-free.
vi.mock("../../../utils/logger.js", () => ({
  authLogger: {
    debug: vi.fn(),
    info: vi.fn(),
    warn: vi.fn(),
    error: vi.fn(),
    success: vi.fn(),
  },
}));

const {
  isOIDCUserAllowed,
  getOIDCConfigFromEnv,
  extractOidcGroups,
  extractOidcGroupsFromSources,
  validateLogoutTokenClaims,
  parseOidcRoleMap,
  resolveOidcMappedRoles,
  verifyOIDCToken,
  describeFetchFailure,
  isOIDCEnvOverrideEnabled,
} = await import("../../../database/routes/user-oidc-utils.js");

const BACKCHANNEL_LOGOUT_EVENT =
  "http://schemas.openid.net/event/backchannel-logout";

afterEach(() => {
  vi.restoreAllMocks();
});

describe("describeFetchFailure", () => {
  it("unwraps the undici cause, which carries the reason that matters", () => {
    // Every transport failure surfaces as this same outer message.
    const error = new TypeError("fetch failed", {
      cause: Object.assign(new Error("getaddrinfo ENOTFOUND idp.example"), {
        code: "ENOTFOUND",
      }),
    });
    expect(describeFetchFailure(error)).toBe(
      "fetch failed: getaddrinfo ENOTFOUND idp.example (ENOTFOUND)",
    );
  });

  it("falls back to the outer message when there is no cause", () => {
    expect(describeFetchFailure(new Error("boom"))).toBe("boom");
  });

  it("handles a non-Error throw", () => {
    expect(describeFetchFailure("nope")).toBe("nope");
  });
});

describe("verifyOIDCToken JWKS diagnostics", () => {
  const issuer = "https://login.microsoftonline.com/example/v2.0";
  const token = "header.payload.signature";

  it("reports every attempted URL and why it failed", async () => {
    vi.spyOn(globalThis, "fetch").mockResolvedValue(
      new Response("not found", { status: 404 }),
    );

    const error = await verifyOIDCToken(token, issuer, "client").catch(
      (e) => e as Error,
    );
    expect(error.message).toMatch(/^Failed to fetch JWKS from any URL/);
    expect(error.message).toContain(
      `${issuer}/.well-known/openid-configuration: HTTP 404`,
    );
    expect(error.message).toContain(
      `${issuer}/.well-known/jwks.json: HTTP 404`,
    );
    expect(error.message).toContain(`${issuer}/jwks/: HTTP 404`);
  });

  it("reports a transport failure with its underlying cause", async () => {
    vi.spyOn(globalThis, "fetch").mockRejectedValue(
      new TypeError("fetch failed", {
        cause: Object.assign(new Error("self-signed certificate"), {
          code: "SELF_SIGNED_CERT_IN_CHAIN",
        }),
      }),
    );

    const error = await verifyOIDCToken(token, issuer, "client").catch(
      (e) => e as Error,
    );
    expect(error.message).toContain(
      "self-signed certificate (SELF_SIGNED_CERT_IN_CHAIN)",
    );
  });

  it("says so when discovery succeeds but advertises no jwks_uri", async () => {
    vi.spyOn(globalThis, "fetch")
      .mockResolvedValueOnce(
        new Response(JSON.stringify({ issuer }), { status: 200 }),
      )
      .mockResolvedValue(new Response("not found", { status: 404 }));

    const error = await verifyOIDCToken(token, issuer, "client").catch(
      (e) => e as Error,
    );
    expect(error.message).toContain("no jwks_uri in the discovery document");
  });

  it("says so when a JWKS response carries no keys array", async () => {
    vi.spyOn(globalThis, "fetch")
      .mockResolvedValueOnce(
        new Response(JSON.stringify({ jwks_uri: "https://idp.example/keys" }), {
          status: 200,
        }),
      )
      .mockResolvedValue(
        new Response(JSON.stringify({ error: "unauthorized" }), {
          status: 200,
        }),
      );

    const error = await verifyOIDCToken(token, issuer, "client").catch(
      (e) => e as Error,
    );
    expect(error.message).toContain(
      'https://idp.example/keys: response contains no "keys" array',
    );
  });
});

describe("verifyOIDCToken", () => {
  it("accepts a discovery document URL as the configured issuer", async () => {
    const { exportJWK, generateKeyPair, SignJWT } = await import("jose");
    const { publicKey, privateKey } = await generateKeyPair("RS256");
    const jwk = await exportJWK(publicKey);
    jwk.kid = "google-key";

    const issuer = "https://accounts.google.com";
    const clientId = "termix-client";
    const token = await new SignJWT({ sub: "user-1" })
      .setProtectedHeader({ alg: "RS256", kid: jwk.kid })
      .setIssuer(issuer)
      .setAudience(clientId)
      .setExpirationTime("5m")
      .sign(privateKey);

    const fetchMock = vi
      .spyOn(globalThis, "fetch")
      .mockResolvedValueOnce(
        new Response(JSON.stringify({ jwks_uri: `${issuer}/keys` }), {
          status: 200,
        }),
      )
      .mockResolvedValueOnce(
        new Response(JSON.stringify({ keys: [jwk] }), { status: 200 }),
      );

    const payload = await verifyOIDCToken(
      token,
      `${issuer}/.well-known/openid-configuration`,
      clientId,
    );

    expect(payload.sub).toBe("user-1");
    expect(fetchMock).toHaveBeenNthCalledWith(
      1,
      `${issuer}/.well-known/openid-configuration`,
      {},
    );
  });

  it("uses the protected-header algorithm when the provider JWK omits alg", async () => {
    const { exportJWK, generateKeyPair, SignJWT } = await import("jose");
    const { publicKey, privateKey } = await generateKeyPair("RS256");
    const jwk = await exportJWK(publicKey);
    jwk.kid = "entra-key";

    const issuer = "https://login.microsoftonline.com/example/v2.0";
    const clientId = "termix-client";
    const token = await new SignJWT({ sub: "user-1" })
      .setProtectedHeader({ alg: "RS256", kid: jwk.kid })
      .setIssuer(issuer)
      .setAudience(clientId)
      .setExpirationTime("5m")
      .sign(privateKey);

    const fetchMock = vi
      .spyOn(globalThis, "fetch")
      .mockResolvedValueOnce(
        new Response(JSON.stringify({ jwks_uri: "https://idp.example/keys" }), {
          status: 200,
        }),
      )
      .mockResolvedValueOnce(
        new Response(JSON.stringify({ keys: [jwk] }), { status: 200 }),
      );

    const payload = await verifyOIDCToken(token, issuer, clientId);

    expect(payload.sub).toBe("user-1");
    expect(fetchMock).toHaveBeenCalledTimes(2);
  });
});

describe("isOIDCUserAllowed", () => {
  it("allows everyone when the allow-list is empty", () => {
    expect(isOIDCUserAllowed("", "alice", "alice@x.com")).toBe(true);
    expect(isOIDCUserAllowed("   ", "alice")).toBe(true);
  });

  it("allows everyone with the '*' wildcard", () => {
    expect(isOIDCUserAllowed("*", "anyone", "anyone@x.com")).toBe(true);
  });

  it("matches an exact identifier (case-insensitive)", () => {
    expect(isOIDCUserAllowed("alice,bob", "alice")).toBe(true);
    expect(isOIDCUserAllowed("Alice", "alice")).toBe(true);
    expect(isOIDCUserAllowed("alice", "ALICE")).toBe(true);
  });

  it("matches against the email as well as the identifier", () => {
    expect(isOIDCUserAllowed("alice@x.com", "sub-123", "alice@x.com")).toBe(
      true,
    );
  });

  it("matches an @domain suffix pattern", () => {
    expect(isOIDCUserAllowed("@company.com", "sub-1", "bob@company.com")).toBe(
      true,
    );
    expect(isOIDCUserAllowed("@company.com", "sub-1", "bob@COMPANY.COM")).toBe(
      true,
    );
  });

  it("denies users not on the list", () => {
    expect(isOIDCUserAllowed("alice,bob", "charlie", "charlie@x.com")).toBe(
      false,
    );
    expect(isOIDCUserAllowed("@company.com", "sub-1", "bob@other.com")).toBe(
      false,
    );
  });

  it("ignores blank entries and surrounding whitespace in the list", () => {
    expect(isOIDCUserAllowed(" alice , , bob ", "bob")).toBe(true);
  });

  it("does not match the email against an identifier-only pattern when email differs", () => {
    expect(isOIDCUserAllowed("alice", "sub-123", "alice@x.com")).toBe(false);
  });

  it("matches *@domain.com wildcard pattern against emails", () => {
    expect(
      isOIDCUserAllowed("*@company.com", "sub-1", "john@company.com"),
    ).toBe(true);
    expect(
      isOIDCUserAllowed("*@company.com", "sub-1", "jane@COMPANY.COM"),
    ).toBe(true);
    expect(isOIDCUserAllowed("*@company.com", "sub-1", "user@other.com")).toBe(
      false,
    );
  });

  it("matches glob patterns with multiple wildcards", () => {
    expect(isOIDCUserAllowed("admin*", "admin_user")).toBe(true);
    expect(isOIDCUserAllowed("admin*", "user_admin")).toBe(false);
  });
});

describe("getOIDCConfigFromEnv", () => {
  const REQUIRED = [
    "OIDC_CLIENT_ID",
    "OIDC_CLIENT_SECRET",
    "OIDC_ISSUER_URL",
    "OIDC_AUTHORIZATION_URL",
    "OIDC_TOKEN_URL",
  ];
  const OPTIONAL = [
    "OIDC_USERINFO_URL",
    "OIDC_IDENTIFIER_PATH",
    "OIDC_NAME_PATH",
    "OIDC_SCOPES",
    "OIDC_ALLOWED_USERS",
    "OIDC_ADMIN_GROUP",
    "OIDC_ENV_OVERRIDE",
  ];
  const saved: Record<string, string | undefined> = {};

  beforeEach(() => {
    for (const key of [...REQUIRED, ...OPTIONAL]) {
      saved[key] = process.env[key];
      delete process.env[key];
    }
  });

  afterEach(() => {
    for (const key of [...REQUIRED, ...OPTIONAL]) {
      if (saved[key] === undefined) delete process.env[key];
      else process.env[key] = saved[key];
    }
  });

  it("returns null when any required variable is missing", () => {
    process.env.OIDC_CLIENT_ID = "id";
    process.env.OIDC_CLIENT_SECRET = "secret";
    // issuer/authorization/token urls intentionally missing
    expect(getOIDCConfigFromEnv()).toBeNull();
  });

  it("builds a config with defaults when all required vars are present", () => {
    process.env.OIDC_CLIENT_ID = "id";
    process.env.OIDC_CLIENT_SECRET = "secret";
    process.env.OIDC_ISSUER_URL = "https://idp.example";
    process.env.OIDC_AUTHORIZATION_URL = "https://idp.example/auth";
    process.env.OIDC_TOKEN_URL = "https://idp.example/token";

    const config = getOIDCConfigFromEnv();
    expect(config).not.toBeNull();
    expect(config?.client_id).toBe("id");
    expect(config?.identifier_path).toBe("sub");
    expect(config?.name_path).toBe("name");
    expect(config?.scopes).toBe("openid email profile");
    expect(config?.userinfo_url).toBe("");
  });

  it("honors overrides for optional vars", () => {
    process.env.OIDC_CLIENT_ID = "id";
    process.env.OIDC_CLIENT_SECRET = "secret";
    process.env.OIDC_ISSUER_URL = "https://idp.example";
    process.env.OIDC_AUTHORIZATION_URL = "https://idp.example/auth";
    process.env.OIDC_TOKEN_URL = "https://idp.example/token";
    process.env.OIDC_IDENTIFIER_PATH = "email";
    process.env.OIDC_SCOPES = "openid";

    const config = getOIDCConfigFromEnv();
    expect(config?.identifier_path).toBe("email");
    expect(config?.scopes).toBe("openid");
  });

  it("only enables database recovery override when explicitly requested", () => {
    expect(isOIDCEnvOverrideEnabled()).toBe(false);
    process.env.OIDC_ENV_OVERRIDE = "true";
    expect(isOIDCEnvOverrideEnabled()).toBe(true);
  });
});

describe("extractOidcGroups", () => {
  it("reads the standard groups claim as an array", () => {
    expect(extractOidcGroups({ groups: ["admin", "user"] })).toEqual([
      "admin",
      "user",
    ]);
  });

  it("splits a comma-separated string claim", () => {
    expect(extractOidcGroups({ roles: "admin, user" })).toEqual([
      "admin",
      "user",
    ]);
  });

  it("falls back through groups, roles, then group", () => {
    expect(extractOidcGroups({ group: "ops" })).toEqual(["ops"]);
  });

  it("reads a custom claim path when provided", () => {
    const userInfo = {
      "zitadel:grants:groups:123": ["user", "admin"],
      groups: ["ignored"],
    };
    expect(extractOidcGroups(userInfo, "zitadel:grants:groups:123")).toEqual([
      "user",
      "admin",
    ]);
  });

  it("uses object keys as group names (Zitadel roles object)", () => {
    const userInfo = {
      "urn:zitadel:iam:org:project:roles": { admin: {}, user: {} },
    };
    expect(
      extractOidcGroups(userInfo, "urn:zitadel:iam:org:project:roles"),
    ).toEqual(["admin", "user"]);
  });

  it("falls back to defaults when the custom claim is absent", () => {
    expect(extractOidcGroups({ groups: ["admin"] }, "missing")).toEqual([
      "admin",
    ]);
  });

  it("returns an empty array when no groups are present", () => {
    expect(extractOidcGroups({})).toEqual([]);
  });
});

describe("extractOidcGroupsFromSources", () => {
  it("preserves ID token groups when userinfo omits them", () => {
    expect(
      extractOidcGroupsFromSources([
        { groups: ["admins", "users"] },
        { sub: "user-1", name: "Example User" },
      ]),
    ).toEqual(["admins", "users"]);
  });

  it("combines and deduplicates groups from both verified sources", () => {
    expect(
      extractOidcGroupsFromSources([
        { roles: ["users", "operators"] },
        { roles: ["operators", "admins"] },
      ]),
    ).toEqual(["users", "operators", "admins"]);
  });

  it("supports a configured group claim across sources", () => {
    expect(
      extractOidcGroupsFromSources(
        [{ custom_groups: ["admins"] }, { custom_groups: ["users"] }],
        "custom_groups",
      ),
    ).toEqual(["admins", "users"]);
  });
});

describe("validateLogoutTokenClaims", () => {
  const validClaims = {
    sub: "subject-1",
    sid: "session-1",
    iat: 1_783_641_600,
    jti: "logout-1",
    events: { [BACKCHANNEL_LOGOUT_EVENT]: {} },
  };

  it("accepts a spec-compliant back-channel logout payload", () => {
    expect(validateLogoutTokenClaims(validClaims)).toEqual({
      sub: "subject-1",
      sid: "session-1",
      jti: "logout-1",
    });
  });

  it("requires the logout event to contain an object", () => {
    expect(() =>
      validateLogoutTokenClaims({
        ...validClaims,
        events: { [BACKCHANNEL_LOGOUT_EVENT]: true },
      }),
    ).toThrow("missing back-channel logout event");
  });

  it("requires iat and jti claims", () => {
    expect(() =>
      validateLogoutTokenClaims({ ...validClaims, iat: undefined }),
    ).toThrow("missing iat claim");
    expect(() =>
      validateLogoutTokenClaims({ ...validClaims, jti: "" }),
    ).toThrow("missing jti claim");
  });

  it("rejects nonce and requires sub or sid", () => {
    expect(() =>
      validateLogoutTokenClaims({ ...validClaims, nonce: "forbidden" }),
    ).toThrow("must not contain a nonce");
    expect(() =>
      validateLogoutTokenClaims({ ...validClaims, sub: null, sid: null }),
    ).toThrow("must contain sub and/or sid");
  });
});

describe("parseOidcRoleMap", () => {
  it("returns an empty map for blank input", () => {
    expect(parseOidcRoleMap(undefined).size).toBe(0);
    expect(parseOidcRoleMap(null).size).toBe(0);
    expect(parseOidcRoleMap("   ").size).toBe(0);
  });

  it("parses comma-separated group:role pairs", () => {
    const map = parseOidcRoleMap(
      "devops-interns:devops-intern,devops-seniors:devops-senior",
    );
    expect(map.get("devops-interns")).toBe("devops-intern");
    expect(map.get("devops-seniors")).toBe("devops-senior");
    expect(map.size).toBe(2);
  });

  it("parses newline-separated pairs and trims whitespace", () => {
    const map = parseOidcRoleMap("  a : role-a \n b:role-b \n");
    expect(map.get("a")).toBe("role-a");
    expect(map.get("b")).toBe("role-b");
  });

  it("normalizes leading slashes and case in group names", () => {
    const map = parseOidcRoleMap("/DevOps-Interns:devops-intern");
    expect(map.get("devops-interns")).toBe("devops-intern");
  });

  it("skips malformed entries instead of throwing", () => {
    const map = parseOidcRoleMap("no-colon,:missing-group,missing-role:,ok:r");
    expect(map.size).toBe(1);
    expect(map.get("ok")).toBe("r");
  });

  it("splits on the last colon so group names may contain colons", () => {
    const map = parseOidcRoleMap("ns:team:role-x");
    expect(map.get("ns:team")).toBe("role-x");
  });

  it("preserves role-name case verbatim", () => {
    // Role names must match roles.name exactly, so they are not lowercased.
    expect(parseOidcRoleMap("g:DevOps_Senior").get("g")).toBe("DevOps_Senior");
  });
});

describe("resolveOidcMappedRoles", () => {
  const roleMap = parseOidcRoleMap(
    "devops-interns:devops-intern,devops-seniors:devops-senior",
  );

  it("reports every mapped role as managed regardless of membership", () => {
    const { managed } = resolveOidcMappedRoles([], roleMap);
    expect([...managed].sort()).toEqual(["devops-intern", "devops-senior"]);
  });

  it("desires only the roles whose groups the user is in", () => {
    const { desired } = resolveOidcMappedRoles(["devops-interns"], roleMap);
    expect([...desired]).toEqual(["devops-intern"]);
  });

  it("matches full group paths emitted by Keycloak", () => {
    const { desired } = resolveOidcMappedRoles(["/devops-seniors"], roleMap);
    expect([...desired]).toEqual(["devops-senior"]);
  });

  it("ignores groups that are not mapped", () => {
    const { desired } = resolveOidcMappedRoles(
      ["finance", "devops-interns"],
      roleMap,
    );
    expect([...desired]).toEqual(["devops-intern"]);
  });

  it("supports a user in multiple mapped groups", () => {
    const { desired } = resolveOidcMappedRoles(
      ["devops-interns", "devops-seniors"],
      roleMap,
    );
    expect([...desired].sort()).toEqual(["devops-intern", "devops-senior"]);
  });

  it("desires nothing when the map is empty", () => {
    const { desired, managed } = resolveOidcMappedRoles(
      ["devops-interns"],
      new Map(),
    );
    expect(desired.size).toBe(0);
    expect(managed.size).toBe(0);
  });
});

// Imported as a namespace rather than destructured into the shared block at the
// top of the file, so this suite stays independent of what that block binds.
const oidcUtils = await import("../../../database/routes/user-oidc-utils.js");

describe("verifyOIDCToken token shape", () => {
  const issuer = "https://idp.example.com/application/o/termix";

  // The shape check runs before any network call, so no fetch stub is needed.
  const fetchSpy = vi.fn();
  beforeEach(() => {
    vi.stubGlobal("fetch", fetchSpy);
  });
  afterEach(() => {
    vi.unstubAllGlobals();
    fetchSpy.mockReset();
  });

  it("reports an encrypted (JWE) token as a format error", async () => {
    const jwe = ["header", "key", "iv", "ciphertext", "tag"].join(".");

    await expect(
      oidcUtils.verifyOIDCToken(jwe, issuer, "client"),
    ).rejects.toThrow(oidcUtils.OIDCTokenFormatError);
    await expect(
      oidcUtils.verifyOIDCToken(jwe, issuer, "client"),
    ).rejects.toThrow(/JWE \(encrypted\)/);
    expect(fetchSpy).not.toHaveBeenCalled();
  });

  it("reports any other non-JWS segment count as a format error", async () => {
    await expect(
      oidcUtils.verifyOIDCToken("header.payload", issuer, "client"),
    ).rejects.toThrow(/expected 3 segments, got 2/);
    await expect(
      oidcUtils.verifyOIDCToken("opaque", issuer, "client"),
    ).rejects.toThrow(/expected 3 segments, got 1/);
    expect(fetchSpy).not.toHaveBeenCalled();
  });

  it("lets a three-segment token through to key resolution", async () => {
    fetchSpy.mockResolvedValue({ ok: false });

    // Reaches JWKS fetching, so it fails on the key lookup rather than the shape.
    await expect(
      oidcUtils.verifyOIDCToken("header.payload.signature", issuer, "client"),
    ).rejects.not.toThrow(oidcUtils.OIDCTokenFormatError);
    expect(fetchSpy).toHaveBeenCalled();
  });
});
