import { ClientResponseError, type BobbinContext, type XrpcRequestInit } from "./client";
import type { NiceCommit } from "./diff";
import { getRepoByName, type RecordView, type RepoRecord } from "./records";
import { branches as knotBranches, log as knotLog, tag as knotTag, tags as knotTags } from "./knot";
import { httpStatusFor } from "./load";
import { rkeyFromUri } from "./uri";
import type * as Tree from "./lexicons/types/sh/tangled/repo/tree";

// log, branches and tags are `*/*` in the lexicons, so these shapes are copied
// from core/types by hand. anything go-git touches keeps its go field names and
// writes hashes as byte arrays, so the hex comes from a sibling field

export interface GitSignature {
	Name: string;
	Email: string;
	When: string;
}

// go-git's IsHash accepts uppercase hex too (hex.DecodeString), both sha1
// and sha256 match case-insensitively
export const FULL_HASH_RE = /^([0-9a-fA-F]{40}|[0-9a-fA-F]{64})$/;

export const parseRawCommit = (spec: string): { ref: string; format: "patch" | "diff" } | null => {
	const dot = spec.lastIndexOf(".");
	if (dot === -1) return null;
	const format = spec.slice(dot + 1);
	if (format !== "patch" && format !== "diff") return null;
	const ref = spec.slice(0, dot);
	return FULL_HASH_RE.test(ref) ? { ref, format } : null;
};

export interface GitCommit {
	Author?: GitSignature;
	Committer?: GitSignature;
	Message?: string;
}

// `this` and `parent` are the hex hashes, `hash` is the byte array
export interface LogCommit {
	this?: string;
	parent?: string;
	author?: GitSignature;
	committer?: GitSignature;
	message?: string;
	tree?: string;
	change_id?: string;
}

export interface LogResponse {
	commits?: LogCommit[];
	ref?: string;
	total?: number;
	page?: number;
}

export interface GitReference {
	name: string;
	hash: string;
}

export interface BranchEntry {
	reference: GitReference;
	commit?: GitCommit;
	is_default?: boolean;
}

export interface BranchesResponse {
	branches?: BranchEntry[];
	total?: number;
}

export interface TagEntry {
	name: string;
	hash: string;
	message?: string;
	tag?: {
		Tagger?: GitSignature;
		Message?: string;
		// the commit an annotated tag points at, bytes like every go-git hash
		Target?: number[];
	};
}

export interface TagsResponse {
	tags?: TagEntry[];
	total?: number;
}

export interface RepoTagResponse {
	tag?: TagEntry;
}

// newer repos get tid rkeys and keep their display name in the record
export const repoNameOf = (view: RecordView<RepoRecord>): string =>
	view.value.name ?? rkeyFromUri(view.uri);

// a repo bobbin has never indexed is a miss, not an error
export const resolveRepoByName = async (
	ctx: BobbinContext,
	ownerDid: string,
	name: string,
	init?: XrpcRequestInit
): Promise<RecordView<RepoRecord> | null> => {
	try {
		return await getRepoByName(ctx, ownerDid, name, init);
	} catch (cause) {
		if (cause instanceof ClientResponseError && httpStatusFor(cause) === 404) return null;
		throw cause;
	}
};

export interface CommitSummary {
	hash: string;
	shortHash: string;
	subject: string;
	body: string;
	authorName: string;
	authorEmail: string;
	when: string;
	changeId?: string;
}

export const splitMessage = (message: string): [string, string] => {
	const separator = message.indexOf("\n\n");
	if (separator === -1) return [message.trim(), ""];
	return [message.slice(0, separator).trim(), message.slice(separator + 2).trim()];
};

const coAuthorPattern = /^Co-authored-by:\s*(.+?)\s*<([^>]+)>/gim;

// trailers carry no date of their own, like types/commit.go every
// co-author gets stamped with the committer's When
export const coAuthorsFrom = (message: string, when: string): GitSignature[] => {
	const seen = new Set<string>();
	const coAuthors: GitSignature[] = [];
	for (const match of message.matchAll(coAuthorPattern)) {
		const name = match[1].trim();
		const email = match[2].trim();
		if (seen.has(email)) continue;
		seen.add(email);
		coAuthors.push({ Name: name, Email: email, When: when });
	}
	return coAuthors;
};

export const toCommitSummary = (commit: LogCommit): CommitSummary => {
	const [subject, body] = splitMessage(commit.message ?? "");
	const hash = commit.this ?? "";
	return {
		hash,
		shortHash: hash.slice(0, 8),
		subject,
		body,
		authorName: commit.author?.Name ?? "",
		authorEmail: commit.author?.Email ?? "",
		when: commit.committer?.When ?? commit.author?.When ?? "",
		changeId: commit.change_id
	};
};

export interface CommitDetail {
	hash: string;
	shortHash: string;
	subject: string;
	body: string;
	authorName: string;
	authorEmail: string;
	authorWhen: string;
	committerName: string;
	committerEmail: string;
	committerWhen: string;
	coAuthors: { name: string; email: string }[];
}

