import { describe, expect, it, vi } from 'vitest';
import { createBobbinClient, type BobbinContext } from './client';
import { IdentityCache, type MiniDoc } from './identity';

const DOC: MiniDoc = { did: 'did:plc:x', handle: 'alice.test' };

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

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

const deferred = <T>(): { promise: Promise<T>; resolve: (value: T) => void } => {
	let resolve!: (value: T) => void;
	const promise = new Promise<T>((r) => {
		resolve = r;
	});
	return { promise, resolve };
};

describe('IdentityCache.resolve', () => {
	it('fetches on the first miss then serves the cached document', async () => {
		const fetchMock = vi.fn<typeof globalThis.fetch>().mockResolvedValue(docResponse(DOC));
		const cache = new IdentityCache(makeCtx(fetchMock));

		const first = await cache.resolve('alice.test');
		const second = await cache.resolve('alice.test');

		expect(first).toEqual(DOC);
		expect(second).toEqual(DOC);
		expect(fetchMock).toHaveBeenCalledTimes(1);
		expect((fetchMock.mock.calls[0][0] as URL).searchParams.get('identifier')).toBe('alice.test');
	});

	it('coalesces concurrent misses for the same key into one in-flight fetch', async () => {
		const gate = deferred<Response>();
		const fetchMock = vi.fn<typeof globalThis.fetch>().mockReturnValue(gate.promise);
		const cache = new IdentityCache(makeCtx(fetchMock));

		const a = cache.resolve('alice.test');
		const b = cache.resolve('alice.test');
		expect(fetchMock).toHaveBeenCalledTimes(1);

		gate.resolve(docResponse(DOC));
		const [ra, rb] = await Promise.all([a, b]);
		expect(ra).toEqual(DOC);
		expect(rb).toEqual(DOC);
		expect(fetchMock).toHaveBeenCalledTimes(1);
	});

	it('re-fetches after the in-flight promise settles (no permanent stampede lock)', async () => {
		const fetchMock = vi.fn<typeof globalThis.fetch>().mockResolvedValue(docResponse(DOC));
		const cache = new IdentityCache(makeCtx(fetchMock));

		await cache.resolve('bob.test');
		fetchMock.mockResolvedValueOnce(docResponse({ did: 'did:plc:y', handle: 'carol.test' }));
		await cache.resolve('carol.test');
		expect(fetchMock).toHaveBeenCalledTimes(2);
	});

	it('populates both directions of the index from a resolved document', async () => {
		const fetchMock = vi.fn<typeof globalThis.fetch>().mockResolvedValue(docResponse(DOC));
		const cache = new IdentityCache(makeCtx(fetchMock));

		await cache.resolve('alice.test');
		expect(cache.didFor('alice.test')).toBe('did:plc:x');
		expect(cache.handleFor('did:plc:x')).toBe('alice.test');
	});
});

describe('IdentityCache.prime & map selection', () => {
	it('seeds both directions without any fetch', () => {
		const fetchMock = vi.fn<typeof globalThis.fetch>();
		const cache = new IdentityCache(makeCtx(fetchMock));

		cache.prime(DOC);
		expect(cache.didFor('alice.test')).toBe('did:plc:x');
		expect(cache.handleFor('did:plc:x')).toBe('alice.test');
		expect(fetchMock).not.toHaveBeenCalled();
	});

	it('resolves a DID against the did map and a handle against the handle map', async () => {
		const fetchMock = vi.fn<typeof globalThis.fetch>();
		const cache = new IdentityCache(makeCtx(fetchMock));
		cache.prime(DOC);

		await expect(cache.resolve('did:plc:x')).resolves.toEqual(DOC);
		await expect(cache.resolve('alice.test')).resolves.toEqual(DOC);
		expect(fetchMock).not.toHaveBeenCalled();

		// unknown dids miss the handle map and fetch.
		fetchMock.mockResolvedValueOnce(docResponse({ did: 'did:plc:z', handle: 'dan.test' }));
		await cache.resolve('did:plc:z');
		expect(fetchMock).toHaveBeenCalledTimes(1);
		expect((fetchMock.mock.calls[0][0] as URL).searchParams.get('identifier')).toBe('did:plc:z');
	});
});
