import { ok } from '@atcute/client';
import type { ResolvedActor } from '@atcute/identity-resolver';
import type { Did, Nsid, ResourceUri } from '@atcute/lexicons/syntax';
import { ShTangledRepo } from '@atcute/tangled';

import {
	appviewFallback,
	appviewJson,
	appviewOptionalKnotRequest,
	getRepoViaAppview,
	listAllAppviewRecords,
	type AppviewRecordView,
} from './appview';
import { getBacklinksPage } from './backlinks';
import type { OAuthUserAgent } from '@atcute/oauth-browser-client';
import { getRpc, normalizeServiceUrl, createServiceAuthRpc, createAuthRpc } from './client';
import { REPO_COLLECTION } from './constants';
import { resolveActor } from './identity';
import {
	maxTimestamp,
	parseAtUri,
	timestampFromDate,
	type RecordCreationResult,
} from './records';

export interface RepoContext {
	owner: ResolvedActor;
	record: RepoRecord;
	repoDid: Did;
	slug: string;
	rkey: string;
	knot: string;
}

export interface RepoRecord {
	uri: ResourceUri;
	cid: string;
	rkey: string;
	value: ShTangledRepo.Main;
}

export interface TreeResponse {
	ref: string;
	parent?: string;
	dotdot?: string;
	lastCommit?: CommitHeadline;
	readme?: {
		contents: string;
		filename: string;
	};
	files: TreeEntry[];
}

export interface TreeEntry {
	name: string;
	mode: string;
	size: number;
	last_commit?: CommitHeadline;
}

export interface CommitHeadline {
	hash: string;
	message: string;
	when: string;
	author?: {
		email: string;
		name: string;
		when: string;
	};
}

export interface BranchResponse {
	branches: BranchEntry[];
}

export interface DefaultBranchResponse {
	name?: string;
	branch?: string;
	reference?: {
		name?: string;
	};
	hash?: string;
	when?: string;
}

export interface BranchEntry {
	reference: {
		name: string;
		hash: string;
	};
	commit?: {
		Message: string;
		Author?: {
			Name: string;
			Email: string;
			When: string;
		};
		Committer?: {
			Name: string;
			Email: string;
			When: string;
		};
	};
	is_default?: boolean;
}

export interface TagResponse {
	tags: Array<{
		name: string;
		hash: string;
		message?: string;
		tag?: {
			Name: string;
			Message: string;
			Tagger?: {
				Name: string;
				Email: string;
				When: string;
			};
		};
	}>;
}

export interface LanguageResponse {
	ref: string;
	totalFiles?: number;
	totalSize?: number;
	languages: Array<{
		name: string;
		percentage: number;
		size: number;
		color?: string;
	}>;
}

export type GitHashValue =
	| string
	| number[]
	| {
			String?: string;
			string?: string;
	  };

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

export interface CommitEntry {
	hash?: GitHashValue;
	this: string;
	message: string;
	tree?: string;
	author?: CommitSignature;
	committer?: CommitSignature;
	pgp_signature?: string;
	parent?: string;
	parent_hashes?: GitHashValue[];
}

export interface LogResponse {
	ref: string;
	total: number;
	page: number;
	per_page: number;
	log: boolean;
	commits: CommitEntry[];
}

export interface DiffTextLine {
	Op: 0 | 1 | 2 | ' ' | '-' | '+';
	Line: string;
}

export interface DiffTextFragment {
	Comment?: string;
	OldPosition: number;
	OldLines: number;
	NewPosition: number;
	NewLines: number;
	LinesAdded: number;
	LinesDeleted: number;
	LeadingContext?: number;
	TrailingContext?: number;
	Lines: DiffTextLine[];
}

export interface RepoDiffFile {
	name: {
		old: string;
		new: string;
	};
	text_fragments: DiffTextFragment[];
	is_binary: boolean;
	is_new: boolean;
	is_delete: boolean;
	is_copy: boolean;
	is_rename: boolean;
}

export interface RepoDiffStat {
	insertions: number;
	deletions: number;
	files_changed: number;
}

export interface RepoDiff {
	commit: CommitEntry;
	stat: RepoDiffStat;
	diff: RepoDiffFile[];
}

