import { browser } from "$app/environment";
import {
	createPersistScheduler,
	loadPersistedPlayerState,
	type PersistablePlayerState,
} from "./persist";
import {
	buildInitialShuffleOrder,
	injectNextIntoShuffleOrder,
	reindexAfterMoveOrRemove,
} from "./shuffle";
import type { Track } from "./types";

/**
 * Global audio player state + queue/shuffle actions (Svelte 5 runes).
 *
 * This module is intended to be imported from UI components and pages.
 * The actual <audio> element lives in a single GlobalPlayer component, which
 * binds to the state here and calls actions.
 *
 * Canonical dedupe id: Track.id === annSongId (number).
 *
 * NOTE: Do NOT use module-level `$effect` here — consumers (e.g. GlobalPlayer)
 * should subscribe via `subscribe()` and drive any side-effects (like persistence).
 */

export type InsertMode = "play" | "playNext" | "add" | "jump";

export type PlayerSnapshot = {
	queue: Track[];
	currentIndex: number | null;

	// derived-ish convenience
	currentTrack: Track | null;

	shuffleEnabled: boolean;
	wrapEnabled: boolean;

	// shuffle traversal
	order: number[];
	history: number[];
	cursor: number;

	volume: number;

	// UI
	uiOpen: boolean;
};

export type PlayerSubscriber = (snapshot: PlayerSnapshot) => void;
export type Unsubscribe = () => void;

const DEFAULT_VOLUME = 1;

function isMobileLike() {
	if (!browser) return false;
	return window.matchMedia?.("(max-width: 1023px)")?.matches ?? false;
}

function clamp01(n: number) {
	if (!Number.isFinite(n)) return DEFAULT_VOLUME;
	return Math.max(0, Math.min(1, n));
}

function arrayRemoveAt<T>(arr: T[], index: number) {
	if (index < 0 || index >= arr.length) return arr;
	return [...arr.slice(0, index), ...arr.slice(index + 1)];
}

function arrayInsertAt<T>(arr: T[], index: number, item: T) {
	const i = Math.max(0, Math.min(index, arr.length));
	return [...arr.slice(0, i), item, ...arr.slice(i)];
}

function arrayMove<T>(arr: T[], from: number, to: number) {
	if (from === to) return arr;
	if (from < 0 || from >= arr.length) return arr;
	if (to < 0 || to >= arr.length) return arr;

	const copy = [...arr];
	const [item] = copy.splice(from, 1);
	copy.splice(to, 0, item);
	return copy;
}

function dedupeTracks(tracks: Track[]) {
	const seen = new Set<number>();
	const out: Track[] = [];
	for (const t of tracks) {
		const id = Number(t.id);
		if (!Number.isFinite(id)) continue;
		if (seen.has(id)) continue;
		seen.add(id);
		out.push(t);
	}
	return out;
}

function visitedSet(history: number[], cursor: number) {
	if (history.length === 0) return new Set<number>();
	const end = Math.max(0, Math.min(cursor, history.length - 1));
	return new Set(history.slice(0, end + 1));
}

function persistableState(): PersistablePlayerState {
	return {
		queue,
		currentIndex,

		shuffleEnabled,
		wrapEnabled,

		order,
		history,
		cursor,

		volume,
		uiOpen,
	};
}

/** --- Initialize state (restore persisted if present) --- */

const persisted = browser ? loadPersistedPlayerState() : null;

let queue = $state<Track[]>(persisted?.queue ?? []);
let currentIndex = $state<number | null>(persisted?.currentIndex ?? null);

let shuffleEnabled = $state<boolean>(persisted?.shuffleEnabled ?? false);
let wrapEnabled = $state<boolean>(persisted?.wrapEnabled ?? false);

let order = $state<number[]>(persisted?.order ?? []);
let history = $state<number[]>(persisted?.history ?? []);
let cursor = $state<number>(persisted?.cursor ?? 0);

let volume = $state<number>(clamp01(persisted?.volume ?? DEFAULT_VOLUME));

