import type { Did, ResourceUri } from '@atcute/lexicons/syntax';

import {
	appviewJson,
	listAllAppviewRecords,
	type AppviewParamValue,
	type AppviewSearchResponse,
	type AppviewStatefulListResponse,
	type AppviewStatefulRecordView,
} from './appview';
import {
	getNearestAppviewCursorCheckpoint,
	rememberAppviewCursorCheckpoint,
} from './appview-cache';
import { appviewRecordToHydrated } from './hydration';
import {
	type HydratedRecord,
	type PaginatedResult,
	maxTimestamp,
	timestampFromDate,
	timestampFromTid,
	toNumberMap,
} from './records';
import { getTangledAppviewService } from '../settings';
import { normalizeServiceUrl } from './client';

export type StatefulHydratedRecord<T, State extends string> = HydratedRecord<T> & {
	number: number;
	state: State;
};

export type StatefulPageOptions<State extends string> = {
	offset: number;
	limit: number;
	state: State;
	author?: Did;
	query?: string;
};

const APPVIEW_PAGE_BATCH_LIMIT = 100;
const SEARCH_PAGE_BATCH_LIMIT = 100;

export const getAppviewRecordSortTimestamp = <T extends { createdAt?: string }>(
	record: HydratedRecord<T>,
): number =>
	maxTimestamp(
		timestampFromDate(record.value.createdAt),
		timestampFromTid(record.rkey),
	);

export const sortStatefulRecordsByNewestRecord = <T extends { createdAt?: string }, State extends string>(
	items: Array<StatefulHydratedRecord<T, State>>,
): Array<StatefulHydratedRecord<T, State>> =>
	[...items].sort((left, right) => {
		const timestampDiff = getAppviewRecordSortTimestamp(right) - getAppviewRecordSortTimestamp(left);
		if (timestampDiff !== 0) {
			return timestampDiff;
		}

		if (left.number !== right.number) {
			return right.number - left.number;
		}

		return right.rkey.localeCompare(left.rkey);
	});

export const sortStatefulRecordsByUpdatedAt = async <T, State extends string>(
	items: Array<StatefulHydratedRecord<T, State>>,
	getUpdatedAt: (item: StatefulHydratedRecord<T, State>) => Promise<number>,
): Promise<Array<StatefulHydratedRecord<T, State>>> => {
	const decorated = await Promise.all(
		items.map(async (item) => ({
			item,
			updatedAt: await getUpdatedAt(item),
		})),
	);

	decorated.sort((left, right) => {
		const updatedDiff = right.updatedAt - left.updatedAt;
		if (updatedDiff !== 0) {
			return updatedDiff;
		}

		if (left.item.number !== right.item.number) {
			return right.item.number - left.item.number;
		}

		return right.item.rkey.localeCompare(left.item.rkey);
	});

	return decorated.map(({ item }) => item);
};

export const statefulRecordMatchesQuery = (value: unknown, query?: string): boolean => {
	const normalized = query?.trim().toLowerCase();
	if (!normalized) {
		return true;
	}

	const record = value as { title?: unknown; body?: unknown };
	return [record.title, record.body]
		.filter((part): part is string => typeof part === 'string')
		.some((part) => part.toLowerCase().includes(normalized));
};

export const paginateStatefulRecords = <T, State extends string>(
	items: Array<StatefulHydratedRecord<T, State>>,
	options: StatefulPageOptions<State>,
): PaginatedResult<StatefulHydratedRecord<T, State>> => {
	const offset = Math.max(options.offset, 0);
	const limit = Math.max(options.limit, 1);
	const matching = items.filter((item) =>
		item.state === options.state &&
		(!options.author || item.author.did === options.author) &&
		statefulRecordMatchesQuery(item.value, options.query),
	);

	return {
		items: matching.slice(offset, offset + limit),
		totalCount: matching.length,
		hasNext: offset + limit < matching.length,
	};
};

const appviewCursorCacheKey = (
	nsid: string,
	subject: string,
	state: string,
	extraParams: Record<string, AppviewParamValue> = {},
): string => {
	const extras = Object.entries(extraParams)
		.filter(([, value]) => value !== undefined && value !== null)
		.sort(([left], [right]) => left.localeCompare(right))
		.map(([key, value]) => `${key}=${String(value)}`)
		.join('&');

	return [
		normalizeServiceUrl(getTangledAppviewService()),
		nsid,
		subject,
		state,
		extras,
	].join('|');
};