export interface RepoCommitResponse {
	ref: string;
	diff?: RepoDiff;
}

export interface BlobResponse {
	path: string;
	ref: string;
	content?: string;
	encoding?: 'base64' | 'utf-8';
	fileTooLarge?: boolean;
	isBinary?: boolean;
	mimeType?: string;
	size?: number;
	lastCommit?: CommitHeadline;
	submodule?: {
		name: string;
		url: string;
		branch?: string;
	};
}

export interface ComparePatch {
	Title: string;
	Body: string;
	Raw: string;
	SHA: string;
	Author?: string;
	Committer?: string;
	AuthorDate?: string;
	CommitterDate?: string;
	SubjectPrefix?: string;
	RawHeaders?: string;
	BodyAppendix?: string;
}

export interface CompareResponse {
	rev1: string;
	rev2: string;
	patch: string;
	format_patch: ComparePatch[];
}

const fetchAllRepoRecords = async (
	actor: ResolvedActor,
	collection: Nsid,
): Promise<Array<{ uri: ResourceUri; cid: string; value: unknown }>> => {
	const rpc = getRpc(actor.pds);
	const records: Array<{ uri: ResourceUri; cid: string; value: unknown }> = [];
	let cursor: string | undefined;

	do {
		const page = await ok(
			rpc.get('com.atproto.repo.listRecords', {
				params: {
					repo: actor.did,
					collection,
					limit: 100,
					cursor,
					reverse: true,
				},
			}),
		);

		records.push(...page.records);
		cursor = page.cursor;
	} while (cursor);

	return records;
};

const listRepoRecordsFromAppview = async (owner: string | ResolvedActor): Promise<RepoRecord[]> => {
	const actor = typeof owner === 'string' ? await resolveActor(owner) : owner;
	const records = await listAllAppviewRecords<ShTangledRepo.Main>('sh.tangled.repo.listRepos', actor.did);

	return records.map((record) => ({
		uri: record.uri,
		cid: record.cid ?? '',
		rkey: parseAtUri(record.uri).rkey,
		value: record.value,
	}));
};

const listRepoRecordsFromPds = async (owner: string | ResolvedActor): Promise<RepoRecord[]> => {
	const actor = typeof owner === 'string' ? await resolveActor(owner) : owner;
	const records = await fetchAllRepoRecords(actor, REPO_COLLECTION);

	return records.map((record) => ({
		uri: record.uri,
		cid: record.cid,
		rkey: parseAtUri(record.uri).rkey,
		value: record.value as ShTangledRepo.Main,
	}));
};

export const listRepoRecords = async (owner: string | ResolvedActor): Promise<RepoRecord[]> =>
	appviewFallback(
		() => listRepoRecordsFromAppview(owner),
		() => listRepoRecordsFromPds(owner),
	);

export const getRepo = async (ownerIdentifier: string, repoSlug: string): Promise<RepoContext> => {
	const owner = await resolveActor(ownerIdentifier);
	const all = await listRepoRecords(owner);
	const wanted = repoSlug.trim().toLowerCase();
	const matches = all.filter((record) => {
		const name = record.value.name?.trim().toLowerCase();
		return name === wanted || record.rkey.toLowerCase() === wanted;
	});

	if (matches.length === 0) {
		throw new Error(`Repository not found: ${ownerIdentifier}/${repoSlug}`);
	}

	// Prefer a record that doesn't use a localhost knot if multiple match
	const match = matches.find((r) => !r.value.knot?.includes('localhost')) || matches[0];

	if (!match.value.repoDid) {
		throw new Error(`Repository is missing repoDid metadata`);
	}

	const slug = match.value.name?.trim() || match.rkey;

	return {
		owner,
		record: match,
		repoDid: match.value.repoDid,
		slug,
		rkey: match.rkey,
		knot: normalizeServiceUrl(match.value.knot),
	};
};

