/**
 * Server-side PocketBase client.
 * Uses admin credentials — never import this from client-side code.
 * All methods talk directly to PocketBase REST API.
 */

import { env } from '$env/dynamic/private';
import { log } from '$lib/server/logger';
import * as cache from '$lib/server/cache';

const PB_URL = env.POCKETBASE_URL ?? 'http://localhost:8090';
const PB_EMAIL = env.POCKETBASE_ADMIN_EMAIL ?? 'admin@libnovel.local';
const PB_PASSWORD = env.POCKETBASE_ADMIN_PASSWORD ?? 'changeme123';

// ─── Types ────────────────────────────────────────────────────────────────────

export interface AIJob {
	id: string;
	kind: string;
	slug: string;
	status: 'pending' | 'running' | 'done' | 'failed' | 'cancelled';
	from_item: number;
	to_item: number;
	items_done: number;
	items_total: number;
	model: string;
	payload: string;
	error_message?: string;
	started?: string;
	finished?: string;
	heartbeat_at?: string;
}

export interface Book {
	id: string;
	slug: string;
	title: string;
	author: string;
	cover: string;
	status: string;
	genres: string[] | string;
	summary: string;
	total_chapters: number;
	source_url: string;
	ranking: number;
	meta_updated: string;
}

export interface ChapterIdx {
	id: string;
	slug: string;
	number: number;
	title: string;
	date_label: string;
}

export interface Progress {
	id?: string;
	session_id: string;
	user_id?: string;
	slug: string;
	chapter: number;
	audio_time?: number;
	updated: string;
}

export interface PBUserSettings {
	id?: string;
	session_id: string;
	user_id?: string;
	auto_next: boolean;
	voice: string;
	speed: number;
	theme?: string;
	locale?: string;
	font_family?: string;
	font_size?: number;
	announce_chapter?: boolean;
	updated?: string;
}

export interface User {
	id: string;
	username: string;
	password_hash: string;
	role: string;
	created: string;
	avatar_url?: string;
	email?: string;
	email_verified?: boolean;
	verification_token?: string;
	verification_token_exp?: string;
	oauth_provider?: string;
	oauth_id?: string;
	polar_customer_id?: string;
	polar_subscription_id?: string;
}

// ─── Auth token cache ─────────────────────────────────────────────────────────

let _token = '';
let _tokenExp = 0;

async function getToken(forceRefresh = false): Promise<string> {
	if (!forceRefresh && _token && Date.now() < _tokenExp) return _token;

	log.debug('pocketbase', 'authenticating with admin credentials', { url: PB_URL, email: PB_EMAIL });

	const res = await fetch(`${PB_URL}/api/collections/_superusers/auth-with-password`, {
		method: 'POST',
		headers: { 'Content-Type': 'application/json' },
		body: JSON.stringify({ identity: PB_EMAIL, password: PB_PASSWORD })
	});

	if (!res.ok) {
		const body = await res.text().catch(() => '');
		log.error('pocketbase', 'admin auth failed', { status: res.status, url: PB_URL, body });
		throw new Error(`PocketBase auth failed: ${res.status} — ${body}`);
	}

	const data = await res.json();
	_token = data.token as string;
	// PocketBase superuser tokens expire in ~1 hour by default.
	// Cache for 50 minutes to stay safely within that window.
	_tokenExp = Date.now() + 50 * 60 * 1000;
	log.info('pocketbase', 'admin auth token refreshed', { url: PB_URL });
	return _token;
}

// ─── Generic helpers ──────────────────────────────────────────────────────────

async function pbGet<T>(path: string): Promise<T> {
	const token = await getToken();
	const res = await fetch(`${PB_URL}${path}`, {
		headers: { Authorization: `Bearer ${token}` }
	});
	if (!res.ok) {
		// On 401/403, the token may have expired on the PocketBase side even if
		// our local TTL hasn't fired yet. Force a refresh and retry once.
		if (res.status === 401 || res.status === 403) {
			const freshToken = await getToken(true);
			const retry = await fetch(`${PB_URL}${path}`, {
				headers: { Authorization: `Bearer ${freshToken}` }
			});
			if (retry.ok) return retry.json() as Promise<T>;
			const retryBody = await retry.text().catch(() => '');
			log.error('pocketbase', 'GET failed', { path, status: retry.status, body: retryBody });
			throw new Error(`PocketBase GET ${path} failed: ${retry.status} — ${retryBody}`);
		}
		const body = await res.text().catch(() => '');
		log.error('pocketbase', 'GET failed', { path, status: res.status, body });
		throw new Error(`PocketBase GET ${path} failed: ${res.status} — ${body}`);
	}
	return res.json() as Promise<T>;
}

async function pbPost(path: string, body: unknown): Promise<Response> {
	const token = await getToken();
	return fetch(`${PB_URL}${path}`, {
		method: 'POST',
		headers: { Authorization: `Bearer ${token}`, 'Content-Type': 'application/json' },
		body: JSON.stringify(body)
	});
}

async function pbPatch(path: string, body: unknown): Promise<Response> {
	const token = await getToken();
	return fetch(`${PB_URL}${path}`, {
		method: 'PATCH',
		headers: { Authorization: `Bearer ${token}`, 'Content-Type': 'application/json' },
		body: JSON.stringify(body)
	});
}

async function pbDelete(path: string): Promise<Response> {
	const token = await getToken();
	return fetch(`${PB_URL}${path}`, {
		method: 'DELETE',
		headers: { Authorization: `Bearer ${token}` }
	});
}

interface PBList<T> {
	items: T[];
	totalItems: number;
}

async function listAll<T>(collection: string, filter = '', sort = ''): Promise<T[]> {
	const perPage = 500;
	const params = new URLSearchParams({ perPage: String(perPage), page: '1' });
	if (filter) params.set('filter', filter);
	if (sort) params.set('sort', sort);

	const first = await pbGet<PBList<T>>(
		`/api/collections/${collection}/records?${params.toString()}`
	);
	const items: T[] = first.items ?? [];
	const total = first.totalItems ?? 0;

	// Fetch remaining pages if there are more records than the first page holds.
	const totalPages = Math.ceil(total / perPage);
	for (let page = 2; page <= totalPages; page++) {
		params.set('page', String(page));
		const data = await pbGet<PBList<T>>(
			`/api/collections/${collection}/records?${params.toString()}`
		);
		items.push(...(data.items ?? []));
	}

	return items;
}

async function listN<T>(collection: string, n: number, filter = '', sort = ''): Promise<T[]> {
	const params = new URLSearchParams({ perPage: String(n) });
	if (filter) params.set('filter', filter);
	if (sort) params.set('sort', sort);
	const data = await pbGet<PBList<T>>(
		`/api/collections/${collection}/records?${params.toString()}`
	);
	return data.items ?? [];
}

async function countCollection(collection: string, filter = ''): Promise<number> {
	const params = new URLSearchParams({ perPage: '1' });
	if (filter) params.set('filter', filter);
	const data = await pbGet<PBList<unknown>>(
		`/api/collections/${collection}/records?${params.toString()}`
	);
	return (data as { totalItems: number }).totalItems ?? 0;
}

async function listOne<T>(collection: string, filter: string, sort = ''): Promise<T | null> {
	const params = new URLSearchParams({ perPage: '1', filter });
	if (sort) params.set('sort', sort);
	const data = await pbGet<PBList<T>>(
		`/api/collections/${collection}/records?${params.toString()}`
	);
	return data.items[0] ?? null;
}

// ─── Books ────────────────────────────────────────────────────────────────────

const BOOKS_CACHE_KEY = 'books:all';
const BOOKS_CACHE_TTL = 5 * 60; // 5 minutes

const RATINGS_CACHE_KEY = 'book_ratings:all';
const RATINGS_CACHE_TTL = 5 * 60; // 5 minutes

const HOME_STATS_CACHE_KEY = 'home:stats';
const HOME_STATS_CACHE_TTL = 10 * 60; // 10 minutes — counts don't need to be exact

const SCRAPING_TASKS_CACHE_KEY = 'admin:scraping_tasks';
const AUDIO_JOBS_CACHE_KEY = 'admin:audio_jobs';
const TRANSLATION_JOBS_CACHE_KEY = 'admin:translation_jobs';
const ADMIN_JOBS_CACHE_TTL = 30; // 30 seconds — admin views poll frequently

const BOOK_SLUGS_CACHE_KEY = 'books:slugs';
const BOOK_SLUGS_CACHE_TTL = 10 * 60; // 10 minutes — slugs change rarely

async function getAllRatings(): Promise<BookRating[]> {
	const cached = await cache.get<BookRating[]>(RATINGS_CACHE_KEY);
	if (cached) return cached;
	const ratings = await listAll<BookRating>('book_ratings', '').catch(() => [] as BookRating[]);
	await cache.set(RATINGS_CACHE_KEY, ratings, RATINGS_CACHE_TTL);
	return ratings;
}

export async function listBooks(): Promise<Book[]> {
	const cached = await cache.get<Book[]>(BOOKS_CACHE_KEY);
	if (cached) {
		log.debug('pocketbase', 'listBooks cache hit', { total: cached.length });
		return cached;
	}
	const books = await listAll<Book>('books', '', '+title');
	const nullTitles = books.filter((b) => b.title == null).length;
	if (nullTitles > 0) {
		log.warn('pocketbase', 'listBooks: books with null title', { count: nullTitles, total: books.length });
	}
	log.debug('pocketbase', 'listBooks cache miss', { total: books.length, nullTitles });
	await cache.set(BOOKS_CACHE_KEY, books, BOOKS_CACHE_TTL);
	return books;
}

export interface BookSlug {
	slug: string;
	title: string;
}

/**
 * Returns only the slug and title of every book. Cheaper than listBooks() —
 * used for datalist autocomplete in admin forms. Cached for 10 minutes.
 */
