import { describe, it, expect } from 'vitest';
import { SlideQueue } from './queue';
import type { FeedUpload } from '$lib/types';

// Minimal FeedUpload factory — the queue reads id, user_id, preview_url, thumbnail_url and
// mime_type. Note the `as unknown as FeedUpload` cast below: it silences the missing fields,
// so adding a field the queue reads does NOT fail typechecking here — it fails at runtime.
// Keep this factory in step with what `merge` actually touches.
const up = (
	id: string,
	opts: { user?: string; preview?: boolean; thumb?: boolean; video?: boolean } = {}
): FeedUpload =>
	({
		id,
		user_id: opts.user ?? 'u1',
		preview_url: opts.preview === false ? null : `/p/${id}`,
		thumbnail_url: opts.thumb ? `/t/${id}` : null,
		mime_type: opts.video ? 'video/mp4' : 'image/jpeg'
	}) as unknown as FeedUpload;

const drain = (q: SlideQueue, n: number): string[] => {
	const out: string[] = [];
	for (let i = 0; i < n; i++) {
		const s = q.next();
		if (!s) break;
		out.push(s.id);
	}
	return out;
};

describe('SlideQueue.merge', () => {
	it('dedups by id and reports the count actually added', () => {
		const q = new SlideQueue();
		expect(q.merge([up('a'), up('b')], { live: false })).toBe(2);
		expect(q.merge([up('b'), up('c')], { live: false })).toBe(1); // b already known
		expect(q.stats().known).toBe(3);
	});

	it('never queues a video, even when it has a usable poster frame', () => {
		const q = new SlideQueue();
		// The projector shows stills only. Before this was keyed on the mime type it depended
		// on whether ffmpeg happened to extract a poster: a video WITH a thumbnail was shown,
		// one without was dropped — the same clip in or out by luck.
		const added = q.merge([up('photo'), up('clip', { video: true, thumb: true })], {
			live: false
		});
		expect(added).toBe(1);
		expect(q.has('clip')).toBe(false);
		expect(q.has('photo')).toBe(true);
	});

	it('skips uploads with no preview or thumbnail (still compressing)', () => {
		const q = new SlideQueue();
		const added = q.merge([up('a'), up('pending', { preview: false }), up('t', { thumb: true })], {
			live: false
		});
		expect(added).toBe(2); // a + t; pending has neither preview nor thumb
		expect(q.has('pending')).toBe(false);
	});

	it('live items drain before the shuffle pool', () => {
		const q = new SlideQueue();
		q.merge([up('s1'), up('s2')], { live: false });
		q.merge([up('L1'), up('L2')], { live: true });
		// Live first, in FIFO order, then the pool.
		expect(drain(q, 2)).toEqual(['L1', 'L2']);
		expect(drain(q, 2).sort()).toEqual(['s1', 's2']);
	});
});

describe('SlideQueue completeness', () => {
	it('eventually shows every merged item across one full cycle (500 items, paged)', () => {
		const q = new SlideQueue();
		const all = new Set<string>();
		// Simulate a paginated load of 500 across 5 pages of 100.
		for (let page = 0; page < 5; page++) {
			const batch = Array.from({ length: 100 }, (_, i) => {
				const id = `img-${page * 100 + i}`;
				all.add(id);
				return up(id);
			});
			q.merge(batch, { live: false });
		}
		q.reshuffle();
		expect(q.stats().known).toBe(500);

		// Drain generously more than the set size; every id must appear at least once.
		const shown = new Set(drain(q, 500 + 50));
		for (const id of all) expect(shown.has(id)).toBe(true);
	});

	it('a burst of live items are all shown before falling back to the pool', () => {
		const q = new SlideQueue();
		q.merge([up('old1'), up('old2')], { live: false });
		const burst = Array.from({ length: 250 }, (_, i) => up(`burst-${i}`));
		q.merge(burst, { live: true });
		const firstBurst = drain(q, 250);
		expect(new Set(firstBurst).size).toBe(250);
		expect(firstBurst.every((id) => id.startsWith('burst-'))).toBe(true);
	});
});

describe('SlideQueue removals', () => {
	it('remove() reports whether the removed id was current', () => {
		const q = new SlideQueue();
		q.merge([up('a'), up('b')], { live: false });
		expect(q.remove('a', 'a').wasCurrent).toBe(true);
		expect(q.remove('b', 'a').wasCurrent).toBe(false);
		expect(q.stats().known).toBe(0);
	});

	it('removeByUser() evicts a whole user and flags current', () => {
		const q = new SlideQueue();
		q.merge([up('a', { user: 'x' }), up('b', { user: 'y' }), up('c', { user: 'x' })], {
			live: false
		});
		const res = q.removeByUser('x', 'c');
		expect(res.wasCurrent).toBe(true);
		expect(q.has('a')).toBe(false);
		expect(q.has('c')).toBe(false);
		expect(q.has('b')).toBe(true);
	});
});

describe('SlideQueue.knownIds + reconcile eviction', () => {
	it('knownIds snapshots exactly the current set', () => {
		const q = new SlideQueue();
		q.merge([up('a'), up('b')], { live: false });
		expect(new Set(q.knownIds())).toEqual(new Set(['a', 'b']));
	});

	// Mirrors the page's reconcile: evict only pre-scan ids the feed no longer lists, so an
	// upload that lands mid-scan survives.
	const reconcileEvict = (q: SlideQueue, before: Set<string>, seen: Set<string>) => {
		for (const id of before) if (!seen.has(id)) q.remove(id, null);
	};

	it('drops a slide the server no longer lists (deleted/banned we missed)', () => {
		const q = new SlideQueue();
		q.merge([up('a'), up('b'), up('c')], { live: false });
		const before = new Set(q.knownIds());
		reconcileEvict(q, before, new Set(['a', 'c'])); // feed no longer has b
		expect(q.has('b')).toBe(false);
		expect(q.has('a')).toBe(true);
		expect(q.has('c')).toBe(true);
	});

	it('never evicts an upload that arrived mid-scan (not in the pre-scan snapshot)', () => {
		const q = new SlideQueue();
		q.merge([up('a'), up('b')], { live: false });
		const before = new Set(q.knownIds()); // {a, b}
		// A live upload lands during the scan; the scan's `seen` predates it.
		q.merge([up('fresh')], { live: true });
		reconcileEvict(q, before, new Set(['a', 'b']));
		expect(q.has('fresh')).toBe(true); // survives — not an eviction candidate
		expect(q.has('a')).toBe(true);
	});
});