export const getRepoByDid = async (repoDid: Did): Promise<RepoContext> => {
	const record = await appviewJson<AppviewRecordView<ShTangledRepo.Main>>(
		'sh.tangled.repo.getRepoByRepoDid',
		{ repoDid },
	);
	const parsed = parseAtUri(record.uri);
	const owner = await resolveActor(parsed.did);
	const slug = record.value.name?.trim() || parsed.rkey;

	if (!record.value.repoDid) {
		throw new Error(`Repository is missing repoDid metadata`);
	}

	return {
		owner,
		record: {
			uri: record.uri,
			cid: record.cid ?? '',
			rkey: parsed.rkey,
			value: record.value,
		},
		repoDid: record.value.repoDid,
		slug,
		rkey: parsed.rkey,
		knot: normalizeServiceUrl(record.value.knot),
	};
};

const getRepoTreeFromKnot = async (
	repo: RepoContext,
	ref: string,
	path = '',
): Promise<TreeResponse> =>
	(await ok(
		getRpc(repo.knot).get('sh.tangled.repo.tree', {
			params: {
				repo: repo.repoDid,
				ref,
				path,
			},
		}),
	)) as TreeResponse;

const getRepoBranchesFromKnot = async (repo: RepoContext): Promise<BranchResponse> =>
	(await ok(
		getRpc(repo.knot).get('sh.tangled.repo.branches', {
			params: {
				repo: repo.repoDid,
			},
			as: 'json',
		}),
	)) as BranchResponse;

const branchUpdatedTimestamp = (branch: BranchEntry): number =>
	maxTimestamp(
		timestampFromDate(branch.commit?.Committer?.When),
		timestampFromDate(branch.commit?.Author?.When),
	);

const sortBranchResponseByUpdatedAt = (response: BranchResponse): BranchResponse => ({
	...response,
	branches: Array.isArray(response?.branches)
		? [...response.branches].sort((left, right) => {
				const updatedDiff = branchUpdatedTimestamp(right) - branchUpdatedTimestamp(left);
				if (updatedDiff !== 0) {
					return updatedDiff;
				}

				if (left.is_default !== right.is_default) {
					return left.is_default ? -1 : 1;
				}

				return left.reference.name.localeCompare(right.reference.name);
			})
		: [],
});

const getRepoDefaultBranchFromKnot = async (repo: RepoContext): Promise<DefaultBranchResponse> =>
	(await ok(
		getRpc(repo.knot).get('sh.tangled.repo.getDefaultBranch', {
			params: {
				repo: repo.repoDid,
			},
		}),
	)) as DefaultBranchResponse;

const getRepoTagsFromKnot = async (repo: RepoContext): Promise<TagResponse> =>
	(await ok(
		getRpc(repo.knot).get('sh.tangled.repo.tags', {
			params: {
				repo: repo.repoDid,
			},
			as: 'json',
		}),
	)) as TagResponse;

const getRepoLanguagesFromKnot = async (repo: RepoContext, ref: string): Promise<LanguageResponse> =>
	(await ok(
		getRpc(repo.knot).get('sh.tangled.repo.languages', {
			params: {
				repo: repo.repoDid,
				ref,
			},
		}),
	)) as LanguageResponse;

export interface RepoLogOptions {
	limit?: number;
	cursor?: string;
	path?: string;
}

const getRepoLogFromKnot = async (
	repo: RepoContext,
	ref: string,
	options: RepoLogOptions = {},
): Promise<LogResponse> =>
	(await ok(
		getRpc(repo.knot).get('sh.tangled.repo.log', {
			params: {
				repo: repo.repoDid,
				ref,
				limit: options.limit ?? 8,
				...(options.cursor ? { cursor: options.cursor } : {}),
				...(options.path ? { path: options.path } : {}),
			},
			as: 'json',
		}),
	)) as LogResponse;

const getRepoCommitFromKnot = async (
	repo: RepoContext,
	ref: string,
): Promise<RepoCommitResponse> =>
	(await ok(
		getRpc(repo.knot).get('sh.tangled.repo.diff', {
			params: {
				repo: repo.repoDid,
				ref,
			},
			as: 'json',
		}),
	)) as RepoCommitResponse;

const getRepoBlobFromKnot = async (
	repo: RepoContext,
	ref: string,
	path: string,
): Promise<BlobResponse> =>
	(await ok(
		getRpc(repo.knot).get('sh.tangled.repo.blob', {
			params: {
				repo: repo.repoDid,
				ref,
				path,
			},
		}),
	)) as BlobResponse;