let uiOpen = $state<boolean>(
	// Default based on the current viewport:
	// - mobile: closed
	// - desktop: open
	//
	// Note: we intentionally do NOT default from persisted `uiOpen`, so the UI
	// always follows the current device/viewport expectation.
	!isMobileLike(),
);

const currentTrack = $derived<Track | null>(
	currentIndex == null ? null : (queue[currentIndex] ?? null),
);

const snapshot = $derived<PlayerSnapshot>({
	queue,
	currentIndex,
	currentTrack,
	shuffleEnabled,
	wrapEnabled,
	order,
	history,
	cursor,
	volume,
	uiOpen,
});

/** --- Lightweight subscription API (to avoid polling) --- */
const subscribers = new Set<PlayerSubscriber>();

function notifySubscribers() {
	// Read `snapshot` here so every subscriber sees a consistent value.
	const s = snapshot;
	for (const fn of subscribers) fn(s);
}

/**
 * Subscribe to player snapshot changes.
 *
 * - Calls `fn` immediately with the current snapshot
 * - Returns an `unsubscribe` function
 */
export function subscribe(fn: PlayerSubscriber): Unsubscribe {
	subscribers.add(fn);
	fn(snapshot);

	return () => {
		subscribers.delete(fn);
	};
}

/**
 * Notify subscribers at the end of the current microtask.
 * This coalesces multiple mutations within the same tick into one update.
 */
let notifyQueued = false;
function queueNotify() {
	if (notifyQueued) return;
	notifyQueued = true;
	queueMicrotask(() => {
		notifyQueued = false;
		notifySubscribers();
	});
}

/**
 * Persistence
 *
 * Persistence must be driven from a component (e.g. GlobalPlayer) via `subscribe()`
 * to avoid orphaned module-level effects.
 */
const schedulePersist = createPersistScheduler(250);

export function schedulePersistNow(): void {
	if (!browser) return;
	schedulePersist(persistableState());
}

/** --- Public reads --- */

export function getSnapshot(): PlayerSnapshot {
	return snapshot;
}

export function hasTrack(id: number): boolean {
	const wanted = Number(id);
	return queue.some((t) => t.id === wanted);
}

export function indexOfTrack(id: number): number {
	const wanted = Number(id);
	return queue.findIndex((t) => t.id === wanted);
}

/** --- Queue traversal helpers --- */

function ensureTraversalStateForCurrent() {
	// Ensure history/cursor reflect currentIndex when possible.
	if (currentIndex == null) {
		history = [];
		cursor = 0;
		return;
	}

	if (history.length === 0) {
		history = [currentIndex];
		cursor = 0;
		return;
	}

	const cur = history[cursor];
	if (cur !== currentIndex) {
		// Repair without rewriting past: append and move cursor to it.
		history = [...history, currentIndex];
		cursor = history.length - 1;
	}
}

function rebuildShuffleOrderPreservingPast() {
	if (!shuffleEnabled) {
		order = [];
		return;
	}

	ensureTraversalStateForCurrent();

	const visited = visitedSet(history, cursor);
	order = buildInitialShuffleOrder(queue.length, visited);
}

/**
 * Get the next queue index according to shuffle/linear mode.
 * Returns null if there is no next item (and wrap is disabled).
 */
function computeNextIndex(): number | null {
	if (queue.length === 0) return null;

	if (currentIndex == null) return 0;

	if (!shuffleEnabled) {
		const next = currentIndex + 1;
		if (next < queue.length) return next;
		return wrapEnabled ? 0 : null;
	}

	ensureTraversalStateForCurrent();

	// If user went backwards in history and presses next, prefer moving forward in history.
	if (cursor < history.length - 1) {
		return history[cursor + 1] ?? null;
	}

	if (order.length === 0) {
		if (!wrapEnabled) return null;

		// Wrap in shuffle mode: keep past history, but allow revisiting by
		// generating a fresh future order excluding "past" only up through cursor.
		// Since at end, visited is all history; to wrap, we treat visited as empty
		// and start a new cycle, but we MUST NOT mutate history.
		// Easiest: generate an order containing all indices except current first,
		// then inject current out.
		const all = new Set<number>();
		// visited empty for wrap cycle
		order = buildInitialShuffleOrder(queue.length, all);
		order = order.filter((i) => i !== history[cursor]);
	}

	return order[0] ?? null;
}