export const listAppviewStatefulRecordsPage = async <T extends { createdAt?: string }, State extends string>(
	nsid: string,
	subject: string,
	options: StatefulPageOptions<State>,
	normalizeState: (value?: string) => State,
	extraParams: Record<string, AppviewParamValue> = {},
	getUpdatedAt?: (
		record: StatefulHydratedRecord<T, State>,
		appviewRecord: AppviewStatefulRecordView<T>,
	) => Promise<number>,
): Promise<PaginatedResult<StatefulHydratedRecord<T, State>>> => {
	if (getUpdatedAt) {
		const records = await listAllAppviewRecords<T>(nsid, subject, extraParams) as Array<AppviewStatefulRecordView<T>>;
		const hydrated = await Promise.all(records.map((record) => appviewRecordToHydrated(record)));
		const numbers = toNumberMap(hydrated);
		const decorated = await Promise.all(
			hydrated.map(async (record, index) => {
				const item: StatefulHydratedRecord<T, State> = {
					...record,
					number: numbers.get(record.uri) ?? index + 1,
					state: normalizeState(records[index].state),
				};

				return {
					item,
					updatedAt: await getUpdatedAt(item, records[index]),
				};
			}),
		);

		decorated.sort((left, right) => {
			const updatedDiff = right.updatedAt - left.updatedAt;
			if (updatedDiff !== 0) {
				return updatedDiff;
			}

			if (left.item.number !== right.item.number) {
				return right.item.number - left.item.number;
			}

			return right.item.rkey.localeCompare(left.item.rkey);
		});

		return paginateStatefulRecords(
			decorated.map(({ item }) => item),
			options,
		);
	}

	// Bobbin exposes cursor pagination, while the UI still uses offsets. Cache
	// page-boundary cursors so sequential pages can resume from the prior page.
	const offset = Math.max(options.offset, 0);
	const limit = Math.max(options.limit, 1);
	const requestedEnd = offset + limit;
	const cacheKey = appviewCursorCacheKey(nsid, subject, options.state, extraParams);
	const start = getNearestAppviewCursorCheckpoint(cacheKey, offset);
	const pendingItems: Array<{
		record: AppviewStatefulRecordView<T>;
		number: number;
		state: State;
	}> = [];

	if (start.exhausted) {
		return {
			items: [],
			totalCount: start.matchingSeen,
			hasNext: false,
		};
	}

	let cursor = start.cursor;
	let matchingSeen = start.matchingSeen;
	let scannedSeen = start.scannedSeen;

	while (true) {
		const pageLimit = Math.min(
			APPVIEW_PAGE_BATCH_LIMIT,
			Math.max(matchingSeen < requestedEnd ? requestedEnd - matchingSeen : 1, 1),
		);
		const page = await appviewJson<AppviewStatefulListResponse<T>>(nsid, {
			subject,
			limit: pageLimit,
			cursor,
			...extraParams,
		});

		for (const record of page.items) {
			const state = normalizeState(record.state);
			const number = scannedSeen + 1;
			scannedSeen += 1;

			if (state !== options.state) {
				continue;
			}

			if (!statefulRecordMatchesQuery(record.value, options.query)) {
				continue;
			}

			if (matchingSeen >= offset && matchingSeen < requestedEnd) {
				pendingItems.push({ record, number, state });
			}

			matchingSeen += 1;
		}

		cursor = page.cursor ?? undefined;
		const exhausted = !cursor;
		rememberAppviewCursorCheckpoint(cacheKey, {
			matchingSeen,
			scannedSeen,
			cursor,
			exhausted,
		});

		if (matchingSeen > requestedEnd) {
			const visibleItems = pendingItems.slice(0, limit);
			const hydrated = await Promise.all(
				visibleItems.map(({ record }) => appviewRecordToHydrated(record)),
			);

			return {
				items: hydrated.map((item, index) => ({
					...item,
					number: visibleItems[index].number,
					state: visibleItems[index].state,
				})),
				hasNext: true,
			};
		}

		if (exhausted || page.items.length === 0) {
			const hydrated = await Promise.all(
				pendingItems.map(({ record }) => appviewRecordToHydrated(record)),
			);

			return {
				items: hydrated.map((item, index) => ({
					...item,
					number: pendingItems[index].number,
					state: pendingItems[index].state,
				})),
				totalCount: matchingSeen,
				hasNext: false,
			};
		}
	}
};

export const listSearchedStatefulRecordsPage = async <T, State extends string>(
	nsid: string,
	repo: { repoDid: Did },
	options: StatefulPageOptions<State>,
	getState: (uri: ResourceUri) => Promise<State>,
): Promise<PaginatedResult<StatefulHydratedRecord<T, State>>> => {
	const query = options.query?.trim();
	if (!query) {
		return { items: [], totalCount: 0, hasNext: false };
	}

	const offset = Math.max(options.offset, 0);
	const limit = Math.max(options.limit, 1);
	const requestedEnd = offset + limit;
	const items: Array<StatefulHydratedRecord<T, State>> = [];
	let cursor: string | undefined;
	let matchingSeen = 0;

	while (true) {
		const page = await appviewJson<AppviewSearchResponse<T>>('sh.tangled.search.query', {
			q: query,
			nsid,
			author: options.author,
			repo: repo.repoDid,
			cursor,
			limit: SEARCH_PAGE_BATCH_LIMIT,
		});
		const hydrated = await Promise.all(page.hits.map((hit) => appviewRecordToHydrated(hit)));
		const states = await Promise.all(hydrated.map((record) => getState(record.uri)));

		for (const [index, record] of hydrated.entries()) {
			const state = states[index];
			if (state !== options.state) {
				continue;
			}

			if (matchingSeen >= offset && matchingSeen < requestedEnd) {
				items.push({
					...record,
					number: 0,
					state,
				});
			}

			matchingSeen += 1;
			if (matchingSeen > requestedEnd) {
				return {
					items: items.slice(0, limit),
					hasNext: true,
				};
			}
		}

		cursor = page.cursor ?? undefined;
		if (!cursor || page.hits.length === 0) {
			return {
				items,
				totalCount: matchingSeen,
				hasNext: false,
			};
		}
	}
};