const compareBranchesFromKnot = async (
	repo: RepoContext,
	rev1: string,
	rev2: string,
): Promise<CompareResponse> =>
	(await ok(
		getRpc(repo.knot).get('sh.tangled.repo.compare', {
			params: {
				repo: repo.repoDid,
				rev1,
				rev2,
			},
			as: 'json',
		}),
	)) as CompareResponse;

export const getRepoTree = async (
	repo: RepoContext,
	ref: string,
	path = '',
): Promise<TreeResponse> =>
	appviewOptionalKnotRequest(
		() => getRepoViaAppview<TreeResponse>('sh.tangled.repo.tree', repo, { ref, path }),
		() => getRepoTreeFromKnot(repo, ref, path),
	);

const isRefNotFoundError = (err: unknown): boolean => {
	if (!err) return false;
	if (typeof err === 'object') {
		const e = err as any;
		if (e.error === 'RefNotFound' || e.status === 404 || e.name === 'RefNotFound' || e.code === 'RefNotFound') {
			return true;
		}
		if (e.message) {
			const lower = String(e.message).toLowerCase();
			if (
				lower.includes('refnotfound') ||
				lower.includes('failed to access ref') ||
				lower.includes('reference not found') ||
				lower.includes('ref not found')
			) {
				return true;
			}
		}
	}
	const msg = String(err).toLowerCase();
	return (
		msg.includes('refnotfound') ||
		msg.includes('failed to access ref') ||
		msg.includes('reference not found') ||
		msg.includes('ref not found')
	);
};

export const getRepoBranches = async (repo: RepoContext): Promise<BranchResponse> =>
	appviewOptionalKnotRequest(
		() => getRepoViaAppview<BranchResponse>('sh.tangled.repo.branches', repo),
		() => getRepoBranchesFromKnot(repo),
	)
		.then(sortBranchResponseByUpdatedAt)
		.catch((err) => {
			if (isRefNotFoundError(err)) {
				return { branches: [] };
			}
			throw err;
		});

export const getRepoDefaultBranch = async (repo: RepoContext): Promise<DefaultBranchResponse> =>
	appviewOptionalKnotRequest(
		() => getRepoViaAppview<DefaultBranchResponse>('sh.tangled.repo.getDefaultBranch', repo),
		() => getRepoDefaultBranchFromKnot(repo),
	);

export const getRepoTags = async (repo: RepoContext): Promise<TagResponse> =>
	appviewOptionalKnotRequest(
		() => getRepoViaAppview<TagResponse>('sh.tangled.repo.tags', repo),
		() => getRepoTagsFromKnot(repo),
	);

export const getRepoLanguages = async (repo: RepoContext, ref: string): Promise<LanguageResponse> =>
	appviewOptionalKnotRequest(
		() => getRepoViaAppview<LanguageResponse>('sh.tangled.repo.languages', repo, { ref }),
		() => getRepoLanguagesFromKnot(repo, ref),
	);

export const getRepoLog = async (
	repo: RepoContext,
	ref: string,
	options: RepoLogOptions = {},
): Promise<LogResponse> =>
	appviewOptionalKnotRequest(
		() => getRepoViaAppview<LogResponse>('sh.tangled.repo.log', repo, { ref, ...options, limit: options.limit ?? 8 }),
		() => getRepoLogFromKnot(repo, ref, options),
	);

export const getRepoCommit = async (
	repo: RepoContext,
	ref: string,
): Promise<RepoCommitResponse> =>
	appviewOptionalKnotRequest(
		() => getRepoViaAppview<RepoCommitResponse>('sh.tangled.repo.diff', repo, { ref }),
		() => getRepoCommitFromKnot(repo, ref),
	);

export const getRepoBlob = async (
	repo: RepoContext,
	ref: string,
	path: string,
): Promise<BlobResponse> =>
	appviewOptionalKnotRequest(
		() => getRepoViaAppview<BlobResponse>('sh.tangled.repo.blob', repo, { ref, path }),
		() => getRepoBlobFromKnot(repo, ref, path),
	);

