<script lang="ts">
	import { page } from '$app/state';
	import { resolve } from '$app/paths';
	import { onDestroy } from 'svelte';
	import { toast } from 'svelte-sonner';
	import { BookOpen, Loader2, TriangleAlert, Upload } from '@lucide/svelte';

	import * as Card from '$lib/components/ui/card/index.js';
	import * as Field from '$lib/components/ui/field/index.js';
	import * as Table from '$lib/components/ui/table/index.js';
	import { Button } from '$lib/components/ui/button/index.js';
	import { Input } from '$lib/components/ui/input/index.js';
	import { Checkbox } from '$lib/components/ui/checkbox/index.js';
	import { Progress } from '$lib/components/ui/progress/index.js';
	import { cancelCalibreImport, getCalibreImport } from '$lib/api/calibre-import.remote';
	import { formatFileSize } from '$lib/utils';
	import type { CalibreImport } from '$lib/schema/library';

	const libraryId = $derived(page.params.libraryId!);

	let archive = $state<File | null>(null);
	let allowDuplicates = $state(false);

	let job = $state<CalibreImport | null>(null);

	let busy = $state(false);
	let problem = $state<string | null>(null);

	/** How much of the archive has reached the server, 0–1, while it is going up. */
	let uploaded = $state<number | null>(null);

	/** How often a running import is asked where it has got to. */
	const POLL_MS = 1000;
	let poll: ReturnType<typeof setInterval> | undefined;

	const running = $derived(job?.state === 'running');

	const percent = $derived(
		job && job.total > 0 ? Math.round((job.processed / job.total) * 100) : 0
	);

	function stopPolling() {
		clearInterval(poll);
		poll = undefined;
	}

	onDestroy(stopPolling);

	function pick(event: Event) {
		const input = event.currentTarget as HTMLInputElement;
		archive = input.files?.[0] ?? null;
		problem = null;
	}

	/**
	 * The backend's own message for a failed proxied response.
	 *
	 * The proxy wraps the upstream body in SvelteKit's error envelope, so the useful
	 * `detail` is one or two layers down.
	 */
	function detailOf(body: string): string {
		try {
			const parsed = JSON.parse(body);

			if (typeof parsed?.detail === 'string') return parsed.detail;
			if (typeof parsed?.message === 'string') return detailOf(parsed.message);
		} catch {
			// Not JSON — the raw text is the best there is.
		}

		return body;
	}

	/**
	 * Follow a running import until it lands.
	 *
	 * Polled rather than pushed: the job lives on the server, so this survives a reload
	 * and does not depend on the tab that started it staying open.
	 */
	function follow(jobId: string) {
		stopPolling();

		poll = setInterval(async () => {
			try {
				job = await getCalibreImport(jobId);
			} catch (error) {
				problem = error instanceof Error ? error.message : 'Lost track of the import';
				stopPolling();
				return;
			}

			if (job.state !== 'running') {
				stopPolling();
				announce(job);
			}
		}, POLL_MS);
	}

	function announce(finished: CalibreImport) {
		const created = `${finished.created} book${finished.created === 1 ? '' : 's'} imported`;

		if (finished.state === 'failed') toast.error(finished.error ?? 'The import failed');
		else if (finished.state === 'cancelled') toast.info(`Import stopped — ${created}`);
		else if (finished.failed > 0) toast.warning(`${created}, ${finished.failed} failed`);
		else toast.success(created);
	}

	/**
	 * Send the archive and start the import it becomes.
	 *
	 * `XMLHttpRequest` rather than `fetch` for the one thing fetch cannot do: report how
	 * much of the body has gone up. On a large archive that is the only progress there is
	 * for minutes at a time.
	 *
	 * It goes through the proxy so the browser streams straight to the backend — a remote
	 * function would put the whole archive through the SvelteKit process first.
	 */
	function send(file: File): Promise<CalibreImport> {
		return new Promise((resolve, reject) => {
			const request = new XMLHttpRequest();

			request.open('POST', `/api/libraries/${libraryId}/imports/calibre/upload`);

			request.upload.addEventListener('progress', (event) => {
				if (event.lengthComputable) uploaded = event.loaded / event.total;
			});

			request.addEventListener('load', () => {
				if (request.status >= 200 && request.status < 300) {
					resolve(JSON.parse(request.responseText));
				} else {
					reject(new Error(detailOf(request.responseText)));
				}
			});

			request.addEventListener('error', () => reject(new Error('The upload failed')));
			request.addEventListener('abort', () => reject(new Error('The upload was stopped')));

			const body = new FormData();
			body.append('archive', file);
			body.append('allow_duplicates', String(allowDuplicates));

			request.send(body);
		});
	}

	async function start() {
		if (!archive) return;

		busy = true;
		problem = null;
		uploaded = 0;

		try {
			job = await send(archive);
			follow(job.id);
		} catch (error) {
			problem = error instanceof Error ? error.message : 'Could not upload the archive';
		} finally {
			busy = false;
			uploaded = null;
		}
	}

	async function stop() {
		if (!job) return;

		try {
			job = await cancelCalibreImport(job.id);
		} catch (error) {
			problem = error instanceof Error ? error.message : 'Could not stop the import';
		}
	}

	function reset() {
		stopPolling();
		job = null;
		problem = null;
		archive = null;
		uploaded = null;
	}
