<script lang="ts">
	import { page } from '$app/state';
	import { resolve } from '$app/paths';
	import { invalidate } from '$app/navigation';
	import { toast } from 'svelte-sonner';
	import { CopyCheck, Fingerprint, GitMerge, SlidersHorizontal, Type } from '@lucide/svelte';

	import * as Card from '$lib/components/ui/card/index.js';
	import * as Empty from '$lib/components/ui/empty/index.js';
	import * as DropdownMenu from '$lib/components/ui/dropdown-menu/index.js';
	import { Badge } from '$lib/components/ui/badge/index.js';
	import { Button, buttonVariants } from '$lib/components/ui/button/index.js';
	import BookImage from '$lib/components/view/book-image.svelte';
	import GeneratedCover from '$lib/components/view/generated-cover.svelte';
	import { dismissDuplicateBooks } from '$lib/api/book.remote';
	import MergeBooks from '$lib/components/forms/merge-books/merge-books.svelte';
	import { mergeInto } from '$lib/components/forms/merge-books/merge';
	import type { Book, DuplicateBookGroup } from '$lib/schema';

	let { data }: { data: { groups: DuplicateBookGroup[]; books: Book[] } } = $props();

	// The group open in the workbench, or null when it is closed.
	let merging = $state<DuplicateBookGroup | null>(null);

	/** The full records behind a group, in the order the group lists them. */
	function recordsFor(group: DuplicateBookGroup): Book[] {
		return group.books
			.map(({ book_id }) => data.books.find((book) => book.id === book_id))
			.filter((book): book is Book => book !== undefined);
	}

	// Which groups have a request in flight — a dismissal or a quick merge — so a
	// slow round trip cannot be started twice.
	let dismissing = $state<number[]>([]);
	let quickMerging = $state<number[]>([]);

	/**
	 * Merge without opening the workbench, keeping the first book as it stands.
	 *
	 * The common case by far: the group is the same book twice, one record is as
	 * good as the other, and the point is to end up with one. Resolving metadata
	 * field by field is the other button.
	 */
	async function quickMerge(group: DuplicateBookGroup) {
		const key = keyOf(group);
		if (quickMerging.includes(key)) return;

		const [survivor, ...folded] = recordsFor(group);
		if (!survivor || folded.length === 0) return;

		quickMerging = [...quickMerging, key];

		try {
			await mergeInto(page.params.libraryId!, survivor, folded);
		} finally {
			quickMerging = quickMerging.filter((id) => id !== key);
		}
	}

	/** A group is named by its lowest book id, which the backend orders it by. */
	function keyOf(group: DuplicateBookGroup) {
		return group.books[0].book_id;
	}

	function reasonLabel(reason: string) {
		if (reason === 'identifier') return 'Same identifier';
		if (reason === 'title-author') return 'Same title and author';
		return reason;
	}

	/**
	 * Dismiss every pairing in a group at once.
	 *
	 * A group is held together pair by pair, so saying "these are not duplicates"
	 * about three books means saying it about all three pairs — dismissing only the
	 * first would leave the rest of the group standing and the screen unchanged.
	 */
	async function notDuplicates(group: DuplicateBookGroup) {
		const key = keyOf(group);
		if (dismissing.includes(key)) return;

		dismissing = [...dismissing, key];

		const ids = group.books.map((book) => book.book_id);
		const pairs = ids.flatMap((a, index) =>
			ids.slice(index + 1).map((b) => ({ book_a_id: a, book_b_id: b }))
		);

		try {
			await Promise.all(pairs.map((pair) => dismissDuplicateBooks(pair)));
			await invalidate('app:duplicate-books');
			toast.success('Marked as different books');
		} catch {
			toast.error('Could not mark these as different books');
		} finally {
			dismissing = dismissing.filter((id) => id !== key);
		}
	}
</script>

<!-- No max width and no padding of its own: the settings pane sets the width and
     is the thing that scrolls, so a wrapper that centres inside it only makes the
     cards narrower than they need to be. -->