export const compareBranches = async (
	repo: RepoContext,
	rev1: string,
	rev2: string,
): Promise<CompareResponse> =>
	appviewOptionalKnotRequest(
		() => getRepoViaAppview<CompareResponse>('sh.tangled.repo.compare', repo, { rev1, rev2 }),
		() => compareBranchesFromKnot(repo, rev1, rev2),
	);

export const decodeBlobText = (blob: BlobResponse): string => {
	if (!blob.content) {
		return '';
	}

	if (blob.encoding === 'base64') {
		const buffer = Uint8Array.from(atob(blob.content), (char) => char.charCodeAt(0));
		return new TextDecoder().decode(buffer);
	}

	return blob.content;
};

export const decodeBlobBytes = (blob: BlobResponse): Uint8Array => {
	if (!blob.content) {
		return new Uint8Array();
	}

	if (blob.encoding === 'base64') {
		return Uint8Array.from(atob(blob.content), (char) => char.charCodeAt(0));
	}

	return new TextEncoder().encode(blob.content);
};

export const buildBlobDataUrl = (blob: BlobResponse): string | null => {
	if (!blob.content || !blob.mimeType) {
		return null;
	}

	if (blob.encoding === 'base64') {
		return `data:${blob.mimeType};base64,${blob.content}`;
	}

	return `data:${blob.mimeType};charset=utf-8,${encodeURIComponent(blob.content)}`;
};

export const createHiddenRef = async (
	agent: OAuthUserAgent,
	forkKnot: string,
	input: {
		forkRef: string;
		remoteRef: string;
		repo: ResourceUri;
	},
): Promise<void> => {
	const rpc = await createServiceAuthRpc(agent, forkKnot, 'sh.tangled.repo.hiddenRef');
	await ok(
		rpc.post('sh.tangled.repo.hiddenRef', {
			input: {
				forkRef: input.forkRef,
				remoteRef: input.remoteRef,
				repo: input.repo,
			},
		}),
	);
};

export const compareFork = async (
	agent: OAuthUserAgent,
	forkRepo: RepoContext,
	targetBranch: string,
	sourceBranch: string,
): Promise<CompareResponse> => {
	await createHiddenRef(agent, forkRepo.knot, {
		forkRef: sourceBranch,
		remoteRef: targetBranch,
		repo: forkRepo.record.uri,
	});

	const hiddenRef = `hidden/${sourceBranch}/${targetBranch}`;
	return compareBranches(forkRepo, hiddenRef, sourceBranch);
};

export const getRepoForkCount = async (repo: RepoContext): Promise<number> => {
	try {
		const source = 'sh.tangled.repo:source';
		const [didPage, uriPage] = await Promise.all([
			getBacklinksPage(repo.repoDid, source, 1),
			getBacklinksPage(repo.record.uri, source, 1),
		]);
		const didCount = didPage.total ?? didPage.records.length;
		const uriCount = uriPage.total ?? uriPage.records.length;
		return didCount + uriCount;
	} catch {
		return 0;
	}
};

export const listUserKnots = async (actorIdentifier: string | ResolvedActor): Promise<string[]> => {
	const actor = typeof actorIdentifier === 'string' ? await resolveActor(actorIdentifier) : actorIdentifier;
	const rpc = getRpc(actor.pds);
	const records: string[] = [];
	let cursor: string | undefined;

	try {
		do {
			const page = await ok(
				rpc.get('com.atproto.repo.listRecords', {
					params: {
						repo: actor.did,
						collection: 'sh.tangled.knot',
						limit: 100,
						cursor,
						reverse: true,
					},
				}),
			);

			for (const r of page.records) {
				const rkey = parseAtUri(r.uri).rkey;
				records.push(rkey);
			}
			cursor = page.cursor;
		} while (cursor);
	} catch (err) {
		console.error('Failed to list knots from PDS:', err);
	}

	return records;
};

const cleanedRepoName = (name: string): string => {
	const repoName = name.trim();
	return repoName.endsWith('.git') ? repoName.slice(0, -4) : repoName;
};

