import { db as defaultDb } from '$lib/server/db';
import { dailyVerses, verseSubmissions } from '$lib/server/db/schema';
import { eq, desc, sql } from 'drizzle-orm';
import { getBookById } from '$lib/server/bible';

// Drizzle instance type alias (the production db or the test db).
export type Db = typeof defaultDb;
import {
	composeVerseWindow,
	formatWindowReference,
	getChapterCount,
	getVerseCount
} from '$lib/server/xml-bible';

// Server-side constants for the community-verse-submission feature
// (spec: "Rate Limiting & Cooldown", "Scheduling Algorithm").
const DAY_MS = 1000 * 60 * 60 * 24;
export const COOLDOWN_MS = 7 * DAY_MS;
export const REPEAT_WINDOW_DAYS = 60;
const MAX_SCHEDULE_RETRIES = 5;
// Safety cap on the day-by-day forward scan. 10k days (~27 years) is well
// beyond any realistic calendar density; prevents an accidental infinite loop.
const MAX_SCAN_DAYS = 10_000;

export interface SubmissionInput {
	bookId: string;
	chapter: number;
	verse: number;
}

export interface ScheduleResult {
	scheduledDate: string;
	reference: string;
	windowText: string;
	bookId: string;
}

/** Add `n` days to a YYYY-MM-DD string using pure UTC arithmetic. */
export function addDays(dateStr: string, n: number): string {
	const d = new Date(dateStr + 'T00:00:00Z');
	d.setUTCDate(d.getUTCDate() + n);
	return d.toISOString().slice(0, 10);
}

/** Whole-day difference (b - a) between two YYYY-MM-DD strings, UTC. */
export function dayDiff(a: string, b: string): number {
	const ta = new Date(a + 'T00:00:00Z').getTime();
	const tb = new Date(b + 'T00:00:00Z').getTime();
	return Math.round((tb - ta) / DAY_MS);
}

/** Current server UTC date as YYYY-MM-DD. */
export function todayUtcStr(): string {
	return new Date().toISOString().slice(0, 10);
}

/**
 * The canonical identity of a 3-verse window, used by the 60-day repeat rule.
 * Combines bookId + formatted reference so identical windows collide regardless
 * of how the underlying row was produced (submission vs. random gap-fill).
 */
function windowRef(bookId: string, reference: string): string {
	return `${bookId}|${reference}`;
}

/** Is this a SQLite unique-constraint violation (used for retry-on-conflict)? */
export function isUniqueConstraintError(err: unknown): boolean {
	const e = err as { code?: string; message?: string } | null;
	return !!e && (
		e.code === 'SQLITE_CONSTRAINT_UNIQUE' ||
		e.code === 'SQLITE_CONSTRAINT' ||
		!!(e.message && /UNIQUE/i.test(e.message))
	);
}

export interface CooldownState {
	/** True if the user submitted within the last 7 days. */
	onCooldown: boolean;
	/** Server UTC millis when the cooldown expires, or null. */
	cooldownEndsAt: number | null;
}

/** Compute the rolling 7-day cooldown state for a user (server UTC). */
export async function getCooldownState(
	db: Db,
	userId: string,
	now: number = Date.now()
): Promise<CooldownState> {
	const [last] = await db
		.select({ submittedAt: verseSubmissions.submittedAt })
		.from(verseSubmissions)
		.where(eq(verseSubmissions.userId, userId))
		.orderBy(desc(verseSubmissions.submittedAt))
		.limit(1);

	if (!last) {
		return { onCooldown: false, cooldownEndsAt: null };
	}

	const cooldownEndsAt = last.submittedAt + COOLDOWN_MS;
	if (now < cooldownEndsAt) {
		return { onCooldown: true, cooldownEndsAt };
	}
	return { onCooldown: false, cooldownEndsAt: null };
}

/** Structural validation of a user-supplied (bookId, chapter, verse) selection. */
export function validateSelection(input: SubmissionInput): string | null {
	const book = getBookById(input.bookId);
	if (!book) return 'Unknown bookId';

	if (!Number.isInteger(input.chapter) || input.chapter < 1) {
		return 'chapter must be a positive integer';
	}
	const chapterCount = getChapterCount(book.order);
	if (input.chapter > chapterCount) {
		return `chapter out of range (1-${chapterCount})`;
	}

	if (!Number.isInteger(input.verse) || input.verse < 1) {
		return 'verse must be a positive integer';
	}
	const verseCount = getVerseCount(book.order, input.chapter);
	if (input.verse > verseCount) {
		return `verse out of range (1-${verseCount})`;
	}

	return null;
}

interface LoadedCalendar {
	byDate: Map<string, { bookId: string; windowRef: string }>;
	datesByWindowRef: Map<string, string[]>;
}

