import { describe, expect, it, vi, type Mock } from "vitest";
import type { OAuthUserAgent } from "@atcute/oauth-browser-client";
import { ClientResponseError, createBobbinClient, type BobbinContext } from "./client";
import { jsonGet, rawGet } from "./_request";

const jsonResponse = (body: unknown, init: ResponseInit = {}): Response =>
	new Response(JSON.stringify(body), {
		status: 200,
		headers: { "content-type": "application/json" },
		...init
	});

const makeCtx = (
	fetchMock: typeof globalThis.fetch,
	serviceUrl = "https://bobbin.test"
): BobbinContext => createBobbinClient({ serviceUrl, fetch: fetchMock });

const fetchedUrl = (mock: Mock<typeof globalThis.fetch>, n = 0): URL =>
	new URL(String(mock.mock.calls[n][0]));

describe("createBobbinClient", () => {
	it("normalizes trailing slashes off the service url", () => {
		const ctx = makeCtx(vi.fn(), "https://bobbin.test///");
		expect(ctx.serviceUrl).toBe("https://bobbin.test");
	});
});

describe("jsonGet URL construction", () => {
	it("appends an array param as repeated keys", async () => {
		const fetchMock = vi.fn<typeof globalThis.fetch>().mockResolvedValue(jsonResponse({ ok: 1 }));
		await jsonGet(makeCtx(fetchMock), "sh.tangled.repo.getRepos", { repos: ["a", "b"] });
		const url = fetchedUrl(fetchMock);
		expect(url.searchParams.getAll("repos")).toEqual(["a", "b"]);
		expect(url.search).toBe("?repos=a&repos=b");
	});

	it("omits undefined params entirely", async () => {
		const fetchMock = vi.fn<typeof globalThis.fetch>().mockResolvedValue(jsonResponse({ ok: 1 }));
		await jsonGet(makeCtx(fetchMock), "sh.tangled.x", { keep: "yes", drop: undefined });
		const url = fetchedUrl(fetchMock);
		expect(url.searchParams.has("drop")).toBe(false);
		expect(url.searchParams.get("keep")).toBe("yes");
	});

	it("resolves the query against the normalized origin", async () => {
		const fetchMock = vi.fn<typeof globalThis.fetch>().mockResolvedValue(jsonResponse({ ok: 1 }));
		await jsonGet(makeCtx(fetchMock, "https://bobbin.test/"), "sh.tangled.x", { a: "1" });
		expect(fetchedUrl(fetchMock).href).toBe("https://bobbin.test/xrpc/sh.tangled.x?a=1");
	});
});

describe("jsonGet request headers & signal", () => {
	it("sends accept: application/json and merges caller headers + signal", async () => {
		const fetchMock = vi.fn<typeof globalThis.fetch>().mockResolvedValue(jsonResponse({ ok: 1 }));
		const controller = new AbortController();
		await jsonGet(makeCtx(fetchMock), "sh.tangled.x", undefined, {
			headers: { "x-custom": "1" },
			signal: controller.signal
		});
		const init = fetchMock.mock.calls[0][1] as RequestInit;
		expect(init.headers).toMatchObject({ accept: "application/json", "x-custom": "1" });
		expect(init.signal).toBe(controller.signal);
	});
});

describe("jsonGet responses", () => {
	it("throws ClientResponseError carrying status + error/description for a JSON error body", async () => {
		const fetchMock = vi
			.fn<typeof globalThis.fetch>()
			.mockResolvedValue(
				jsonResponse({ error: "RecordNotFound", message: "no such repo" }, { status: 404 })
			);
		const err = await jsonGet(makeCtx(fetchMock), "sh.tangled.x").catch((e: unknown) => e);
		expect(err).toBeInstanceOf(ClientResponseError);
		const cre = err as ClientResponseError;
		expect(cre.status).toBe(404);
		expect(cre.error).toBe("RecordNotFound");
		expect(cre.description).toBe("no such repo");
	});

	it("falls back to the status line for a non-JSON error body (does not hang)", async () => {
		const fetchMock = vi.fn<typeof globalThis.fetch>().mockResolvedValue(
			new Response("<html>502 Bad Gateway</html>", {
				status: 502,
				statusText: "Bad Gateway"
			})
		);
		const err = await jsonGet(makeCtx(fetchMock), "sh.tangled.x").catch((e: unknown) => e);
		expect(err).toBeInstanceOf(ClientResponseError);
		const cre = err as ClientResponseError;
		expect(cre.status).toBe(502);
		expect(cre.error).toBe("XRPCError");
		expect(cre.description).toBe("Bad Gateway");
	});
});