const repoRkeyFromName = (name: string): string => cleanedRepoName(name).toLowerCase();

const forkSourceUrl = (sourceRepo: RepoContext): string =>
	`${normalizeServiceUrl(sourceRepo.knot).replace(/\/+$/, '')}/${sourceRepo.repoDid}`;

const deleteRepoFromKnot = async (
	agent: OAuthUserAgent,
	knot: string,
	rkey: string,
): Promise<void> => {
	const knotDeleteClient = await createServiceAuthRpc(agent, knot, 'sh.tangled.repo.delete');
	await ok(
		knotDeleteClient.post('sh.tangled.repo.delete', {
			input: {
				did: agent.sub,
				name: rkey,
				rkey,
			},
			as: null,
		}),
	);
};

export const createRepo = async (
	agent: OAuthUserAgent,
	input: {
		name: string;
		description?: string;
		defaultBranch: string;
		knot: string;
	},
): Promise<RecordCreationResult> => {
	const cleanedName = cleanedRepoName(input.name);
	const rkey = repoRkeyFromName(input.name);

	const knotClient = await createServiceAuthRpc(agent, input.knot, 'sh.tangled.repo.create');
	const createResult = (await ok(
		knotClient.post('sh.tangled.repo.create', {
			input: {
				rkey,
				name: rkey,
				defaultBranch: input.defaultBranch || 'main',
			},
		}),
	)) as { repoDid?: string };

	const repoDid = createResult.repoDid;
	if (!repoDid) {
		throw new Error('Knot failed to mint a repo DID.');
	}

	const pdsRpc = createAuthRpc(agent);
	const record = {
		$type: 'sh.tangled.repo',
		name: cleanedName,
		knot: input.knot,
		repoDid: repoDid,
		description: input.description?.trim() || '',
		createdAt: new Date().toISOString(),
	};

	try {
		const result = await ok(
			pdsRpc.post('com.atproto.repo.createRecord', {
				input: {
					repo: agent.sub,
					collection: REPO_COLLECTION,
					rkey: rkey,
					record,
				},
			}),
		);

		return {
			uri: result.uri,
			rkey: parseAtUri(result.uri).rkey,
		};
	} catch (pdsErr) {
		try {
			await deleteRepoFromKnot(agent, input.knot, rkey);
		} catch (deleteErr) {
			console.error('Failed to clean up repo on knot:', deleteErr);
		}
		throw pdsErr;
	}
};

export const forkRepo = async (
	agent: OAuthUserAgent,
	input: {
		sourceRepo: RepoContext;
		name: string;
		description?: string;
		knot: string;
	},
): Promise<RecordCreationResult> => {
	const cleanedName = cleanedRepoName(input.name);
	const rkey = repoRkeyFromName(input.name);

	const knotClient = await createServiceAuthRpc(agent, input.knot, 'sh.tangled.repo.create');
	const createResult = (await ok(
		knotClient.post('sh.tangled.repo.create', {
			input: {
				rkey,
				name: rkey,
				source: forkSourceUrl(input.sourceRepo),
			},
		}),
	)) as { repoDid?: string };

	const repoDid = createResult.repoDid;
	if (!repoDid) {
		throw new Error('Knot failed to mint a repo DID.');
	}

	const pdsRpc = createAuthRpc(agent);
	const record = {
		$type: 'sh.tangled.repo',
		name: cleanedName,
		knot: input.knot,
		repoDid,
		source: input.sourceRepo.repoDid || input.sourceRepo.record.uri,
		description: input.description?.trim() || '',
		createdAt: new Date().toISOString(),
	};

	try {
		const result = await ok(
			pdsRpc.post('com.atproto.repo.createRecord', {
				input: {
					repo: agent.sub,
					collection: REPO_COLLECTION,
					rkey,
					record,
				},
			}),
		);

		return {
			uri: result.uri,
			rkey: parseAtUri(result.uri).rkey,
		};
	} catch (pdsErr) {
		try {
			await deleteRepoFromKnot(agent, input.knot, rkey);
		} catch (deleteErr) {
			console.error('Failed to clean up fork on knot:', deleteErr);
		}
		throw pdsErr;
	}
};
