import { describe, expect, it, vi } from "vitest";
import {
	logFor,
	repoNameOf,
	resolveRepoByName,
	sortTreeEntries,
	toBranchSummary,
	toCommitSummary,
	toTagSummary,
	toTreeEntrySummary,
	treeEntryKind,
	type BranchEntry,
	type LogCommit,
	type TagEntry,
	type TreeEntrySummary
} from "./repo";
import { ClientResponseError, createBobbinClient, type BobbinContext } from "./client";
import type { RecordView, RepoRecord } from "./records";

const jsonResponse = (body: unknown): Response =>
	new Response(JSON.stringify(body), {
		status: 200,
		headers: { "content-type": "application/json" }
	});

const makeCtx = (fetchMock: typeof globalThis.fetch): BobbinContext =>
	createBobbinClient({ serviceUrl: "https://bobbin.test", fetch: fetchMock });

describe("repoNameOf", () => {
	const view = (uri: string, name?: string): RecordView<RepoRecord> => ({
		uri: uri as RecordView<RepoRecord>["uri"],
		value: { $type: "sh.tangled.repo", createdAt: "", knot: "knot.test", name }
	});

	it("prefers the record's cosmetic name over a tid rkey", () => {
		expect(repoNameOf(view("at://did:plc:o/sh.tangled.repo/3lzg6enurmo22", "infra"))).toBe("infra");
	});

	it("falls back to the rkey for unnamed records", () => {
		expect(repoNameOf(view("at://did:plc:o/sh.tangled.repo/infra"))).toBe("infra");
	});
});

describe("treeEntryKind", () => {
	it("maps git file modes to entry kinds", () => {
		expect(treeEntryKind("0040000")).toBe("directory");
		expect(treeEntryKind("040000")).toBe("directory");
		expect(treeEntryKind("0100644")).toBe("file");
		expect(treeEntryKind("0100755")).toBe("file");
		expect(treeEntryKind("0120000")).toBe("symlink");
		expect(treeEntryKind("0160000")).toBe("submodule");
	});
});

describe("sortTreeEntries", () => {
	it("puts directories and submodules before files, then sorts by name", () => {
		const entry = (name: string, kind: TreeEntrySummary["kind"]): TreeEntrySummary => ({
			name,
			kind,
			size: 0
		});
		const sorted = sortTreeEntries([
			entry("readme.md", "file"),
			entry("src", "directory"),
			entry(".gitignore", "file"),
			entry("vendor", "submodule")
		]);
		expect(sorted.map((item) => item.name)).toEqual(["src", "vendor", ".gitignore", "readme.md"]);
	});
});

describe("toCommitSummary", () => {
	// `this` is where the hex hash lives, `hash` is a byte array on the wire
	const commit: LogCommit = {
		this: "0c4d0e9b07940033721395a434b5873f0fb9e6c8",
		author: { Name: "Ada", Email: "ada@example.com", When: "2026-07-01T10:00:00Z" },
		committer: { Name: "Ada", Email: "ada@example.com", When: "2026-07-02T10:00:00Z" },
		message: "web: add repo index\n\nWith a longer body.\n",
		change_id: "abc123"
	};

	it("splits the subject from the body and shortens the hash", () => {
		const summary = toCommitSummary(commit);
		expect(summary).toMatchObject({
			hash: "0c4d0e9b07940033721395a434b5873f0fb9e6c8",
			shortHash: "0c4d0e9b",
			subject: "web: add repo index",
			body: "With a longer body.",
			authorName: "Ada",
			when: "2026-07-02T10:00:00Z",
			changeId: "abc123"
		});
	});

	it("leaves the body empty for single-line messages", () => {
		const summary = toCommitSummary({ ...commit, message: "one liner\n" });
		expect(summary.body).toBe("");
		expect(summary.subject).toBe("one liner");
	});
});

describe("logFor", () => {
	it("passes the cursor through to the knot", async () => {
		const fetchMock = vi
			.fn<typeof globalThis.fetch>()
			.mockResolvedValue(jsonResponse({ commits: [], total: 42 }));
		await logFor(makeCtx(fetchMock), "at://did:plc:o/sh.tangled.repo/abc", "master", 20, "40");
		const url = new URL(String(fetchMock.mock.calls[0][0]));
		expect(url.pathname).toBe("/xrpc/sh.tangled.repo.log");
		expect(url.searchParams.get("ref")).toBe("master");
		expect(url.searchParams.get("limit")).toBe("20");
		expect(url.searchParams.get("cursor")).toBe("40");
	});
});

