import { error } from "@sveltejs/kit";
import { ClientResponseError } from "$lib/api/client";
import { blob as gitBlob, gitTarget } from "$lib/api/gitclient";
import { toHttpError } from "$lib/api/load";
import { toTreeCommitSummary, type CommitSummary } from "$lib/api/repo";
import { isMarkdownFile } from "$lib/markup";
import { renderReadme, type RepoLoadEvent, type RepoParent } from "./repoIndex";

export type BlobKind = "code" | "markup" | "svg" | "image" | "video" | "submodule" | "other";

export type BlobViewMode = "code" | "rendered";

export interface RepoBlobView {
	ref: string;
	path: string;
	kind: BlobKind;
	sizeLabel: string | null;
	lines: number | null;
	contents: string | null;
	renderedHtml: string | null;
	fileTooLarge: boolean;
	defaultView: BlobViewMode;
	submodule: { name: string; url: string } | null;
	lastCommit: CommitSummary | null;
}

// same 1MiB inline cap as the appview's maxBlobSize
export const MAX_INLINE_SIZE = 1 << 20;

// go-humanize's Bytes
export const formatBytes = (bytes: number): string => {
	if (bytes < 1000) return `${bytes} B`;
	const units = ["kB", "MB", "GB", "TB", "PB", "EB"];
	let value = bytes;
	let unit = "B";
	for (const next of units) {
		if (value < 1000) break;
		value /= 1000;
		unit = next;
	}
	let rendered = value < 10 ? value.toFixed(1) : String(Math.round(value));
	// rounding can spill over the boundary ("1000 kB"), promote to the
	// next unit and render again ("1.0 MB")
	if (Number.parseFloat(rendered) >= 1000 && unit !== "EB") {
		value /= 1000;
		unit = units[units.indexOf(unit) + 1];
		rendered = value < 10 ? value.toFixed(1) : String(Math.round(value));
	}
	return `${rendered} ${unit}`;
};

// textual application/* types (json, toml, ...) arrive with isBinary=false
// instead of a text/* mime
export const classifyBlob = (blob: {
	path: string;
	mimeType?: string;
	isBinary?: boolean;
	submodule?: unknown;
}): BlobKind => {
	if (blob.submodule) return "submodule";
	const mediaType = (blob.mimeType ?? "").split(";")[0].trim().toLowerCase();
	if (mediaType === "image/svg+xml") return "svg";
	if (mediaType.startsWith("image/")) return "image";
	if (mediaType.startsWith("video/")) return "video";
	if (blob.isBinary === false || mediaType.startsWith("text/")) {
		return isMarkdownFile(blob.path) ? "markup" : "code";
	}
	return "other";
};

export const hasTextView = (kind: BlobKind): boolean =>
	kind === "code" || kind === "markup" || kind === "svg";

export const hasRenderedView = (kind: BlobKind): boolean => kind === "markup" || kind === "svg";

// a trailing newline terminates the last line instead of starting a new one
const countLines = (text: string): number => {
	if (text === "") return 0;
	const lines = text.split("\n").length;
	return text.endsWith("\n") ? lines - 1 : lines;
};

export const loadRepoBlob = async (
	event: RepoLoadEvent,
	parent: RepoParent,
	ref: string,
	path: string
): Promise<RepoBlobView> => {
	const git = gitTarget(parent.publicConfig, parent.repo, event.fetch);

	// the knot 404s directories and missing paths alike
	// knot2's repo_blob answers 413 over its serving limit instead of
	// sending size json (knot-xrpc reads.rs)
	const output = await gitBlob(git, { ref, path }, MAX_INLINE_SIZE).catch(
		(cause: unknown): Awaited<ReturnType<typeof gitBlob>> | null => {
			if (cause instanceof ClientResponseError && cause.status === 404) {
				error(404, `${path} does not exist at ${ref}`);
			}
			if (
				cause instanceof ClientResponseError &&
				(cause.status === 413 || cause.error === "BlobTooLarge")
			) {
				return null;
			}
			return toHttpError(cause, "Could not load file");
		}
	);

	if (output === null) {
		return {
			ref,
			path,
			kind: "other",
			sizeLabel: null,
			lines: null,
			contents: null,
			renderedHtml: null,
			fileTooLarge: true,
			defaultView: "code",
			submodule: null,
			lastCommit: null
		};
	}

	const kind = classifyBlob(output);
	const size = output.size ?? null;
	const fileTooLarge =
		output.fileTooLarge === true || (hasTextView(kind) && size !== null && size > MAX_INLINE_SIZE);

	const contents =
		hasTextView(kind) && !fileTooLarge && output.encoding === "utf-8"
			? (output.content ?? null)
			: null;

	const renderedHtml =
		kind === "markup" && contents !== null
			? await renderReadme(
					{ filename: path, contents },
					parent,
					event,
					ref,
					path.includes("/") ? path.slice(0, path.lastIndexOf("/")) : undefined
				)
			: null;

	// renderReadme gives up over its source limit, fall back to code
	const viewKind = kind === "markup" && renderedHtml === null ? "code" : kind;

	const lastCommit = output.lastCommit
		? toTreeCommitSummary({
				hash: output.lastCommit.hash,
				message: output.lastCommit.message,
				when: output.lastCommit.when,
				author: output.lastCommit.author
			})
		: null;

	return {
		ref,
		path,
		kind: viewKind,
		sizeLabel: size === null ? null : formatBytes(size),
		lines: contents !== null ? countLines(contents) : null,
		contents,
		renderedHtml,
		fileTooLarge,
		defaultView: hasRenderedView(viewKind)
			? event.url.searchParams.has("code")
				? "code"
				: "rendered"
			: "code",
		submodule: output.submodule ? { name: output.submodule.name, url: output.submodule.url } : null,
		lastCommit
	};
};