export async function listBookSlugs(): Promise<BookSlug[]> {
	const cached = await cache.get<BookSlug[]>(BOOK_SLUGS_CACHE_KEY);
	if (cached) return cached;
	// Re-use full books cache if already warm — avoids a second PocketBase call.
	const fullCached = await cache.get<Book[]>(BOOKS_CACHE_KEY);
	if (fullCached) {
		const slugs = fullCached.map((b) => ({ slug: b.slug, title: b.title }));
		await cache.set(BOOK_SLUGS_CACHE_KEY, slugs, BOOK_SLUGS_CACHE_TTL);
		return slugs;
	}
	const items = await listAll<BookSlug>('books', '', '+title').catch(() => [] as BookSlug[]);
	const slugs = items.map((b) => ({ slug: b.slug, title: b.title }));
	await cache.set(BOOK_SLUGS_CACHE_KEY, slugs, BOOK_SLUGS_CACHE_TTL);
	return slugs;
}

/**
 * Fetch only the books whose slugs are in the given set.
 * Uses PocketBase filter `slug IN (...)` — a single request regardless of how
 * many slugs are requested. Falls back to empty array on error.
 *
 * Use this instead of listBooks() whenever you only need a small subset of
 * books (e.g. the user's reading list or saved shelf).
 *
 * PocketBase filter syntax for IN: slug='a' || slug='b' || ...
 * Limited to 200 slugs to keep the filter URL sane; callers with larger sets
 * should fall back to listBooks().
 */
export async function getBooksBySlugs(slugs: Iterable<string>): Promise<Book[]> {
	const slugArr = [...new Set(slugs)].slice(0, 200);
	if (slugArr.length === 0) return [];

	// Check cache for each slug individually (populated by prior listBooks calls).
	// If all slugs hit, skip the network round-trip entirely.
	const cached = await cache.get<Book[]>(BOOKS_CACHE_KEY);
	if (cached) {
		const slugSet = new Set(slugArr);
		const found = cached.filter((b) => slugSet.has(b.slug));
		if (found.length === slugArr.length) {
			log.debug('pocketbase', 'getBooksBySlugs cache hit', { count: found.length });
			return found;
		}
	}

	// Build filter: slug='a' || slug='b' || ...
	const filter = slugArr.map((s) => `slug='${s.replace(/'/g, "\\'")}'`).join(' || ');
	const books = await listAll<Book>('books', filter, '+title');

	// Deduplicate by slug — PocketBase may have multiple records for the same
	// slug if the scraper ran concurrently or the upsert raced. First record wins.
	const seen = new Set<string>();
	const deduped = books.filter((b) => {
		if (seen.has(b.slug)) return false;
		seen.add(b.slug);
		return true;
	});

	if (deduped.length !== books.length) {
		log.warn('pocketbase', 'getBooksBySlugs: duplicate slugs in DB', {
			requested: slugArr.length,
			raw: books.length,
			deduped: deduped.length
		});
	} else {
		log.debug('pocketbase', 'getBooksBySlugs', { requested: slugArr.length, found: books.length });
	}
	return deduped;
}

/** Invalidate the books cache (call after a book is created/updated/deleted). */
export async function invalidateBooksCache(): Promise<void> {
	await Promise.all([
		cache.invalidate(BOOKS_CACHE_KEY),
		cache.invalidate(HOME_STATS_CACHE_KEY),
		cache.invalidatePattern('books:recent:*'),
		cache.invalidatePattern('books:recently-updated:*'),
		cache.invalidatePattern('books:trending:*'),
		cache.invalidatePattern('books:recs:*')
	]);
}

/** Books sorted by ranking (lower = more popular). Excludes unranked (ranking=0). */
export async function getTrendingBooks(limit = 8): Promise<Book[]> {
	const key = `books:trending:${limit}`;
	const cached = await cache.get<Book[]>(key);
	if (cached) return cached;
	const books = await listN<Book>('books', limit, 'ranking>0', '+ranking');
	await cache.set(key, books, 15 * 60);
	return books;
}

/**
 * Books matching the given genres that the user hasn't read yet.
 * The raw genre-query result is cached (shared across users); per-user slug
 * exclusion is applied in memory afterwards.
 */
export async function getRecommendedBooks(
	topGenres: string[],
	excludeSlugs: Set<string>,
	limit = 8
): Promise<Book[]> {
	if (topGenres.length === 0) return [];
	const sortedGenres = [...topGenres].sort();
	const key = `books:recs:${sortedGenres.join(':')}:${limit}`;
	let books = await cache.get<Book[]>(key);
	if (!books) {
		const genreFilter = sortedGenres
			.map((g) => `genres~"${g.replace(/"/g, '')}"`)
			.join('||');
		books = await listN<Book>('books', limit * 4, genreFilter, '+ranking');
		await cache.set(key, books, 10 * 60);
	}
	return books.filter((b) => !excludeSlugs.has(b.slug)).slice(0, limit);
}

export async function getBook(slug: string): Promise<Book | null> {
	return listOne<Book>('books', `slug="${slug}"`);
}

export async function recentlyAddedBooks(limit = 6): Promise<Book[]> {
	const key = `books:recent:${limit}`;
	const cached = await cache.get<Book[]>(key);
	if (cached) return cached;
	const books = await listN<Book>('books', limit, '', '-meta_updated');
	await cache.set(key, books, 5 * 60);
	return books;
}

/**
 * Books with the most recently added chapters, ordered by chapter insertion time.
 * Queries chapters_idx sorted by -created, deduplicates by slug, then loads books.
 * This correctly reflects actual chapter activity, unlike meta_updated on books.
 */
export async function recentlyUpdatedBooks(limit = 8): Promise<Book[]> {
	const key = `books:recently-updated:${limit}`;
	const cached = await cache.get<Book[]>(key);
	if (cached) return cached;

	try {
		// Fetch enough recent chapter rows to find `limit` distinct books
		const rows = await listN<{ slug: string; created: string }>(
			'chapters_idx', limit * 25, '', '-created'
		);

		const seen = new Set<string>();
		const slugs: string[] = [];
		for (const row of rows) {
			if (!seen.has(row.slug)) {
				seen.add(row.slug);
				slugs.push(row.slug);
				if (slugs.length >= limit) break;
			}
		}

		if (!slugs.length) return recentlyAddedBooks(limit);

		const books = await getBooksBySlugs(new Set(slugs));
		// Restore recency order (getBooksBySlugs returns in title sort order)
		const bookMap = new Map(books.map((b) => [b.slug, b]));
		const ordered = slugs.flatMap((s) => (bookMap.has(s) ? [bookMap.get(s)!] : []));

		await cache.set(key, ordered, 5 * 60);
		return ordered;
	} catch {
		// Fall back to meta_updated sort if chapters_idx query fails
		return recentlyAddedBooks(limit);
	}
}

export interface HomeStats {
	totalBooks: number;
	totalChapters: number;
}

export async function getHomeStats(): Promise<HomeStats> {
	const cached = await cache.get<HomeStats>(HOME_STATS_CACHE_KEY);
	if (cached) return cached;
	const [totalBooks, totalChapters] = await Promise.all([
		countCollection('books'),
		countCollection('chapters_idx')
	]);
	const stats = { totalBooks, totalChapters };
	await cache.set(HOME_STATS_CACHE_KEY, stats, HOME_STATS_CACHE_TTL);
	return stats;
}

export async function invalidateHomeStatsCache(): Promise<void> {
	await cache.invalidate(HOME_STATS_CACHE_KEY);
}

// ─── Chapter index ────────────────────────────────────────────────────────────

export async function listChapterIdx(slug: string): Promise<ChapterIdx[]> {
	return listAll<ChapterIdx>('chapters_idx', `slug="${slug}"`, '+number');
}

// ─── Reading progress ─────────────────────────────────────────────────────────

/**
 * Build the PocketBase filter string for a progress lookup.
 * When userId is set, keyed by user_id (portable across devices).
 * When only sessionId is set, keyed by session_id (anonymous).
 */
function progressFilter(sessionId: string, slug: string, userId?: string): string {
	if (userId) return `user_id="${userId}"&&slug="${slug}"`;
	return `session_id="${sessionId}"&&slug="${slug}"`;
}

function allProgressFilter(sessionId: string, userId?: string): string {
	if (userId) return `user_id="${userId}"`;
	return `session_id="${sessionId}"`;
}

export async function getProgress(
	sessionId: string,
	slug: string,
	userId?: string
): Promise<Progress | null> {
	return listOne<Progress>('progress', progressFilter(sessionId, slug, userId));
}

export async function allProgress(sessionId: string, userId?: string): Promise<Progress[]> {
	return listAll<Progress>('progress', allProgressFilter(sessionId, userId), '-updated');
}

export async function setProgress(
	sessionId: string,
	slug: string,
	chapter: number,
	userId?: string
): Promise<void> {
	const existing = await listOne<Progress & { id: string }>(
		'progress',
		progressFilter(sessionId, slug, userId)
	);

	const payload: Partial<Progress> = {
		session_id: sessionId,
		slug,
		chapter,
		updated: new Date().toISOString()
	};
	if (userId) payload.user_id = userId;

	if (existing) {
		const res = await pbPatch(`/api/collections/progress/records/${existing.id}`, payload);
		if (!res.ok) {
			const body = await res.text().catch(() => '');
			log.error('pocketbase', 'setProgress PATCH failed', { slug, chapter, status: res.status, body });
		}
	} else {
		const res = await pbPost('/api/collections/progress/records', payload);
		if (!res.ok) {
			const body = await res.text().catch(() => '');
			log.error('pocketbase', 'setProgress POST failed', { slug, chapter, status: res.status, body });
		}
	}
}

/**
 * Delete progress entry for a specific book (removes from library/continue reading).
 */
export async function deleteProgress(
	sessionId: string,
	slug: string,
	userId?: string
): Promise<void> {
	const existing = await listOne<Progress & { id: string }>(
		'progress',
		progressFilter(sessionId, slug, userId)
	);

	if (!existing) {
		log.debug('pocketbase', 'deleteProgress: no record found', { sessionId, slug, userId });
		return;
	}

	const res = await pbDelete(`/api/collections/progress/records/${existing.id}`);
	if (!res.ok) {
		const body = await res.text().catch(() => '');
		log.error('pocketbase', 'deleteProgress failed', {
			slug,
			id: existing.id,
			status: res.status,
			body
		});
		throw new Error(`Failed to delete progress: ${res.status}`);
	}
	log.info('pocketbase', 'deleteProgress success', { slug, id: existing.id });
}

