/**
 * context-board.test.ts — Tests for the always-in-context invariants board.
 *
 * Covers:
 *   - SQL migration: table exists after sf-db initialization
 *   - addBoardEntry + getBoardEntries round-trip per (repository, branch)
 *   - pruneBoardEntry removes only the targeted entry
 *   - formatBoardForPrompt respects byte cap with truncation marker
 *   - Tool invocations: executeContextBoardAdd and executeContextBoardPrune
 */

import { mkdirSync, mkdtempSync, rmSync } from "node:fs";
import { tmpdir } from "node:os";
import { join } from "node:path";
import { afterEach, describe, expect, it } from "vitest";
import {
	addBoardEntry,
	formatBoardForPrompt,
	getBoardEntries,
	pruneBoardEntry,
} from "../context-board.js";
import { closeDatabase, openDatabase } from "../sf-db.js";
import {
	executeContextBoardAdd,
	executeContextBoardPrune,
} from "../tools/context-board-tool.js";

const tmpDirs: string[] = [];

afterEach(() => {
	closeDatabase();
	while (tmpDirs.length > 0) {
		const dir = tmpDirs.pop();
		if (dir) rmSync(dir, { recursive: true, force: true });
	}
});

function makeProject(): string {
	const dir = mkdtempSync(join(tmpdir(), "sf-context-board-"));
	tmpDirs.push(dir);
	mkdirSync(join(dir, ".sf"), { recursive: true });
	openDatabase(join(dir, ".sf", "sf.db"));
	return dir;
}

// ─── SQL migration ─────────────────────────────────────────────────────────────

describe("SQL migration", () => {
	it("context_board table exists after sf-db initialization", () => {
		makeProject();
		// addBoardEntry would throw or return null if the table doesn't exist
		const id = addBoardEntry({
			content: "migration check",
			repository: "/repo",
			branch: "main",
		});
		expect(id).toBeTruthy();
		expect(typeof id).toBe("string");
	});
});

// ─── addBoardEntry + getBoardEntries round-trip ────────────────────────────────

describe("addBoardEntry / getBoardEntries", () => {
	it("round-trips an entry for a given (repository, branch)", () => {
		makeProject();
		const id = addBoardEntry({
			content: "Do not edit generated files",
			category: "arch",
			repository: "/my/repo",
			branch: "main",
		});
		expect(id).toBeTruthy();

		const entries = getBoardEntries({ repository: "/my/repo", branch: "main" });
		expect(entries).toHaveLength(1);
		expect(entries[0].id).toBe(id);
		expect(entries[0].content).toBe("Do not edit generated files");
		expect(entries[0].category).toBe("arch");
		expect(entries[0].added_at).toBeTruthy();
	});

	it("entries are scoped per (repository, branch) — different branch sees empty board", () => {
		makeProject();
		addBoardEntry({
			content: "main-only invariant",
			repository: "/my/repo",
			branch: "main",
		});

		const mainEntries = getBoardEntries({
			repository: "/my/repo",
			branch: "main",
		});
		const featureEntries = getBoardEntries({
			repository: "/my/repo",
			branch: "feature/x",
		});

		expect(mainEntries).toHaveLength(1);
		expect(featureEntries).toHaveLength(0);
	});

	it("entries are scoped per (repository, branch) — different repo sees empty board", () => {
		makeProject();
		addBoardEntry({
			content: "repo-a invariant",
			repository: "/repo-a",
			branch: "main",
		});

		const repoAEntries = getBoardEntries({
			repository: "/repo-a",
			branch: "main",
		});
		const repoBEntries = getBoardEntries({
			repository: "/repo-b",
			branch: "main",
		});

		expect(repoAEntries).toHaveLength(1);
		expect(repoBEntries).toHaveLength(0);
	});

	it("returns multiple entries ordered by added_at ASC", () => {
		makeProject();
		const id1 = addBoardEntry({
			content: "first invariant",
			repository: "/repo",
			branch: "main",
		});
		const id2 = addBoardEntry({
			content: "second invariant",
			repository: "/repo",
			branch: "main",
		});

		const entries = getBoardEntries({ repository: "/repo", branch: "main" });
		expect(entries).toHaveLength(2);
		// Both IDs should be present (order may vary for same-millisecond adds)
		const ids = entries.map((e) => e.id);
		expect(ids).toContain(id1);
		expect(ids).toContain(id2);
	});

	it("returns empty array when db is not open", () => {
		// Do NOT call makeProject() — db is closed
		const entries = getBoardEntries({ repository: "/repo", branch: "main" });
		expect(entries).toHaveLength(0);
	});

	it("addBoardEntry returns null when db is not open", () => {
		// Do NOT call makeProject()
		const id = addBoardEntry({
			content: "test",
			repository: "/repo",
			branch: "main",
		});
		expect(id).toBeNull();
	});
});

// ─── pruneBoardEntry ──────────────────────────────────────────────────────────

