/**
 * Tests for orphan-worktree-sweep.ts
 *
 * Uses a tmpdir for a fake project root. We don't need real git plumbing because
 * the sweep only reads the filesystem (auto.lock for PID, .git file for structure).
 * removeWorktree is mocked where needed to avoid git side-effects.
 */

import assert from "node:assert/strict";
import {
	existsSync,
	mkdirSync,
	mkdtempSync,
	readdirSync,
	readFileSync,
	rmSync,
	writeFileSync,
} from "node:fs";
import { tmpdir } from "node:os";
import { join } from "node:path";
import { afterEach, beforeEach, describe, test, vi } from "vitest";

// ─── Module under test ───────────────────────────────────────────────────────
// We import after vi.mock declarations so the mocks are in place.

// Mock removeWorktree so tests don't need a live git repo.
vi.mock("../worktree-manager.js", async (importOriginal) => {
	const real = await importOriginal<typeof import("../worktree-manager.js")>();
	return {
		...real,
		removeWorktree: vi.fn(),
	};
});

import { sweepOrphanWorktrees } from "../orphan-worktree-sweep.ts";
import { removeWorktree } from "../worktree-manager.js";
import type { JournalEntry } from "../journal.js";

// ─── Helpers ─────────────────────────────────────────────────────────────────

/** Create a minimal fake project tree: <root>/.sf/worktrees/ */
function makeProject(): string {
	const root = mkdtempSync(join(tmpdir(), "orphan-sweep-test-"));
	mkdirSync(join(root, ".sf", "worktrees"), { recursive: true });
	// Minimal .sf/ so journal writes don't fail
	mkdirSync(join(root, ".sf", "journal"), { recursive: true });
	return root;
}

/**
 * Create a worktree directory with a valid .git pointer file.
 * The `gitdir:` target doesn't need to exist for the structural check.
 */
function makeHealthyWorktree(root: string, id: string): string {
	const wtPath = join(root, ".sf", "worktrees", id);
	mkdirSync(join(wtPath, ".sf"), { recursive: true });
	writeFileSync(join(wtPath, ".git"), "gitdir: ../../.git/worktrees/" + id + "\n");
	return wtPath;
}

/**
 * Create a worktree directory that is BROKEN — no .git file at all.
 */
function makeBrokenWorktree(root: string, id: string): string {
	const wtPath = join(root, ".sf", "worktrees", id);
	mkdirSync(join(wtPath, ".sf"), { recursive: true });
	// Intentionally no .git file
	return wtPath;
}

/**
 * Write a fake auto.lock into a worktree with a given PID.
 */
function writeLock(wtPath: string, pid: number): void {
	writeFileSync(
		join(wtPath, ".sf", "auto.lock"),
		JSON.stringify({ pid, startedAt: new Date().toISOString(), unitType: "execute-task", unitId: "T01", unitStartedAt: new Date().toISOString() }),
	);
}

/** Read all journal entries from a temp project's .sf/journal directory. */
function readJournal(root: string): JournalEntry[] {
	const journalDir = join(root, ".sf", "journal");
	try {
		const files = readdirSync(journalDir)
			.filter((f) => f.endsWith(".jsonl"))
			.sort();
		const entries: JournalEntry[] = [];
		for (const file of files) {
			const raw = readFileSync(join(journalDir, file), "utf-8");
			for (const line of raw.split("\n")) {
				if (!line.trim()) continue;
				try {
					entries.push(JSON.parse(line) as JournalEntry);
				} catch {
					// skip
				}
			}
		}
		return entries;
	} catch {
		return [];
	}
}

// ─── Setup ────────────────────────────────────────────────────────────────────

let projectRoot: string;

beforeEach(() => {
	vi.clearAllMocks();
	projectRoot = makeProject();
});

afterEach(() => {
	rmSync(projectRoot, { recursive: true, force: true });
});

// ─── Test cases ───────────────────────────────────────────────────────────────