describe("toBranchSummary", () => {
	it("reads the nested reference and the go-git commit fields", () => {
		const branch: BranchEntry = {
			reference: { name: "master", hash: "ff3a3678" },
			commit: { Committer: { Name: "Ada", Email: "a@b.c", When: "2026-07-02T10:00:00Z" } },
			is_default: true
		};
		expect(toBranchSummary(branch)).toEqual({
			name: "master",
			hash: "ff3a3678",
			when: "2026-07-02T10:00:00Z",
			isDefault: true
		});
	});

	it("treats a missing is_default as not default", () => {
		expect(toBranchSummary({ reference: { name: "topic", hash: "abc" } }).isDefault).toBe(false);
	});
});

describe("toTagSummary", () => {
	// an annotated tag's own hash is the tag object, the commit is in Target
	it("reads the inlined reference and follows an annotated tag to its commit", () => {
		const tag: TagEntry = {
			name: "v1.0.0",
			hash: "63fa1d4b",
			message: "release",
			tag: {
				Tagger: { Name: "Ada", Email: "a@b.c", When: "2026-07-01T10:00:00Z" },
				Target: [75, 78, 254, 37]
			}
		};
		expect(toTagSummary(tag)).toEqual({
			name: "v1.0.0",
			hash: "63fa1d4b",
			commitHash: "4b4efe25",
			when: "2026-07-01T10:00:00Z",
			message: "release"
		});
	});

	it("a lightweight tag is its own commit", () => {
		const summary = toTagSummary({ name: "v1.0.0", hash: "eebb477b" });
		expect(summary.commitHash).toBe("eebb477b");
	});
});

describe("toTreeEntrySummary", () => {
	it("carries the last commit over from snake_case", () => {
		expect(
			toTreeEntrySummary({
				name: "flake.nix",
				mode: "0100644",
				size: 213,
				last_commit: { hash: "f0b11b85", message: "init", when: "2026-07-01T10:00:00Z" }
			})
		).toEqual({
			name: "flake.nix",
			kind: "file",
			size: 213,
			lastCommitHash: "f0b11b85",
			lastCommitWhen: "2026-07-01T10:00:00Z",
			lastCommitMessage: "init"
		});
	});
});

describe("resolveRepoByName", () => {
	const owner = "did:plc:owner";

	it("asks bobbin for the owner and name", async () => {
		const fetchMock = vi.fn<typeof globalThis.fetch>().mockResolvedValue(
			jsonResponse({
				uri: `at://${owner}/sh.tangled.repo/3lzg6enurmo22`,
				value: { $type: "sh.tangled.repo", knot: "knot.test", name: "infra" }
			})
		);
		const view = await resolveRepoByName(makeCtx(fetchMock), owner, "infra");
		expect(view?.uri).toBe(`at://${owner}/sh.tangled.repo/3lzg6enurmo22`);
		const url = new URL(String(fetchMock.mock.calls[0][0]));
		expect(url.pathname).toBe("/xrpc/sh.tangled.repo.getRepoByName");
		expect(url.searchParams.get("owner")).toBe(owner);
		expect(url.searchParams.get("name")).toBe("infra");
	});

	it("returns null when the owner has no such repo", async () => {
		const fetchMock = vi.fn<typeof globalThis.fetch>().mockResolvedValue(
			new Response(JSON.stringify({ error: "RecordNotFound" }), {
				status: 404,
				headers: { "content-type": "application/json" }
			})
		);
		expect(await resolveRepoByName(makeCtx(fetchMock), owner, "missing")).toBeNull();
	});

	it("propagates anything that is not a miss", async () => {
		const fetchMock = vi.fn<typeof globalThis.fetch>().mockResolvedValue(
			new Response(JSON.stringify({ error: "UpstreamFailed" }), {
				status: 502,
				headers: { "content-type": "application/json" }
			})
		);
		await expect(resolveRepoByName(makeCtx(fetchMock), owner, "infra")).rejects.toBeInstanceOf(
			ClientResponseError
		);
	});
});
