/**
 * gitignore-tracked-sf.test.ts — Regression tests for #1364.
 *
 * Verifies that ensureGitignore() does NOT add ".sf" to .gitignore
 * when .sf/ contains git-tracked files, and that migrateToExternalState()
 * aborts migration for tracked .sf/ directories.
 *
 * Uses real temporary git repos — no mocks.
 */

import assert from "node:assert/strict";
import { execFileSync } from "node:child_process";
import {
	existsSync,
	mkdirSync,
	mkdtempSync,
	readFileSync,
	rmSync,
	writeFileSync,
} from "node:fs";
import { tmpdir } from "node:os";
import { join } from "node:path";
import { test, afterEach } from 'vitest';

import { ensureGitignore, hasGitTrackedSfFiles } from "../../gitignore.ts";
import { migrateToExternalState } from "../../migrate-external.ts";

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

function git(dir: string, ...args: string[]): string {
	return execFileSync("git", args, {
		cwd: dir,
		stdio: "pipe",
		encoding: "utf-8",
	}).trim();
}

function makeTempRepo(): string {
	const dir = mkdtempSync(join(tmpdir(), "sf-gitignore-test-"));
	git(dir, "init");
	git(dir, "config", "user.email", "test@test.com");
	git(dir, "config", "user.name", "Test");
	writeFileSync(join(dir, "README.md"), "# init\n");
	git(dir, "add", "-A");
	git(dir, "commit", "-m", "init");
	git(dir, "branch", "-M", "main");
	return dir;
}

function cleanup(dir: string): void {
	try {
		rmSync(dir, { recursive: true, force: true });
	} catch {
		// ignore
	}
}

// ─── hasGitTrackedSfFiles ───────────────────────────────────────────

test("hasGitTrackedSfFiles returns false when .sf/ does not exist", (t) => {
	const dir = makeTempRepo();
	afterEach(() => {
		cleanup(dir);
	});

	assert.equal(hasGitTrackedSfFiles(dir), false);
});

test("hasGitTrackedSfFiles returns true when .sf/ has tracked files", (t) => {
	const dir = makeTempRepo();
	afterEach(() => {
		cleanup(dir);
	});

	mkdirSync(join(dir, ".sf", "milestones"), { recursive: true });
	writeFileSync(join(dir, ".sf", "PROJECT.md"), "# Test Project\n");
	git(dir, "add", ".sf/PROJECT.md");
	git(dir, "commit", "-m", "add sf");
	assert.equal(hasGitTrackedSfFiles(dir), true);
});

test("hasGitTrackedSfFiles returns false when .sf/ exists but is untracked", (t) => {
	const dir = makeTempRepo();
	afterEach(() => {
		cleanup(dir);
	});

	mkdirSync(join(dir, ".sf"), { recursive: true });
	writeFileSync(join(dir, ".sf", "STATE.md"), "state\n");
	// Not git-added — should return false
	assert.equal(hasGitTrackedSfFiles(dir), false);
});

// ─── ensureGitignore — tracked .sf/ protection ─────────────────────

test("ensureGitignore does NOT add .sf when .sf/ has tracked files (#1364)", (_t) => {
	const dir = makeTempRepo();
	try {
		// Set up .sf/ with tracked files
		mkdirSync(join(dir, ".sf", "milestones"), { recursive: true });
		writeFileSync(join(dir, ".sf", "PROJECT.md"), "# Test Project\n");
		writeFileSync(join(dir, ".sf", "DECISIONS.md"), "# Decisions\n");
		git(dir, "add", ".sf/");
		git(dir, "commit", "-m", "track sf state");

		// Run ensureGitignore
		ensureGitignore(dir);

		// Verify .sf is NOT in .gitignore
		const gitignore = readFileSync(join(dir, ".gitignore"), "utf-8");
		const lines = gitignore.split("\n").map((l) => l.trim());
		assert.ok(
			!lines.includes(".sf"),
			`Expected .sf NOT to appear in .gitignore, but it does:\n${gitignore}`,
		);

		// Other baseline patterns should still be present
		assert.ok(lines.includes(".DS_Store"), "Expected .DS_Store in .gitignore");
		assert.ok(
			lines.includes("node_modules/"),
			"Expected node_modules/ in .gitignore",
		);
	} finally {
		cleanup(dir);
	}
});

test("ensureGitignore excludes SF runtime patterns when .sf/ has NO tracked files", (_t) => {
	const dir = makeTempRepo();
	try {
		// Run ensureGitignore (no .sf/ at all)
		ensureGitignore(dir);

		const exclude = readFileSync(join(dir, ".git", "info", "exclude"), "utf-8");
		const lines = exclude.split("\n").map((l) => l.trim());
		// Per ADR-001, local directories use granular runtime exclusions,
		// not the blanket .sf pattern (which is reserved for symlink mode).
		assert.ok(
			lines.includes(".sf-id"),
			`Expected .sf-id in .git/info/exclude, but it's missing:\n${exclude}`,
		);
		assert.doesNotThrow(() => git(dir, "check-ignore", "-q", ".sf-id"));
	} finally {
		cleanup(dir);
	}
});

