import { getContext, setContext } from 'svelte';
import { invalidate } from '$app/navigation';

import type { Book, BooksUploadResult, DuplicateBook, DuplicateFile } from '$lib/schema';

export type UploadStatus = 'queued' | 'uploading' | 'done' | 'skipped' | 'failed';

export interface UploadJob {
	id: string;
	/** The book's folder, or the filename for a book that is a single file. */
	label: string;
	libraryId: number | string;
	files: File[];
	size: number;
	status: UploadStatus;
	error?: string;
	book?: Book;
	/**
	 * Files the library already held, which were not stored again. A job with
	 * nothing left over lands as `skipped`; one that had something new is `done`
	 * and still carries these, since the reader asked for those files too.
	 */
	duplicates?: DuplicateFile[];
	/**
	 * Books already in the library that the one this job created might be a second
	 * copy of. Much weaker than `duplicates`: the bytes are new and only the metadata
	 * agrees, which a second edition and a translation both do. The book was stored.
	 */
	possibleDuplicates?: DuplicateBook[];
}

export interface UploadSummary {
	created: number;
	skipped: number;
	failed: number;
	firstBook?: Book;
}

/**
 * Matches the grouping in BookService.create_books_from_files: files are grouped
 * by their parent directory, except those at the root, which each become their
 * own book. Diverging from it would split one book across two records.
 */
function groupByBook(files: File[]): [key: string, files: File[]][] {
	// A plain record rather than a Map: this is a throwaway local, and Svelte's
	// lint rule steers any Map towards the reactive SvelteMap.
	const groups: Record<string, File[]> = {};

	for (const file of files) {
		const cut = file.name.lastIndexOf('/');
		const key = cut === -1 ? file.name : file.name.slice(0, cut);

		(groups[key] ??= []).push(file);
	}

	return Object.entries(groups);
}

function label(key: string) {
	const cut = key.lastIndexOf('/');
	return cut === -1 ? key : key.slice(cut + 1);
}

function warnBeforeUnload(event: BeforeUnloadEvent) {
	event.preventDefault();
}

/** How long a clean run stays on screen before clearing itself. */
const DISMISS_AFTER_MS = 6000;

/** How often a run that is still going refreshes the list behind it. */
const REFRESH_EVERY_MS = 2000;

/**
 * Uploads books one request at a time and keeps the result on screen.
 *
 * Lives above the dialog so a run outlives it — the dialog only adds to the
 * queue, and the tray in the root layout is what reports on it. This is why
 * uploads do not go through a remote function: one request per book is what
 * makes progress real, bounds how much any single request buffers, and stops
 * one bad file taking the whole import with it.
 */
export class UploadQueueState {
	jobs = $state<UploadJob[]>([]);
	collapsed = $state(false);

	readonly total = $derived(this.jobs.length);
	readonly done = $derived(this.jobs.filter((job) => job.status === 'done').length);
	readonly skipped = $derived(this.jobs.filter((job) => job.status === 'skipped').length);
	readonly failed = $derived(this.jobs.filter((job) => job.status === 'failed').length);
	readonly active = $derived(
		this.jobs.some((job) => job.status === 'queued' || job.status === 'uploading')
	);
	readonly current = $derived(this.jobs.find((job) => job.status === 'uploading'));
	readonly settled = $derived(this.done + this.skipped + this.failed);

	/**
	 * Whether the run left something the reader still has to see.
	 *
	 * A skipped book is not a failure, but it is the only place that says the file
	 * was already here — and the only place to override it from. A book that may
	 * already be in the library counts too: it is a note the reader has to actually
	 * read, and a tray that clears itself after six seconds is one they never will.
	 */
	readonly needsAttention = $derived(
		this.jobs.some(
			(job) =>
				job.status === 'failed' ||
				(job.duplicates?.length ?? 0) > 0 ||
				(job.possibleDuplicates?.length ?? 0) > 0
		)
	);

	/**
	 * Set by a list that has loaded past its first page, to keep a mid-run refresh
	 * from throwing the reader back to the top. See #scheduleRefresh.
	 */
	holdRefresh = false;

	#running = false;
	#dismissTimer: ReturnType<typeof setTimeout> | undefined;
	#refreshTimer: ReturnType<typeof setTimeout> | undefined;
	#held = false;

