import { describe, expect, it, vi, type Mock } from 'vitest';
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('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);
	});
});
