import { getContext, setContext } from 'svelte';
import { goto, replaceState } from '$app/navigation';
import type { Book, DynamicFilterData, FilterOption, PaginatedResponse } from '$lib/schema';
import { page } from '$app/state';
import { BookOperationsState } from './bookOperations.svelte';
import type { BookPreset } from '$lib/presets';

/** The browse views, shared with view-toggle.svelte so the two cannot drift. */
export const BOOK_VIEWS = ['grid', 'list', 'table'] as const;

export type BookView = (typeof BOOK_VIEWS)[number];

/** Anything unrecognised falls back to grid rather than blanking the page. */
export function parseView(value: string | null | undefined): BookView {
	return BOOK_VIEWS.includes(value as BookView) ? (value as BookView) : 'grid';
}

export class BookCollectionState {
	public sortOrder = $state<string>('');
	public orderBy = $state<string>('');
	public view = $state<BookView>('grid');
	public filters = $state<Record<string, string[]>>({});

	readonly hasActiveSort = $derived(this.orderBy !== 'title' || this.sortOrder !== 'asc');
	readonly hasActiveFilters = $derived(Object.values(this.filters).some((arr) => arr.length > 0));

	public books = $state<Book[]>([]);
	public moreBooks = $state(false);
	private currentBookPage = $state(1);
	public loading = $state(false);
	/** A page append, as opposed to `loading`, which replaces the whole list. */
	public loadingMore = $state(false);

	/**
	 * Whether the reader has loaded past the first page. Anything that refetches
	 * gets page one back, which is a jump to the top from here.
	 */
	readonly pastFirstPage = $derived(this.currentBookPage > 1);

	public filterOptions: FilterOption[] = $state([]);

	private ops: BookOperationsState;

	readonly sortOptions = [
		{
			value: 'title',
			name: 'Title'
		},
		{
			value: 'created_at',
			name: 'Date Added'
		},
		{
			value: 'published_date',
			name: 'Date Published'
		},
		{
			value: 'pages',
			name: 'Pages'
		},
		{
			value: 'last_accessed',
			name: 'Last Accessed'
		}
	];

	constructor(
		ops: BookOperationsState,
		books: PaginatedResponse<Book>,
		filterData: DynamicFilterData
	) {
		this.ops = ops;
		this.books = books.items;
		this.moreBooks = books.total > books.items.length;
		this.filterOptions = this.buildFilterOptions(filterData);

		// Determine sort order and direction based on page data
		this.orderBy = page.url.searchParams.get('orderBy') || 'title';
		this.sortOrder = page.url.searchParams.get('sortOrder') || 'asc';

		// Read during SSR too, so a shared ?view=table link renders the table on
		// the server rather than flashing the grid first.
		this.view = parseView(page.url.searchParams.get('view'));

		// Construct initial filters based on the page data
		this.filters = {
			authors: page.url.searchParams.getAll('authors'),
			publishers: page.url.searchParams.getAll('publishers'),
			progress: page.url.searchParams.getAll('progress'),
			tags: page.url.searchParams.getAll('tags'),
			shelves: page.url.searchParams.getAll('shelves')
		};
	}

	/**
	 * Shallow routing: replaceState updates the URL and page.url without running
	 * any load function. Switching view changes presentation only, so it must not
	 * take the updateSearchParams path — that calls goto() and refetches the list.
	 *
	 * replaceState rather than pushState because a view is a preference, not a
	 * destination; Back should leave the page, not step through view changes.
	 */
	setView(next: BookView) {
		if (next === this.view) return;

		this.view = next;

		// eslint-disable-next-line svelte/prefer-svelte-reactivity -- local temporary, not reactive state
		const url = new URL(page.url);
		url.searchParams.set('view', next);
		// Not a route to resolve — this is the current URL with one query param
		// changed, so it is already fully qualified.
		// eslint-disable-next-line svelte/no-navigation-without-resolve
		replaceState(url, page.state);
	}

	updateSort(sortValue: string) {
		if (sortValue === this.orderBy) {
			this.sortOrder = this.sortOrder === 'asc' ? 'desc' : 'asc';
		} else {
			this.orderBy = sortValue;
		}
		this.updateSearchParams();
	}

	toggleFilter(filter: string, value: string) {
		if (!this.filters[filter]) {
			this.filters[filter] = [];
		}

		const index = this.filters[filter].indexOf(value);
		if (index > -1) {
			this.filters[filter].splice(index, 1);
		} else {
			this.filters[filter].push(value);
		}
		this.updateSearchParams();
	}

	updateSearchParams() {
		// Update URL
		// eslint-disable-next-line svelte/prefer-svelte-reactivity -- local temporary, not reactive state
		const url = new URL(window.location.href);

		Object.entries(this.filters).forEach(([filter, values]) => {
			url.searchParams.delete(filter);
			values.forEach((val) => url.searchParams.append(filter, val.toString()));
		});

		url.searchParams.set('orderBy', this.orderBy);
		url.searchParams.set('sortOrder', this.sortOrder);

		// Same as setView: the current URL with sort params rewritten, not a route.
		// eslint-disable-next-line svelte/no-navigation-without-resolve
		goto(url.toString());

		this.loadNewBooks();
	}

	async loadNewBooks() {
		this.loading = true;

		const result = await this.ops.listBooks({
			currentPage: 1,
			pageSize: 50,
			sortOrder: this.sortOrder,
			orderBy: this.orderBy,
			...this.filters
		});

		this.books = [...result.items];
		this.moreBooks = result.total > result.items.length;
		this.currentBookPage = 1;
		this.loading = false;
	}