/**
 * Get the previous queue index according to shuffle history or linear mode.
 * Returns null if there is no previous item (and wrap is disabled).
 */
function computePrevIndex(currentTimeSeconds = 0): number | null {
	if (queue.length === 0) return null;
	if (currentIndex == null) return null;

	// Standard behavior: if you've listened a bit, restart track.
	if (currentTimeSeconds > 3) return currentIndex;

	if (!shuffleEnabled) {
		const prev = currentIndex - 1;
		if (prev >= 0) return prev;
		return wrapEnabled ? queue.length - 1 : null;
	}

	ensureTraversalStateForCurrent();

	if (cursor > 0) return history[cursor - 1] ?? null;
	if (!wrapEnabled) return null;

	// Wrap backwards in shuffle mode:
	// We can jump to the last item in history if it exists, otherwise pick any.
	if (history.length > 0) return history[history.length - 1] ?? null;
	return 0;
}

function applyCurrentIndex(next: number | null) {
	currentIndex = next;

	if (next == null) {
		// stop traversal, but keep queue
		return;
	}

	if (!shuffleEnabled) return;

	// Ensure traversal exists and is anchored at *this* currentIndex.
	// This is important for jump-to-index behavior in shuffle mode.
	ensureTraversalStateForCurrent();

	// If we jumped to an index that's not reflected at the current cursor,
	// align cursor/history so that prev/next work relative to the jumped item.
	// Strategy:
	// - if next is already somewhere in history, move cursor there
	// - otherwise, append it and set cursor to the end
	const existingPos = history.indexOf(next);
	if (existingPos !== -1) {
		cursor = existingPos;
	} else if (history[cursor] !== next) {
		history = [...history, next];
		cursor = history.length - 1;
	}

	// If this index was scheduled in the future order, remove it so we don't revisit.
	order = order.filter((i) => i !== next);

	// If it was at the head, that's implicitly consumed as well.
	if (order[0] === next) order = order.slice(1);
}

/** --- Public traversal actions --- */

export function next(): void {
	const idx = computeNextIndex();
	if (idx == null) {
		currentIndex = null;
		queueNotify();
		return;
	}
	applyCurrentIndex(idx);
	queueNotify();
}

export function prev(currentTimeSeconds = 0): void {
	const idx = computePrevIndex(currentTimeSeconds);
	if (idx == null) return;

	// If idx === currentIndex, we interpret that as "restart track"
	applyCurrentIndex(idx);
	queueNotify();
}

/** Jump to an existing queued track by id (does not reorder). */
export function jumpToTrack(id: number): void {
	const i = indexOfTrack(id);
	if (i === -1) return;
	applyCurrentIndex(i);
	queueNotify();
}

/** --- Queue mutation primitives (keep traversal state consistent) --- */

function removeAt(index: number) {
	if (index < 0 || index >= queue.length) return;

	queue = arrayRemoveAt(queue, index);

	// Fix currentIndex
	if (currentIndex != null) {
		if (currentIndex === index) {
			// Removing current track -> advance to next if possible else stop
			currentIndex = null;
		} else if (currentIndex > index) {
			currentIndex = currentIndex - 1;
		}
	}

	if (shuffleEnabled) {
		const re = reindexAfterMoveOrRemove({
			order,
			history,
			cursor,
			currentIndex,
			fromIndex: index,
			toIndex: null,
		});
		order = re.order;
		history = re.history;
		cursor = re.cursor;
		currentIndex = re.currentIndex;
	}

	// If current was removed, attempt to advance
	if (currentIndex == null && queue.length > 0) {
		const idx = computeNextIndex();
		if (idx != null) applyCurrentIndex(idx);
	}
}

