/**
 * context-board.js — Always-in-context invariants board for SF.
 *
 * Persists a small, agent-maintained board of repo/branch-scoped invariants
 * into the `context_board` SQLite table. The board is rendered in every system
 * prompt so the agent never operates without it (unlike queryable memory which
 * must be explicitly fetched).
 *
 * Discipline (same as Copilot rem-agent): add + prune only. No update.
 * If an entry needs changing, prune it and add a new one.
 *
 * Consumer: auto/phases.js (system-prompt injection), context-board-tool.js.
 */
import { randomUUID } from "node:crypto";
import { getDatabase, isDbAvailable, withQueryTimeout } from "./sf-db.js";

/** Maximum byte budget for formatBoardForPrompt (default 4 KB). */
const DEFAULT_MAX_BYTES = 4096;

/**
 * Add a new invariant entry to the board.
 *
 * @param {object} opts
 * @param {string} opts.content    — The invariant text (1-3 sentences).
 * @param {string} [opts.category] — Optional label (e.g. "milestone", "arch", "gotcha").
 * @param {string} opts.repository — Repo identifier (e.g. project root path or remote URL).
 * @param {string} opts.branch     — Git branch name.
 * @returns {string|null} The new entry id, or null on failure.
 */
export function addBoardEntry({
	content,
	category = null,
	repository,
	branch,
}) {
	if (!isDbAvailable()) return null;
	const db = getDatabase();
	const id = randomUUID().replace(/-/g, "").slice(0, 16);
	const added_at = new Date().toISOString();
	try {
		db.prepare(
			`INSERT INTO context_board (id, content, category, added_at, repository, branch)
			 VALUES (:id, :content, :category, :added_at, :repository, :branch)`,
		).run({
			":id": id,
			":content": String(content ?? "").trim(),
			":category": category ? String(category).trim() : null,
			":added_at": added_at,
			":repository": String(repository ?? ""),
			":branch": String(branch ?? ""),
		});
		return id;
	} catch {
		return null;
	}
}

/**
 * Remove an entry from the board by id.
 *
 * @param {string} id — Entry id returned by addBoardEntry.
 * @returns {boolean} true if a row was deleted.
 */
export function pruneBoardEntry(id) {
	if (!isDbAvailable()) return false;
	const db = getDatabase();
	try {
		const result = db.prepare(`DELETE FROM context_board WHERE id = ?`).run(id);
		return (result?.changes ?? 0) > 0;
	} catch {
		return false;
	}
}

/**
 * Fetch all board entries for a given (repository, branch), ordered by added_at ASC.
 *
 * @param {object} opts
 * @param {string} opts.repository
 * @param {string} opts.branch
 * @returns {Array<{id:string, content:string, category:string|null, added_at:string}>}
 */
export function getBoardEntries({ repository, branch }) {
	if (!isDbAvailable()) return [];
	const db = getDatabase();
	try {
		const rows = withQueryTimeout(
			() =>
				db
					.prepare(
						`SELECT id, content, category, added_at
						 FROM context_board
						 WHERE repository = ? AND branch = ?
						 ORDER BY added_at ASC`,
					)
					.all(String(repository ?? ""), String(branch ?? "")),
			[],
		);
		return Array.isArray(rows) ? rows : [];
	} catch {
		return [];
	}
}

/**
 * Render board entries as a Markdown block suitable for injection into a
 * system prompt. Entries are included oldest-first. If the total byte size
 * would exceed maxBytes, the oldest entries are truncated first and an
 * "[older entries truncated]" marker is prepended.
 *
 * @param {Array} entries — from getBoardEntries()
 * @param {object} [opts]
 * @param {number} [opts.maxBytes=4096]
 * @returns {string} Rendered Markdown block, or empty string if no entries.
 */
export function formatBoardForPrompt(
	entries,
	{ maxBytes = DEFAULT_MAX_BYTES } = {},
) {
	if (!entries || entries.length === 0) return "";

	const header = "### Invariants for this repo/branch\n\n";
	const footer =
		"\n\n> These invariants are always in context. " +
		"Use `context_board` tool to add (add) or remove (prune) entries.";

	/** Format a single entry line */
	function formatEntry(e) {
		const cat = e.category ? ` [${e.category}]` : "";
		return `- **${e.id}**${cat}: ${e.content}`;
	}

	const allLines = entries.map(formatEntry);
	const TRUNCATION_MARKER =
		"- *[older entries truncated — board exceeded byte cap]*";

	// Build from newest end first, then reverse to restore oldest-first order
	const encoder = new TextEncoder();
	const headerBytes = encoder.encode(header + footer).length;
	const markerBytes = encoder.encode(TRUNCATION_MARKER + "\n").length;
	const budget = maxBytes - headerBytes;

	let usedBytes = 0;
	const kept = [];
	let truncated = false;

	// Walk from newest to oldest so we keep the most recent entries under cap
	for (let i = allLines.length - 1; i >= 0; i--) {
		const lineBytes = encoder.encode(allLines[i] + "\n").length;
		if (
			usedBytes + lineBytes + (truncated || i === 0 ? 0 : markerBytes) >
			budget
		) {
			truncated = true;
			continue;
		}
		usedBytes += lineBytes;
		kept.unshift(allLines[i]);
	}

	if (truncated) {
		kept.unshift(TRUNCATION_MARKER);
	}

	if (kept.length === 0) return "";
	return header + kept.join("\n") + footer;
}