test("ensureGitignore respects manageGitignore: false", (t) => {
	const dir = makeTempRepo();
	afterEach(() => {
		cleanup(dir);
	});

	const result = ensureGitignore(dir, { manageGitignore: false });
	assert.equal(result, false);
	assert.ok(
		!existsSync(join(dir, ".gitignore")),
		"Should not create .gitignore",
	);
});

// ─── ensureGitignore — verify no tracked files become invisible ─────

test("ensureGitignore with tracked .sf/ does not cause git to see files as deleted", (_t) => {
	const dir = makeTempRepo();
	try {
		// Create tracked .sf/ files
		mkdirSync(join(dir, ".sf", "milestones", "M001"), { recursive: true });
		writeFileSync(join(dir, ".sf", "PROJECT.md"), "# Project\n");
		writeFileSync(
			join(dir, ".sf", "milestones", "M001", "M001-CONTEXT.md"),
			"# M001\n",
		);
		git(dir, "add", ".sf/");
		git(dir, "commit", "-m", "track sf state");

		// Run ensureGitignore
		ensureGitignore(dir);

		// git status should show NO deleted files under .sf/
		const status = git(dir, "status", "--porcelain", ".sf/");

		// Filter for deletions (lines starting with " D" or "D ")
		const deletions = status
			.split("\n")
			.filter((l) => l.match(/^\s*D\s/) || l.match(/^D\s/));

		assert.equal(
			deletions.length,
			0,
			`Expected no deleted .sf/ files, but found:\n${deletions.join("\n")}`,
		);
	} finally {
		cleanup(dir);
	}
});

test("hasGitTrackedSfFiles returns true (fail-safe) when git is not available", (_t) => {
	const dir = makeTempRepo();
	try {
		// Create and track .sf/ files
		mkdirSync(join(dir, ".sf"), { recursive: true });
		writeFileSync(join(dir, ".sf", "PROJECT.md"), "# Project\n");
		git(dir, "add", ".sf/");
		git(dir, "commit", "-m", "track sf");

		// Corrupt the git index to simulate git failure
		const indexPath = join(dir, ".git", "index.lock");
		writeFileSync(indexPath, "locked");

		// Should fail safe — assume tracked rather than silently returning false
		// (The index lock causes git ls-files to fail; rev-parse also fails → true)
		const result = hasGitTrackedSfFiles(dir);
		assert.equal(
			result,
			true,
			"Should return true (fail-safe) when git is unavailable",
		);
	} finally {
		cleanup(dir);
	}
});

// ─── migrateToExternalState — tracked .sf/ protection ──────────────

test("migrateToExternalState aborts when .sf/ has tracked files (#1364)", (_t) => {
	const dir = makeTempRepo();
	try {
		// Create tracked .sf/ files
		mkdirSync(join(dir, ".sf", "milestones"), { recursive: true });
		writeFileSync(join(dir, ".sf", "PROJECT.md"), "# Project\n");
		git(dir, "add", ".sf/");
		git(dir, "commit", "-m", "track sf state");

		// Attempt migration — should abort without moving anything
		const result = migrateToExternalState(dir);

		assert.equal(result.migrated, false, "Should NOT migrate tracked .sf/");
		assert.equal(
			result.error,
			undefined,
			"Should not report an error — just skip",
		);

		// .sf/ should still be a real directory, not a symlink
		assert.ok(
			existsSync(join(dir, ".sf", "PROJECT.md")),
			".sf/PROJECT.md should still exist",
		);

		// No .sf.migrating should exist
		assert.ok(
			!existsSync(join(dir, ".sf.migrating")),
			".sf.migrating should not exist",
		);
	} finally {
		cleanup(dir);
	}
});

test("migrateToExternalState cleans git index so tracked files don't show as deleted (#1364 path 2)", (_t) => {
	const dir = makeTempRepo();
	try {
		// Track .sf/ files, then untrack them so migration proceeds
		mkdirSync(join(dir, ".sf", "milestones", "M001"), { recursive: true });
		writeFileSync(join(dir, ".sf", "PROJECT.md"), "# Project\n");
		writeFileSync(
			join(dir, ".sf", "milestones", "M001", "PLAN.md"),
			"# Plan\n",
		);
		git(dir, "add", ".sf/");
		git(dir, "commit", "-m", "track sf state");
		git(dir, "rm", "-r", "--cached", ".sf/");
		git(dir, "commit", "-m", "untrack sf (simulates pre-migration project)");

		const result = migrateToExternalState(dir);
		assert.equal(result.migrated, true, "Migration should succeed");

		// git status must show NO deleted files after migration
		const status = git(dir, "status", "--porcelain");
		const deletions = status
			.split("\n")
			.filter((l) => /^\s*D\s/.test(l) || /^D\s/.test(l));
		assert.equal(
			deletions.length,
			0,
			`Expected no deleted files after migration, but found:\n${deletions.join("\n")}`,
		);
	} finally {
		cleanup(dir);
	}
});