describe("createBobbinClient service auth", () => {
	// mintServiceAuth goes through `new Client({ handler: agent })`, and
	// OAuthUserAgent satisfies FetchHandlerObject — so `handle` is where the
	// com.atproto.server.getServiceAuth call lands and where the minted claims
	// can be read back off the query string.
	const makeAgent = (token = "jwt-1") => {
		const handle = vi
			.fn<(pathname: string, init: RequestInit) => Promise<Response>>()
			.mockImplementation(() => Promise.resolve(jsonResponse({ token })));
		return { agent: { handle, sub: "did:plc:tester" } as unknown as OAuthUserAgent, handle };
	};

	const mintedParams = (handle: Mock, n = 0): URLSearchParams =>
		new URL(String(handle.mock.calls[n][0]), "https://pds.test").searchParams;

	const authHeader = (fetchMock: Mock<typeof globalThis.fetch>, n = 0): string | null =>
		new Headers((fetchMock.mock.calls[n][1] as RequestInit | undefined)?.headers).get(
			"authorization"
		);

	// a Response body reads once, so each call needs its own
	const okFetch = () =>
		vi
			.fn<typeof globalThis.fetch>()
			.mockImplementation(() => Promise.resolve(jsonResponse({ ok: 1 })));

	it("sends no authorization header when no agent is given", async () => {
		const fetchMock = vi.fn<typeof globalThis.fetch>().mockResolvedValue(jsonResponse({ ok: 1 }));
		await jsonGet(makeCtx(fetchMock), "sh.tangled.x");
		expect(authHeader(fetchMock)).toBeNull();
	});

	it("attaches a minted bearer token when an agent is given", async () => {
		const fetchMock = okFetch();
		const { agent } = makeAgent("jwt-abc");
		const ctx = createBobbinClient({ serviceUrl: "https://appview.test", fetch: fetchMock, agent });

		await jsonGet(ctx, "org.tangled.temp.search.searchCode", { q: "main" });

		expect(authHeader(fetchMock)).toBe("Bearer jwt-abc");
	});

	it("scopes each token to the method being called", async () => {
		const fetchMock = okFetch();
		const { agent, handle } = makeAgent();
		const ctx = createBobbinClient({ serviceUrl: "https://appview.test", fetch: fetchMock, agent });

		await jsonGet(ctx, "org.tangled.temp.search.searchCode", { q: "main" });
		await jsonGet(ctx, "org.tangled.temp.notification.getUnreadCount");

		expect(handle).toHaveBeenCalledTimes(2);
		expect(mintedParams(handle, 0).get("lxm")).toBe("org.tangled.temp.search.searchCode");
		expect(mintedParams(handle, 1).get("lxm")).toBe("org.tangled.temp.notification.getUnreadCount");
	});

	it("audiences the token at did:web:<host>, percent-encoding the port", async () => {
		const fetchMock = okFetch();
		const { agent, handle } = makeAgent();
		// the dev default from lib/server/config.ts
		const ctx = createBobbinClient({
			serviceUrl: "http://127.0.0.1:8080",
			fetch: fetchMock,
			agent
		});

		await jsonGet(ctx, "org.tangled.temp.search.searchCode", { q: "main" });

		expect(mintedParams(handle).get("aud")).toBe("did:web:127.0.0.1%3A8080");
	});

	it("mints nothing for a fetch that is not an xrpc call", async () => {
		const fetchMock = okFetch();
		const { agent, handle } = makeAgent();
		const ctx = createBobbinClient({ serviceUrl: "https://appview.test", fetch: fetchMock, agent });

		await ctx.fetch("https://appview.test/healthz");

		expect(handle).not.toHaveBeenCalled();
		expect(authHeader(fetchMock)).toBeNull();
	});
});

describe("rawGet", () => {
	it("throws ClientResponseError on a non-2xx status", async () => {
		const fetchMock = vi
			.fn<typeof globalThis.fetch>()
			.mockResolvedValue(
				jsonResponse({ error: "UpstreamFailed", message: "knot down" }, { status: 502 })
			);
		const err = await rawGet(makeCtx(fetchMock), "sh.tangled.repo.archive").catch(
			(e: unknown) => e
		);
		expect(err).toBeInstanceOf(ClientResponseError);
		expect((err as ClientResponseError).status).toBe(502);
	});
});
