// Two-queue slide state machine. Pure logic — no DOM, no Svelte, no network. Lets us
// unit-test the policy without spinning up a browser.
//
// Policy: live posts always drain first; the shuffle queue refills from `allKnown`
// (minus the most recent N items) once it empties. A new live post pushes onto the
// live queue and waits for the next slide transition — it never interrupts the
// current slide.
//
// Completeness is the whole point: `allKnown` is meant to converge to the server's full
// set of eligible uploads. The page feeds it via `merge` (incremental / paginated) and
// `retainOnly` (evict anything the server no longer returns — deletions, bans, hides),
// so every eligible image is eventually shown and no ineligible one lingers.

import type { FeedUpload } from '$lib/types';

const RECENT_RING_SIZE = 5;

export class SlideQueue {
	/** Live queue — FIFO. New uploads land here via `merge(_, { live: true })`. */
	private liveQueue: FeedUpload[] = [];
	/** Shuffle queue — refilled from `allKnown` minus `recentlyShown` when emptied. */
	private shuffleQueue: FeedUpload[] = [];
	/** Every slide we've ever seen, keyed by id. Source for shuffle refills. */
	private allKnown: Map<string, FeedUpload> = new Map();
	/** Ring buffer of the last N shown ids — excluded from the next shuffle pool. */
	private recentlyShown: string[] = [];

	/**
	 * Add uploads, de-duplicated by id. `live` items jump onto the live queue (shown next);
	 * otherwise they join the shuffle pool. Returns the count newly added.
	 *
	 * Two things are skipped:
	 *
	 * 1. **Videos, deliberately and by mime type.** The projector shows stills only — a clip
	 *    would either hold the slide for its full duration or be cut off mid-way, and it has
	 *    no audio path on a room's screen. This used to happen only ACCIDENTALLY, as a side
	 *    effect of the derivative check below: a video whose poster frame extracted fine has
	 *    a `thumbnail_url` and so was shown as a still frame, while one whose extraction
	 *    failed was dropped — the same upload included or excluded depending on whether
	 *    ffmpeg happened to find a frame. Now the rule is the mime type, so it is consistent.
	 * 2. **Images with no derivative yet.** A still-compressing photo has no image to show;
	 *    it is picked up by a later sync once its preview exists.
	 */
	merge(uploads: FeedUpload[], opts: { live: boolean }): number {
		let added = 0;
		for (const u of uploads) {
			if (u.mime_type.startsWith('video/')) continue;
			if (!u.preview_url && !u.thumbnail_url) continue;
			if (this.allKnown.has(u.id)) continue;
			this.allKnown.set(u.id, u);
			if (opts.live) this.liveQueue.push(u);
			else this.shuffleQueue.push(u);
			added++;
		}
		return added;
	}

	/** True if this id is already in the known set — used to stop a catch-up scan once it
	 *  reaches territory we've already ingested. */
	has(id: string): boolean {
		return this.allKnown.has(id);
	}

	/** Re-randomise the not-yet-shown shuffle pool. Called once after the initial paginated
	 *  load so the first cycle isn't just newest-first (merge appends in feed order). */
	reshuffle(): void {
		this.shuffleQueue = shuffle(this.shuffleQueue);
	}

	/** Pop the next slide. Returns null while both queues are empty (event has no posts). */
	next(): FeedUpload | null {
		// 1. Drain live first.
		const live = this.liveQueue.shift();
		if (live) {
			this.markShown(live.id);
			return live;
		}
		// 2. Refill shuffle queue from `allKnown` minus recently shown.
		if (this.shuffleQueue.length === 0) {
			this.shuffleQueue = shuffle(
				Array.from(this.allKnown.values()).filter((s) => !this.recentlyShown.includes(s.id))
			);
			// If everything is recently shown (small event), fall back to the full pool.
			if (this.shuffleQueue.length === 0) {
				this.shuffleQueue = shuffle(Array.from(this.allKnown.values()));
			}
		}
		const next = this.shuffleQueue.shift() ?? null;
		if (next) this.markShown(next.id);
		return next;
	}

	/**
	 * Remove a slide that was deleted or hidden. Returns true if it was the current
	 * "head" and the caller should advance immediately (UX: don't keep showing a
	 * post that the host just deleted).
	 */
	remove(id: string, currentId: string | null): { wasCurrent: boolean } {
		this.allKnown.delete(id);
		this.liveQueue = this.liveQueue.filter((s) => s.id !== id);
		this.shuffleQueue = this.shuffleQueue.filter((s) => s.id !== id);
		this.recentlyShown = this.recentlyShown.filter((sid) => sid !== id);
		return { wasCurrent: currentId === id };
	}

	/**
	 * Remove every slide belonging to a user (banned with hide_uploads). Returns
	 * true if the current head was one of them so the caller advances immediately.
	 */
	removeByUser(userId: string, currentId: string | null): { wasCurrent: boolean } {
		let wasCurrent = false;
		for (const [id, slide] of this.allKnown) {
			if (slide.user_id === userId) {
				if (id === currentId) wasCurrent = true;
				this.allKnown.delete(id);
			}
		}
		this.liveQueue = this.liveQueue.filter((s) => s.user_id !== userId);
		this.shuffleQueue = this.shuffleQueue.filter((s) => s.user_id !== userId);
		this.recentlyShown = this.recentlyShown.filter((sid) => this.allKnown.has(sid));
		return { wasCurrent };
	}

	/**
	 * Snapshot of all currently-known ids. The page captures this BEFORE a reconcile scan,
	 * then evicts only ids that were known before AND the server no longer lists — the
	 * reconciliation half of `merge`. Snapshotting first means an upload that lands *during*
	 * the scan (not in the snapshot) is never wrongly evicted, so a busy event doesn't
	 * flicker freshly-added slides out and back in.
	 */
	knownIds(): string[] {
		return Array.from(this.allKnown.keys());
	}

	/** Look up a slide by id — for the diashow page to render the current slide. */
	get(id: string): FeedUpload | undefined {
		return this.allKnown.get(id);
	}

	/** Snapshot — useful for stats / debugging. */
	stats() {
		return {
			known: this.allKnown.size,
			live: this.liveQueue.length,
			shuffle: this.shuffleQueue.length
		};
	}

	private markShown(id: string): void {
		this.recentlyShown.push(id);
		while (this.recentlyShown.length > RECENT_RING_SIZE) {
			this.recentlyShown.shift();
		}
	}
}

/** Fisher–Yates. Mutates a copy of the input so callers can pass `allKnown.values()`. */
function shuffle<T>(arr: T[]): T[] {
	const out = arr.slice();
	for (let i = out.length - 1; i > 0; i--) {
		const j = Math.floor(Math.random() * (i + 1));
		[out[i], out[j]] = [out[j], out[i]];
	}
	return out;
}
