import { error } from "@sveltejs/kit";
import { ClientResponseError, createBobbinClient } from "$lib/api/client";
import { languages as knotLanguages, tree as knotTree } from "$lib/api/knot";
import {
	branches as knotMirrorBranches,
	createKnotMirrorClient,
	languages as knotMirrorLanguages,
	log as knotMirrorLog,
	tags as knotMirrorTags,
	tree as knotMirrorTree
} from "$lib/api/knotmirror";
import { parallel } from "$lib/api/load";
import {
	branchesFor,
	logFor,
	sortTreeEntries,
	tagsByCommitHash,
	tagsFor,
	toBranchSummary,
	toCommitSummary,
	toTagSummary,
	toTreeCommitSummary,
	toTreeEntrySummary
} from "$lib/api/repo";
import { renderDocument } from "$lib/markup";
import type { LanguageSlice, RepoInfo } from "$lib/components/repo/types";
import type * as Tree from "./lexicons/types/sh/tangled/repo/tree";

// `/tree/{ref}` is this same page at another ref, so they share a load

const COMMIT_LIMIT = 10;
const BRANCH_LIMIT = 5;
const TAG_LIMIT = 5;
// a knot only ever lists 100 refs, so any total we get is really a minimum
const REF_LIMIT = 100;

export interface RepoParent {
	publicConfig: { bobbinUrl: string; knotMirrorUrl: string; camoEnabled: boolean };
	repo: RepoInfo;
}

export interface RepoLoadEvent {
	fetch: typeof globalThis.fetch;
	url: URL;
}

interface RepoDataSource {
	tree: (ref: string, path?: string) => Promise<Tree.$output>;
	log: (ref: string, limit: number) => Promise<Awaited<ReturnType<typeof logFor>>>;
	branches: (limit: number) => Promise<Awaited<ReturnType<typeof branchesFor>>>;
	tags: (limit: number) => Promise<Awaited<ReturnType<typeof tagsFor>>>;
	languages: (ref: string) => Promise<{ languages?: { name: string; size: number }[] }>;
}

const orNull = <T>(promise: Promise<T>): Promise<T | null> => promise.catch(() => null);

interface Attempt<T> {
	value: T | null;
	error: unknown | null;
}

const attempt = <T>(promise: Promise<T>): Promise<Attempt<T>> =>
	promise.then(
		(value) => ({ value, error: null }),
		(error) => ({ value: null, error })
	);

const isUnsupported = (cause: unknown): boolean =>
	cause instanceof ClientResponseError && cause.status === 404;

export const classifyRepoAvailability = (
	attempts: readonly { value: unknown | null; error: unknown | null }[]
) => {
	const needsUpgrade = attempts.every(
		(result) => result.value === null && isUnsupported(result.error)
	);
	return {
		needsUpgrade,
		knotUnreachable: !needsUpgrade && attempts.every((result) => result.value === null)
	};
};

const toLanguageSlices = (languages: { name: string; size: number }[]): LanguageSlice[] => {
	const sized = languages.filter((language) => language.size > 0);
	const total = sized.reduce((sum, language) => sum + language.size, 0);
	if (total === 0) return [];

	const slices = sized.map((language) => {
		const share = (language.size / total) * 100;
		return { name: language.name, share, percentage: Math.floor(share) };
	});

	const short = 100 - slices.reduce((sum, slice) => sum + slice.percentage, 0);
	[...slices]
		.sort((a, b) => (b.share % 1) - (a.share % 1) || b.share - a.share)
		.slice(0, Math.max(0, short))
		.forEach((slice) => {
			slice.percentage += 1;
		});

	return slices.sort((a, b) => b.share - a.share);
};

const refNames = (branches: { name: string }[], tags: { name: string }[]) => ({
	branches: branches.map((branch) => branch.name),
	tags: tags.map((tag) => tag.name),
	capped: branches.length >= REF_LIMIT || tags.length >= REF_LIMIT
});

const renderReadme = (
	readme: { filename: string; contents: string } | null,
	parent: RepoParent,
	event: RepoLoadEvent,
	ref: string,
	dir?: string
) =>
	readme
		? renderDocument(readme.filename, readme.contents, {
				repo: `${parent.repo.ownerHandle}/${parent.repo.name}`,
				ref,
				dir,
				host: event.url.host,
				camo: parent.publicConfig.camoEnabled
			})
		: Promise.resolve(null);

// the knot sends a readme with empty fields when a directory has none
const readmeOf = (tree: { readme?: { filename: string; contents: string } } | null) => {
	const readme = tree?.readme;
	return readme?.filename ? readme : null;
};