describe("sweepOrphanWorktrees", () => {
	// 1. Empty worktrees directory
	test("empty .sf/worktrees/ returns all-empty arrays, no errors", () => {
		const result = sweepOrphanWorktrees(projectRoot);
		assert.deepStrictEqual(result.resumed, []);
		assert.deepStrictEqual(result.pruned, []);
		assert.deepStrictEqual(result.errors, []);
	});

	// 2. Active worktree (PID is our own process — guaranteed alive)
	test("active worktree (live PID in auto.lock) is left alone", () => {
		const wtPath = makeHealthyWorktree(projectRoot, "M001");
		writeLock(wtPath, process.pid); // Our own PID is always alive

		const result = sweepOrphanWorktrees(projectRoot);
		assert.deepStrictEqual(result.resumed, [], "active worktree must not appear in resumed");
		assert.deepStrictEqual(result.pruned, [], "active worktree must not appear in pruned");
		assert.deepStrictEqual(result.errors, []);
		assert.ok(existsSync(wtPath), "worktree directory must still exist");
	});

	// 3. Resumable orphan — healthy worktree, no live lock
	test("resumable orphan is left intact and journaled with reason 'resumable'", () => {
		makeHealthyWorktree(projectRoot, "M002");
		// No auto.lock written → no active PID

		const result = sweepOrphanWorktrees(projectRoot);
		assert.deepStrictEqual(result.resumed, ["M002"]);
		assert.deepStrictEqual(result.pruned, []);
		assert.deepStrictEqual(result.errors, []);

		// worktree directory must still exist
		assert.ok(existsSync(join(projectRoot, ".sf", "worktrees", "M002")));

		// Journal must have a worktree-orphaned event with reason 'resumable'
		const events = readJournal(projectRoot).filter(
			(e) => e.eventType === "worktree-orphaned",
		);
		assert.equal(events.length, 1, "one orphaned event expected");
		assert.equal(events[0].data?.milestoneId, "M002");
		assert.equal(events[0].data?.reason, "resumable");
	});

	// 4. Broken orphan — missing .git file
	test("broken orphan (missing .git) is pruned and journaled with reason 'broken-pruned'", () => {
		makeBrokenWorktree(projectRoot, "M003");

		const result = sweepOrphanWorktrees(projectRoot);
		assert.deepStrictEqual(result.pruned, ["M003"]);
		assert.deepStrictEqual(result.resumed, []);
		assert.deepStrictEqual(result.errors, []);

		// removeWorktree must have been called
		assert.ok(
			(removeWorktree as ReturnType<typeof vi.fn>).mock.calls.some(
				(args) => args[1] === "M003",
			),
			"removeWorktree called for M003",
		);

		// Journal event
		const events = readJournal(projectRoot).filter(
			(e) => e.eventType === "worktree-orphaned",
		);
		assert.equal(events.length, 1, "one orphaned event expected");
		assert.equal(events[0].data?.milestoneId, "M003");
		assert.equal(events[0].data?.reason, "broken-pruned");
	});

	// 5. Mixed: one active, one resumable, one broken
	test("mixed scenario: active/resumable/broken triaged correctly", () => {
		// Active — use current process PID
		const activeWt = makeHealthyWorktree(projectRoot, "M010");
		writeLock(activeWt, process.pid);

		// Resumable
		makeHealthyWorktree(projectRoot, "M011");

		// Broken
		makeBrokenWorktree(projectRoot, "M012");

		const result = sweepOrphanWorktrees(projectRoot);

		assert.deepStrictEqual(result.resumed, ["M011"]);
		assert.deepStrictEqual(result.pruned, ["M012"]);
		assert.deepStrictEqual(result.errors, []);

		// Active worktree still exists
		assert.ok(existsSync(activeWt), "active worktree directory must survive");

		// removeWorktree called only for broken
		const calls = (removeWorktree as ReturnType<typeof vi.fn>).mock.calls;
		assert.ok(
			calls.some((args) => args[1] === "M012"),
			"removeWorktree called for broken",
		);
		assert.ok(
			!calls.some((args) => args[1] === "M010"),
			"removeWorktree NOT called for active",
		);
		assert.ok(
			!calls.some((args) => args[1] === "M011"),
			"removeWorktree NOT called for resumable",
		);

		// Two journal events: one resumable, one broken-pruned
		const events = readJournal(projectRoot).filter(
			(e) => e.eventType === "worktree-orphaned",
		);
		assert.equal(events.length, 2);
		const reasons = events.map((e) => e.data?.reason as string).sort();
		assert.deepStrictEqual(reasons, ["broken-pruned", "resumable"]);
	});

	// 6. Error path — removeWorktree throws; error captured, sweep continues
	test("removeWorktree throw is captured in errors[] and sweep continues", () => {
		makeBrokenWorktree(projectRoot, "M020");
		makeHealthyWorktree(projectRoot, "M021"); // resumable — processed after error

		(removeWorktree as ReturnType<typeof vi.fn>).mockImplementationOnce(() => {
			throw new Error("simulated git failure");
		});

		const result = sweepOrphanWorktrees(projectRoot);

		// M020 threw, M021 is resumable — both processed
		assert.equal(result.errors.length, 1, "error accumulated for M020");
		assert.equal(result.errors[0].id, "M020");
		assert.ok(result.errors[0].reason.includes("simulated git failure"));

		// M021 still processed as resumable
		assert.deepStrictEqual(result.resumed, ["M021"]);
		assert.deepStrictEqual(result.pruned, []);
	});

	// 7. No .sf/worktrees/ directory — returns empty result immediately
	test("no .sf/worktrees/ directory returns empty result", () => {
		rmSync(join(projectRoot, ".sf", "worktrees"), { recursive: true, force: true });

		const result = sweepOrphanWorktrees(projectRoot);
		assert.deepStrictEqual(result.resumed, []);
		assert.deepStrictEqual(result.pruned, []);
		assert.deepStrictEqual(result.errors, []);
	});

	// 8. Idempotency — running twice with no orphans returns empty arrays
	test("idempotent: running twice with no orphans returns empty arrays both times", () => {
		const r1 = sweepOrphanWorktrees(projectRoot);
		const r2 = sweepOrphanWorktrees(projectRoot);

		for (const result of [r1, r2]) {
			assert.deepStrictEqual(result.resumed, []);
			assert.deepStrictEqual(result.pruned, []);
			assert.deepStrictEqual(result.errors, []);
		}
	});
});