/**
 * Merge anonymous session progress into a user account on login/register.
 *
 * For each book tracked under sessionId, upserts a user-keyed record keeping
 * whichever chapter is more recent (or higher if timestamps are equal).
 * This makes progress portable across devices for logged-in users.
 */
export async function mergeSessionProgress(sessionId: string, userId: string): Promise<void> {
	let sessionRows: Progress[];
	try {
		sessionRows = await allProgress(sessionId);
	} catch (e) {
		log.warn('pocketbase', 'mergeSessionProgress: failed to read session progress', {
			sessionId,
			err: String(e)
		});
		return;
	}
	if (sessionRows.length === 0) return;

	for (const row of sessionRows) {
		try {
			const userRow = await listOne<Progress & { id: string }>(
				'progress',
				`user_id="${userId}"&&slug="${row.slug}"`
			);

			// Keep the record with the more recent update (or higher chapter if timestamps match)
			const sessionTs = row.updated ? new Date(row.updated).getTime() : 0;
			const userTs = userRow?.updated ? new Date(userRow.updated).getTime() : 0;
			const shouldOverwrite = !userRow || sessionTs > userTs ||
				(sessionTs === userTs && row.chapter > (userRow?.chapter ?? 0));

			if (shouldOverwrite) {
				const payload: Partial<Progress> = {
					session_id: sessionId,
					user_id: userId,
					slug: row.slug,
					chapter: row.chapter,
					updated: row.updated ?? new Date().toISOString()
				};
				if (userRow) {
					await pbPatch(`/api/collections/progress/records/${userRow.id}`, payload);
				} else {
					await pbPost('/api/collections/progress/records', payload);
				}
			}
		} catch (e) {
			log.warn('pocketbase', 'mergeSessionProgress: failed to merge row', {
				slug: row.slug,
				err: String(e)
			});
		}
	}
	log.info('pocketbase', 'mergeSessionProgress: done', { sessionId, userId, count: sessionRows.length });
}

// ─── User library (saved books) ───────────────────────────────────────────────

export interface UserLibraryEntry {
	id?: string;
	session_id: string;
	user_id?: string;
	slug: string;
	saved_at: string;
	shelf?: string;
}

function libraryFilter(sessionId: string, userId?: string): string {
	if (userId) return `user_id="${userId}"`;
	return `session_id="${sessionId}"`;
}

/** Returns all slugs the user has explicitly saved to their library. */
export async function getSavedSlugs(sessionId: string, userId?: string): Promise<Set<string>> {
	const rows = await listAll<UserLibraryEntry>(
		'user_library',
		libraryFilter(sessionId, userId)
	);
	return new Set(rows.map((r) => r.slug));
}

/** Returns whether a specific slug is saved. */
export async function isBookSaved(
	sessionId: string,
	slug: string,
	userId?: string
): Promise<boolean> {
	const filter = userId
		? `user_id="${userId}"&&slug="${slug}"`
		: `session_id="${sessionId}"&&slug="${slug}"`;
	const row = await listOne<UserLibraryEntry>('user_library', filter);
	return row !== null;
}

/** Returns the shelf the user has placed this book on, or '' if not saved / no shelf set. */
export async function getBookShelf(
	sessionId: string,
	slug: string,
	userId?: string
): Promise<ShelfName> {
	const filter = userId
		? `user_id="${userId}"&&slug="${slug}"`
		: `session_id="${sessionId}"&&slug="${slug}"`;
	const row = await listOne<UserLibraryEntry>('user_library', filter).catch(() => null);
	return (row?.shelf as ShelfName) || '';
}

/** Save a book to the user's library. No-op if already saved. */
export async function saveBook(
	sessionId: string,
	slug: string,
	userId?: string
): Promise<void> {
	const alreadySaved = await isBookSaved(sessionId, slug, userId);
	if (alreadySaved) return;
	const payload: Partial<UserLibraryEntry> = {
		session_id: sessionId,
		slug,
		saved_at: new Date().toISOString()
	};
	if (userId) payload.user_id = userId;
	const res = await pbPost('/api/collections/user_library/records', payload);
	if (!res.ok) {
		const body = await res.text().catch(() => '');
		log.error('pocketbase', 'saveBook POST failed', { slug, status: res.status, body });
	}
}

/** Remove a book from the user's library. */
export async function unsaveBook(
	sessionId: string,
	slug: string,
	userId?: string
): Promise<void> {
	const filter = userId
		? `user_id="${userId}"&&slug="${slug}"`
		: `session_id="${sessionId}"&&slug="${slug}"`;
	const row = await listOne<UserLibraryEntry & { id: string }>('user_library', filter);
	if (!row) return;
	const token = await getToken();
	await fetch(`${PB_URL}/api/collections/user_library/records/${row.id}`, {
		method: 'DELETE',
		headers: { Authorization: `Bearer ${token}` }
	});
}

// ─── Users ────────────────────────────────────────────────────────────────────

import { scryptSync, randomBytes, timingSafeEqual, createHash } from 'node:crypto';

function hashPassword(password: string): string {
	const salt = randomBytes(16).toString('hex');
	const hash = scryptSync(password, salt, 64).toString('hex');
	return `${salt}:${hash}`;
}

function verifyPassword(password: string, stored: string): boolean {
	const [salt, hash] = stored.split(':');
	if (!salt || !hash) return false;
	const derived = scryptSync(password, salt, 64);
	const hashBuf = Buffer.from(hash, 'hex');
	if (derived.length !== hashBuf.length) return false;
	return timingSafeEqual(derived, hashBuf);
}

/**
 * Look up a user by username. Returns null if not found.
 */
export async function getUserByUsername(username: string): Promise<User | null> {
	return listOne<User>('app_users', `username="${username.replace(/"/g, '\\"')}"`);
}

/**
 * Look up a user by their PocketBase record ID. Returns null if not found.
 */
export async function getUserById(id: string): Promise<User | null> {
	const token = await getToken();
	const res = await fetch(`${PB_URL}/api/collections/app_users/records/${encodeURIComponent(id)}`, {
		headers: { Authorization: `Bearer ${token}` }
	});
	if (res.status === 404) return null;
	if (!res.ok) return null;
	return res.json() as Promise<User>;
}

/**
 * Look up a user by email. Returns null if not found.
 */
export async function getUserByEmail(email: string): Promise<User | null> {
	return listOne<User>('app_users', `email="${email.replace(/"/g, '\\"')}"`);
}

/**
 * Look up a user by OAuth provider + provider user ID. Returns null if not found.
 */
export async function getUserByOAuth(provider: string, oauthId: string): Promise<User | null> {
	return listOne<User>(
		'app_users',
		`oauth_provider="${provider.replace(/"/g, '\\"')}"&&oauth_id="${oauthId.replace(/"/g, '\\"')}"`
	);
}

/**
 * Look up a user by their Polar customer ID. Returns null if not found.
 */
export async function getUserByPolarCustomerId(polarCustomerId: string): Promise<User | null> {
	return listOne<User>(
		'app_users',
		`polar_customer_id="${polarCustomerId.replace(/"/g, '\\"')}"`
	);
}

/**
 * Patch arbitrary fields on an app_user record.
 */
export async function patchUser(userId: string, fields: Partial<User & Record<string, unknown>>): Promise<void> {
	const res = await pbPatch(`/api/collections/app_users/records/${encodeURIComponent(userId)}`, fields);
	if (!res.ok) {
		const body = await res.text().catch(() => '');
		log.error('pocketbase', 'patchUser failed', { userId, status: res.status, body });
		throw new Error(`patchUser failed: ${res.status} — ${body}`);
	}
}

/**
 * Create a new user via OAuth (no password). email_verified is true since the
 * provider already verified it. Throws on DB errors.
 */
export async function createOAuthUser(
	username: string,
	email: string,
	provider: string,
	oauthId: string,
	avatarUrl?: string,
	role = 'user'
): Promise<User> {
	log.info('pocketbase', 'createOAuthUser', { username, email, provider });
	const res = await pbPost('/api/collections/app_users/records', {
		username,
		password_hash: '',
		role,
		email,
		email_verified: true,
		oauth_provider: provider,
		oauth_id: oauthId,
		avatar_url: avatarUrl ?? '',
		created: new Date().toISOString()
	});
	if (!res.ok) {
		const body = await res.text().catch(() => '');
		log.error('pocketbase', 'createOAuthUser: PocketBase rejected record', {
			username,
			status: res.status,
			body
		});
		throw new Error(`Failed to create OAuth user: ${res.status} ${body}`);
	}
	return res.json() as Promise<User>;
}

/**
 * Link an OAuth provider to an existing user account.
 */
export async function linkOAuthToUser(
	userId: string,
	provider: string,
	oauthId: string
): Promise<void> {
	const res = await pbPatch(`/api/collections/app_users/records/${userId}`, {
		oauth_provider: provider,
		oauth_id: oauthId,
		email_verified: true
	});
	if (!res.ok) {
		const body = await res.text().catch(() => '');
		log.error('pocketbase', 'linkOAuthToUser: PATCH failed', { userId, status: res.status, body });
		throw new Error(`Failed to link OAuth: ${res.status}`);
	}
}

/**
 * Look up a user by verification token. Returns null if not found.
 * @deprecated Email verification removed — kept only for migration safety.
 */
export async function getUserByVerificationToken(token: string): Promise<User | null> {
	return listOne<User>('app_users', `verification_token="${token.replace(/"/g, '\\"')}"`);
}

/**
 * Create a new user with a hashed password. Throws if username already exists.
 * Stores email + verification token but does NOT log the user in.
 */