function moveIndex(from: number, to: number) {
	if (from === to) return;
	if (from < 0 || from >= queue.length) return;
	if (to < 0 || to >= queue.length) return;

	queue = arrayMove(queue, from, to);

	// Fix currentIndex (linear)
	if (currentIndex != null) {
		if (currentIndex === from) currentIndex = to;
		else if (to < from) {
			// moved earlier
			if (currentIndex >= to && currentIndex < from) currentIndex += 1;
		} else {
			// moved later
			if (currentIndex > from && currentIndex <= to) currentIndex -= 1;
		}
	}

	if (shuffleEnabled) {
		const re = reindexAfterMoveOrRemove({
			order,
			history,
			cursor,
			currentIndex,
			fromIndex: from,
			toIndex: to,
		});
		order = re.order;
		history = re.history;
		cursor = re.cursor;
		currentIndex = re.currentIndex;
	}
}

/** Insert a new track (that is NOT currently in queue) at index. */
function insertNewAt(index: number, track: Track) {
	queue = arrayInsertAt(queue, index, track);

	// Fix currentIndex if insertion occurs before/at current
	if (currentIndex != null && index <= currentIndex) {
		currentIndex += 1;
	}

	if (!shuffleEnabled) return;

	// When shuffle is enabled, new item should be eligible for future play.
	// We do not touch past history.
	ensureTraversalStateForCurrent();

	// We inserted at `index`, which shifts all existing indices >= index by +1.
	// Reindex traversal state for a conceptual move "fromIndex = -1" doesn't fit,
	// so perform a manual shift.
	const shiftUp = (i: number) => (i >= index ? i + 1 : i);

	history = history.map(shiftUp);
	order = order.map(shiftUp);

	// New track is at `index`. By default it should appear in the remaining order.
	// We'll append; specific actions (play/playNext) will inject it as needed.
	order = [...order, index];
}

/** --- Public queue actions --- */

export function clearQueue(): void {
	queue = [];
	currentIndex = null;

	order = [];
	history = [];
	cursor = 0;

	queueNotify();
}

export function removeTrack(id: number): void {
	const i = indexOfTrack(id);
	if (i === -1) return;
	removeAt(i);
	queueNotify();
}

/**
 * Reorder an existing queued track by id.
 *
 * Semantics depend on shuffle:
 * - Linear (shuffle off): reorder the underlying `queue` and adjust `currentIndex`.
 * - Shuffle (shuffle on): reorder the UPCOMING play order (the `order` list), not the
 *   underlying `queue`, because the UI is presenting a shuffled traversal that users
 *   expect to be able to rearrange.
 *
 * In shuffle mode this only affects tracks that are still in the future schedule
 * (`order`). Already-played history is left intact.
 *
 * Notes:
 * - `toIndex` is a queue index when shuffle is OFF (destination position in `queue`).
 * - `toIndex` is a queue index when shuffle is ON too, but it is interpreted as
 *   "place this track before the track currently at queue index `toIndex` in the
 *   UPCOMING order", i.e. it changes `order`, not `queue`.
 */
export function reorderTrackById(id: number, toIndex: number): void {
	const from = indexOfTrack(id);
	if (from === -1) return;

	const clampedTo = Math.max(
		0,
		Math.min(queue.length - 1, Math.floor(toIndex)),
	);

	// Shuffle: reorder upcoming traversal schedule, not underlying queue.
	if (shuffleEnabled) {
		// Ensure traversal state exists
		ensureTraversalStateForCurrent();

		// Only reorder within the future schedule (`order`).
		// If the dragged track isn't in `order`, it's either current, already played, or unscheduled.
		const fromPos = order.indexOf(from);
		if (fromPos === -1) {
			queueNotify();
			return;
		}

		// Determine insertion position relative to the target queue index within `order`.
		// If target isn't currently in `order`, we clamp to the end of the future schedule.
		let toPos = order.indexOf(clampedTo);
		if (toPos === -1) toPos = order.length;

		// Moving an item forward past itself needs an index adjustment after removal.
		const without = order.filter((i) => i !== from);
		if (toPos > fromPos) toPos = Math.max(0, toPos - 1);

		const nextPos = Math.max(0, Math.min(without.length, toPos));
		order = [...without.slice(0, nextPos), from, ...without.slice(nextPos)];

		queueNotify();
		return;
	}

	// Linear: reorder the underlying queue
	if (from === clampedTo) return;

	moveIndex(from, clampedTo);
	queueNotify();
}

