<!--
	One duplicate stack rendered as a card. Each variant file is a clickable
	tile; clicking selects it as the candidate "best". Committing promotes
	the selected file to Primary (via `setPrimary`) and deletes the rest from
	the stack (via `deleteFile` — PhotoPrism's flat `DELETE /photos/:uid/
	files/:fid` route).

	Why DELETE instead of unstack-then-archive (which the plan started with):
	PhotoPrism's `/unstack` returns `only originals can be unstacked` for
	sidecar JPGs and `Changes could not be saved` for live-photo HEIC+MOV
	pairs. DELETE works for all of them — and cascades through the live-
	photo group automatically, so one click resolves the whole stack. The
	on-disk file is renamed with a hash suffix (not erased), so a future
	manual reindex can recover it if needed.

	Keyboard:
	- Section is tabindex=0; focusing it captures arrow keys + Enter.
	- Left/Right move the "best" highlight one file; Up/Down move by the
	  grid's computed column count (same trick the timeline uses for
	  cross-row arrow nav).
	- Enter commits the current selection. Esc removes focus from the card.
	- The page's first card auto-focuses on mount so the user can drive
	  the workflow keyboard-first.
-->
<script lang="ts">
	import { useQueryClient } from '@tanstack/svelte-query';
	import { toast } from 'svelte-sonner';
	import { deleteFile, setPrimary } from '$lib/services/photoprism';
	import { thumbUrl } from '$lib/stores/session.svelte';
	import type { DuplicateGroup } from '$lib/services/adapters/duplicates';
	import { view } from '$lib/stores/view.svelte';

	interface Props {
		group: DuplicateGroup;
		/** When true, the section auto-focuses on mount so the user can
		 *  arrow-key/Enter the workflow without reaching for the mouse.
		 *  Only the page's first card should get this. */
		autoFocus?: boolean;
	}
	let { group, autoFocus = false }: Props = $props();

	const qc = useQueryClient();
	let best = $state('');
	let busy = $state(false);
	let sectionEl: HTMLElement | undefined = $state();
	let gridEl: HTMLElement | undefined = $state();
	let cols = $state(1);

	$effect(() => {
		// Seed / re-seed `best` from the prop when the underlying group
		// changes (keyed each + UID key normally keeps this stable, but
		// the guard handles prop swaps without overwriting user clicks).
		if (!best || !group.files.some((f) => f.UID === best)) {
			best = group.bestFileUid;
		}
	});

	$effect(() => {
		if (autoFocus && sectionEl) sectionEl.focus({ preventScroll: true });
	});

	// Track the grid's column count via ResizeObserver — same approach
	// the timeline uses. Reading `gridTemplateColumns` from computed
	// style is O(1) regardless of how many tiles render.
	$effect(() => {
		if (!gridEl) return;
		const measure = () => {
			if (!gridEl) return;
			const n = getComputedStyle(gridEl)
				.gridTemplateColumns.split(' ')
				.filter(Boolean).length;
			cols = Math.max(1, n);
		};
		measure();
		const ro = new ResizeObserver(measure);
		ro.observe(gridEl);
		return () => ro.disconnect();
	});
	// thumbnailSize changes alter cols without resizing the grid; re-
	// measure on the next microtask.
	$effect(() => {
		void view.thumbnailSize;
		queueMicrotask(() => {
			if (!gridEl) return;
			const n = getComputedStyle(gridEl)
				.gridTemplateColumns.split(' ')
				.filter(Boolean).length;
			cols = Math.max(1, n);
		});
	});

	function shortPath(name: string): string {
		const segs = name.split('/').filter(Boolean);
		if (segs.length <= 2) return name;
		return '…/' + segs.slice(-2).join('/');
	}

	function dims(f: { Width?: number; Height?: number }): string {
		if (!f.Width || !f.Height) return '';
		return `${f.Width}×${f.Height}`;
	}

	function sizeLabel(bytes?: number): string {
		if (!bytes) return '';
		if (bytes > 1_000_000) return `${(bytes / 1_000_000).toFixed(1)} MB`;
		return `${Math.round(bytes / 1024)} KB`;
	}

	function moveBest(delta: number) {
		const i = group.files.findIndex((f) => f.UID === best);
		if (i < 0) return;
		const next = Math.min(Math.max(0, i + delta), group.files.length - 1);
		best = group.files[next].UID;
	}

	function onKeydown(e: KeyboardEvent) {
		if (busy) return;
		switch (e.key) {
			case 'ArrowLeft':
				e.preventDefault();
				moveBest(-1);
				return;
			case 'ArrowRight':
				e.preventDefault();
				moveBest(1);
				return;
			case 'ArrowUp':
				e.preventDefault();
				moveBest(-cols);
				return;
			case 'ArrowDown':
				e.preventDefault();
				moveBest(cols);
				return;
			case 'Enter':
				e.preventDefault();
				void commit();
				return;
			case 'Escape':
				(e.target as HTMLElement)?.blur();
				return;
		}
	}

	async function commit() {
		if (busy || group.files.length < 2) return;
		busy = true;
		const photoUid = group.photo.UID;
		const losers = group.files.filter((f) => f.UID !== best);
		try {
			// 1. Promote the user's pick to Primary first (idempotent — if
			//    it's already Primary, the call is a no-op on the server).
			const currentPrimary = group.files.find((f) => f.Primary)?.UID;
			if (best !== currentPrimary) {
				await setPrimary(photoUid, best);
			}
			// 2. Delete each non-best file. PhotoPrism cascades through
			//    related variants in the same logical group (live-photo
			//    pairs, sidecar companions), so a single DELETE on one
			//    HEIC variant clears the whole HEIC+MOV pair in one go.
			//    Loop tolerates partial success — if PhotoPrism already
			//    cleared the file via cascade, the next DELETE 404s and
			//    we move on.
			for (const f of losers) {
				try {
					await deleteFile(photoUid, f.UID);
				} catch (err) {
					// 404 means the file's already gone (cascade) — fine.
					// Any other status means we have a real problem; bubble it.
					const status = (err as { response?: { status?: number } })?.response
						?.status;
					if (status !== 404) throw err;
				}
			}
			toast.success(`Resolved · kept 1 of ${group.files.length}`);
			void qc.invalidateQueries({ queryKey: ['duplicates'] });
			void qc.invalidateQueries({ queryKey: ['photos'] });
		} catch (err) {
			const msg =
				err instanceof Error && err.message ? err.message : 'Resolve failed';
			toast.error(msg);
		} finally {
			busy = false;
		}
	}
</script>

<!-- Section is focusable so we can capture arrow keys + Enter. `outline-
     none` because we paint our own focus ring on .focus-visible below
     (otherwise the browser default outline would clash with the tile
     selection ring). -->
<!--
	`role="application"` declares this as a custom keyboard widget (arrow
	keys + Enter, not standard reading order). The element below is a
	`<div>` rather than `<section>` because Svelte's a11y linter treats
	`<section>` as strictly non-interactive even with an explicit
	application role.
-->
<!-- svelte-ignore a11y_no_noninteractive_tabindex -->
<!-- svelte-ignore a11y_no_noninteractive_element_interactions -->
<div
	bind:this={sectionEl}
	tabindex="0"
	role="application"
	aria-label={`Duplicate stack of ${group.files.length} files — arrow keys pick the file to keep, Enter resolves`}
	onkeydown={onKeydown}
	class="space-y-2 rounded-md border border-border bg-card/30 p-3 outline-none
	       focus-visible:ring-2 focus-visible:ring-primary/50"
>
	<header class="flex items-center justify-between gap-3">
		<div class="min-w-0">
			<div class="text-sm font-medium text-foreground">
				{group.files.length} files in this stack
			</div>
			<div class="truncate text-xs text-muted-foreground">
				{group.photo.OriginalName ?? group.photo.FileName ?? group.photo.Name ?? ''}
			</div>
		</div>
		<button
			type="button"
			class="inline-flex shrink-0 items-center gap-1.5 rounded-md border border-border px-3 py-1.5 text-xs hover:bg-accent disabled:opacity-50"
			disabled={busy || group.files.length < 2}
			onclick={commit}
			title="Promote the selected file and delete the rest from this stack"
		>
			Keep selected, delete rest
			<kbd
				class="rounded bg-muted px-1 py-0.5 text-[9px] font-medium text-muted-foreground"
				>Enter</kbd
			>
		</button>
	</header>

	<div
		bind:this={gridEl}
		class="grid gap-2"
		style="grid-template-columns: repeat(auto-fill, minmax({view.thumbnailSize}px, 1fr));"
	>
		{#each group.files as file (file.UID)}
			{@const isBest = file.UID === best}
			{@const sizeStr = sizeLabel(file.Size)}
			<button
				type="button"
				onclick={() => (best = file.UID)}
				class:scale-95={isBest}
				class:ring-2={isBest}
				class:ring-blue-500={isBest}
				class:ring-offset-2={isBest}
				class:ring-offset-background={isBest}
				class:transition-[transform,box-shadow]={isBest}
				class:duration-300={isBest}
				class:ease-[cubic-bezier(0.34,1.56,0.64,1)]={isBest}
				class="group flex flex-col overflow-hidden rounded-md border border-border bg-secondary p-0 text-left outline-none focus:outline-none"
			>
				<div class="relative aspect-square w-full overflow-hidden">
					<img
						src={thumbUrl(file.Hash, 'tile_500')}
						alt={file.Name}
						loading="lazy"
						class="h-full w-full object-cover"
					/>
					{#if isBest}
						<span
							class="absolute left-1.5 top-1.5 rounded bg-blue-500 px-1.5 py-0.5 text-[10px] font-semibold text-white"
						>
							Best
						</span>
					{/if}
					{#if dims(file)}
						<span
							class="absolute right-1.5 top-1.5 rounded bg-background/80 px-1.5 py-0.5 text-[10px] text-foreground"
						>
							{dims(file)}
						</span>
					{/if}
				</div>
				<div
					class="space-y-0.5 px-2 py-1.5 text-[10px] leading-tight text-muted-foreground"
					title={`${file.Name}${sizeStr ? ` · ${sizeStr}` : ''}`}
				>
					<div class="truncate text-foreground/90">{shortPath(file.Name)}</div>
					{#if sizeStr}
						<div>{sizeStr}</div>
					{/if}
				</div>
			</button>
		{/each}
	</div>
</div>