	/**
	 * Prefetching means the trigger can fire again while a page is still in
	 * flight, so the guard is here rather than in the observer — every caller
	 * gets it, and a second call is dropped instead of duplicating a page.
	 */
	async loadMoreBooks() {
		if (this.loadingMore || !this.moreBooks) return;

		this.loadingMore = true;
		try {
			const result = await this.ops.listBooks({
				currentPage: this.currentBookPage + 1,
				pageSize: 50,
				sortOrder: this.sortOrder,
				orderBy: this.orderBy,
				...this.filters
			});

			this.books = [...this.books, ...result.items];
			// An empty page means the count we were given was stale; stop asking
			// rather than loop on a page that never grows the list.
			this.moreBooks = result.items.length > 0 && result.total > this.books.length;
			this.currentBookPage++;
		} finally {
			this.loadingMore = false;
		}
	}

	updateBooks(books: PaginatedResponse<Book>) {
		this.books = books.items;
		this.moreBooks = books.total > books.items.length;
	}

	resetState(books: PaginatedResponse<Book>, filterData: DynamicFilterData) {
		this.books = books.items;
		this.moreBooks = books.total > books.items.length;
		this.filterOptions = this.buildFilterOptions(filterData);

		// The loader hands back the first page, so the counter has to say so —
		// otherwise loadMoreBooks resumes from wherever the reader had scrolled to
		// and skips every page in between.
		this.currentBookPage = 1;

		// eslint-disable-next-line svelte/prefer-svelte-reactivity -- local temporary, not reactive state
		const urlParams = new URLSearchParams(window.location.search);

		// Re-read sort and filter state from URL
		this.orderBy = urlParams.get('orderBy') || 'title';
		this.sortOrder = urlParams.get('sortOrder') || 'asc';

		this.filters = {
			authors: urlParams.getAll('authors'),
			publishers: urlParams.getAll('publishers'),
			progress: urlParams.getAll('progress'),
			tags: urlParams.getAll('tags'),
			shelves: urlParams.getAll('shelves')
		};
	}

	buildFilterOptions(data: DynamicFilterData): FilterOption[] {
		return [
			{
				value: 'shelves',
				name: 'Bookshelves',
				items: data.bookshelves.items
			},
			{
				value: 'authors',
				name: 'Authors',
				items: data.authors.items
			},
			{
				value: 'tags',
				name: 'Tags',
				items: data.tags.items
			},
			{
				value: 'publishers',
				name: 'Publishers',
				items: data.publishers.items
			},
			{
				value: 'progress',
				name: 'Progress',
				items: [
					{ id: 'read', name: 'Read' },
					{ id: 'in_progress', name: 'In Progress' },
					{ id: 'unread', name: 'Not Started' }
				]
			}
		];
	}

	updateFilterOptions(
		filterOptions: { value: string; name: string; items: { name: string; id: number | string }[] }[]
	) {
		this.filterOptions = filterOptions;
	}

	clearFilters() {
		Object.values(this.filters).forEach((val) => (val.length = 0));
		this.updateSearchParams();
	}

	/**
	 * A preset is a whole view, not an extra filter — applying one replaces the
	 * filters and the sort. Accumulating them instead produces empty results the
	 * moment two overlap (Reading plus Unread returns nothing) with no visible
	 * reason why.
	 */
	applyPreset(preset: BookPreset) {
		Object.values(this.filters).forEach((val) => (val.length = 0));

		for (const [key, values] of Object.entries(preset.filters)) {
			this.filters[key] = [...values];
		}

		this.orderBy = preset.orderBy ?? 'title';
		this.sortOrder = preset.sortOrder ?? 'asc';

		this.updateSearchParams();
	}

	/** Back to the unfiltered, title-sorted default. */
	clearView() {
		Object.values(this.filters).forEach((val) => (val.length = 0));
		this.orderBy = 'title';
		this.sortOrder = 'asc';
		this.updateSearchParams();
	}

	/**
	 * Active only on an exact match. Adding a tag on top of a preset deselects
	 * the chip while keeping the filters — the chip stops claiming to describe a
	 * view it no longer describes.
	 */
	isPresetActive(preset: BookPreset) {
		if (this.orderBy !== (preset.orderBy ?? 'title')) return false;
		if (this.sortOrder !== (preset.sortOrder ?? 'asc')) return false;

		const active = Object.entries(this.filters).filter(([, values]) => values.length > 0);
		const wanted = Object.entries(preset.filters);
		if (active.length !== wanted.length) return false;

		return wanted.every(([key, values]) => {
			const current = this.filters[key] ?? [];
			return current.length === values.length && values.every((value) => current.includes(value));
		});
	}

	isFilterSelected(filter: string, value: string) {
		return this.filters[filter]?.includes(value) || false;
	}
}

const BOOK_COLLECTION_KEY = Symbol('BOOK_COLLECTION');

export function setBookCollectionState(
	ops: BookOperationsState,
	books: PaginatedResponse<Book>,
	filterData: DynamicFilterData
) {
	return setContext(BOOK_COLLECTION_KEY, new BookCollectionState(ops, books, filterData));
}

export function getBookCollectionState() {
	return getContext<ReturnType<typeof setBookCollectionState>>(BOOK_COLLECTION_KEY);
}