export async function createUser(
	username: string,
	password: string,
	email: string,
	role = 'user'
): Promise<User> {
	log.info('pocketbase', 'createUser: checking for existing username', { username });
	const existing = await getUserByUsername(username);
	if (existing) {
		log.warn('pocketbase', 'createUser: username already taken', { username });
		throw new Error('Username already taken');
	}
	const existingEmail = await getUserByEmail(email);
	if (existingEmail) {
		log.warn('pocketbase', 'createUser: email already in use', { email });
		throw new Error('Email already in use');
	}
	const password_hash = hashPassword(password);
	const verification_token = randomBytes(32).toString('hex');
	const verification_token_exp = new Date(Date.now() + 24 * 60 * 60 * 1000).toISOString();
	log.info('pocketbase', 'createUser: inserting new user', { username, email, role });
	const res = await pbPost('/api/collections/app_users/records', {
		username,
		password_hash,
		role,
		email,
		email_verified: false,
		verification_token,
		verification_token_exp,
		created: new Date().toISOString()
	});
	if (!res.ok) {
		const body = await res.text().catch(() => '');
		log.error('pocketbase', 'createUser: PocketBase rejected record', {
			username,
			status: res.status,
			body
		});
		throw new Error(`Failed to create user: ${res.status} ${body}`);
	}
	log.info('pocketbase', 'createUser: user created', { username, role });
	return res.json() as Promise<User>;
}

/**
 * Mark a user's email as verified and clear the verification token.
 */
export async function verifyUserEmail(userId: string): Promise<void> {
	const res = await pbPatch(`/api/collections/app_users/records/${userId}`, {
		email_verified: true,
		verification_token: '',
		verification_token_exp: ''
	});
	if (!res.ok) {
		const body = await res.text().catch(() => '');
		log.error('pocketbase', 'verifyUserEmail: PATCH failed', { userId, status: res.status, body });
		throw new Error(`Failed to verify email: ${res.status}`);
	}
	log.info('pocketbase', 'verifyUserEmail: success', { userId });
}

/**
 * Change a user's password. Verifies the current password first.
 * Returns true on success, false if currentPassword is wrong.
 * Throws on unexpected errors.
 */
export async function changePassword(
	userId: string,
	currentPassword: string,
	newPassword: string
): Promise<boolean> {
	// Fetch the user record directly by id to verify current password
	const token = await getToken();
	const res = await fetch(`${PB_URL}/api/collections/app_users/records/${userId}`, {
		headers: { Authorization: `Bearer ${token}` }
	});
	if (!res.ok) {
		const body = await res.text().catch(() => '');
		log.error('pocketbase', 'changePassword: fetch user failed', { userId, status: res.status, body });
		throw new Error(`Failed to fetch user: ${res.status}`);
	}
	const user = (await res.json()) as User;
	if (!verifyPassword(currentPassword, user.password_hash)) {
		log.warn('pocketbase', 'changePassword: wrong current password', { userId });
		return false;
	}
	const newHash = hashPassword(newPassword);
	const patch = await pbPatch(`/api/collections/app_users/records/${userId}`, {
		password_hash: newHash
	});
	if (!patch.ok) {
		const body = await patch.text().catch(() => '');
		log.error('pocketbase', 'changePassword: PATCH failed', { userId, status: patch.status, body });
		throw new Error(`Failed to update password: ${patch.status}`);
	}
	log.info('pocketbase', 'changePassword: success', { userId });
	return true;
}

/**
 * Verify username + password. Returns the user on success, null on failure.
 * Only used for legacy accounts that still have a password_hash.
 */
export async function loginUser(username: string, password: string): Promise<User | null> {
	log.debug('pocketbase', 'loginUser: lookup', { username });
	const user = await getUserByUsername(username);
	if (!user) {
		log.warn('pocketbase', 'loginUser: username not found', { username });
		return null;
	}
	if (!user.password_hash) {
		log.warn('pocketbase', 'loginUser: account has no password (OAuth-only)', { username });
		return null;
	}
	const ok = verifyPassword(password, user.password_hash);
	if (!ok) {
		log.warn('pocketbase', 'loginUser: wrong password', { username });
		return null;
	}
	log.info('pocketbase', 'loginUser: success', { username, role: user.role });
	return user;
}

// ─── User settings ────────────────────────────────────────────────────────────

function settingsFilter(sessionId: string, userId?: string): string {
	if (userId) return `user_id="${userId}"`;
	return `session_id="${sessionId}"`;
}

export async function getSettings(
	sessionId: string,
	userId?: string
): Promise<PBUserSettings | null> {
	return listOne<PBUserSettings>('user_settings', settingsFilter(sessionId, userId));
}

export async function saveSettings(
	sessionId: string,
	settings: { autoNext: boolean; voice: string; speed: number; theme?: string; locale?: string; fontFamily?: string; fontSize?: number; announceChapter?: boolean },
	userId?: string
): Promise<void> {
	const existing = await listOne<PBUserSettings & { id: string }>(
		'user_settings',
		settingsFilter(sessionId, userId)
	);

	const payload: Partial<PBUserSettings> = {
		session_id: sessionId,
		auto_next: settings.autoNext,
		voice: settings.voice,
		speed: settings.speed,
		updated: new Date().toISOString()
	};
	if (settings.theme !== undefined) payload.theme = settings.theme;
	if (settings.locale !== undefined) payload.locale = settings.locale;
	if (settings.fontFamily !== undefined) payload.font_family = settings.fontFamily;
	if (settings.fontSize !== undefined) payload.font_size = settings.fontSize;
	if (settings.announceChapter !== undefined) payload.announce_chapter = settings.announceChapter;
	if (userId) payload.user_id = userId;

	if (existing) {
		const res = await pbPatch(`/api/collections/user_settings/records/${existing.id}`, payload);
		if (!res.ok) {
			const body = await res.text().catch(() => '');
			log.error('pocketbase', 'saveSettings PATCH failed', { status: res.status, body });
		}
	} else {
		const res = await pbPost('/api/collections/user_settings/records', payload);
		if (!res.ok) {
			const body = await res.text().catch(() => '');
			log.error('pocketbase', 'saveSettings POST failed', { status: res.status, body });
		}
	}
}

// ─── Audio time ───────────────────────────────────────────────────────────────

export async function setAudioTime(
	sessionId: string,
	slug: string,
	chapter: number,
	audioTime: number,
	userId?: string
): Promise<void> {
	const existing = await listOne<Progress & { id: string }>(
		'progress',
		progressFilter(sessionId, slug, userId)
	);
	if (!existing) {
		// No progress record yet — create one with audio_time
		const payload: Partial<Progress> = {
			session_id: sessionId,
			slug,
			chapter,
			audio_time: audioTime,
			updated: new Date().toISOString()
		};
		if (userId) payload.user_id = userId;
		const res = await pbPost('/api/collections/progress/records', payload);
		if (!res.ok) {
			const body = await res.text().catch(() => '');
			log.error('pocketbase', 'setAudioTime POST failed', { slug, chapter, status: res.status, body });
		}
		return;
	}
	const res = await pbPatch(`/api/collections/progress/records/${existing.id}`, {
		audio_time: audioTime,
		updated: new Date().toISOString()
	});
	if (!res.ok) {
		const body = await res.text().catch(() => '');
		log.error('pocketbase', 'setAudioTime PATCH failed', { slug, chapter, status: res.status, body });
	}
}

// ─── Audio cache ──────────────────────────────────────────────────────────────
// There is no separate audio_cache collection — completed audio jobs in the
// audio_jobs collection serve as the cache record.  We project them here.

export interface AudioCacheEntry {
	id: string;
	cache_key: string;
	filename: string;
	updated: string;
}

// ─── Scraping tasks ───────────────────────────────────────────────────────────

export interface ScrapingTask {
	id: string;
	kind: string;
	target_url: string;
	status: string;
	books_found: number;
	chapters_scraped: number;
	chapters_skipped: number;
	from_chapter: number;
	to_chapter: number;
	errors: number;
	started: string;
	finished: string;
	error_message: string;
}

export async function listScrapingTasks(): Promise<ScrapingTask[]> {
	const cached = await cache.get<ScrapingTask[]>(SCRAPING_TASKS_CACHE_KEY);
	if (cached) return cached;
	const tasks = await listN<ScrapingTask>('scraping_tasks', 500, '', '-started');
	await cache.set(SCRAPING_TASKS_CACHE_KEY, tasks, ADMIN_JOBS_CACHE_TTL);
	return tasks;
}

export async function getScrapingTask(id: string): Promise<ScrapingTask | null> {
	return listOne<ScrapingTask>('scraping_tasks', `id="${id}"`);
}

// ─── Audio jobs ───────────────────────────────────────────────────────────────

export interface AudioJob {
	id: string;
	cache_key: string; // "slug/chapter/voice"
	slug: string;
	chapter: number;
	voice: string;
	status: string; // "pending" | "generating" | "done" | "failed"
	error_message: string;
	started: string;
	finished: string;
}

export async function listAudioJobs(): Promise<AudioJob[]> {
	const cached = await cache.get<AudioJob[]>(AUDIO_JOBS_CACHE_KEY);
	if (cached) return cached;
	const jobs = await listN<AudioJob>('audio_jobs', 500, '', '-started');
	await cache.set(AUDIO_JOBS_CACHE_KEY, jobs, ADMIN_JOBS_CACHE_TTL);
	return jobs;
}

/**
 * Returns the set of book slugs that have at least one completed audio job.
 * Used by the catalogue page to show audio-available badges.
 */
export async function getSlugsWithAudio(): Promise<Set<string>> {
	const jobs = await listAll<AudioJob>('audio_jobs', 'status="done"', 'slug');
	return new Set(jobs.map((j) => j.slug));
}

// ─── Translation jobs ─────────────────────────────────────────────────────────

export interface TranslationJob {
	id: string;
	cache_key: string; // "slug/chapter/lang"
	slug: string;
	chapter: number;
	lang: string;
	status: string; // "pending" | "running" | "done" | "failed"
	error_message: string;
	started: string;
	finished: string;
}

export async function listTranslationJobs(): Promise<TranslationJob[]> {
	const cached = await cache.get<TranslationJob[]>(TRANSLATION_JOBS_CACHE_KEY);
	if (cached) return cached;
	const jobs = await listN<TranslationJob>('translation_jobs', 500, '', '-started');
	await cache.set(TRANSLATION_JOBS_CACHE_KEY, jobs, ADMIN_JOBS_CACHE_TTL);
	return jobs;
}

