import { describe, expect, it, vi, type Mock } from 'vitest';
import { createBobbinClient, type BobbinContext } from './client';
import { collect, pages, paginateBy } from './pagination';

const NAME = 'sh.tangled.feed.listStars';
const PARAMS = { subject: 'did:plc:x' } as const;

interface Page {
	items: readonly { uri: string }[];
	cursor?: string;
}

const pageResponse = (page: Page): Response =>
	new Response(JSON.stringify(page), {
		status: 200,
		headers: { 'content-type': 'application/json' }
	});

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

const cursorOf = (mock: Mock<typeof globalThis.fetch>, n: number): string | null =>
	new URL(String(mock.mock.calls[n][0])).searchParams.get('cursor');

const threePageFetch = (): Mock<typeof globalThis.fetch> =>
	vi
		.fn<typeof globalThis.fetch>()
		.mockResolvedValueOnce(pageResponse({ items: [{ uri: 'a' }, { uri: 'b' }], cursor: 'c1' }))
		.mockResolvedValueOnce(pageResponse({ items: [{ uri: 'c' }], cursor: 'c2' }))
		.mockResolvedValueOnce(pageResponse({ items: [{ uri: 'd' }] }));

describe('pages / items / collect follow the cursor', () => {
	it('pages() yields every page then stops when a page omits the cursor', async () => {
		const fetchMock = threePageFetch();
		const seen: Page[] = [];
		for await (const p of pages(makeCtx(fetchMock), NAME, PARAMS)) seen.push(p as Page);
		expect(fetchMock).toHaveBeenCalledTimes(3);
		expect(seen.map((p) => p.cursor)).toEqual(['c1', 'c2', undefined]);
		expect(cursorOf(fetchMock, 0)).toBeNull();
		expect(cursorOf(fetchMock, 1)).toBe('c1');
		expect(cursorOf(fetchMock, 2)).toBe('c2');
	});
});

describe('pagination caps', () => {
	it('maxPages caps the number of network round-trips even with unbounded cursors', async () => {
		const fetchMock = vi
			.fn<typeof globalThis.fetch>()
			.mockImplementation(async () => pageResponse({ items: [{ uri: 'x' }], cursor: 'always' }));
		const out = await collect(makeCtx(fetchMock), NAME, PARAMS, { maxPages: 2 });
		expect(fetchMock).toHaveBeenCalledTimes(2);
		expect(out).toHaveLength(2);
	});

	it('collect({ max }) caps items mid-page without fetching the next page', async () => {
		const fetchMock = vi
			.fn<typeof globalThis.fetch>()
			.mockResolvedValueOnce(
				pageResponse({ items: [{ uri: 'a' }, { uri: 'b' }, { uri: 'c' }], cursor: 'c1' })
			);
		const out = await collect(makeCtx(fetchMock), NAME, PARAMS, { max: 2 });
		expect(out).toEqual([{ uri: 'a' }, { uri: 'b' }]);
		expect(fetchMock).toHaveBeenCalledTimes(1);
	});
});

describe('paginateBy', () => {
	it('treats cursor: null as the end of the stream', async () => {
		const load = vi.fn(async () => ({
			items: [1, 2] as const,
			cursor: null
		}));
		const out: number[] = [];
		for await (const n of paginateBy(load)) out.push(n);
		expect(out).toEqual([1, 2]);
		expect(load).toHaveBeenCalledTimes(1);
	});

	it('follows a string cursor and forwards it to the loader', async () => {
		const load = vi
			.fn<
				(
					cursor: string | undefined
				) => Promise<{ items: readonly number[]; cursor?: string | null }>
			>()
			.mockResolvedValueOnce({ items: [1], cursor: 'p2' })
			.mockResolvedValueOnce({ items: [2], cursor: undefined });
		const out: number[] = [];
		for await (const n of paginateBy(load)) out.push(n);
		expect(out).toEqual([1, 2]);
		expect(load).toHaveBeenCalledTimes(2);
		expect(load.mock.calls[0][0]).toBeUndefined();
		expect(load.mock.calls[1][0]).toBe('p2');
	});

	it('maxPages caps loader invocations', async () => {
		const load = vi.fn(async () => ({
			items: [0],
			cursor: 'always'
		}));
		const out: number[] = [];
		for await (const n of paginateBy(load, { maxPages: 3 })) out.push(n);
		expect(load).toHaveBeenCalledTimes(3);
		expect(out).toHaveLength(3);
	});
});
