// page fetchers for the profile tabs, shared between the route load (first
// page) and the tab components (pagination). identities come from the enrich
// sidecar's minidoc payloads, resolveMiniDoc only fires for sidecar misses

import type { BobbinContext } from "$lib/api/client";
import type { Did } from "@atcute/lexicons/syntax";
import {
	enrich,
	countOf,
	viewerUriOf,
	miniDocOf,
	TYPE_COUNT,
	TYPE_VIEWER,
	TYPE_MINIDOC,
	type Sidecar,
	type LinkDescriptor,
	type LinkSource
} from "$lib/api/enrich";
import { fetchPage } from "$lib/api/pagination";
import { IdentityCache, type MiniDoc } from "$lib/api/identity";
import type { RecordView, RepoRecord } from "$lib/api/records";
import type { SearchPage } from "$lib/api/search";
import { didFromUri, rkeyFromUri } from "$lib/api/uri";
import type { VouchRecord } from "$lib/api/graph";
import type * as ShTangledFeedStar from "$lib/api/lexicons/types/sh/tangled/feed/star";
import type * as ShTangledString from "$lib/api/lexicons/types/sh/tangled/string";
import type * as ShTangledGraphFollow from "$lib/api/lexicons/types/sh/tangled/graph/follow";
import type { RepoCardData, StringCardData, PersonData, VouchData, StarData } from "./types";
import { repoKey } from "./types";

// same page size as the appview's lists
export const PROFILE_PAGE_LIMIT = 30;

export interface ListPage<T> {
	items: T[];
	cursor?: string;
}

interface ListItem {
	uri: string;
	value: unknown;
}

// the hand-rolled RecordList omits the cursor the wire output carries
type RecordPage<V> = {
	items: RecordView<V>[];
	cursor?: string;
};

const STAR_COUNT: LinkDescriptor = { source: "sh.tangled.feed.star:subject", type: TYPE_COUNT };
const STAR_VIEWER: LinkDescriptor = { source: "sh.tangled.feed.star:subject", type: TYPE_VIEWER };
const FOLLOW_STATS: LinkDescriptor[] = [
	{ source: "sh.tangled.graph.follow:subject", type: TYPE_COUNT },
	{ source: "sh.tangled.graph.follow:.repo", type: TYPE_COUNT }
];
const FOLLOW_VIEWER: LinkDescriptor = {
	source: "sh.tangled.graph.follow:subject",
	type: TYPE_VIEWER
};
const FOLLOWER_DOCS: LinkDescriptor = {
	source: "sh.tangled.graph.follow:.repo",
	type: TYPE_MINIDOC
};
const FOLLOWING_DOCS: LinkDescriptor = {
	source: "sh.tangled.graph.follow:subject",
	type: TYPE_MINIDOC
};
const REPO_OWNER_DOCS: LinkDescriptor = { source: "sh.tangled.repo:.repo", type: TYPE_MINIDOC };
const STAR_SUBJECT_DOCS: LinkDescriptor = {
	source: "sh.tangled.feed.star:subject",
	type: TYPE_MINIDOC
};
const VOUCHER_DOCS: LinkDescriptor = { source: "sh.tangled.graph.vouch:.repo", type: TYPE_MINIDOC };

const starDescriptors = (viewerDid: string | undefined) =>
	viewerDid ? [STAR_COUNT, STAR_VIEWER] : [STAR_COUNT];

const target = (
	descriptor: LinkDescriptor,
	targets: NonNullable<LinkDescriptor["targets"]>
): LinkDescriptor => ({ ...descriptor, targets });

const targetAll = (
	descriptors: LinkDescriptor[],
	targets: NonNullable<LinkDescriptor["targets"]>
): LinkDescriptor[] => descriptors.map((descriptor) => target(descriptor, targets));

const toRepoCard = (item: ListItem, ownerHandle: string): RepoCardData => {
	const value = item.value as RepoRecord;
	return {
		rkey: rkeyFromUri(item.uri),
		name: value.name ?? rkeyFromUri(item.uri),
		repoDid: value.repoDid ?? "",
		ownerHandle,
		description: value.description,
		knot: value.knot,
		createdAt: value.createdAt
	};
};

const resolveRepoCard = (item: ListItem, ownerHandle: string, data: Sidecar): RepoCardData => {
	const repo = toRepoCard(item, ownerHandle);
	if (!repo.repoDid) return { ...repo, stars: 0, viewerStarRkey: null };
	const stars = countOf(data, repo.repoDid, STAR_COUNT.source);
	const viewerUri = viewerUriOf(data, repo.repoDid, STAR_VIEWER.source);
	return { ...repo, stars, viewerStarRkey: viewerUri ? rkeyFromUri(viewerUri) : viewerUri };
};