export async function getAudioTime(
	sessionId: string,
	slug: string,
	chapter: number,
	userId?: string
): Promise<number | null> {
	const row = await listOne<Progress>('progress', progressFilter(sessionId, slug, userId));
	if (!row || !row.audio_time) return null;
	return row.audio_time;
}

// ─── User sessions ────────────────────────────────────────────────────────────

export interface UserSession {
	id: string;
	user_id: string;
	session_id: string; // the auth session ID embedded in the token
	user_agent: string;
	ip: string;
	device_fingerprint: string;
	created_at: string;
	last_seen: string;
}

/**
 * Generate a short device fingerprint from the user-agent alone.
 * IP is intentionally excluded so that network changes (VPN, mobile data,
 * home vs. office wifi) don't create duplicate sessions for the same device.
 * SHA-256 of the user-agent string, first 16 hex chars.
 */
function deviceFingerprint(userAgent: string, _ip?: string): string {
	return createHash('sha256')
		.update(userAgent)
		.digest('hex')
		.slice(0, 16);
}

/**
 * Upsert a session record on login.
 * - If a session already exists for this user + device fingerprint, touch it and
 *   return the existing authSessionId (so the caller can reuse the same token).
 * - Otherwise create a new record.
 * Returns `{ authSessionId, recordId }`.
 */
export async function upsertUserSession(
	userId: string,
	authSessionId: string,
	userAgent: string,
	ip: string
): Promise<{ authSessionId: string; recordId: string }> {
	const fp = deviceFingerprint(userAgent, ip);

	// Look for an existing session from the same device
	const existing = await listOne<UserSession>(
		'user_sessions',
		`user_id="${userId}" && device_fingerprint="${fp}"`
	);

	if (existing) {
		// Touch last_seen and update IP (may have changed due to network switch).
		// Return the existing authSessionId so no new session row is created.
		const token = await getToken();
		await fetch(`${PB_URL}/api/collections/user_sessions/records/${existing.id}`, {
			method: 'PATCH',
			headers: { Authorization: `Bearer ${token}`, 'Content-Type': 'application/json' },
			body: JSON.stringify({ last_seen: new Date().toISOString(), ip })
		}).catch(() => {});
		return { authSessionId: existing.session_id, recordId: existing.id };
	}

	// Create a new session record
	const now = new Date().toISOString();
	const res = await pbPost('/api/collections/user_sessions/records', {
		user_id: userId,
		session_id: authSessionId,
		user_agent: userAgent,
		ip,
		device_fingerprint: fp,
		created_at: now,
		last_seen: now
	});
	if (!res.ok) {
		const body = await res.text().catch(() => '');
		log.error('pocketbase', 'upsertUserSession POST failed', { userId, status: res.status, body });
		throw new Error(`Failed to create session: ${res.status}`);
	}
	const rec = (await res.json()) as { id: string };
	// Best-effort: prune stale/excess sessions in the background
	pruneStaleUserSessions(userId).catch(() => {});
	return { authSessionId, recordId: rec.id };
}

/**
 * @deprecated Use upsertUserSession instead.
 * Kept temporarily so callers can be migrated incrementally.
 */
export async function createUserSession(
	userId: string,
	authSessionId: string,
	userAgent: string,
	ip: string
): Promise<string> {
	const { recordId } = await upsertUserSession(userId, authSessionId, userAgent, ip);
	return recordId;
}

/**
 * Update last_seen on a session (best-effort, non-fatal if it fails).
 */
export async function touchUserSession(authSessionId: string): Promise<void> {
	const row = await listOne<UserSession & { id: string }>(
		'user_sessions',
		`session_id="${authSessionId}"`
	);
	if (!row) return;
	const token = await getToken();
	await fetch(`${PB_URL}/api/collections/user_sessions/records/${row.id}`, {
		method: 'PATCH',
		headers: { Authorization: `Bearer ${token}`, 'Content-Type': 'application/json' },
		body: JSON.stringify({ last_seen: new Date().toISOString() })
	});
}

/**
 * Check whether a session has been revoked (i.e., not present in DB).
 * Returns true if revoked/missing, false if valid.
 */
export async function isSessionRevoked(authSessionId: string): Promise<boolean> {
	const row = await listOne<UserSession>('user_sessions', `session_id="${authSessionId}"`);
	return row === null;
}

/**
 * List all active sessions for a user.
 */
export async function listUserSessions(userId: string): Promise<UserSession[]> {
	return listAll<UserSession>('user_sessions', `user_id="${userId}"`, '-last_seen');
}

/**
 * Delete sessions for a user that haven't been seen in the last `days` days,
 * and cap the total number of sessions at `maxSessions` (pruning oldest first).
 * Called on login so the list self-cleans without a separate cron job.
 */
async function pruneStaleUserSessions(
	userId: string,
	days = 30,
	maxSessions = 10
): Promise<void> {
	const token = await getToken();
	const all = await listAll<UserSession>('user_sessions', `user_id="${userId}"`, '-last_seen');

	const cutoff = new Date(Date.now() - days * 24 * 60 * 60 * 1000).toISOString();
	const toDelete = new Set<string>();

	// Mark stale sessions
	for (const s of all) {
		if (s.last_seen < cutoff) toDelete.add(s.id);
	}

	// Mark excess sessions beyond the cap (oldest first — list is sorted -last_seen)
	const remaining = all.filter((s) => !toDelete.has(s.id));
	if (remaining.length > maxSessions) {
		remaining.slice(maxSessions).forEach((s) => toDelete.add(s.id));
	}

	if (toDelete.size === 0) return;

	await Promise.all(
		[...toDelete].map((id) =>
			fetch(`${PB_URL}/api/collections/user_sessions/records/${id}`, {
				method: 'DELETE',
				headers: { Authorization: `Bearer ${token}` }
			}).catch(() => {})
		)
	);
}

/**
 * Revoke (delete) a specific session by its PocketBase record ID.
 * Only allows deletion if the session belongs to the given userId.
 */
export async function revokeUserSession(recordId: string, userId: string): Promise<boolean> {
	// Verify ownership before deleting
	const token = await getToken();
	const res = await fetch(`${PB_URL}/api/collections/user_sessions/records/${recordId}`, {
		headers: { Authorization: `Bearer ${token}` }
	});
	if (!res.ok) return false;
	const rec = (await res.json()) as UserSession;
	if (rec.user_id !== userId) return false;

	const del = await fetch(`${PB_URL}/api/collections/user_sessions/records/${recordId}`, {
		method: 'DELETE',
		headers: { Authorization: `Bearer ${token}` }
	});
	return del.ok || del.status === 204;
}

/**
 * Revoke all sessions for a user (used on password change etc).
 */
export async function revokeAllUserSessions(userId: string): Promise<void> {
	const sessions = await listUserSessions(userId);
	const token = await getToken();
	await Promise.all(
		sessions.map((s) =>
			fetch(`${PB_URL}/api/collections/user_sessions/records/${s.id}`, {
				method: 'DELETE',
				headers: { Authorization: `Bearer ${token}` }
			}).catch(() => {})
		)
	);
}

/**
 * Update the avatar_url field for a user record.
 */
export async function updateUserAvatarUrl(userId: string, avatarUrl: string): Promise<void> {
	const token = await getToken();
	const res = await fetch(`${PB_URL}/api/collections/app_users/records/${userId}`, {
		method: 'PATCH',
		headers: { Authorization: `Bearer ${token}`, 'Content-Type': 'application/json' },
		body: JSON.stringify({ avatar_url: avatarUrl })
	});
	if (!res.ok) {
		const body = await res.text().catch(() => '');
		throw new Error(`updateUserAvatarUrl failed: ${res.status} ${body}`);
	}
}

// ─── Comments ─────────────────────────────────────────────────────────────────

export interface PBBookComment {
	id: string;
	slug: string;
	chapter?: number;   // 0 or absent = book-level; N = chapter N
	user_id: string;
	username: string;
	body: string;
	upvotes: number;
	downvotes: number;
	created: string;
	parent_id?: string; // empty / absent = top-level; set = reply
}

export interface CommentVote {
	id: string;
	comment_id: string;
	user_id: string;
	session_id: string;
	vote: 'up' | 'down';
}

export type CommentSort = 'top' | 'new';

/**
 * List top-level comments for a book or a specific chapter.
 * chapter=0 (default) → book-level comments only
 * chapter=N           → comments for chapter N only
 * sort='top'  → by net score (upvotes − downvotes) desc, then newest
 * sort='new'  → newest first (default)
 * Replies (parent_id != "") are NOT included — fetch them separately.
 */
