import { toast } from 'svelte-sonner';
import { batchEdit } from '$lib/services/batch';
import { invalidatePhotos } from '$lib/services/bulk';
import {
	addToHeap,
	approvePhoto,
	batchArchive,
	batchDelete,
	batchRestore,
	likePhoto,
	removeFromHeap,
	unlikePhoto,
	type PpAlbum
} from '$lib/services/photoprism';
import { queryClient } from '$lib/queryClient';
import { filters } from '$lib/stores/filters.svelte';
import { closePreview, openPreview, preview } from '$lib/stores/preview.svelte';
import {
	clearSelection,
	focusAfter,
	indexOf,
	selectRange,
	selection,
	setAnchor,
	setFocused,
	toggle
} from '$lib/stores/selection.svelte';
import { popAndRun, push as pushUndo } from '$lib/stores/undo.svelte';
import { toggleLeftSidebar, toggleRightSidebar } from '$lib/stores/view.svelte';
import type { PpPhoto } from '$lib/types/photoprism';

/**
 * Optional parameters the host passes via `use:gridKeyNav={...}`.
 *
 * - `scrollToIndex`: invoked when the action's own arrow-nav lands on a
 *   tile that's currently windowed-out of the DOM. The host expands its
 *   render window and scrolls the now-mounted shell into view.
 * - `onArrow`: when provided, the action delegates ALL arrow keys to the
 *   host instead of computing moves itself. Required for grids with
 *   interleaved non-tile rows (e.g. month headers): linear +/-cols math
 *   skips wrong because the column count of header rows is 1 (full-span),
 *   not the tile column count. The host owns the visual-row map and
 *   handles the (row, col) translation. Mirrors mule-image's
 *   `useGridKeyNav` pattern.
 */
export type ArrowKey = 'ArrowLeft' | 'ArrowRight' | 'ArrowUp' | 'ArrowDown';

export interface GridKeyNavParams {
	scrollToIndex?: (i: number) => void;
	onArrow?: (key: ArrowKey, extending: boolean) => void;
}

/**
 * Svelte `action` for the timeline grid. Owns:
 *  - Arrow-key focus navigation (with shift-extend) inside the visible grid
 *  - Click + shift/ctrl click selection mutations
 *  - Window-level shortcuts mirroring mule-image's keyboard layer:
 *    x archive-toggle, u restore, f favorite-toggle, s + (1–9) add to
 *    heap N (bare s adds to the currently-viewed heap), b/Tab toggles
 *    left sidebar, i toggles right sidebar, space/enter opens preview,
 *    esc clears, ⌘Z undoes, ⌘A selects all visible.
 *  Rating + color labels are mouse-driven via the metadata sidebar — no
 *  keyboard shortcuts.
 *
 * Archive / restore target a synthesized "cull target list" — in priority:
 *   1. preview overlay uid (when open) — applies to the visible preview
 *      photo even if the grid still shows a stale selection
 *   2. multi-selection set
 *   3. focused tile
 */