	/** Adds one job per book and starts the runner if it is not already going. */
	enqueue(
		libraryId: number | string,
		files: File[],
		onFinished?: (summary: UploadSummary) => void
	) {
		const added = groupByBook(files).map(([key, group]) => ({
			id: `${libraryId}:${key}`,
			label: label(key),
			libraryId,
			files: group,
			size: group.reduce((sum, file) => sum + file.size, 0),
			status: 'queued' as UploadStatus
		}));

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

		clearTimeout(this.#dismissTimer);

		// A finished run stays on screen until dismissed; adding to it starts fresh
		// rather than appending to a report the reader has already read.
		if (!this.active) this.jobs = [];

		this.jobs = [...this.jobs, ...added];
		this.collapsed = false;

		void this.#run(onFinished);
	}

	dismiss() {
		if (this.active) return;
		clearTimeout(this.#dismissTimer);
		this.jobs = [];
	}

	/**
	 * Holds a finished run on screen while the pointer is over it.
	 *
	 * Without this the tray can vanish from under a reader who is part-way
	 * through reading which books were added.
	 */
	hold() {
		this.#held = true;
		clearTimeout(this.#dismissTimer);
	}

	release() {
		this.#held = false;
		if (!this.active && !this.needsAttention && this.total > 0) this.#scheduleDismiss();
	}

	/**
	 * Clears itself only when everything went in cleanly. A run with failures or
	 * skipped files stays until dismissed — it is the only record of what did not
	 * make it in, and the only place to retry or override from.
	 */
	#scheduleDismiss() {
		clearTimeout(this.#dismissTimer);
		if (this.#held) return;

		this.#dismissTimer = setTimeout(() => {
			if (!this.active && !this.needsAttention) this.jobs = [];
		}, DISMISS_AFTER_MS);
	}

	/**
	 * Shows the books added so far, without waiting for the rest of the run.
	 *
	 * A refetch rather than inserting the created book into the list by hand: the
	 * server owns the sort, the filters and the paging, so asking it again is what
	 * makes a part-finished import look the same as a finished one.
	 *
	 * Trailing, and at most one refresh per REFRESH_EVERY_MS — a folder of small
	 * books lands faster than the list can usefully redraw.
	 */
	#scheduleRefresh() {
		if (this.#refreshTimer) return;

		this.#refreshTimer = setTimeout(() => {
			this.#refreshTimer = undefined;

			// The loaders only ever return the first page, so refreshing under a
			// reader who has scrolled past it would drop them back to the top. Their
			// list catches up when the run ends.
			if (this.holdRefresh) return;

			void invalidate('app:books');
		}, REFRESH_EVERY_MS);
	}

	/** Re-queues everything that failed, so one bad book is not a reason to start over. */
	retryFailed() {
		this.jobs = this.jobs.map((job) =>
			job.status === 'failed' ? { ...job, status: 'queued' as UploadStatus, error: undefined } : job
		);
		void this.#run();
	}

	async #run(onFinished?: (summary: UploadSummary) => void) {
		if (this.#running) return;
		this.#running = true;

		// Nothing here is resumable, so leaving mid-run loses whatever is left.
		window.addEventListener('beforeunload', warnBeforeUnload);

		let created = 0;
		let firstBook: Book | undefined;

		try {
			for (;;) {
				const index = this.jobs.findIndex((job) => job.status === 'queued');
				if (index === -1) break;

				const job = this.jobs[index];
				this.#patch(index, { status: 'uploading' });

				try {
					const body = new FormData();
					for (const file of job.files) body.append('files', file);

					const response = await fetch(
						`/api/books/fromFiles?library_id=${encodeURIComponent(String(job.libraryId))}`,
						{ method: 'POST', body }
					);

					if (!response.ok) throw new Error(`The server returned ${response.status}`);

					const result: BooksUploadResult = await response.json();
					const book = result.created[0];

					created += result.created.length;
					firstBook ??= book;

					// Nothing created means every file in this folder was already here.
					// That is not a failure, and it is not something to hide either.
					this.#patch(index, {
						status: result.created.length === 0 ? 'skipped' : 'done',
						book,
						duplicates: result.skipped,
						// One job is one book, so it has at most one set of candidates.
						possibleDuplicates: result.possible_duplicates?.[0]?.candidates
					});

					if (result.created.length > 0) this.#scheduleRefresh();
				} catch (error) {
					// One bad book must not take the rest of the queue with it.
					console.error(`Failed to upload ${this.jobs[index].label}`, error);
					this.#patch(index, {
						status: 'failed',
						error: error instanceof Error ? error.message : 'Upload failed'
					});
				}
			}
		} finally {
			this.#running = false;
			window.removeEventListener('beforeunload', warnBeforeUnload);
		}

		// The run is over, so the refresh below is the one that counts — a pending
		// mid-run one would only repeat it a moment later, and it is the one that
		// honours holdRefresh.
		clearTimeout(this.#refreshTimer);
		this.#refreshTimer = undefined;

		if (created > 0) await invalidate('app:books');

		if (!this.needsAttention) this.#scheduleDismiss();

		onFinished?.({ created, skipped: this.skipped, failed: this.failed, firstBook });
	}

	#patch(index: number, changes: Partial<UploadJob>) {
		this.jobs[index] = { ...this.jobs[index], ...changes };
	}
}

const UPLOAD_QUEUE_KEY = Symbol('UPLOAD_QUEUE');

export function setUploadQueueState() {
	return setContext(UPLOAD_QUEUE_KEY, new UploadQueueState());
}

export function getUploadQueueState() {
	return getContext<ReturnType<typeof setUploadQueueState>>(UPLOAD_QUEUE_KEY);
}