</script>

<div class="flex flex-col gap-4">
	<Card.Root>
		<Card.Header>
			<Card.Title>Import from Calibre</Card.Title>
			<Card.Description>
				Zip your Calibre library folder and upload it here. Nothing is taken from the original — the
				books are copied in, and uploading the same library again only picks up what is new.
			</Card.Description>
		</Card.Header>

		<Card.Content class="flex flex-col gap-4">
			<Field.Field>
				<Field.Label for="archive">Zipped Calibre library</Field.Label>
				<!--
					The native file button inherits the input's own text styling, which leaves
					"Browse…" looking like the first half of the sentence "Browse… No file
					selected." The `file:` variants target ::file-selector-button, so it can be
					made to read as a button without replacing the input with a custom one.
					Styled here rather than in `ui/input`, which is generated.
				-->
				<Input
					id="archive"
					type="file"
					accept=".zip,application/zip"
					onchange={pick}
					disabled={running || busy}
					class="py-1.5 file:mr-3 file:cursor-pointer file:rounded-sm file:border file:border-input file:bg-secondary file:px-2 file:py-0.5 file:text-secondary-foreground file:hover:bg-secondary/80"
				/>
				<Field.Description>
					The zip has to contain <code class="font-mono text-xs">metadata.db</code> — zip the whole Calibre
					folder rather than just the books.
				</Field.Description>
			</Field.Field>

			{#if archive}
				<p class="text-sm text-muted-foreground">
					{archive.name} · {formatFileSize(archive.size)}
				</p>
			{/if}

			<div class="flex items-center gap-2">
				<Checkbox id="allow-duplicates" bind:checked={allowDuplicates} disabled={running || busy} />
				<label for="allow-duplicates" class="text-sm">
					Import books this library already holds
				</label>
			</div>

			{#if uploaded !== null}
				<div class="flex flex-col gap-1">
					<Progress value={Math.round(uploaded * 100)} />
					<p class="text-xs text-muted-foreground">
						Uploading · {Math.round(uploaded * 100)}%
					</p>
				</div>
			{/if}

			{#if problem}
				<p class="flex items-center gap-2 text-sm text-destructive">
					<TriangleAlert class="size-4 shrink-0" />
					{problem}
				</p>
			{/if}
		</Card.Content>

		<Card.Footer class="gap-2">
			{#if running}
				<Button variant="outline" onclick={stop}>Stop after this book</Button>
			{:else if job}
				<Button onclick={reset}>Import another</Button>
			{:else}
				<!--
					No preview step: the catalogue cannot be read until the archive is on the
					server, and by then it has been carried across anyway. Unpacking it is what
					refuses an archive that is not a Calibre library.
				-->
				<Button onclick={start} disabled={busy || !archive}>
					{#if busy}
						<Loader2 class="size-4 animate-spin" />
					{:else}
						<Upload class="size-4" />
					{/if}
					Upload and import
				</Button>
			{/if}
		</Card.Footer>
	</Card.Root>

	{#if job}
		<Card.Root>
			<Card.Header>
				<Card.Title class="flex items-center gap-2">
					{#if running}<Loader2 class="size-4 animate-spin" />{/if}
					{running ? 'Importing' : job.state === 'failed' ? 'Import failed' : 'Import finished'}
				</Card.Title>
				<Card.Description>
					{#if running && job.current_title}
						{job.processed} of {job.total} · {job.current_title}
					{:else if job.state === 'cancelled'}
						Stopped after {job.processed} of {job.total}. Everything imported is complete.
					{:else if job.state === 'failed'}
						{job.error ?? 'The import stopped before it finished.'}
					{:else}
						{job.processed} of {job.total} books considered.
					{/if}
				</Card.Description>
			</Card.Header>

			<Card.Content class="flex flex-col gap-4">
				<Progress value={percent} />

				<div class="grid grid-cols-3 gap-3 text-sm">
					<div>
						<p class="font-mono text-lg tabular-nums">{job.created}</p>
						<p class="text-muted-foreground">imported</p>
					</div>
					<div>
						<p class="font-mono text-lg tabular-nums">{job.skipped}</p>
						<p class="text-muted-foreground">already here</p>
					</div>
					<div>
						<p class="font-mono text-lg tabular-nums">{job.failed}</p>
						<p class="text-muted-foreground">failed</p>
					</div>
				</div>

				{#if job.created > 0}
					<a
						href={resolve('/(root)/(library)/library/[libraryId]/view', { libraryId })}
						class="inline-flex w-fit items-center gap-2 text-sm hover:underline"
					>
						<BookOpen class="size-4" />
						See the books
					</a>
				{/if}

				<!--
					An import into a library that already holds books is the likeliest way to
					end up with two records for one book, and that screen already handles them.
				-->
				{#if job.possible_duplicates > 0}
					<div class="rounded-lg border p-3 text-sm">
						<p>
							{job.possible_duplicates} imported book{job.possible_duplicates === 1 ? '' : 's'}
							look{job.possible_duplicates === 1 ? 's' : ''} like something this library already had.
							They were imported all the same — a metadata match is a guess.
						</p>
						<a
							href={resolve('/(root)/settings/libraries/[libraryId]/duplicates', { libraryId })}
							class="mt-2 inline-block font-medium hover:underline"
						>
							Review duplicates
						</a>
					</div>
				{/if}

				{#if job.failures?.length}
					<div class="flex flex-col gap-2">
						<p class="text-sm font-medium">Books that could not be imported</p>
						<div class="overflow-x-auto rounded-lg border">
							<Table.Root>
								<Table.Header>
									<Table.Row>
										<Table.Head class="w-[80px]">Calibre</Table.Head>
										<Table.Head>Title</Table.Head>
										<Table.Head>Reason</Table.Head>
									</Table.Row>
								</Table.Header>
								<Table.Body>
									{#each job.failures as failure (failure.calibre_id)}
										<Table.Row>
											<Table.Cell class="font-mono text-xs">#{failure.calibre_id}</Table.Cell>
											<Table.Cell>{failure.title}</Table.Cell>
											<Table.Cell class="font-mono text-xs">{failure.reason}</Table.Cell>
										</Table.Row>
									{/each}
								</Table.Body>
							</Table.Root>
						</div>
					</div>
				{/if}
			</Card.Content>
		</Card.Root>
	{/if}
</div>