/**
 * Core insertion behavior per your rules.
 *
 * - "jump": jump to an existing queued track (does not reorder)
 * - "play": move/insert to right-after-current and then skip to it
 * - "playNext": move/insert to right-after-current but don't skip
 * - "add": append (deduped)
 *
 * Dedupe semantics:
 * - If exists, we MOVE it instead of duplicating (except "jump", which never moves).
 */
export function insertTrack(track: Track, mode: InsertMode): void {
	// Normalize + basic guard
	if (!track || !Number.isFinite(track.id) || !track.src) return;

	// Clicking an already-queued song should MOVE PLAYHEAD to that queue position,
	// not reshuffle the queue around the current track.
	if (mode === "jump") {
		const i = indexOfTrack(track.id);
		if (i === -1) return;

		applyCurrentIndex(i);

		// In shuffle mode, make sure the future order is still valid relative to the
		// new cursor position (i.e., "next" should come from order after this jump).
		// We rebuild the remaining order while preserving already-played history.
		if (shuffleEnabled) {
			rebuildShuffleOrderPreservingPast();
		}

		queueNotify();
		return;
	}

	// If the user hits "Play" / "Play next" on the *currently playing* track,
	// treat it as a no-op. This avoids trying to move the current track to
	// "right after itself" and then skipping, which can produce confusing
	// queue changes (and in some cases corrupt traversal state).
	//
	// NOTE: "Add to queue" still dedupes below, but we early-return there too.
	if (currentIndex != null && queue[currentIndex]?.id === track.id) {
		queueNotify();
		return;
	}

	// Empty queue behavior
	if (queue.length === 0) {
		queue = [track];
		currentIndex = 0;

		// Initialize traversal state
		if (shuffleEnabled) {
			history = [0];
			cursor = 0;
			order = [];
		} else {
			history = [];
			cursor = 0;
			order = [];
		}

		if (mode === "add" || mode === "playNext") {
			// If user only adds, do not auto-start; but we still set currentIndex?
			// Per your desired behavior: "start the queue if it's empty" occurs on Play,
			// not necessarily on Add/PlayNext. We'll keep "currentIndex = 0" so it shows
			// a selected track, but GlobalPlayer should remain paused until user hits play.
			// NOTE: If you prefer currentIndex to remain null for add/playNext on empty,
			// we can tweak later.
		}

		// For play: skipping to inserted is effectively current track already.
		queueNotify();
		return;
	}

	// Determine insertion target: right after current, or at end if nothing selected.
	const base =
		currentIndex == null
			? -1
			: Math.max(-1, Math.min(currentIndex, queue.length - 1));
	const targetIndex = base + 1;

	const existingIndex = indexOfTrack(track.id);

	if (mode === "add") {
		if (existingIndex !== -1) return;
		insertNewAt(queue.length, track);
		// No traversal tweaks required
		queueNotify();
		return;
	}

	// play / playNext:
	if (existingIndex === -1) {
		// Insert as a new item at targetIndex
		insertNewAt(targetIndex, track);

		if (shuffleEnabled) {
			// Ensure it becomes next in shuffled traversal
			ensureTraversalStateForCurrent();
			order = injectNextIntoShuffleOrder({
				order,
				history,
				cursor,
				nextIndex: targetIndex,
			});
		}
	} else {
		// Move existing to targetIndex (account for removal shifting)
		// If existing is before target, the "real" target after removal shifts by -1
		const adjustedTarget =
			existingIndex < targetIndex ? targetIndex - 1 : targetIndex;

		if (existingIndex !== adjustedTarget) {
			moveIndex(existingIndex, adjustedTarget);
		}

		if (shuffleEnabled) {
			ensureTraversalStateForCurrent();
			order = injectNextIntoShuffleOrder({
				order,
				history,
				cursor,
				nextIndex: adjustedTarget,
			});
		}
	}

	if (mode === "play") {
		// Skip current -> go to that "next"
		next();
	} else {
		queueNotify();
	}
}

