interface PendingProgress {
	percentage: number;
	epub_cfi: string;
	completed: boolean;
}

/** Treat the last stretch as finished: fraction is a float and never lands on 1. */
const COMPLETE_AT = 0.99;

const SAVE_DEBOUNCE_MS = 3000;

/**
 * Debounced reading-progress writer.
 *
 * Progress goes through the catch-all proxy rather than a remote function. That
 * looks like a convention violation but is the documented exception: the browser
 * itself must send this, and the closing write uses sendBeacon, which needs a
 * plain URL and body rather than a remote command's envelope. The httpOnly
 * authToken cookie rides along and the proxy attaches the bearer header.
 */
export class ProgressReporter {
	#url: string;
	#timer: ReturnType<typeof setTimeout> | undefined;
	#pending: PendingProgress | null = null;
	#listening = false;

	constructor(bookId: string | number) {
		this.#url = `/api/books/progress/${bookId}`;
	}

	/**
	 * Starts flushing on the way out.
	 *
	 * pagehide and a hidden visibilitychange, not beforeunload: mobile Safari
	 * fires beforeunload unreliably and it blocks the bfcache.
	 */
	listen() {
		if (this.#listening || typeof document === 'undefined') return;
		document.addEventListener('visibilitychange', this.#onVisibilityChange);
		window.addEventListener('pagehide', this.#onPageHide);
		this.#listening = true;
	}

	record(percentage: number, epubCfi: string) {
		this.#pending = {
			percentage,
			epub_cfi: epubCfi,
			completed: percentage >= COMPLETE_AT
		};

		clearTimeout(this.#timer);
		this.#timer = setTimeout(() => void this.flush(), SAVE_DEBOUNCE_MS);
	}

	/** Sends anything outstanding and waits for it. */
	async flush() {
		const body = this.#take();
		if (!body) return;

		try {
			const response = await fetch(this.#url, {
				method: 'POST',
				// Without this the browser stamps text/plain and the proxy forwards it
				// verbatim. Litestar decodes it anyway, which is why it went unnoticed.
				headers: { 'Content-Type': 'application/json' },
				body: JSON.stringify(body),
				keepalive: true
			});

			// A failed save must not interrupt reading, but should not be silent either.
			if (!response.ok) console.error('Could not save reading progress', response.status);
		} catch (error) {
			console.error('Could not save reading progress', error);
		}
	}

	/**
	 * Fire-and-forget flush for teardown.
	 *
	 * The old reader cleared its debounce timer on destroy without flushing, so a
	 * page turn within three seconds of leaving was dropped — and leaving is
	 * exactly when someone turns a last page.
	 */
	flushSync() {
		const body = this.#take();
		if (!body) return;

		const blob = new Blob([JSON.stringify(body)], { type: 'application/json' });
		if (!navigator.sendBeacon?.(this.#url, blob)) void this.flush();
	}

	dispose() {
		this.flushSync();
		clearTimeout(this.#timer);
		if (!this.#listening) return;
		document.removeEventListener('visibilitychange', this.#onVisibilityChange);
		window.removeEventListener('pagehide', this.#onPageHide);
		this.#listening = false;
	}

	/** Claims the pending write so it cannot be sent twice. */
	#take(): PendingProgress | null {
		const body = this.#pending;
		this.#pending = null;
		clearTimeout(this.#timer);
		return body;
	}

	#onVisibilityChange = () => {
		if (document.visibilityState === 'hidden') this.flushSync();
	};

	#onPageHide = () => this.flushSync();
}