<div class="flex flex-col gap-6 pb-4">
	<p class="text-sm text-muted-foreground">
		Books whose metadata matches. A second edition, a translation and a different scan of one book
		all look like this, so nothing here has been changed or removed — this is a list to read, not a
		problem to fix.
	</p>

	{#if data.groups.length === 0}
		<Empty.Root>
			<Empty.Header>
				<Empty.Media variant="icon">
					<CopyCheck />
				</Empty.Media>
				<Empty.Title>Nothing looks duplicated</Empty.Title>
				<Empty.Description>
					No two books in this library share an identifier, or a title and an author.
				</Empty.Description>
			</Empty.Header>
		</Empty.Root>
	{:else}
		{#each data.groups as group (keyOf(group))}
			{@const busy = dismissing.includes(keyOf(group)) || quickMerging.includes(keyOf(group))}
			{@const records = recordsFor(group)}
			<Card.Root>
				<Card.Header>
					<Card.Title>{group.books.length} books look like the same book</Card.Title>
					<Card.Action class="flex gap-2">
						<Button
							variant="outline"
							size="sm"
							disabled={busy}
							onclick={() => notDuplicates(group)}
						>
							Not duplicates
						</Button>
						<!-- A choice rather than a jump straight into the workbench:
						     most groups are one book twice, where there is nothing to
						     resolve and the dialog is a step in the way.

						     Disabled until every record in the group came back, so
						     neither action can run on a partial group. -->
						<DropdownMenu.Root>
							<DropdownMenu.Trigger
								class={buttonVariants({ size: 'sm' })}
								disabled={busy || records.length !== group.books.length}
							>
								Merge…
							</DropdownMenu.Trigger>
							<DropdownMenu.Content align="end">
								<DropdownMenu.Item onclick={() => quickMerge(group)}>
									<GitMerge class="size-4 shrink-0" />
									Merge into first selected
								</DropdownMenu.Item>
								<DropdownMenu.Item onclick={() => (merging = group)}>
									<SlidersHorizontal class="size-4 shrink-0" />
									Manual merge…
								</DropdownMenu.Item>
							</DropdownMenu.Content>
						</DropdownMenu.Root>
					</Card.Action>
				</Card.Header>

				<Card.Content>
					<ul class="flex flex-wrap gap-4">
						{#each group.books as book (book.book_id)}
							<li class="w-36">
								<a
									href={resolve('/(root)/(library)/book/[bookId]', {
										bookId: String(book.book_id)
									})}
									class="group flex flex-col gap-2"
								>
									<span
										class="block aspect-9/12 w-full overflow-hidden rounded-sm bg-muted shadow-md transition-all group-hover:brightness-75"
									>
										{#if book.cover_image}
											<BookImage src="/api/{book.cover_image}" class="h-full w-full object-cover" />
										{:else}
											<!-- A blank swatch here is worse than anywhere else: this screen
											     is a side-by-side comparison, and two of them are impossible
											     to tell apart. The candidate carries author names rather than
											     records, which is all the cover draws. -->
											<GeneratedCover
												book={{
													title: book.title,
													authors: book.authors.map((name) => ({ name }))
												}}
											/>
										{/if}
									</span>

									<span class="line-clamp-2 font-serif text-sm group-hover:underline">
										{book.title}
									</span>
								</a>

								<p class="line-clamp-2 text-xs text-muted-foreground">
									{book.authors.join(', ')}
								</p>

								<!-- Why this book is in the group, so the reader can judge the
								     evidence rather than take the grouping on trust. -->
								<p class="mt-1 flex flex-wrap gap-1">
									{#each book.matched_on as reason (reason)}
										<Badge variant="secondary" class="gap-1 text-[10px] font-normal">
											{#if reason === 'identifier'}
												<Fingerprint class="size-3" />
											{:else}
												<Type class="size-3" />
											{/if}
											{reasonLabel(reason)}
										</Badge>
									{/each}
								</p>
							</li>
						{/each}
					</ul>
				</Card.Content>
			</Card.Root>
		{/each}
	{/if}
</div>

<!--
	Keyed on the group so a second merge starts from that group's records rather
	than the previous one's draft, the same way the edit dialog keys on the book.
-->
{#if merging}
	{#key merging.books[0].book_id}
		<MergeBooks
			books={recordsFor(merging)}
			libraryId={page.params.libraryId!}
			bind:open={
				() => merging !== null,
				(value) => {
					// Bound, not passed as a bare `true`: the dialog closes itself on an
					// outside click or Escape, and if that never reaches `merging` the
					// group stays set — the menu item then re-assigns the same group,
					// nothing changes, and the workbench never reopens.
					if (!value) merging = null;
				}
			}
			onmerged={() => (merging = null)}
		/>
	{/key}
{/if}