const docOrResolve = (
	data: Sidecar,
	source: LinkSource,
	cache: IdentityCache,
	did: string
): Promise<MiniDoc | null> => {
	const doc = miniDocOf(data, did, source);
	return doc ? Promise.resolve(doc) : cache.resolve(did).catch(() => null);
};

const toStringCard = (item: ListItem, ownerHandle: string): StringCardData => {
	const value = item.value as ShTangledString.Main;
	return {
		rkey: rkeyFromUri(item.uri),
		ownerHandle,
		filename: value.filename,
		description: value.description,
		createdAt: value.createdAt,
		lines: value.contents?.split("\n").length ?? 1
	};
};

// the data sidecar already carries follower counts and viewer status, the
// only extra cost is one miniDoc per did
const resolvePeople = async (
	dids: string[],
	data: Sidecar,
	docSource: LinkSource,
	cache: IdentityCache,
	viewerDid?: string
): Promise<PersonData[]> => {
	const unique = [...new Set(dids)];

	const docs = await Promise.all(unique.map((did) => docOrResolve(data, docSource, cache, did)));

	const byDid = new Map<string, PersonData>();
	unique.forEach((did, index) => {
		const doc = docs[index];
		const followers = countOf(data, did, "sh.tangled.graph.follow:subject");
		const following = countOf(data, did, "sh.tangled.graph.follow:.repo");
		const isSelf = viewerDid === did;
		const viewerUri = viewerUriOf(data, did, FOLLOW_VIEWER.source);
		const viewerFollowRkey = viewerUri ? rkeyFromUri(viewerUri) : viewerUri;
		byDid.set(
			did,
			doc
				? {
						did: doc.did,
						handle: doc.handle,
						followers,
						following,
						isSelf,
						viewerFollowRkey
					}
				: { did, handle: did, followers, following, isSelf, viewerFollowRkey }
		);
	});
	return unique.map((did) => byDid.get(did) as PersonData);
};

const resolveVouches = async (
	items: ListItem[],
	direction: "incoming" | "outgoing",
	data: Sidecar | null,
	cache: IdentityCache
): Promise<VouchData[]> => {
	return Promise.all(
		items.map(async (item): Promise<VouchData> => {
			const value = item.value as VouchRecord;
			const otherDid = direction === "incoming" ? didFromUri(item.uri) : rkeyFromUri(item.uri);
			// outgoing vouches name the subject in the rkey, which the sidecar
			// can't see. those still resolve client-side
			const doc =
				(direction === "incoming" && data
					? miniDocOf(data, otherDid, VOUCHER_DOCS.source)
					: undefined) ?? (await cache.resolve(otherDid).catch(() => null));
			return {
				uri: item.uri,
				did: otherDid,
				handle: doc?.handle ?? otherDid,
				kind: value.kind === "denounce" ? "denounce" : "vouch",
				direction,
				reason: value.reason,
				createdAt: value.createdAt
			};
		})
	);
};

const resolveStars = async (
	ctx: BobbinContext,
	starData: Sidecar,
	items: ListItem[],
	cache: IdentityCache,
	viewerDid?: string
): Promise<StarData[]> => {
	const repoDids = [
		...new Set(
			items
				.map((item) => (item.value as ShTangledFeedStar.Main).subject)
				.flatMap((s) => (s && "did" in s && s.did ? [s.did] : []))
		)
	];
	const enriched =
		repoDids.length > 0
			? await enrich<{ items: ListItem[] }>(ctx, {
					xrpc: "sh.tangled.repo.getReposByRepoDids",
					params: { dids: repoDids },
					enrich: [
						...targetAll(starDescriptors(viewerDid), ["items[].value.repoDid"]),
						target(REPO_OWNER_DOCS, ["items[].uri"])
					],
					...(viewerDid ? { viewer: viewerDid } : {})
				})
			: { output: { items: [] }, data: {} as Sidecar };
	const reposByDid = new Map(
		enriched.output.items.map((item) => [(item.value as RepoRecord).repoDid, item])
	);
	const resolved = await Promise.all(
		items.map(async (item): Promise<StarData | null> => {
			const value = item.value as ShTangledFeedStar.Main;
			const subject = value.subject;
			if (subject && "did" in subject && subject.did) {
				const repo = reposByDid.get(subject.did);
				if (!repo) return null;
				const ownerDid = didFromUri(repo.uri);
				const owner = await docOrResolve(enriched.data, REPO_OWNER_DOCS.source, cache, ownerDid);
				return {
					kind: "repo",
					uri: item.uri,
					createdAt: value.createdAt,
					repo: resolveRepoCard(repo, owner?.handle ?? ownerDid, enriched.data)
				};
			}
			if (subject && "uri" in subject && subject.uri) {
				const ownerDid = didFromUri(subject.uri);
				const owner = await docOrResolve(starData, STAR_SUBJECT_DOCS.source, cache, ownerDid);
				return {
					kind: "string",
					uri: item.uri,
					createdAt: value.createdAt,
					ownerHandle: owner?.handle ?? ownerDid,
					rkey: rkeyFromUri(subject.uri)
				};
			}
			return null;
		})
	);
	return resolved.filter((star): star is StarData => star !== null);
};