const repoDataSource = (event: RepoLoadEvent, parent: RepoParent): RepoDataSource => {
	if (parent.publicConfig.knotMirrorUrl && parent.repo.repoDid) {
		const ctx = createKnotMirrorClient(parent.publicConfig.knotMirrorUrl, event.fetch);
		const repo = parent.repo.repoDid;
		return {
			tree: (ref, path) => knotMirrorTree(ctx, { repo, ref, path }),
			log: (ref, limit) => knotMirrorLog(ctx, { repo, ref, limit }),
			branches: (limit) => knotMirrorBranches(ctx, { repo, limit }),
			tags: (limit) => knotMirrorTags(ctx, { repo, limit }),
			languages: (ref) => knotMirrorLanguages(ctx, { repo, ref })
		};
	}

	const ctx = createBobbinClient({ serviceUrl: parent.publicConfig.bobbinUrl, fetch: event.fetch });
	const repo = parent.repo.uri;
	return {
		tree: (ref, path) => knotTree(ctx, { repo, ref, path }),
		log: (ref, limit) => logFor(ctx, repo, ref, limit),
		branches: (limit) => branchesFor(ctx, repo, limit),
		tags: (limit) => tagsFor(ctx, repo, limit),
		languages: (ref) => knotLanguages(ctx, { repo, ref })
	};
};

export interface RepoIndexOptions {
	requireRef?: boolean;
}

export const loadRepoIndex = async (
	event: RepoLoadEvent,
	parent: RepoParent,
	ref: string,
	// a ref from the url has to resolve or a typo looks like a repo with no
	// files. the default branch renders whatever the knot managed to answer
	{ requireRef = false }: RepoIndexOptions = {}
) => {
	const source = repoDataSource(event, parent);

	// each list falls back on its own, so half a page still renders
	const results = await parallel({
		tree: attempt(source.tree(ref)),
		log: attempt(source.log(ref, COMMIT_LIMIT)),
		branches: attempt(source.branches(REF_LIMIT)),
		tags: attempt(source.tags(REF_LIMIT)),
		languages: attempt(source.languages(ref))
	});

	const branches = (results.branches.value?.branches ?? []).map(toBranchSummary);
	const tags = (results.tags.value?.tags ?? []).map(toTagSummary);
	const commits = (results.log.value?.commits ?? []).map(toCommitSummary);
	const files = sortTreeEntries((results.tree.value?.files ?? []).map(toTreeEntrySummary));

	const languages = toLanguageSlices(results.languages.value?.languages ?? []);

	const readme = readmeOf(results.tree.value);
	const readmeHtml = await renderReadme(readme, parent, event, ref);

	const contentAttempts = [results.tree, results.log, results.branches];
	const { needsUpgrade, knotUnreachable } = classifyRepoAvailability(contentAttempts);
	const isEmpty = !knotUnreachable && files.length === 0 && branches.length === 0;

	// there are refs but not this one, so it is not a real ref. an empty repo has
	// no refs at all and still gets a page
	if (requireRef && results.tree.value === null && branches.length > 0) {
		error(404, `${ref} does not exist in this repository`);
	}

	return {
		ref,
		isEmpty,
		needsUpgrade,
		knotUnreachable,
		files,
		readme,
		readmeHtml,
		commits,
		tagsByCommit: tagsByCommitHash(commits, tags),
		totalCommits: results.log.value?.total ?? commits.length,
		branches: branches.slice(0, BRANCH_LIMIT),
		totalBranches: branches.length,
		tags: tags.slice(0, TAG_LIMIT),
		totalTags: tags.length,
		// the switcher needs every ref, not just the visible slice
		refs: refNames(branches, tags),
		languages
	};
};

export const loadRepoTree = async (
	event: RepoLoadEvent,
	parent: RepoParent,
	ref: string,
	path: string
) => {
	const source = repoDataSource(event, parent);

	// the tree is the whole page here, so a miss is just a 404
	const tree = await orNull(source.tree(ref, path));
	const files = sortTreeEntries((tree?.files ?? []).map(toTreeEntrySummary));
	// git cannot store an empty directory. so nothing here means the path is a
	// file, or was never there
	if (tree === null || files.length === 0) {
		error(404, `${path} does not exist at ${ref}`);
	}

	const readme = readmeOf(tree);
	const readmeHtml = await renderReadme(readme, parent, event, ref, path);

	return {
		ref,
		path,
		files,
		readme,
		readmeHtml,
		lastCommit: tree.lastCommit ? toTreeCommitSummary(tree.lastCommit) : null
	};
};