// the diff endpoint's commit carries both signatures, unlike the log's
// summary shape
export const toCommitDetail = (commit: NiceCommit): CommitDetail => {
	const [subject, body] = splitMessage(commit.message ?? "");
	const hash = commit.this ?? "";
	const committerWhen = commit.committer?.When ?? "";
	return {
		hash,
		shortHash: hash.slice(0, 8),
		subject,
		body,
		authorName: commit.author?.Name ?? "",
		authorEmail: commit.author?.Email ?? "",
		authorWhen: commit.author?.When ?? "",
		committerName: commit.committer?.Name ?? "",
		committerEmail: commit.committer?.Email ?? "",
		committerWhen,
		coAuthors: coAuthorsFrom(commit.message ?? "", committerWhen).map(({ Name, Email }) => ({
			name: Name,
			email: Email
		}))
	};
};

// the tree endpoint uses lexicon casing where the log ones pass through go field
// names, so this cannot share `toCommitSummary`
export const toTreeCommitSummary = (commit: {
	hash: string;
	message?: string;
	when?: string;
	author?: { name?: string; email?: string; when?: string };
}): CommitSummary => {
	const [subject, body] = splitMessage(commit.message ?? "");
	return {
		hash: commit.hash,
		shortHash: commit.hash.slice(0, 8),
		subject,
		body,
		authorName: commit.author?.name ?? "",
		authorEmail: commit.author?.email ?? "",
		when: commit.when ?? commit.author?.when ?? ""
	};
};

export interface BranchSummary {
	name: string;
	hash: string;
	when?: string;
	isDefault: boolean;
	// full commit message
	message?: string;
}

export const toBranchSummary = (branch: BranchEntry): BranchSummary => ({
	name: branch.reference.name,
	hash: branch.reference.hash,
	when: branch.commit?.Committer?.When ?? branch.commit?.Author?.When,
	isDefault: branch.is_default === true,
	message: branch.commit?.Message
});

export interface TagSummary {
	name: string;
	hash: string;
	// an annotated tag has its own hash, this is the commit it points at
	commitHash: string;
	when?: string;
	message?: string;
	// annotated tags carry their tagger
	taggerName?: string;
}

export const hexFromBytes = (bytes: number[]): string =>
	bytes.map((byte) => byte.toString(16).padStart(2, "0")).join("");

export const toTagSummary = (tag: TagEntry): TagSummary => {
	const target = tag.tag?.Target;
	return {
		name: tag.name,
		hash: tag.hash,
		commitHash: target?.length ? hexFromBytes(target) : tag.hash,
		when: tag.tag?.Tagger?.When,
		message: tag.message ?? tag.tag?.Message,
		taggerName: tag.tag?.Tagger?.Name
	};
};

export type TreeEntryKind = "file" | "directory" | "symlink" | "submodule";

// modes come back octal and zero padded
export const treeEntryKind = (mode: string): TreeEntryKind => {
	switch (mode.replace(/^0+/, "").padStart(6, "0")) {
		case "040000":
			return "directory";
		case "120000":
			return "symlink";
		case "160000":
			return "submodule";
		default:
			return "file";
	}
};

export interface TreeEntrySummary {
	name: string;
	kind: TreeEntryKind;
	size: number;
	lastCommitHash?: string;
	lastCommitWhen?: string;
	lastCommitMessage?: string;
}

export const toTreeEntrySummary = (entry: Tree.TreeEntry): TreeEntrySummary => ({
	name: entry.name,
	kind: treeEntryKind(entry.mode),
	size: entry.size,
	lastCommitHash: entry.last_commit?.hash,
	lastCommitWhen: entry.last_commit?.when,
	lastCommitMessage: entry.last_commit?.message
});

export const tagsByCommitHash = (
	commits: CommitSummary[],
	tags: TagSummary[]
): Record<string, string[]> => {
	const shown = new Set(commits.map((commit) => commit.hash));
	return tags.reduce<Record<string, string[]>>((acc, tag) => {
		if (shown.has(tag.commitHash)) (acc[tag.commitHash] ??= []).push(tag.name);
		return acc;
	}, {});
};

export const sortTreeEntries = (entries: TreeEntrySummary[]): TreeEntrySummary[] =>
	[...entries].sort((a, b) => {
		const aDir = a.kind === "directory" || a.kind === "submodule";
		const bDir = b.kind === "directory" || b.kind === "submodule";
		if (aDir !== bDir) return aDir ? -1 : 1;
		return a.name.localeCompare(b.name);
	});

// the log cursor is a numeric offset encoded as a string
export const logFor = (
	ctx: BobbinContext,
	repo: string,
	ref: string,
	limit: number,
	cursor?: string,
	init?: XrpcRequestInit
) => knotLog<LogResponse>(ctx, { repo, ref, limit, cursor }, init);

export const branchesFor = (
	ctx: BobbinContext,
	repo: string,
	limit: number,
	cursor?: string,
	init?: XrpcRequestInit
) => knotBranches<BranchesResponse>(ctx, { repo, limit, cursor }, init);

export const tagsFor = (
	ctx: BobbinContext,
	repo: string,
	limit: number,
	cursor?: string,
	init?: XrpcRequestInit
) => knotTags<TagsResponse>(ctx, { repo, limit, cursor }, init);

export const tagFor = (ctx: BobbinContext, repo: string, tag: string, init?: XrpcRequestInit) =>
	knotTag<RepoTagResponse>(ctx, { repo, tag }, init);