export interface ReposPageOptions {
	did: string;
	handle: string;
	viewerDid?: string;
	q?: string;
	cursor?: string;
	limit?: number;
}

export const fetchReposPage = async (
	ctx: BobbinContext,
	{ did, handle, viewerDid, q, cursor, limit = PROFILE_PAGE_LIMIT }: ReposPageOptions
): Promise<ListPage<RepoCardData>> => {
	const descriptors = starDescriptors(viewerDid);
	if (!q) {
		const enriched = await enrich<RecordPage<RepoRecord>>(ctx, {
			xrpc: "sh.tangled.repo.listRepos",
			params: { subject: did, limit, cursor },
			enrich: targetAll(descriptors, ["items[].value.repoDid"]),
			...(viewerDid ? { viewer: viewerDid } : {})
		});
		const cards = new Map<string, RepoCardData>();
		for (const item of enriched.output.items) {
			const card = resolveRepoCard(item, handle, enriched.data);
			cards.set(repoKey(card), card);
		}
		return {
			items: Array.from(cards.values()),
			cursor: enriched.output.cursor
		};
	}
	const enriched = await enrich<SearchPage>(ctx, {
		xrpc: "sh.tangled.search.query",
		params: { q, nsid: "sh.tangled.repo", author: did, limit, cursor },
		enrich: targetAll(descriptors, ["hits[].value.repoDid"]),
		...(viewerDid ? { viewer: viewerDid } : {})
	});
	return {
		items: enriched.output.hits.map((item) => resolveRepoCard(item, handle, enriched.data)),
		cursor: enriched.output.cursor ?? undefined
	};
};

export interface StringsPageOptions {
	did: string;
	handle: string;
	cursor?: string;
	limit?: number;
}

export const fetchStringsPage = async (
	ctx: BobbinContext,
	{ did, handle, cursor, limit = PROFILE_PAGE_LIMIT }: StringsPageOptions
): Promise<ListPage<StringCardData>> => {
	const page = await fetchPage(ctx, "sh.tangled.string.listStrings", {
		subject: did as Did,
		limit,
		cursor
	});
	return { items: page.items.map((item) => toStringCard(item, handle)), cursor: page.cursor };
};

export interface StarredPageOptions {
	did: string;
	viewerDid?: string;
	cursor?: string;
	cache?: IdentityCache;
	limit?: number;
}

export const fetchStarredPage = async (
	ctx: BobbinContext,
	{ did, viewerDid, cursor, cache, limit = PROFILE_PAGE_LIMIT }: StarredPageOptions
): Promise<ListPage<StarData>> => {
	const page = await enrich<RecordPage<ShTangledFeedStar.Main>>(ctx, {
		xrpc: "sh.tangled.feed.listStarsBy",
		params: { subject: did, limit, cursor },
		enrich: [target(STAR_SUBJECT_DOCS, ["items[].value.subject.uri"])]
	});
	return {
		items: await resolveStars(
			ctx,
			page.data,
			page.output.items,
			cache ?? new IdentityCache(ctx),
			viewerDid
		),
		cursor: page.output.cursor
	};
};

export interface PeoplePageOptions {
	did: string;
	viewerDid?: string;
	direction: "followers" | "following";
	cursor?: string;
	cache?: IdentityCache;
	limit?: number;
}