export function gridKeyNav(node: HTMLElement, params: GridKeyNavParams = {}) {
	let scrollToIndex = params.scrollToIndex;
	let onArrow = params.onArrow;

	/** Cached column count for the visible grid. Read from CSS
	 * (`grid-template-columns` resolves to a space-separated list of px
	 * sizes), invalidated by a `ResizeObserver` on the grid host. This
	 * keeps the read DOM-cheap regardless of how many tiles are mounted —
	 * critical once windowing renders only a slice of the order. */
	let cachedCols: number | null = null;
	let gridEl: HTMLElement | null = null;

	function findGrid(): HTMLElement | null {
		// The `[role="group"][aria-label="Photos"]` or simply the first
		// element whose computed grid-template-columns has >1 track. The
		// timeline grid sits inside `node` (the action target = <main>).
		if (gridEl && node.contains(gridEl)) return gridEl;
		const candidate = node.querySelector<HTMLElement>('[data-photo-grid]');
		if (candidate) {
			gridEl = candidate;
			return candidate;
		}
		// Fallback: the first descendant that's display: grid with ≥2 cols.
		// Avoids a hard coupling on the data-attribute in case the host
		// hasn't tagged it yet.
		for (const el of node.querySelectorAll<HTMLElement>('*')) {
			const cs = getComputedStyle(el);
			if (cs.display === 'grid' && cs.gridTemplateColumns.split(' ').length > 1) {
				gridEl = el;
				return el;
			}
		}
		return null;
	}

	function tilesPerRow(): number {
		if (cachedCols !== null) return cachedCols;
		const grid = findGrid();
		if (!grid) return 1;
		const cols = getComputedStyle(grid).gridTemplateColumns.split(' ').filter(Boolean).length;
		cachedCols = Math.max(1, cols);
		return cachedCols;
	}

	const ro = new ResizeObserver(() => {
		// Container width changed → column count likely changed too.
		// Cheaper to invalidate than to recompute; tilesPerRow recomputes
		// on next access (which is per keystroke at most).
		cachedCols = null;
	});
	ro.observe(node);

	function focusedIndex(): number {
		return indexOf(selection.focused);
	}

	/** Move the focus cursor by `delta` tiles. When the move is NOT a
	 * shift-extension, the anchor is bumped to the new focused tile so the
	 * next shift-click/arrow starts from the user's current cursor (the
	 * "starting photo") instead of a stale toggle/selectOnly anchor.
	 *
	 * Scroll-into-view tries the direct DOM lookup first (works pre-
	 * windowing AND post-windowing for tiles already in the visible
	 * window); if the tile isn't rendered (windowed out), defer to the
	 * host-provided `scrollToIndex` which expands the window. */
	function moveFocus(delta: number, extending: boolean) {
		if (selection.order.length === 0) return;
		const cur = focusedIndex();
		const next =
			cur < 0
				? delta > 0
					? 0
					: selection.order.length - 1
				: Math.min(Math.max(0, cur + delta), selection.order.length - 1);
		const nextUid = selection.order[next];
		setFocused(nextUid);
		if (!extending) setAnchor(nextUid);
		const tile = node.querySelector<HTMLElement>(`[data-uid="${nextUid}"]`);
		if (tile) {
			tile.scrollIntoView({ block: 'nearest', inline: 'nearest' });
		} else {
			scrollToIndex?.(next);
		}
	}

	/** Synthesize a target list. Preview wins, then multi, then focused. */
	function cullTargets(): string[] {
		if (preview.uid) return [preview.uid];
		if (selection.ids.size > 0) return Array.from(selection.ids);
		if (selection.focused) return [selection.focused];
		return [];
	}

	/** Look up a photo's current cached state without forcing a refetch.
	 * Walks every `['photos', …]` cache entry first, then the per-photo
	 * cache. Lets `x` decide "archive vs restore" based on the actual current
	 * state instead of always sending Archived=true.
	 *
	 * The `['photos', …]` namespace holds two shapes: a flat `PpPhoto[]`
	 * (e.g. ratings/colors pools) and TanStack's `InfiniteData` envelope
	 * (`{pages: PpPhoto[][], pageParams}`) used by the timeline's infinite
	 * scroll. Walk both — assuming a flat array on the timeline cache used
	 * to throw `list.find is not a function` and abort the F/X handlers. */
	function cachedPhoto(uid: string): PpPhoto | undefined {
		const lists = queryClient.getQueriesData({ queryKey: ['photos'] });
		for (const [, data] of lists) {
			if (!data) continue;
			if (Array.isArray(data)) {
				const hit = (data as PpPhoto[]).find((p) => p.UID === uid);
				if (hit) return hit;
				continue;
			}
			const pages = (data as { pages?: PpPhoto[][] }).pages;
			if (!Array.isArray(pages)) continue;
			for (const page of pages) {
				const hit = page?.find?.((p) => p.UID === uid);
				if (hit) return hit;
			}
		}
		return queryClient.getQueryData<PpPhoto>(['photo', uid]);
	}

	async function toggleArchive(direction: 'archive' | 'restore' | 'toggle') {
		const ids = cullTargets();
		if (ids.length === 0) {
			const verb = direction === 'restore' ? 'restore' : 'archive';
			toast.message(`Nothing to ${verb}`, {
				description: 'Click a photo or select some first'
			});
			return;
		}

		let target: boolean;
		if (direction === 'archive') target = true;
		else if (direction === 'restore') target = false;
		else {
			const first = cachedPhoto(ids[0]);
			target = !(first?.Archived ?? false);
		}

		// PhotoPrism's photo PUT silently drops the Archived field — the
		// only working path is /api/v1/batch/photos/{archive,restore}. The
		// previous patchTargets call PUT'd `{Archived: true}` and got a 200
		// back, so the toast fired but nothing moved.
		try {
			if (target) await batchArchive(ids);
			else await batchRestore(ids);
		} catch (err) {
			toast.error(err instanceof Error ? err.message : 'Archive failed');
			return;
		}
		// Move focus forward before the photos query refetches, so the
		// user can keep X-ing through the timeline without their cursor
		// snapping back to photo[0]. Walks past every uid we just
		// archived/restored — relevant when the cull targets came from a
		// multi-selection rather than the single focused tile.
		focusAfter(ids);
		invalidatePhotos(ids);
		const label = target ? `Archived ${ids.length}` : `Restored ${ids.length}`;
		toast.success(label);
		pushUndo(label, async () => {
			if (target) await batchRestore(ids);
			else await batchArchive(ids);
			invalidatePhotos(ids);
		});
	}

	/** Permanently delete cull targets — only callable from the archive
	 *  section (X is rerouted away from archive-toggle there). PhotoPrism
	 *  rejects deletion of un-archived photos with a 4xx, so the section
	 *  gate doubles as a safety guard against accidental deletes from the
	 *  main timeline. Confirm dialog is mandatory — no undo path exists. */
	async function deleteCullTargets() {
		const ids = cullTargets();
		if (ids.length === 0) {
			toast.message('Nothing to delete', {
				description: 'Click a photo or select some first'
			});
			return;
		}
		const msg =
			ids.length === 1
				? 'Permanently delete this photo? This cannot be undone.'
				: `Permanently delete ${ids.length} photos? This cannot be undone.`;
		if (!confirm(msg)) return;
		try {
			await batchDelete(ids);
		} catch (err) {
			toast.error(err instanceof Error ? err.message : 'Delete failed');
			return;
		}
		invalidatePhotos(ids);
		toast.success(`Deleted ${ids.length}`);
	}

	/** Approve cull targets — clears them out of the review pile by
	 *  bumping each photo's quality score above PhotoPrism's review
	 *  threshold. The op is one-way (no /unapprove route), so we don't
	 *  push an undo entry: a re-keyed S would just be a no-op on
	 *  already-approved photos. */
	async function approveCullTargets() {
		const ids = cullTargets();
		if (ids.length === 0) {
			toast.message('Nothing to keep', {
				description: 'Click a photo or select some first'
			});
			return;
		}
		const { updated, errors } = await batchEdit(ids, (id) => approvePhoto(id));
		invalidatePhotos(ids);
		if (errors.length) {
			toast.error(`Kept ${updated.length}; ${errors.length} failed`, {
				description: errors[0].message
			});
			return;
		}
		toast.success(`Kept ${ids.length}`);
	}

	/** Flip the Favorite (heart) flag on cull targets. Reads the first
	 * target's cached `Favorite` to decide direction so a mixed selection
	 * resolves to "make them all favorited" when the first isn't, mirroring
	 * the way `toggleArchive('toggle')` works. */
	async function toggleFavoriteOnTargets() {
		const ids = cullTargets();
		if (ids.length === 0) {
			toast.message('Nothing to favorite', {
				description: 'Click a photo or select some first'
			});
			return;
		}
		const first = cachedPhoto(ids[0]);
		const next = !(first?.Favorite ?? false);
		const { updated, errors } = await batchEdit(ids, (id) =>
			next ? likePhoto(id) : unlikePhoto(id)
		);
		void queryClient.invalidateQueries({ queryKey: ['photos'] });
		for (const id of ids) {
			void queryClient.invalidateQueries({ queryKey: ['photo', id] });
		}
		const verb = next ? 'Favorited' : 'Unfavorited';
		if (errors.length) {
			// Surface the actual first error message — silent failures here are
			// the #1 reason `f` "doesn't work" (e.g. permission, network, 404).
			toast.error(`${verb} ${updated.length}; ${errors.length} failed`, {
				description: errors[0].message
			});
			return;
		}
		toast.success(`${verb} ${ids.length}`);
		pushUndo(`${verb} ${ids.length}`, async () => {
			await batchEdit(ids, (id) => (next ? unlikePhoto(id) : likePhoto(id)));
			void queryClient.invalidateQueries({ queryKey: ['photos'] });
			for (const id of ids) {
				void queryClient.invalidateQueries({ queryKey: ['photo', id] });
			}
		});
	}

	// ── S chord (add-to-heap) ────────────────────────────────────────────
	// Press S: arm a short timer. A digit 1–9 within the window adds the
	// cull targets to the Nth heap in the heap list. Any other key cancels
	// the chord without firing. On timeout, fall back to the currently-
	// viewed heap (i.e. when section==='heap'); otherwise show a hint toast.
	let sChordTimer: number | null = null;
	const S_CHORD_MS = 500;

	function clearSChord() {
		if (sChordTimer !== null) {
			window.clearTimeout(sChordTimer);
			sChordTimer = null;
		}
	}

	async function addCullTargetsToHeap(heap: PpAlbum) {
		const ids = cullTargets();
		if (ids.length === 0) {
			toast.message('Nothing to add', {
				description: 'Click a photo or select some first'
			});
			return;
		}
		try {
			const { added } = await addToHeap(heap.UID, ids);
			void queryClient.invalidateQueries({ queryKey: ['heaps'] });
			void queryClient.invalidateQueries({ queryKey: ['photos'] });
			// PhotoPrism returns 200 even when nothing was added — distinguish
			// "really added N" from "skipped all N" so the toast tells the
			// truth.
			if (added.length === 0) {
				toast.error(`Nothing added to ${heap.Title}`, {
					description: `PhotoPrism rejected all ${ids.length} UIDs (already in heap, or not indexed).`
				});
				return;
			}
			if (added.length < ids.length) {
				toast.success(`Added ${added.length}/${ids.length} → ${heap.Title}`, {
					description: 'The rest were already in this heap.'
				});
			} else {
				toast.success(`Added ${added.length} → ${heap.Title}`);
			}
			pushUndo(`Added ${added.length} to ${heap.Title}`, async () => {
				await removeFromHeap(heap.UID, added);
				void queryClient.invalidateQueries({ queryKey: ['heaps'] });
				void queryClient.invalidateQueries({ queryKey: ['photos'] });
			});
		} catch (err) {
			toast.error(err instanceof Error ? err.message : 'Add-to-heap failed');
		}
	}

	async function addCullTargetsToHeapByIndex(idx: number) {
		const heaps = queryClient.getQueryData<PpAlbum[]>(['heaps']) ?? [];
		if (idx < 1 || idx > heaps.length) {
			toast.message(`No heap #${idx}`);
			return;
		}
		await addCullTargetsToHeap(heaps[idx - 1]);
	}

	async function addCullTargetsToActiveHeap() {
		if (filters.section !== 'heap' || !filters.heapUid) {
			toast.message('Press S then 1–9 to pick a heap');
			return;
		}
		const heaps = queryClient.getQueryData<PpAlbum[]>(['heaps']) ?? [];
		const heap = heaps.find((h) => h.UID === filters.heapUid);
		if (!heap) {
			toast.message('Active heap not found');
			return;
		}
		await addCullTargetsToHeap(heap);
	}

	function openPreviewFromGrid() {
		const id = selection.focused ?? selection.order[0];
		if (!id) return;
		openPreview(id, selection.order);
	}

	function togglePreview() {
		if (preview.uid) closePreview();
		else openPreviewFromGrid();
	}

	async function onKey(e: KeyboardEvent) {
		// Don't hijack typing inside form fields.
		const tag = (e.target as HTMLElement | null)?.tagName?.toLowerCase();
		if (tag === 'input' || tag === 'textarea' || tag === 'select') return;

		// S+digit chord. A digit 1–9 within the chord window consumes the key
		// and fires add-to-heap-N. Any other key cancels the chord without
		// firing the default active-heap action — the user switched intent —
		// and falls through to normal handling for that key.
		if (sChordTimer !== null) {
			if (/^[1-9]$/.test(e.key)) {
				e.preventDefault();
				clearSChord();
				void addCullTargetsToHeapByIndex(parseInt(e.key, 10));
				return;
			}
			clearSChord();
		}

		const meta = e.metaKey || e.ctrlKey;
		const shift = e.shiftKey;
		const inPreview = preview.uid !== null;

		// ── Grid-only nav keys (preview owns its own Arrow/Esc) ──────────────
		if (!inPreview) {
			switch (e.key) {
				case 'ArrowLeft':
				case 'ArrowRight':
				case 'ArrowUp':
				case 'ArrowDown':
					e.preventDefault();
					if (onArrow) {
						// Host owns the visual-row map (needed for grids with
						// interleaved headers). The host calls setFocused +
						// scrollToIndex + selectRange-on-shift itself.
						onArrow(e.key, shift);
					} else {
						const delta =
							e.key === 'ArrowLeft'
								? -1
								: e.key === 'ArrowRight'
									? 1
									: e.key === 'ArrowUp'
										? -tilesPerRow()
										: tilesPerRow();
						moveFocus(delta, shift);
						if (shift && selection.focused) selectRange(selection.focused);
					}
					return;
				case 'Escape':
					clearSelection();
					setFocused(null);
					return;
			}
		}

		// ── Mode-aware shortcuts (work in grid AND preview) ──────────────────
		switch (e.key) {
			case ' ':
			case 'Enter':
				e.preventDefault();
				togglePreview();
				return;
			case 'Tab':
				// Tab in the grid context = mule-image's left-sidebar toggle.
				// Browsers reserve Tab for focus traversal — preventDefault
				// here is fine because the grid owns this surface.
				e.preventDefault();
				toggleLeftSidebar();
				return;
			case 'i':
			case 'I':
				if (!meta && !shift && !inPreview) {
					e.preventDefault();
					toggleRightSidebar();
				}
				return;
			case 'b':
			case 'B':
				if (!meta && !shift) {
					e.preventDefault();
					toggleLeftSidebar();
				}
				return;
			case 'z':
			case 'Z':
				if (meta) {
					e.preventDefault();
					const entry = await popAndRun();
					if (entry) toast.success(`Undone: ${entry.label}`);
					else toast.message('Nothing to undo');
				}
				return;
			case 'a':
			case 'A':
				if (meta && !inPreview) {
					e.preventDefault();
					for (const id of selection.order) selection.ids.add(id);
				}
				return;
			case 'x':
			case 'X':
				if (meta || shift) return;
				e.preventDefault();
				// Archive section: X becomes permanent delete (Keep/Delete
				// is the binary flow there, mirroring Review's Keep/Archive).
				// Everywhere else X toggles archive on the cull targets.
				if (filters.section === 'archive') {
					void deleteCullTargets();
					return;
				}
				void toggleArchive('toggle');
				return;
			case 'u':
			case 'U':
				if (meta || shift) return;
				e.preventDefault();
				void toggleArchive('restore');
				return;
			case 'f':
			case 'F':
				if (meta || shift) return;
				e.preventDefault();
				void toggleFavoriteOnTargets();
				return;
			case 's':
			case 'S':
				if (meta || shift) return;
				e.preventDefault();
				// Review section repurposes S as the Keep affordance —
				// matches the BulkActionBar button and keeps the binary
				// Keep/Archive flow on home-row keys (S/X). The heap chord
				// is meaningless here anyway (review photos can't sensibly
				// be filed before they're approved).
				if (filters.section === 'review') {
					void approveCullTargets();
					return;
				}
				// Archive section: S = Keep = restore back to the timeline
				// (inverse of Delete on X). Same rationale as review —
				// heap-filing an archived photo isn't a flow that fits the
				// section's intent.
				if (filters.section === 'archive') {
					void toggleArchive('restore');
					return;
				}
				// Arm the chord. A digit 1–9 within S_CHORD_MS picks heap N;
				// otherwise we fall back to the currently-viewed heap.
				clearSChord();
				sChordTimer = window.setTimeout(() => {
					sChordTimer = null;
					void addCullTargetsToActiveHeap();
				}, S_CHORD_MS);
				return;
		}
	}

	function onClick(e: MouseEvent) {
		const tile = (e.target as HTMLElement | null)?.closest<HTMLElement>('[data-tile]');
		if (!tile) return;
		const uid = tile.dataset.uid;
		if (!uid) return;
		// Modifier clicks are the only paths this document-level handler
		// owns. Plain clicks bubble to the tile button's onclick, which
		// reduces selection to just that tile.
		if (e.shiftKey) {
			e.preventDefault();
			selectRange(uid);
			setFocused(uid);
		} else if (e.metaKey || e.ctrlKey) {
			e.preventDefault();
			toggle(uid);
			setFocused(uid);
		}
	}

	node.addEventListener('click', onClick);
	// Keydown lives on the window so arrow keys, Esc, ⌘Z, etc. work
	// immediately on page load regardless of which element holds focus.
	// The filters inside `onKey` keep form-field typing and preview mode
	// safe (preview owns its own Arrow/Esc).
	window.addEventListener('keydown', onKey);

	return {
		update(next: GridKeyNavParams = {}) {
			scrollToIndex = next.scrollToIndex;
			onArrow = next.onArrow;
		},
		destroy() {
			clearSChord();
			node.removeEventListener('click', onClick);
			window.removeEventListener('keydown', onKey);
			ro.disconnect();
		}
	};
}