describe("pruneBoardEntry", () => {
	it("removes only the targeted entry", () => {
		makeProject();
		const id1 = addBoardEntry({
			content: "keep this",
			repository: "/repo",
			branch: "main",
		});
		const id2 = addBoardEntry({
			content: "prune this",
			repository: "/repo",
			branch: "main",
		});

		expect(id1).toBeTruthy();
		expect(id2).toBeTruthy();

		const removed = pruneBoardEntry(id2!);
		expect(removed).toBe(true);

		const remaining = getBoardEntries({ repository: "/repo", branch: "main" });
		expect(remaining).toHaveLength(1);
		expect(remaining[0].id).toBe(id1);
	});

	it("returns false when entry does not exist", () => {
		makeProject();
		const removed = pruneBoardEntry("nonexistent-id");
		expect(removed).toBe(false);
	});

	it("returns false when db is not open", () => {
		const removed = pruneBoardEntry("some-id");
		expect(removed).toBe(false);
	});
});

// ─── formatBoardForPrompt ─────────────────────────────────────────────────────

describe("formatBoardForPrompt", () => {
	it("returns empty string for empty entries", () => {
		const result = formatBoardForPrompt([]);
		expect(result).toBe("");
	});

	it("includes header, entries, and footer", () => {
		const entries = [
			{
				id: "abc123",
				content: "Use vitest not jest",
				category: "convention",
				added_at: "2026-05-13T00:00:00Z",
			},
		];
		const result = formatBoardForPrompt(entries);
		expect(result).toContain("### Invariants for this repo/branch");
		expect(result).toContain("abc123");
		expect(result).toContain("Use vitest not jest");
		expect(result).toContain("[convention]");
		expect(result).toContain("context_board");
	});

	it("renders entries without category", () => {
		const entries = [
			{
				id: "noCat1",
				content: "No category entry",
				category: null,
				added_at: "2026-05-13T00:00:00Z",
			},
		];
		const result = formatBoardForPrompt(entries);
		expect(result).toContain("noCat1");
		expect(result).not.toContain("[null]");
		expect(result).not.toContain("[]");
	});

	it("truncates oldest entries when byte cap is exceeded, preserving truncation marker", () => {
		// Create many entries that together exceed 512 bytes
		const entries = Array.from({ length: 20 }, (_, i) => ({
			id: `entry${String(i).padStart(3, "0")}`,
			content: `This is a board entry with enough text to consume space in the output buffer. Entry number ${i}.`,
			category: "arch",
			added_at: `2026-05-${String(i + 1).padStart(2, "0")}T00:00:00Z`,
		}));

		const result = formatBoardForPrompt(entries, { maxBytes: 512 });

		// Should include truncation marker since we exceeded the cap
		expect(result).toContain("[older entries truncated");
		// Should still include at least the newest entry
		expect(result).toContain("entry019");
	});

	it("does not truncate when entries fit within cap", () => {
		const entries = [
			{
				id: "short1",
				content: "Short entry",
				category: null,
				added_at: "2026-05-13T00:00:00Z",
			},
		];
		const result = formatBoardForPrompt(entries, { maxBytes: 4096 });
		expect(result).not.toContain("truncated");
		expect(result).toContain("short1");
	});
});

// ─── Tool invocations ─────────────────────────────────────────────────────────

describe("executeContextBoardAdd", () => {
	it("adds an entry and returns its id", () => {
		makeProject();
		const result = executeContextBoardAdd({
			content: "Never merge PRs on Fridays",
			category: "gotcha",
			repository: "/repo",
			branch: "main",
		});
		expect(result.isError).toBeFalsy();
		expect(result.details?.id).toBeTruthy();
		expect(result.content[0].text).toContain("Board entry added");
		// Verify it was actually stored
		const entries = getBoardEntries({ repository: "/repo", branch: "main" });
		expect(entries).toHaveLength(1);
		expect(entries[0].content).toBe("Never merge PRs on Fridays");
	});

	it("returns error when content is missing", () => {
		makeProject();
		const result = executeContextBoardAdd({
			content: "",
			repository: "/repo",
			branch: "main",
		});
		expect(result.isError).toBe(true);
		expect(result.content[0].text).toContain("content is required");
	});

	it("returns error when db unavailable", () => {
		// DB not open
		const result = executeContextBoardAdd({
			content: "test",
			repository: "/repo",
			branch: "main",
		});
		expect(result.isError).toBe(true);
		expect(result.details?.error).toBe("db_unavailable");
	});
});

describe("executeContextBoardPrune", () => {
	it("removes an existing entry", () => {
		makeProject();
		const id = addBoardEntry({
			content: "to be pruned",
			repository: "/repo",
			branch: "main",
		});
		expect(id).toBeTruthy();

		const result = executeContextBoardPrune({ id: id! });
		expect(result.isError).toBeFalsy();
		expect(result.details?.removed).toBe(true);
		expect(result.content[0].text).toContain("pruned");

		const remaining = getBoardEntries({ repository: "/repo", branch: "main" });
		expect(remaining).toHaveLength(0);
	});

	it("returns non-error result (not found) for missing id", () => {
		makeProject();
		const result = executeContextBoardPrune({ id: "ghost-id" });
		expect(result.isError).toBeFalsy();
		expect(result.details?.removed).toBe(false);
	});

	it("returns error when id is empty", () => {
		makeProject();
		const result = executeContextBoardPrune({ id: "" });
		expect(result.isError).toBe(true);
		expect(result.content[0].text).toContain("id is required");
	});

	it("returns error when db unavailable", () => {
		const result = executeContextBoardPrune({ id: "some-id" });
		expect(result.isError).toBe(true);
		expect(result.details?.error).toBe("db_unavailable");
	});
});