export const fetchPeoplePage = async (
	ctx: BobbinContext,
	{ did, viewerDid, direction, cursor, cache, limit = PROFILE_PAGE_LIMIT }: PeoplePageOptions
): Promise<ListPage<PersonData>> => {
	const docs = direction === "followers" ? FOLLOWER_DOCS : FOLLOWING_DOCS;
	const targets = direction === "followers" ? ["items[].uri"] : ["items[].value.subject"];
	const enriched = await enrich<RecordPage<ShTangledGraphFollow.Main>>(ctx, {
		xrpc:
			direction === "followers" ? "sh.tangled.graph.listFollows" : "sh.tangled.graph.listFollowsBy",
		params: { subject: did, limit, cursor },
		enrich: targetAll(
			viewerDid ? [...FOLLOW_STATS, FOLLOW_VIEWER, docs] : [...FOLLOW_STATS, docs],
			targets
		),
		...(viewerDid ? { viewer: viewerDid } : {})
	});
	const dids =
		direction === "followers"
			? enriched.output.items.map((item) => didFromUri(item.uri))
			: enriched.output.items.map((item) => (item.value as ShTangledGraphFollow.Main).subject);
	return {
		items: await resolvePeople(
			dids,
			enriched.data,
			docs.source,
			cache ?? new IdentityCache(ctx),
			viewerDid
		),
		cursor: enriched.output.cursor
	};
};

// null marks an exhausted direction, absent means not started yet
export interface VouchCursors {
	incoming?: string | null;
	outgoing?: string | null;
}

export interface VouchesPage {
	items: VouchData[];
	cursors: VouchCursors;
}

export interface VouchesPageOptions {
	did: string;
	cursors?: VouchCursors;
	cache?: IdentityCache;
	limit?: number;
}

export const fetchVouchesPage = async (
	ctx: BobbinContext,
	{ did, cursors = {}, cache, limit = PROFILE_PAGE_LIMIT }: VouchesPageOptions
): Promise<VouchesPage> => {
	const identity = cache ?? new IdentityCache(ctx);
	const [incoming, outgoingPage] = await Promise.all([
		cursors.incoming === null
			? { output: { items: [], cursor: undefined }, data: {} as Sidecar }
			: enrich<RecordPage<VouchRecord>>(ctx, {
					xrpc: "sh.tangled.graph.listVouches",
					params: { subject: did, limit, cursor: cursors.incoming },
					enrich: [target(VOUCHER_DOCS, ["items[].uri"])]
				}),
		cursors.outgoing === null
			? { items: [], cursor: undefined }
			: fetchPage(ctx, "sh.tangled.graph.listVouchesBy", {
					subject: did as Did,
					limit,
					cursor: cursors.outgoing
				})
	]);
	const [incomingVouches, outgoingVouches] = await Promise.all([
		resolveVouches(incoming.output.items, "incoming", incoming.data, identity),
		resolveVouches(outgoingPage.items, "outgoing", null, identity)
	]);
	return {
		items: [...incomingVouches, ...outgoingVouches].sort(
			(a, b) => new Date(b.createdAt).getTime() - new Date(a.createdAt).getTime()
		),
		cursors: {
			incoming: incoming.output.cursor ?? null,
			outgoing: outgoingPage.cursor ?? null
		}
	};
};

export interface PinnedOptions {
	keys: readonly string[];
	handle: string;
	viewerDid?: string;
}

// pinned keys are repoDids or record uris, fetch them directly instead of
// paging the owner's whole repo list. bobbin drops records it can no longer
// hydrate
export const fetchPinned = async (
	ctx: BobbinContext,
	{ keys, handle, viewerDid }: PinnedOptions
): Promise<RepoCardData[]> => {
	const dids = keys.filter((key) => key.startsWith("did:"));
	const uris = keys.filter((key) => key.startsWith("at://"));
	const descriptors = starDescriptors(viewerDid);
	const empty = { output: { items: [] as ListItem[] }, data: {} as Sidecar };
	const [byDid, byUri] = await Promise.all([
		dids.length > 0
			? enrich<{ items: ListItem[] }>(ctx, {
					xrpc: "sh.tangled.repo.getReposByRepoDids",
					params: { dids },
					enrich: targetAll(descriptors, ["items[].value.repoDid"]),
					...(viewerDid ? { viewer: viewerDid } : {})
				})
			: empty,
		uris.length > 0
			? enrich<{ items: ListItem[] }>(ctx, {
					xrpc: "sh.tangled.repo.getRepos",
					params: { repos: uris },
					enrich: targetAll(descriptors, ["items[].value.repoDid"]),
					...(viewerDid ? { viewer: viewerDid } : {})
				})
			: empty
	]);
	const cards = new Map<string, RepoCardData>();
	for (const item of byDid.output.items) {
		const repoDid = (item.value as RepoRecord).repoDid;
		if (repoDid) cards.set(repoDid, resolveRepoCard(item, handle, byDid.data));
	}
	for (const item of byUri.output.items) {
		cards.set(item.uri, resolveRepoCard(item, handle, byUri.data));
	}
	return keys
		.map((key) => cards.get(key))
		.filter((card): card is RepoCardData => card !== undefined);
};