export function insertTracks(tracks: Track[], mode: InsertMode): void {
	const incoming = dedupeTracks(tracks).filter((t) => t?.src);

	if (incoming.length === 0) return;

	if (mode === "add") {
		// Append in order
		for (const t of incoming) insertTrack(t, "add");
		queueNotify();
		return;
	}

	// For play/playNext with multiple tracks:
	// Place them sequentially after current in the order provided.
	// - playNext: do not skip
	// - play: skip to first inserted (becomes next), but keep subsequent ones after it
	const base =
		queue.length === 0
			? -1
			: currentIndex == null
				? queue.length - 1
				: currentIndex;

	let insertPos = base + 1;

	for (const t of incoming) {
		const existing = indexOfTrack(t.id);
		if (existing === -1) {
			insertNewAt(insertPos, t);
			if (shuffleEnabled) {
				ensureTraversalStateForCurrent();
				order = injectNextIntoShuffleOrder({
					order,
					history,
					cursor,
					nextIndex: insertPos,
				});
			}
			insertPos += 1;
		} else {
			const adjustedTarget = existing < insertPos ? insertPos - 1 : insertPos;
			if (existing !== adjustedTarget) moveIndex(existing, adjustedTarget);
			if (shuffleEnabled) {
				ensureTraversalStateForCurrent();
				order = injectNextIntoShuffleOrder({
					order,
					history,
					cursor,
					nextIndex: adjustedTarget,
				});
			}
			insertPos += 1;
		}
	}

	if (mode === "play") {
		next();
	} else {
		queueNotify();
	}
}

/** --- Toggles / settings --- */

export function setVolume(v: number): void {
	volume = clamp01(v);
	queueNotify();
}

export function setUiOpen(open: boolean): void {
	uiOpen = !!open;
	queueNotify();
}

export function toggleUiOpen(): void {
	uiOpen = !uiOpen;
	queueNotify();
}

export function toggleWrap(): void {
	wrapEnabled = !wrapEnabled;
	queueNotify();
}

export function enableShuffle(enable: boolean): void {
	const nextVal = !!enable;
	if (shuffleEnabled === nextVal) return;

	shuffleEnabled = nextVal;

	if (!shuffleEnabled) {
		order = [];
		history = [];
		cursor = 0;
		queueNotify();
		return;
	}

	// Turning shuffle on: preserve current as starting history point
	if (currentIndex != null) {
		history = [currentIndex];
		cursor = 0;
	} else {
		history = [];
		cursor = 0;
	}

	rebuildShuffleOrderPreservingPast();
	queueNotify();
}

export function toggleShuffle(): void {
	enableShuffle(!shuffleEnabled);
}

/**
 * Ensure traversal state is sane if queue was externally replaced.
 * Not expected in normal usage, but handy if you implement "replace queue" later.
 */
export function setQueue(
	tracks: Track[],
	opts?: { startIndex?: number | null },
) {
	queue = dedupeTracks(tracks);
	currentIndex =
		opts?.startIndex == null
			? null
			: opts.startIndex >= 0 && opts.startIndex < queue.length
				? opts.startIndex
				: null;

	order = [];
	history = [];
	cursor = 0;

	if (shuffleEnabled) rebuildShuffleOrderPreservingPast();

	queueNotify();
}

/** --- Convenience wrappers that match UI wording --- */

export function play(track: Track): void {
	insertTrack(track, "play");
}

export function playNext(track: Track): void {
	insertTrack(track, "playNext");
}

export function addToQueue(track: Track): void {
	insertTrack(track, "add");
}

export function addAllToQueue(tracks: Track[]): void {
	insertTracks(tracks, "add");
}

export function playAllNext(tracks: Track[]): void {
	insertTracks(tracks, "playNext");
}

/**
 * Minimal "now playing" string helpers for UI.
 * Keeping here avoids repeated null checks in templates.
 */
export function nowPlayingLabel(): string {
	if (!currentTrack) return "Nothing playing";
	const t = currentTrack;
	const title = (t.title ?? "").trim() || "Unknown title";
	const artist = (t.artist ?? "").trim() || "Unknown Artist";
	return `${title} — ${artist}`;
}