/** Load every committed daily_verses row into in-memory indexes for the scan. */
async function loadCalendar(db: Db): Promise<LoadedCalendar> {
	const rows = await db
		.select({
			date: dailyVerses.date,
			bookId: dailyVerses.bookId,
			reference: dailyVerses.reference
		})
		.from(dailyVerses);

	const byDate = new Map<string, { bookId: string; windowRef: string }>();
	const datesByWindowRef = new Map<string, string[]>();

	for (const r of rows) {
		const wr = windowRef(r.bookId, r.reference);
		byDate.set(r.date, { bookId: r.bookId, windowRef: wr });
		const arr = datesByWindowRef.get(wr);
		if (arr) arr.push(r.date);
		else datesByWindowRef.set(wr, [r.date]);
	}

	return { byDate, datesByWindowRef };
}

/**
 * Find the earliest valid candidate date `D` (scanning forward from tomorrow,
 * server UTC) for a submission with the given book + window identity.
 *
 * Validity (spec "Scheduling Algorithm"):
 *  1. Empty:            no daily_verses row at D.
 *  2. No back-to-back:  D-1 (if committed) has a different book.
 *  3. No back-to-back:  D+1 (if committed) has a different book.
 *  4. 60-day repeat:    no identical window within [D-60, D+60] inclusive.
 */
async function findCandidateDate(
	db: Db,
	bookId: string,
	submissionWindowRef: string,
	opts?: { startFrom?: string; today?: string }
): Promise<string | null> {
	const { byDate, datesByWindowRef } = await loadCalendar(db);

	let cursor = opts?.startFrom ?? addDays(opts?.today ?? todayUtcStr(), 1);
	for (let i = 0; i < MAX_SCAN_DAYS; i++) {
		// Rule 1: must be empty.
		if (!byDate.has(cursor)) {
			// Rules 2 & 3: no back-to-back with committed neighbors.
			const prev = byDate.get(addDays(cursor, -1));
			const next = byDate.get(addDays(cursor, 1));
			const backToBack =
				(!!prev && prev.bookId === bookId) ||
				(!!next && next.bookId === bookId);

			if (!backToBack) {
				// Rule 4: 60-day repeat distance for the identical window.
				const sameWindowDates = datesByWindowRef.get(submissionWindowRef) ?? [];
				const tooClose = sameWindowDates.some(
					(d) => Math.abs(dayDiff(d, cursor)) <= REPEAT_WINDOW_DAYS
				);
				if (!tooClose) {
					return cursor;
				}
			}
		}
		cursor = addDays(cursor, 1);
	}

	return null;
}

/**
 * Run the scheduling scan and write the daily_verses + verse_submissions rows
 * in a transaction. On a unique-constraint conflict (concurrent submission
 * picked the same date), re-run the scan from the next day; retry up to a
 * small bound.
 */
export async function scheduleSubmission(
	db: Db,
	input: SubmissionInput,
	userId: string,
	now: number = Date.now(),
	opts?: { today?: string }
): Promise<ScheduleResult> {
	const book = getBookById(input.bookId);
	if (!book) throw new Error('Invalid bookId');

	const window = composeVerseWindow(book.order, input.chapter, input.verse);
	if (!window) throw new Error('Invalid chapter/verse for this book');

	const reference = formatWindowReference(
		window.bookName,
		window.startChapter,
		window.startVerse,
		window.endChapter,
		window.endVerse
	);
	const windowText = window.verses.join(' ');
	const submissionWindowRef = windowRef(book.id, reference);
	let lastCandidate = '';

	for (let attempt = 0; attempt < MAX_SCHEDULE_RETRIES; attempt++) {
		// Re-compute the candidate each attempt — a concurrent submission may
		// have claimed the previous candidate between scan and insert. After a
		// conflict, re-scan from the day *after* the contested candidate.
		const candidate = await findCandidateDate(
			db,
			book.id,
			submissionWindowRef,
			attempt === 0
				? { today: opts?.today }
				: { startFrom: addDays(lastCandidate, 1) }
		);
		if (!candidate) {
			throw new Error('No valid candidate date found within the scan window');
		}
		lastCandidate = candidate;

		try {
			db.transaction((tx) => {
				tx.insert(dailyVerses)
					.values({
						id: Bun.randomUUIDv7(),
						date: candidate,
						bookId: book.id,
						verseText: windowText,
						reference,
						createdAt: sql`${Math.floor(now / 1000)}`
					})
					.run();
				tx.insert(verseSubmissions)
					.values({
						id: Bun.randomUUIDv7(),
						userId,
						scheduledDate: candidate,
						selectedBookId: input.bookId,
						selectedChapter: input.chapter,
						selectedVerse: input.verse,
						submittedAt: now
					})
					.run();
			});

			return {
				scheduledDate: candidate,
				reference,
				windowText,
				bookId: book.id
			};
		} catch (err) {
			if (isUniqueConstraintError(err)) {
				continue; // retry — re-scan from tomorrow
			}
			throw err;
		}
	}

	throw new Error('Failed to schedule submission after retries');
}