export async function listComments(
	slug: string,
	sort: CommentSort = 'new',
	chapter = 0
): Promise<PBBookComment[]> {
	const token = await getToken();
	const slugEsc = slug.replace(/"/g, '\\"');
	const chapterFilter = chapter > 0
		? `&&chapter=${chapter}`
		: `&&(chapter=0||chapter=null)`;
	const filter = encodeURIComponent(`slug="${slugEsc}"${chapterFilter}&&(parent_id=""||parent_id=null)`);
	const res = await fetch(
		`${PB_URL}/api/collections/book_comments/records?filter=${filter}&sort=-created&perPage=200`,
		{ headers: { Authorization: `Bearer ${token}` } }
	);
	if (!res.ok) return [];
	const data = await res.json();
	let items = (data.items ?? []) as PBBookComment[];
	if (sort === 'top') {
		items = items.sort((a, b) => {
			const scoreB = (b.upvotes ?? 0) - (b.downvotes ?? 0);
			const scoreA = (a.upvotes ?? 0) - (a.downvotes ?? 0);
			if (scoreB !== scoreA) return scoreB - scoreA;
			return new Date(b.created).getTime() - new Date(a.created).getTime();
		});
	}
	return items;
}

/**
 * Count unique readers for a book in the last 7 days.
 * Uses progress.updated timestamp; counts both session-based and user-based.
 */
export async function countReadersThisWeek(slug: string): Promise<number> {
	const token = await getToken();
	const cutoff = new Date(Date.now() - 7 * 24 * 60 * 60 * 1000).toISOString();
	const filter = encodeURIComponent(`slug="${slug.replace(/"/g, '\\"')}"&&updated>"${cutoff}"`);
	const res = await fetch(
		`${PB_URL}/api/collections/progress/records?filter=${filter}&perPage=500&fields=user_id,session_id`,
		{ headers: { Authorization: `Bearer ${token}` } }
	);
	if (!res.ok) return 0;
	const data = await res.json();
	const items = (data.items ?? []) as { user_id?: string; session_id?: string }[];
	// Deduplicate: prefer user_id when present, fall back to session_id
	const unique = new Set(items.map((r) => r.user_id || r.session_id || '').filter(Boolean));
	return unique.size;
}

/**
 * List replies (1-level deep) for a single parent comment.
 * Always sorted oldest-first so the conversation reads naturally.
 */
export async function listReplies(parentId: string): Promise<PBBookComment[]> {
	const token = await getToken();
	const filter = encodeURIComponent(`parent_id="${parentId.replace(/"/g, '\\"')}"`);
	const res = await fetch(
		`${PB_URL}/api/collections/book_comments/records?filter=${filter}&sort=created&perPage=100`,
		{ headers: { Authorization: `Bearer ${token}` } }
	);
	if (!res.ok) return [];
	const data = await res.json();
	return (data.items ?? []) as PBBookComment[];
}

/**
 * Create a new comment. Returns the created record.
 * Pass parentId to create a reply; omit / pass undefined for a top-level comment.
 */
export async function createComment(
	slug: string,
	body: string,
	userId: string | undefined,
	username: string,
	parentId?: string,
	chapter = 0
): Promise<PBBookComment> {
	const token = await getToken();
	const res = await fetch(`${PB_URL}/api/collections/book_comments/records`, {
		method: 'POST',
		headers: { Authorization: `Bearer ${token}`, 'Content-Type': 'application/json' },
		body: JSON.stringify({
			id: crypto.randomUUID().replace(/-/g, '').slice(0, 15),
			slug,
			chapter,
			body,
			user_id: userId ?? '',
			username,
			upvotes: 0,
			downvotes: 0,
			parent_id: parentId ?? '',
			created: new Date().toISOString()
		})
	});
	if (!res.ok) {
		const text = await res.text().catch(() => '');
		throw new Error(`createComment failed: ${res.status} ${text}`);
	}
	return res.json() as Promise<PBBookComment>;
}

/**
 * Delete a comment (and optionally its replies) by ID.
 * Only the comment owner (matched by userId) may delete.
 * Throws if the comment doesn't exist or the user doesn't own it.
 */
export async function deleteComment(commentId: string, userId: string): Promise<void> {
	const token = await getToken();

	// Fetch the comment to verify ownership
	const getRes = await fetch(`${PB_URL}/api/collections/book_comments/records/${commentId}`, {
		headers: { Authorization: `Bearer ${token}` }
	});
	if (!getRes.ok) throw new Error(`Comment not found: ${commentId}`);
	const comment = (await getRes.json()) as PBBookComment;
	if (comment.user_id !== userId) throw new Error('Not authorized to delete this comment');

	// Delete any replies first
	const repliesFilter = encodeURIComponent(`parent_id="${commentId.replace(/"/g, '\\"')}"`);
	const repliesRes = await fetch(
		`${PB_URL}/api/collections/book_comments/records?filter=${repliesFilter}&perPage=100`,
		{ headers: { Authorization: `Bearer ${token}` } }
	);
	if (repliesRes.ok) {
		const repliesData = await repliesRes.json();
		const replies = (repliesData.items ?? []) as PBBookComment[];
		await Promise.all(
			replies.map((r) =>
				fetch(`${PB_URL}/api/collections/book_comments/records/${r.id}`, {
					method: 'DELETE',
					headers: { Authorization: `Bearer ${token}` }
				})
			)
		);
	}

	// Delete the comment itself
	const delRes = await fetch(`${PB_URL}/api/collections/book_comments/records/${commentId}`, {
		method: 'DELETE',
		headers: { Authorization: `Bearer ${token}` }
	});
	if (!delRes.ok) throw new Error(`deleteComment failed: ${delRes.status}`);
}

/**
 * Get an existing vote by this voter (identified by user_id or session_id) on a comment.
 */
export async function getCommentVote(
	commentId: string,
	sessionId: string,
	userId?: string
): Promise<CommentVote | null> {
	const token = await getToken();
	const voterFilter = userId
		? `comment_id="${commentId}"&&user_id="${userId}"`
		: `comment_id="${commentId}"&&session_id="${sessionId}"`;
	const res = await fetch(
		`${PB_URL}/api/collections/comment_votes/records?filter=${encodeURIComponent(voterFilter)}&perPage=1`,
		{ headers: { Authorization: `Bearer ${token}` } }
	);
	if (!res.ok) return null;
	const data = await res.json();
	const items = (data.items ?? []) as CommentVote[];
	return items[0] ?? null;
}

/**
 * Cast or change a vote on a comment. Handles:
 * - New vote: creates vote record, increments counter.
 * - Same vote again: removes it (toggle off), decrements counter.
 * - Changed vote: updates record, adjusts both counters.
 * Returns the updated comment.
 */
export async function voteComment(
	commentId: string,
	vote: 'up' | 'down',
	sessionId: string,
	userId?: string
): Promise<PBBookComment> {
	const token = await getToken();

	// Fetch current comment
	const commentRes = await fetch(`${PB_URL}/api/collections/book_comments/records/${commentId}`, {
		headers: { Authorization: `Bearer ${token}` }
	});
	if (!commentRes.ok) throw new Error(`Comment not found: ${commentId}`);
	const comment = (await commentRes.json()) as PBBookComment;

	const existing = await getCommentVote(commentId, sessionId, userId);

	let upDelta = 0;
	let downDelta = 0;

	if (!existing) {
		// New vote
		await fetch(`${PB_URL}/api/collections/comment_votes/records`, {
			method: 'POST',
			headers: { Authorization: `Bearer ${token}`, 'Content-Type': 'application/json' },
			body: JSON.stringify({ comment_id: commentId, user_id: userId ?? '', session_id: sessionId, vote })
		});
		vote === 'up' ? upDelta++ : downDelta++;
	} else if (existing.vote === vote) {
		// Toggle off — remove vote
		await fetch(`${PB_URL}/api/collections/comment_votes/records/${existing.id}`, {
			method: 'DELETE',
			headers: { Authorization: `Bearer ${token}` }
		});
		vote === 'up' ? upDelta-- : downDelta--;
	} else {
		// Changed vote
		await fetch(`${PB_URL}/api/collections/comment_votes/records/${existing.id}`, {
			method: 'PATCH',
			headers: { Authorization: `Bearer ${token}`, 'Content-Type': 'application/json' },
			body: JSON.stringify({ vote })
		});
		if (vote === 'up') { upDelta++; downDelta--; }
		else { upDelta--; downDelta++; }
	}

	// Patch comment counters
	const patchRes = await fetch(`${PB_URL}/api/collections/book_comments/records/${commentId}`, {
		method: 'PATCH',
		headers: { Authorization: `Bearer ${token}`, 'Content-Type': 'application/json' },
		body: JSON.stringify({
			upvotes: Math.max(0, (comment.upvotes ?? 0) + upDelta),
			downvotes: Math.max(0, (comment.downvotes ?? 0) + downDelta)
		})
	});
	if (!patchRes.ok) throw new Error(`Failed to update vote counts on comment ${commentId}`);
	return patchRes.json() as Promise<PBBookComment>;
}

/**
 * Fetch votes cast by this session/user, keyed by comment_id.
 * Returns a map of commentId → 'up' | 'down'.
 */
export async function getMyVotes(
	commentIds: string[],
	sessionId: string,
	userId?: string
): Promise<Record<string, 'up' | 'down'>> {
	if (commentIds.length === 0) return {};
	const token = await getToken();
	const idFilter = commentIds.map((id) => `comment_id="${id}"`).join('||');
	const voterPart = userId ? `user_id="${userId}"` : `session_id="${sessionId}"`;
	const filter = encodeURIComponent(`(${idFilter})&&${voterPart}`);
	const res = await fetch(
		`${PB_URL}/api/collections/comment_votes/records?filter=${filter}&perPage=200`,
		{ headers: { Authorization: `Bearer ${token}` } }
	);
	if (!res.ok) return {};
	const data = await res.json();
	const map: Record<string, 'up' | 'down'> = {};
	for (const v of (data.items ?? []) as CommentVote[]) {
		map[v.comment_id] = v.vote as 'up' | 'down';
	}
	return map;
}

// ─── User subscriptions ───────────────────────────────────────────────────────

export interface UserSubscription {
	id: string;
	follower_id: string;
	followee_id: string;
	created: string;
}

/**
 * Returns the subscription record if follower_id follows followee_id, else null.
 */
export async function getSubscription(
	followerId: string,
	followeeId: string
): Promise<UserSubscription | null> {
	const filter = encodeURIComponent(`follower_id="${followerId}"&&followee_id="${followeeId}"`);
	const res = await pbGet<{ items: UserSubscription[]; totalItems: number }>(
		`/api/collections/user_subscriptions/records?filter=${filter}&perPage=1`
	).catch(() => null);
	return res?.items?.[0] ?? null;
}

/**
 * Subscribe follower_id to followee_id. No-ops if already subscribed.
 * Returns the subscription record.
 */
export async function subscribe(followerId: string, followeeId: string): Promise<void> {
	const existing = await getSubscription(followerId, followeeId);
	if (existing) return;
	const res = await pbPost('/api/collections/user_subscriptions/records', {
		follower_id: followerId,
		followee_id: followeeId,
		created: new Date().toISOString()
	});
	if (!res.ok) {
		const body = await res.text().catch(() => '');
		throw new Error(`Failed to subscribe: ${res.status} — ${body}`);
	}
}

/**
 * Unsubscribe follower_id from followee_id. No-ops if not subscribed.
 */
export async function unsubscribe(followerId: string, followeeId: string): Promise<void> {
	const existing = await getSubscription(followerId, followeeId);
	if (!existing) return;
	const token = await getToken();
	await fetch(`${PB_URL}/api/collections/user_subscriptions/records/${existing.id}`, {
		method: 'DELETE',
		headers: { Authorization: `Bearer ${token}` }
	});
}

/**
 * Returns the list of user IDs that followerId is subscribed to.
 */
export async function getFollowingIds(followerId: string): Promise<string[]> {
	const items = await listAll<UserSubscription>(
		'user_subscriptions',
		`follower_id="${followerId}"`,
		'-created'
	).catch(() => [] as UserSubscription[]);
	return items.map((s) => s.followee_id);
}

/**
 * Returns the count of subscribers (followers) for a given user.
 */
export async function getFollowerCount(followeeId: string): Promise<number> {
	return countCollection('user_subscriptions', `followee_id="${followeeId}"`).catch(() => 0);
}

/**
 * Returns the count of accounts a user is following.
 */
export async function getFollowingCount(followerId: string): Promise<number> {
	return countCollection('user_subscriptions', `follower_id="${followerId}"`).catch(() => 0);
}

/**
 * Public profile data for a user.
 */
export interface PublicProfile {
	id: string;
	username: string;
	avatar_url?: string;
	created: string;
	followerCount: number;
	followingCount: number;
}

/**
 * Returns a user's public profile (no sensitive fields) by username.
 */
export async function getPublicProfile(username: string): Promise<PublicProfile | null> {
	const user = await getUserByUsername(username);
	if (!user) return null;
	const [followerCount, followingCount] = await Promise.all([
		getFollowerCount(user.id),
		getFollowingCount(user.id)
	]);
	return {
		id: user.id,
		username: user.username,
		avatar_url: user.avatar_url,
		created: user.created,
		followerCount,
		followingCount
	};
}

/**
 * Returns a user's public library: books they have saved or are reading.
 * Only includes books with progress or explicit saves (user_library).
 */
export async function getUserPublicLibrary(
	userId: string
): Promise<Array<{ book: Book; chapter: number | null; saved: boolean }>> {
	const [allBooks, progressList, savedEntries] = await Promise.all([
		listBooks(),
		listAll<Progress>('progress', `user_id="${userId}"`, '-updated').catch(() => [] as Progress[]),
		listAll<{ id: string; slug: string; saved_at: string }>(
			'user_library',
			`user_id="${userId}"`,
			'-saved_at'
		).catch(() => [] as { id: string; slug: string; saved_at: string }[])
	]);

	const bookMap = new Map<string, Book>(allBooks.map((b) => [b.slug, b]));
	const result: Array<{ book: Book; chapter: number | null; saved: boolean }> = [];
	const seen = new Set<string>();

	// Books with progress first (most recently read)
	for (const p of progressList) {
		const book = bookMap.get(p.slug);
		if (!book || seen.has(p.slug)) continue;
		seen.add(p.slug);
		result.push({ book, chapter: p.chapter, saved: false });
	}

	// Saved-only books next
	for (const e of savedEntries) {
		const book = bookMap.get(e.slug);
		if (!book || seen.has(e.slug)) continue;
		seen.add(e.slug);
		result.push({ book, chapter: null, saved: true });
	}

	// Mark saved flag for books that are both in progress AND saved
	const savedSlugs = new Set(savedEntries.map((e) => e.slug));
	return result.map((r) => ({ ...r, saved: savedSlugs.has(r.book.slug) }));
}

/**
 * Returns the currently-reading books (books with progress, not completed)
 * for a given user ID.
 */
export async function getUserCurrentlyReading(
	userId: string
): Promise<Array<{ book: Book; chapter: number }>> {
	const [allBooks, progressList] = await Promise.all([
		listBooks(),
		listAll<Progress>('progress', `user_id="${userId}"`, '-updated').catch(() => [] as Progress[])
	]);
	const bookMap = new Map<string, Book>(allBooks.map((b) => [b.slug, b]));
	return progressList
		.filter((p) => {
			const book = bookMap.get(p.slug);
			return book && p.chapter > 0 && p.chapter < book.total_chapters;
		})
		.slice(0, 10)
		.map((p) => ({ book: bookMap.get(p.slug)!, chapter: p.chapter }));
}

/**
 * Returns recently-updated books from ALL users that followerId is subscribed to.
 * Deduplicates across followed users; sorts by most recently updated.
 */
export async function getSubscriptionFeed(
	followerId: string,
	limit = 12
): Promise<Array<{ book: Book; readerUsername: string }>> {
	const followingIds = await getFollowingIds(followerId);
	if (followingIds.length === 0) return [];

	// Fetch all users we follow (for display names)
	const token = await getToken();
	const userFetches = followingIds.map((id) =>
		fetch(`${PB_URL}/api/collections/app_users/records/${id}`, {
			headers: { Authorization: `Bearer ${token}` }
		})
			.then((r) => (r.ok ? (r.json() as Promise<User>) : null))
			.catch(() => null)
	);
	const users = (await Promise.all(userFetches)).filter(Boolean) as User[];
	const userMap = new Map<string, User>(users.map((u) => [u.id, u]));

	// Fetch progress for each followed user
	const progressFetches = followingIds.map((id) =>
		listAll<Progress>('progress', `user_id="${id}"`, '-updated').catch(() => [] as Progress[])
	);
	const allProgressArrays = await Promise.all(progressFetches);

	const allBooks = await listBooks();
	const bookMap = new Map<string, Book>(allBooks.map((b) => [b.slug, b]));

	// Merge: per slug take the most-recent progress entry
	const seen = new Set<string>();
	const feed: Array<{ book: Book; readerUsername: string; updated: string }> = [];

	for (let i = 0; i < followingIds.length; i++) {
		const uid = followingIds[i];
		const username = userMap.get(uid)?.username ?? 'unknown';
		for (const p of allProgressArrays[i]) {
			if (seen.has(p.slug)) continue;
			const book = bookMap.get(p.slug);
			if (!book) continue;
			seen.add(p.slug);
			feed.push({ book, readerUsername: username, updated: p.updated });
		}
	}

	// Sort by most recently read across all followed users
	feed.sort((a, b) => b.updated.localeCompare(a.updated));
	return feed.slice(0, limit).map(({ book, readerUsername }) => ({ book, readerUsername }));
}

// ─── Discovery ────────────────────────────────────────────────────────────────
// NOTE: Requires a `discovery_votes` collection in PocketBase with fields:
//   - session_id (text, required)
//   - user_id    (text, optional)
//   - slug       (text, required)
//   - action     (text, required) — one of: like | skip | nope | read_now

export interface DiscoveryVote {
	id?: string;
	session_id: string;
	user_id?: string;
	slug: string;
	action: 'like' | 'skip' | 'nope' | 'read_now';
}

export interface DiscoveryPrefs {
	genres: string[];
	status: 'either' | 'ongoing' | 'completed';
}

function parseGenresLocal(genres: string[] | string): string[] {
	if (Array.isArray(genres)) return genres;
	if (!genres) return [];
	try { return JSON.parse(genres) as string[]; } catch { return []; }
}

function discoveryFilter(sessionId: string, userId?: string): string {
	if (userId) return `user_id="${userId}"`;
	return `session_id="${sessionId}"`;
}

export async function getVotedSlugs(sessionId: string, userId?: string): Promise<Set<string>> {
	const rows = await listAll<DiscoveryVote>(
		'discovery_votes',
		discoveryFilter(sessionId, userId)
	).catch(() => [] as DiscoveryVote[]);
	return new Set(rows.map((r) => r.slug));
}

export async function upsertDiscoveryVote(
	sessionId: string,
	slug: string,
	action: DiscoveryVote['action'],
	userId?: string
): Promise<void> {
	const filter = userId
		? `user_id="${userId}"&&slug="${slug}"`
		: `session_id="${sessionId}"&&slug="${slug}"`;
	const existing = await listOne<DiscoveryVote & { id: string }>('discovery_votes', filter);
	const payload: Partial<DiscoveryVote> = { session_id: sessionId, slug, action };
	if (userId) payload.user_id = userId;

	if (existing) {
		const res = await pbPatch(`/api/collections/discovery_votes/records/${existing.id}`, payload);
		if (!res.ok) log.warn('pocketbase', 'upsertDiscoveryVote PATCH failed', { slug, status: res.status });
	} else {
		const res = await pbPost('/api/collections/discovery_votes/records', payload);
		if (!res.ok) log.warn('pocketbase', 'upsertDiscoveryVote POST failed', { slug, status: res.status });
	}
}

export async function clearDiscoveryVotes(sessionId: string, userId?: string): Promise<void> {
	const filter = discoveryFilter(sessionId, userId);
	const rows = await listAll<DiscoveryVote & { id: string }>('discovery_votes', filter).catch(() => []);
	await Promise.all(
		rows.map((r) =>
			pbDelete(`/api/collections/discovery_votes/records/${r.id}`).catch(() => {})
		)
	);
}

// ─── Ratings ──────────────────────────────────────────────────────────────────

export interface BookRating {
	session_id: string;
	user_id?: string;
	slug: string;
	rating: number; // 1–5
}

export async function getBookRating(
	sessionId: string,
	slug: string,
	userId?: string
): Promise<number> {
	const filter = userId
		? `(session_id="${sessionId}" || user_id="${userId}") && slug="${slug}"`
		: `session_id="${sessionId}" && slug="${slug}"`;
	const row = await listOne<BookRating>('book_ratings', filter).catch(() => null);
	return row?.rating ?? 0;
}

export async function getBookAvgRating(
	slug: string
): Promise<{ avg: number; count: number }> {
	const rows = await listAll<BookRating>('book_ratings', `slug="${slug}"`).catch(() => []);
	if (!rows.length) return { avg: 0, count: 0 };
	const avg = rows.reduce((s, r) => s + r.rating, 0) / rows.length;
	return { avg: Math.round(avg * 10) / 10, count: rows.length };
}

export async function setBookRating(
	sessionId: string,
	slug: string,
	rating: number,
	userId?: string
): Promise<void> {
	const filter = userId
		? `(session_id="${sessionId}" || user_id="${userId}") && slug="${slug}"`
		: `session_id="${sessionId}" && slug="${slug}"`;
	const existing = await listOne<BookRating & { id: string }>('book_ratings', filter).catch(() => null);
	const payload: Partial<BookRating> = { session_id: sessionId, slug, rating };
	if (userId) payload.user_id = userId;
	if (existing) {
		await pbPatch(`/api/collections/book_ratings/records/${existing.id}`, payload);
	} else {
		await pbPost('/api/collections/book_ratings/records', payload);
	}
	await cache.invalidate(RATINGS_CACHE_KEY);
}

// ─── Shelves ───────────────────────────────────────────────────────────────────

export type ShelfName = '' | 'plan_to_read' | 'completed' | 'dropped';

export async function updateBookShelf(
	sessionId: string,
	slug: string,
	shelf: ShelfName,
	userId?: string
): Promise<void> {
	const filter = userId
		? `(session_id="${sessionId}" || user_id="${userId}") && slug="${slug}"`
		: `session_id="${sessionId}" && slug="${slug}"`;
	const existing = await listOne<{ id: string }>('user_library', filter).catch(() => null);
	if (!existing) {
		// Save + set shelf in one shot
		const payload: Record<string, unknown> = { session_id: sessionId, slug, shelf, saved_at: new Date().toISOString() };
		if (userId) payload.user_id = userId;
		await pbPost('/api/collections/user_library/records', payload);
	} else {
		await pbPatch(`/api/collections/user_library/records/${existing.id}`, { shelf });
	}
}

export async function getShelfMap(
	sessionId: string,
	userId?: string
): Promise<Record<string, ShelfName>> {
	const filter = userId
		? `session_id="${sessionId}" || user_id="${userId}"`
		: `session_id="${sessionId}"`;
	const rows = await listAll<{ slug: string; shelf: string }>('user_library', filter).catch(() => []);
	const map: Record<string, ShelfName> = {};
	for (const r of rows) map[r.slug] = (r.shelf as ShelfName) || '';
	return map;
}

export async function getBooksForDiscovery(
	sessionId: string,
	userId?: string,
	prefs?: DiscoveryPrefs
): Promise<Book[]> {
	const [allBooks, votedSlugs, savedSlugs] = await Promise.all([
		listBooks(),
		getVotedSlugs(sessionId, userId),
		getSavedSlugs(sessionId, userId)
	]);

	let candidates = allBooks.filter((b) => !votedSlugs.has(b.slug) && !savedSlugs.has(b.slug));

	if (prefs?.genres?.length) {
		const preferred = new Set(prefs.genres.map((g) => g.toLowerCase()));
		const genreFiltered = candidates.filter((b) => {
			const genres = parseGenresLocal(b.genres);
			return genres.some((g) => preferred.has(g.toLowerCase()));
		});
		if (genreFiltered.length >= 5) candidates = genreFiltered;
	}

	if (prefs?.status && prefs.status !== 'either') {
		const sf = candidates.filter((b) => b.status?.toLowerCase().includes(prefs.status));
		if (sf.length >= 3) candidates = sf;
	}

	// Fetch avg ratings for candidates, weight top-rated books to surface earlier.
	// Fetch in one shot for all candidate slugs. Low-rated / unrated books still
	// appear — they're just pushed further back via a stable sort before shuffle.
	const ratingRows = await getAllRatings();
	const ratingMap = new Map<string, { sum: number; count: number }>();
	for (const r of ratingRows) {
		const cur = ratingMap.get(r.slug) ?? { sum: 0, count: 0 };
		cur.sum += r.rating;
		cur.count += 1;
		ratingMap.set(r.slug, cur);
	}
	const avgRating = (slug: string) => {
		const e = ratingMap.get(slug);
		return e && e.count > 0 ? e.sum / e.count : 0;
	};

	// Sort by avg desc (unrated = 0, treated as unknown → middle of pack after rated)
	// Then apply Fisher-Yates only within each rating tier so ordering feels natural.
	candidates.sort((a, b) => avgRating(b.slug) - avgRating(a.slug));

	// Shuffle within rating tiers (±0.5 star buckets) to avoid pure determinism
	const tierOf = (slug: string) => Math.round(avgRating(slug) * 2); // 0–10
	let start = 0;
	while (start < candidates.length) {
		let end = start + 1;
		while (end < candidates.length && tierOf(candidates[end].slug) === tierOf(candidates[start].slug)) end++;
		for (let i = end - 1; i > start; i--) {
			const j = start + Math.floor(Math.random() * (i - start + 1));
			[candidates[i], candidates[j]] = [candidates[j], candidates[i]];
		}
		start = end;
	}

	return candidates.slice(0, 50);
}

// ─── Discovery history ─────────────────────────────────────────────────────────

export interface VotedBook {
	slug: string;
	action: DiscoveryVote['action'];
	votedAt: string;
	book?: Book;
}

export async function getVotedBooks(
	sessionId: string,
	userId?: string
): Promise<VotedBook[]> {
	const votes = await listAll<DiscoveryVote & { id: string; created: string }>(
		'discovery_votes',
		discoveryFilter(sessionId, userId),
		'-created'
	).catch(() => []);

	if (!votes.length) return [];

	const slugs = [...new Set(votes.map((v) => v.slug))];
	const books = await getBooksBySlugs(new Set(slugs)).catch(() => [] as Book[]);
	const bookMap = new Map(books.map((b) => [b.slug, b]));

	return votes.map((v) => ({
		slug: v.slug,
		action: v.action,
		votedAt: v.created,
		book: bookMap.get(v.slug)
	}));
}

export async function undoDiscoveryVote(
	sessionId: string,
	slug: string,
	userId?: string
): Promise<void> {
	const filter = `${discoveryFilter(sessionId, userId)}&&slug="${slug}"`;
	const row = await listOne<{ id: string }>('discovery_votes', filter).catch(() => null);
	if (row) {
		await pbDelete(`/api/collections/discovery_votes/records/${row.id}`).catch(() => {});
	}
}

// ─── User stats ────────────────────────────────────────────────────────────────

export interface UserStats {
	totalChaptersRead: number;
	booksReading: number;
	booksCompleted: number;
	booksPlanToRead: number;
	booksDropped: number;
	topGenres: string[];       // top 3 by frequency
	avgRatingGiven: number;    // 0 if no ratings
	streak: number;            // consecutive days with progress
}

export async function getUserStats(
	sessionId: string,
	userId?: string
): Promise<UserStats> {
	const filter = userId ? `user_id="${userId}"` : `session_id="${sessionId}"`;

	const [progressRows, libraryRows, ratingRows, allBooks] = await Promise.all([
		listAll<Progress & { updated: string }>('progress', filter, '-updated').catch(() => []),
		listAll<{ slug: string; shelf: string }>('user_library', filter).catch(() => []),
		listAll<BookRating>('book_ratings', filter).catch(() => []),
		listBooks().catch(() => [] as Book[])
	]);

	// shelf counts
	const shelfCounts = { reading: 0, completed: 0, plan_to_read: 0, dropped: 0 };
	for (const r of libraryRows) {
		const s = r.shelf || 'reading';
		if (s in shelfCounts) shelfCounts[s as keyof typeof shelfCounts]++;
	}

	// top genres from books in progress/library
	const libSlugs = new Set(libraryRows.map((r) => r.slug));
	const progSlugs = new Set(progressRows.map((r) => r.slug));
	const allSlugs = new Set([...libSlugs, ...progSlugs]);
	const bookMap = new Map(allBooks.map((b) => [b.slug, b]));
	const genreFreq = new Map<string, number>();
	for (const slug of allSlugs) {
		const book = bookMap.get(slug);
		if (!book) continue;
		for (const g of parseGenresLocal(book.genres)) {
			genreFreq.set(g, (genreFreq.get(g) ?? 0) + 1);
		}
	}
	const topGenres = [...genreFreq.entries()]
		.sort((a, b) => b[1] - a[1])
		.slice(0, 3)
		.map(([g]) => g);

	// avg rating given
	const avgRatingGiven =
		ratingRows.length > 0
			? Math.round((ratingRows.reduce((s, r) => s + r.rating, 0) / ratingRows.length) * 10) / 10
			: 0;

	// reading streak: count consecutive calendar days (UTC) with a progress update
	const days = new Set(
		progressRows
			.filter((r) => r.updated)
			.map((r) => r.updated.slice(0, 10))
	);
	let streak = 0;
	const today = new Date();
	for (let i = 0; i < 365; i++) {
		const d = new Date(today);
		d.setUTCDate(d.getUTCDate() - i);
		if (days.has(d.toISOString().slice(0, 10))) streak++;
		else if (i > 0) break; // gap — stop
	}

	return {
		totalChaptersRead: progressRows.length,
		booksReading: shelfCounts.reading,
		booksCompleted: shelfCounts.completed,
		booksPlanToRead: shelfCounts.plan_to_read,
		booksDropped: shelfCounts.dropped,
		topGenres,
		avgRatingGiven,
		streak
	};
}

// ─── AI Jobs ──────────────────────────────────────────────────────────────────

const AI_JOBS_CACHE_KEY = 'admin:ai_jobs';
const AI_JOBS_CACHE_TTL = 30; // 30 seconds — same as other admin job lists

/**
 * List all AI jobs from PocketBase, sorted by started descending.
 * Short-lived cache (30s) to avoid hammering PocketBase on every navigation.
 */
export async function listAIJobs(): Promise<AIJob[]> {
	const cached = await cache.get<AIJob[]>(AI_JOBS_CACHE_KEY);
	if (cached) return cached;
	const jobs = await listAll<AIJob>('ai_jobs', '', '-started');
	await cache.set(AI_JOBS_CACHE_KEY, jobs, AI_JOBS_CACHE_TTL);
	return jobs;
}
