// 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; misses render as handle.invalid without retrying

import type { BobbinContext } from "$lib/api/client";
import type { Did } from "@atcute/lexicons/syntax";
import {
	enrich,
	countOf,
	handleOf,
	viewerUriOf,
	TYPE_COUNT,
	TYPE_VIEWER,
	TYPE_MINIDOC,
	type Sidecar,
	type LinkDescriptor,
	type LinkSource
} from "$lib/api/enrich";
import { fetchPage } from "$lib/api/pagination";
import { IdentityCache, INVALID_HANDLE } 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 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
	};
};

const resolvePeople = (
	dids: string[],
	data: Sidecar,
	docSource: LinkSource,
	viewerDid?: string
): PersonData[] => {
	return [...new Set(dids)].map((did) => {
		const handle = handleOf(data, did, docSource);
		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;
		return { did, handle, followers, following, isSelf, viewerFollowRkey };
	});
};

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 handle =
				direction === "incoming" && data
					? handleOf(data, otherDid, VOUCHER_DOCS.source)
					: ((await cache.resolve(otherDid).catch(() => null))?.handle ?? INVALID_HANDLE);
			return {
				uri: item.uri,
				did: otherDid,
				handle,
				kind: value.kind === "denounce" ? "denounce" : "vouch",
				direction,
				reason: value.reason,
				createdAt: value.createdAt
			};
		})
	);
};

const resolveStars = async (
	ctx: BobbinContext,
	starData: Sidecar,
	items: ListItem[],
	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 = items.map((item): 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 ownerHandle = handleOf(enriched.data, ownerDid, REPO_OWNER_DOCS.source);
			return {
				kind: "repo",
				uri: item.uri,
				createdAt: value.createdAt,
				repo: resolveRepoCard(repo, ownerHandle, enriched.data)
			};
		}
		if (subject && "uri" in subject && subject.uri) {
			const ownerDid = didFromUri(subject.uri);
			const ownerHandle = handleOf(starData, ownerDid, STAR_SUBJECT_DOCS.source);
			return {
				kind: "string",
				uri: item.uri,
				createdAt: value.createdAt,
				ownerHandle,
				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;
	limit?: number;
}

export const fetchStarredPage = async (
	ctx: BobbinContext,
	{ did, viewerDid, cursor, 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, viewerDid),
		cursor: page.output.cursor
	};
};

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

export const fetchPeoplePage = async (
	ctx: BobbinContext,
	{ did, viewerDid, direction, cursor, 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: resolvePeople(dids, enriched.data, docs.source, 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);
};
