import { afterEach, beforeEach, describe, expect, mock, test } from "bun:test";
import { LogtoManagementClient } from "../../src/server/services/logto.service";

// Mock fetch at module level
const originalFetch = globalThis.fetch;

describe("LogtoManagementClient", () => {
	let client: LogtoManagementClient;
	let fetchMock: ReturnType<typeof mock>;
	let fetchCalls: Array<{ url: string; options: RequestInit }>;

	beforeEach(() => {
		fetchCalls = [];
		fetchMock = mock((url: string, options: RequestInit) => {
			fetchCalls.push({ url: url.toString(), options });

			// Default: token endpoint
			if (url.includes("/oidc/token")) {
				return Promise.resolve(
					new Response(
						JSON.stringify({
							access_token: "test-token-123",
							expires_in: 3600,
						}),
						{ status: 200, headers: { "Content-Type": "application/json" } },
					),
				);
			}

			// GET user
			if (
				url.includes("/api/users/") &&
				options.method === "GET" &&
				!url.includes("has-password")
			) {
				return Promise.resolve(
					new Response(
						JSON.stringify({
							id: "logto-sub-123",
							primaryEmail: "user@example.com",
							name: "Test User",
							avatar: null,
							createdAt: 1700000000000,
						}),
						{ status: 200, headers: { "Content-Type": "application/json" } },
					),
				);
			}

			// has-password
			if (url.includes("has-password")) {
				return Promise.resolve(
					new Response(JSON.stringify({ hasPassword: true }), {
						status: 200,
						headers: { "Content-Type": "application/json" },
					}),
				);
			}

			// verify password
			if (url.includes("password/verify")) {
				return Promise.resolve(new Response(null, { status: 204 }));
			}

			// PATCH password
			if (url.includes("/password") && options.method === "PATCH") {
				return Promise.resolve(new Response(null, { status: 200 }));
			}

			// PATCH user (email update)
			if (options.method === "PATCH" && !url.includes("/password")) {
				return Promise.resolve(new Response(null, { status: 200 }));
			}

			// DELETE user
			if (options.method === "DELETE") {
				return Promise.resolve(new Response(null, { status: 204 }));
			}

			return Promise.resolve(new Response(null, { status: 404 }));
		}) as any;

		globalThis.fetch = fetchMock as any;

		client = new LogtoManagementClient({
			issuer: "https://logto.example.com/oidc",
			m2mAppId: "test-app-id",
			m2mAppSecret: "test-app-secret",
			apiResource: "https://default.logto.app/api",
		});
	});

	afterEach(() => {
		globalThis.fetch = originalFetch;
	});

	test("getAccessToken fetches token via client_credentials grant", async () => {
		const token = await client.getAccessToken();
		expect(token).toBe("test-token-123");

		const tokenCall = fetchCalls.find((c) => c.url.includes("/oidc/token"));
		expect(tokenCall).toBeDefined();
		expect(tokenCall!.options.method).toBe("POST");
		expect(tokenCall!.options.headers).toHaveProperty(
			"Content-Type",
			"application/x-www-form-urlencoded",
		);
		// Check Authorization header uses Basic auth
		const authHeader = (tokenCall!.options.headers as Record<string, string>)
			.Authorization;
		expect(authHeader).toStartWith("Basic ");
	});

	test("getAccessToken returns cached token on second call", async () => {
		await client.getAccessToken();
		await client.getAccessToken();

		// Only one call to token endpoint — second call uses cache
		const tokenCalls = fetchCalls.filter((c) => c.url.includes("/oidc/token"));
		expect(tokenCalls.length).toBe(1);
	});

	test("getAccessToken refreshes token when expired", async () => {
		// Get initial token
		await client.getAccessToken();

		// Simulate expiry by manipulating internal state
		// Access private field via any cast
		(client as any).tokenExpiry = Date.now() - 1000;

		await client.getAccessToken();

		const tokenCalls = fetchCalls.filter((c) => c.url.includes("/oidc/token"));
		expect(tokenCalls.length).toBe(2);
	});

	test("verifyPassword calls correct endpoint and returns true on 204", async () => {
		const result = await client.verifyPassword("sub-123", "my-password");
		expect(result).toBe(true);

		const verifyCall = fetchCalls.find((c) =>
			c.url.includes("/api/users/sub-123/password/verify"),
		);
		expect(verifyCall).toBeDefined();
		expect(verifyCall!.options.method).toBe("POST");
	});

	test("verifyPassword returns false on 422", async () => {
		// Override fetch for this specific test
		globalThis.fetch = mock((url: string, _options: RequestInit) => {
			if (url.includes("/oidc/token")) {
				return Promise.resolve(
					new Response(
						JSON.stringify({ access_token: "token", expires_in: 3600 }),
						{ status: 200, headers: { "Content-Type": "application/json" } },
					),
				);
			}
			if (url.includes("password/verify")) {
				return Promise.resolve(new Response(null, { status: 422 }));
			}
			return Promise.resolve(new Response(null, { status: 404 }));
		}) as any;

		// Need fresh client to reset cached token
		const freshClient = new LogtoManagementClient({
			issuer: "https://logto.example.com/oidc",
			m2mAppId: "test-app-id",
			m2mAppSecret: "test-app-secret",
			apiResource: "https://default.logto.app/api",
		});

		const result = await freshClient.verifyPassword("sub-123", "wrong-pass");
		expect(result).toBe(false);
	});

	test("updatePassword calls PATCH on password endpoint", async () => {
		await client.updatePassword("sub-123", "new-secure-password");

		const patchCall = fetchCalls.find(
			(c) =>
				c.url.includes("/api/users/sub-123/password") &&
				c.options.method === "PATCH" &&
				!c.url.includes("verify"),
		);
		expect(patchCall).toBeDefined();
		const body = JSON.parse(patchCall!.options.body as string);
		expect(body.password).toBe("new-secure-password");
	});

	test("hasPassword calls GET has-password endpoint and returns boolean", async () => {
		const result = await client.hasPassword("sub-123");
		expect(result).toBe(true);

		const hasPassCall = fetchCalls.find((c) =>
			c.url.includes("/api/users/sub-123/has-password"),
		);
		expect(hasPassCall).toBeDefined();
		expect(hasPassCall!.options.method).toBe("GET");
	});

	test("updateEmail calls PATCH with primaryEmail field", async () => {
		await client.updateEmail("sub-123", "new@example.com");

		const patchCall = fetchCalls.find(
			(c) =>
				c.url.includes("/api/users/sub-123") &&
				c.options.method === "PATCH" &&
				!c.url.includes("password"),
		);
		expect(patchCall).toBeDefined();
		const body = JSON.parse(patchCall!.options.body as string);
		expect(body.primaryEmail).toBe("new@example.com");
	});

	test("deleteUser calls DELETE on user endpoint", async () => {
		await client.deleteUser("sub-123");

		const deleteCall = fetchCalls.find(
			(c) =>
				c.url.includes("/api/users/sub-123") && c.options.method === "DELETE",
		);
		expect(deleteCall).toBeDefined();
	});

	test("getUser calls GET and returns user object", async () => {
		const user = await client.getUser("logto-sub-123");
		expect(user.id).toBe("logto-sub-123");
		expect(user.primaryEmail).toBe("user@example.com");
		expect(user.name).toBe("Test User");

		const getCall = fetchCalls.find(
			(c) =>
				c.url.includes("/api/users/logto-sub-123") &&
				c.options.method === "GET" &&
				!c.url.includes("has-password"),
		);
		expect(getCall).toBeDefined();
	});

	test("throws when M2M not configured", () => {
		const unconfiguredClient = new LogtoManagementClient();
		expect(unconfiguredClient.getAccessToken()).rejects.toThrow(
			"Logto M2M not configured",
		);
	});

	test("strips /oidc suffix from issuer for base URL", async () => {
		await client.getUser("test-sub");

		// Token call should go to https://logto.example.com/oidc/token
		const tokenCall = fetchCalls.find((c) => c.url.includes("/oidc/token"));
		expect(tokenCall!.url).toBe("https://logto.example.com/oidc/token");

		// API call should go to https://logto.example.com/api/users/test-sub
		const apiCall = fetchCalls.find(
			(c) =>
				c.url.includes("/api/users/test-sub") && c.options.method === "GET",
		);
		expect(apiCall!.url).toBe("https://logto.example.com/api/users/test-sub");
	});
});
