/**
 * Self-Feedback channel — sf records its own anomalies (caught by runtime
 * detectors or reported via the sf_self_report tool) so they can be addressed
 * by future units.
 *
 * Routing:
 *  - When the current project IS singularity-forge itself (detected via
 *    package.json `name`), entries land in two places:
 *      • `<basePath>/.sf/sf.db`                — structured source of truth
 *      • `<basePath>/.sf/SELF-FEEDBACK.md`     — human-readable projection
 *    Legacy `self-feedback.jsonl` is imported when present but is not the
 *    DB-backed runtime source.
 *  - For any other project, entries land in
 *    `~/.sf/agent/upstream-feedback.jsonl` — a global cross-project log so
 *    anomalies in sf's behavior are not lost when sf is dogfooded on
 *    third-party codebases.
 *
 * Severity → blocking semantics:
 *  - low/medium: log-and-continue. Bug accumulates in self-feedback.
 *  - high:       blocking — the unit that produced the report must not seal
 *                successfully. On next auto session-start, getBlockedEntries()
 *                returns this entry; the dispatcher checks whether sfVersion
 *                has bumped since the report and re-queues if so.
 *  - critical:   reserved for inline-fix mode (forge only). For now treated
 *                as `high`. The dispatcher hook is wired in a follow-up.
 *
 * This module is intentionally I/O-only and side-effect-free relative to
 * the in-memory workflow state. Write failures must NEVER propagate —
 * sf keeps running.
 */
import {
	appendFileSync,
	existsSync,
	mkdirSync,
	readFileSync,
	renameSync,
	writeFileSync,
} from "node:fs";
import { homedir } from "node:os";
import { dirname, join } from "node:path";
import { resolveMilestoneFile, sfRuntimeRoot } from "./paths.js";
import {
	insertSelfFeedbackEntry,
	isDbAvailable,
	listSelfFeedbackEntries,
	resolveSelfFeedbackEntry,
} from "./sf-db.js";

const SF_HOME = process.env.SF_HOME || join(homedir(), ".sf");
const SELF_FEEDBACK_HEADER =
	"# SF Self-Feedback\n\n" +
	"Anomalies caught during auto runs (by runtime detectors or via the\n" +
	"`sf_self_report` tool). Each row is a candidate work item for sf to\n" +
	"address in itself. This markdown file is a compact working view; the\n" +
	"durable source of truth is `.sf/sf.db`.\n\n" +
	"Blocking entries (severity high+) remain active until an sf fix explicitly\n" +
	"marks them resolved with evidence.\n\n";
const RECENT_RESOLVED_MARKDOWN_LIMIT = 20;
const MARKDOWN_DETAIL_CHAR_LIMIT = 2_000;
const SELF_FEEDBACK_SCHEMA_VERSION = 1;
// ─── Identity & version helpers ────────────────────────────────────────────
function isForgeRepo(basePath) {
	try {
		const pkgPath = join(basePath, "package.json");
		if (!existsSync(pkgPath)) return false;
		const pkg = JSON.parse(readFileSync(pkgPath, "utf-8"));
		return pkg?.name === "singularity-forge";
	} catch {
		return false;
	}
}
function getCurrentSfVersion() {
	if (process.env.SF_VERSION && process.env.SF_VERSION !== "0.0.0") {
		return process.env.SF_VERSION;
	}
	return "unknown";
}
function deriveBlocking(severity) {
	return severity === "high" || severity === "critical";
}
function newId() {
	const ts = Date.now().toString(36);
	const rnd = Math.random().toString(36).slice(2, 8);
	return `sf-${ts}-${rnd}`;
}
// ─── Path helpers ──────────────────────────────────────────────────────────
function projectJsonlPath(basePath) {
	return join(sfRuntimeRoot(basePath), "self-feedback.jsonl");
}
function projectMarkdownPath(basePath) {
	return join(sfRuntimeRoot(basePath), "SELF-FEEDBACK.md");
}
function upstreamLogPath() {
	const sfHome = process.env.SF_HOME || SF_HOME;
	return join(sfHome, "agent", "upstream-feedback.jsonl");
}
/**
 * Return the operator-facing destination for new self-feedback in `basePath`.
 *
 * Purpose: keep startup notices honest when sf is dogfooded in other repos;
 * external-project findings are about sf but are persisted globally, not in
 * the target repo's `.sf/` directory.
 *
 * Consumer: session_start notifications that summarize detector writes.
 */
export function selfFeedbackDestinationLabel(basePath) {
	return isForgeRepo(basePath)
		? ".sf/SELF-FEEDBACK.md"
		: "~/.sf/agent/upstream-feedback.jsonl";
}
/**
 * Migrate the legacy filename. Older sf versions wrote `BACKLOG.md`; the
 * canonical name is now `SELF-FEEDBACK.md` (matches `self-feedback.jsonl`).
 * Idempotent + non-fatal: silent if the new file already exists or rename
 * fails. Called from session_start before any write/read.
 */
export function migrateLegacyBacklogFilename(basePath) {
	try {
		const newPath = projectMarkdownPath(basePath);
		const legacyPath = join(sfRuntimeRoot(basePath), "BACKLOG.md");
		if (existsSync(legacyPath) && !existsSync(newPath)) {
			renameSync(legacyPath, newPath);
		}
	} catch {
		/* non-fatal */
	}
}
function ensureDir(path) {
	const dir = dirname(path);
	if (!existsSync(dir)) mkdirSync(dir, { recursive: true });
}
/**
 * Regenerate SELF-FEEDBACK.md from the current self-feedback state.
 * This keeps the markdown as a bounded work queue instead of a permanent audit log.
 *
 * Purpose: prevent old resolved/applied feedback from making the operator-facing
 * file too long to scan while preserving full history in SQLite or the legacy
 * JSONL fallback.
 *
 * Consumer: recordSelfFeedback and markResolved after mutating the durable source
 * of truth.
 */
function regenerateSelfFeedbackMarkdown(basePath) {
	try {
		const entries = readAllSelfFeedback(basePath);
		const path = projectMarkdownPath(basePath);
		ensureDir(path);
		// Separate unresolved and resolved entries
		const unresolved = entries.filter((e) => !e.resolvedAt);
		const resolved = entries.filter((e) => e.resolvedAt);
		const recentResolved = resolved
			.slice(-RECENT_RESOLVED_MARKDOWN_LIMIT)
			.reverse();
		const compactedResolved = Math.max(
			0,
			resolved.length - recentResolved.length,
		);
		let md = SELF_FEEDBACK_HEADER;
		md +=
			"## Open Entries\n\n" +
			"| Timestamp | Kind | Severity | Blocking | sfVersion | Unit | Summary |\n" +
			"|---|---|---|---|---|---|---|\n";
		if (unresolved.length === 0) {
			md +=
				"| — | — | — | — | — | — | No unresolved self-feedback entries. |\n";
		} else {
			for (const entry of unresolved) {
				md += formatOpenMarkdownRow(entry);
				if (entry.blocking) {
					md += formatEntryDetails(entry);
				}
			}
		}
		// Write resolved section if there are resolved entries
		if (recentResolved.length > 0) {
			md +=
				"\n## Recently Resolved\n\n" +
				"| Resolved At | Kind | Severity | sfVersion | Unit | Summary | Resolution |\n" +
				"|---|---|---|---|---|---|---|\n";
			for (const entry of recentResolved) {
				const unit = formatUnitCell(entry.occurredIn);
				const summary = escapeCell(entry.summary);
				const resolution = entry.resolvedEvidence
					? `${entry.resolvedEvidence.kind}${entry.resolvedEvidence.kind === "agent-fix" && entry.resolvedEvidence.commitSha ? ` (${entry.resolvedEvidence.commitSha.slice(0, 7)})` : ""}`
					: entry.resolvedReason || "—";
				md += `| ${entry.resolvedAt} | ${entry.kind} | ${entry.severity} | ${entry.sfVersion} | ${unit} | ${summary} | ${resolution} |\n`;
			}
		}
		if (compactedResolved > 0) {
			md += `\n_Compacted ${compactedResolved} older resolved entr${compactedResolved === 1 ? "y" : "ies"}; full history remains in \`.sf/sf.db\` when SQLite is available, or \`self-feedback.jsonl\` as legacy fallback._\n`;
		}
		writeFileSync(path, md, "utf-8");
	} catch {
		// Non-fatal — markdown is human-facing; DB state is authoritative when available,
		// with JSONL retained only as a legacy fallback.
	}
}
/**
 * Rewrite SELF-FEEDBACK.md as the compact working view from durable state.
 *
 * Purpose: let session-start maintenance drain legacy long markdown files even
 * when no new feedback entry or resolution is recorded in that run.
 *
 * Consumer: startup self-feedback maintenance and operator repair commands.
 */
export function compactSelfFeedbackMarkdown(basePath = process.cwd()) {
	try {
		regenerateSelfFeedbackMarkdown(basePath);
		return true;
	} catch {
		return false;
	}
}
// ─── Writers ───────────────────────────────────────────────────────────────
function appendJsonl(path, entry) {
	ensureDir(path);
	appendFileSync(path, `${JSON.stringify(entry)}\n`, "utf-8");
}
function readJsonl(path) {
	try {
		if (!existsSync(path)) return [];
		const out = [];
		for (const line of readFileSync(path, "utf-8").split("\n")) {
			if (!line.trim()) continue;
			try {
				out.push(JSON.parse(line));
			} catch {
				/* skip malformed lines */
			}
		}
		return out;
	} catch {
		return [];
	}
}
function importLegacyJsonlToDb(basePath) {
	if (!isDbAvailable()) return;
	for (const entry of readJsonl(projectJsonlPath(basePath))) {
		try {
			insertSelfFeedbackEntry(entry);
		} catch {
			/* non-fatal compatibility import */
		}
	}
}
function formatOpenMarkdownRow(entry) {
	const unit = formatUnitCell(entry.occurredIn);
	const summary = escapeCell(entry.summary);
	const blocking = entry.blocking ? "yes" : "no";
	return `| ${entry.ts} | ${entry.kind} | ${entry.severity} | ${blocking} | ${entry.sfVersion} | ${unit} | ${summary} |\n`;
}
function formatEntryDetails(entry) {
	if (entry.evidence || entry.suggestedFix) {
		return (
			`\n<details><summary>${entry.id} — ${entry.kind}</summary>\n\n` +
			(entry.evidence
				? `**Evidence:**\n\n\`\`\`\n${truncateMarkdownDetail(entry.evidence)}\n\`\`\`\n\n`
				: "") +
			(entry.suggestedFix
				? `**Suggested fix:** ${truncateMarkdownDetail(entry.suggestedFix)}\n\n`
				: "") +
			`</details>\n`
		);
	}
	return "";
}
function truncateMarkdownDetail(text) {
	if (text.length <= MARKDOWN_DETAIL_CHAR_LIMIT) return text;
	return `${text.slice(0, MARKDOWN_DETAIL_CHAR_LIMIT).trimEnd()}\n\n[truncated; full detail remains in .sf/sf.db when SQLite is available, or self-feedback.jsonl as legacy fallback]`;
}
function formatUnitCell(occurred) {
	if (!occurred) return "—";
	const parts = [occurred.milestone, occurred.slice, occurred.task].filter(
		Boolean,
	);
	if (parts.length === 0) return occurred.unitType ?? "—";
	const path = parts.join("/");
	return occurred.unitType ? `${occurred.unitType} ${path}` : path;
}
function escapeCell(text) {
	return text.replace(/\|/g, "\\|").replace(/\n/g, " ").trim();
}
// ─── Auto-fill from active unit ───────────────────────────────────────────
function readActiveUnit(basePath) {
	try {
		const lockPath = join(sfRuntimeRoot(basePath), "auto.lock");
		if (!existsSync(lockPath)) return undefined;
		const lock = JSON.parse(readFileSync(lockPath, "utf-8"));
		const id = lock?.unitId;
		if (!id) return { unitType: lock?.unitType };
		const [milestone, slice, task] = id.split("/");
		return { milestone, slice, task, unitType: lock?.unitType };
	} catch {
		return undefined;
	}
}
// ─── Public API ────────────────────────────────────────────────────────────
/**
 * Record a self-feedback entry to the appropriate channel (project or upstream).
 * Non-fatal — write errors are swallowed and null is returned on failure.
 */
export function recordSelfFeedback(entry, basePath = process.cwd()) {
	try {
		const occurredIn = entry.occurredIn ?? readActiveUnit(basePath);
		const persisted = {
			schemaVersion: SELF_FEEDBACK_SCHEMA_VERSION,
			...entry,
			occurredIn,
			id: newId(),
			ts: new Date().toISOString(),
			basePath,
			repoIdentity: isForgeRepo(basePath) ? "forge" : "external",
			sfVersion: getCurrentSfVersion(),
			blocking: deriveBlocking(entry.severity),
		};
		if (persisted.repoIdentity === "forge") {
			if (isDbAvailable()) {
				importLegacyJsonlToDb(basePath);
				insertSelfFeedbackEntry(persisted);
			} else {
				appendJsonl(projectJsonlPath(basePath), persisted);
			}
			regenerateSelfFeedbackMarkdown(basePath);
		} else {
			appendJsonl(upstreamLogPath(), persisted);
		}
		return { entry: persisted, blocking: persisted.blocking };
	} catch {
		return null;
	}
}
/**
 * Read all entries from the appropriate channel for `basePath`.
 * Reads DB rows for forge-local feedback when SQLite is available. Legacy JSONL
 * is imported on read; markdown remains a projection.
 */
export function readAllSelfFeedback(basePath = process.cwd()) {
	if (isForgeRepo(basePath)) {
		if (isDbAvailable()) {
			try {
				importLegacyJsonlToDb(basePath);
				return listSelfFeedbackEntries();
			} catch {
				/* fall through to legacy JSONL */
			}
		}
		return readJsonl(projectJsonlPath(basePath));
	}
	return readJsonl(upstreamLogPath());
}
/**
 * Return blocking entries that have not been resolved.
 * Used by auto's session-start hook to drive retry-on-bump.
 */
export function getBlockedEntries(basePath = process.cwd()) {
	return readAllSelfFeedback(basePath).filter(
		(e) => e.blocking && !e.resolvedAt,
	);
}
/**
 * Mark an entry as resolved. Updates SQLite for forge-local feedback when
 * available, otherwise rewrites the legacy JSONL fallback in place. Entries are
 * append-only otherwise; resolution is the one mutation we support so blocking
 * entries don't trigger re-queue forever.
 *
 * Resolution requires structured `evidence` so the fix is traceable:
 *  - `agent-fix` should cite a commit SHA or test path
 *  - `auto-version-bump` is for session_start's automatic resolver
 *  - `human-clear` is the catch-all for operator interventions
 *  - `promoted-to-requirement` is for the threshold-promotion sweeper
 *
 * If the entry has `acceptanceCriteria`, callers SHOULD pass `criteriaMet`
 * naming which criteria were satisfied. (Not enforced — entries without
 * acceptanceCriteria are common during the bootstrap of this channel.)
 *
 * After resolution, SELF-FEEDBACK.md is regenerated as a compact working view.
 */
export function markResolved(entryId, resolution, basePath = process.cwd()) {
	if (isForgeRepo(basePath) && isDbAvailable()) {
		try {
			importLegacyJsonlToDb(basePath);
			const mutated = resolveSelfFeedbackEntry(entryId, {
				...resolution,
				resolvedBySfVersion: getCurrentSfVersion(),
			});
			if (mutated) regenerateSelfFeedbackMarkdown(basePath);
			return mutated;
		} catch {
			/* fall through to legacy JSONL */
		}
	}
	const paths = isForgeRepo(basePath)
		? [projectJsonlPath(basePath), upstreamLogPath()]
		: [upstreamLogPath()];
	try {
		for (const path of paths) {
			if (!existsSync(path)) continue;
			const lines = readFileSync(path, "utf-8").split("\n");
			const out = [];
			let mutated = false;
			for (const line of lines) {
				if (!line.trim()) {
					out.push(line);
					continue;
				}
				try {
					const e = JSON.parse(line);
					if (e.id === entryId && !e.resolvedAt) {
						e.resolvedAt = new Date().toISOString();
						e.resolvedReason = resolution.reason;
						e.resolvedBySfVersion = getCurrentSfVersion();
						e.resolvedEvidence = resolution.evidence;
						if (resolution.criteriaMet) {
							e.resolvedCriteriaMet = resolution.criteriaMet;
						}
						mutated = true;
						out.push(JSON.stringify(e));
					} else {
						out.push(line);
					}
				} catch {
					out.push(line);
				}
			}
			if (mutated) {
				writeFileSync(path, out.join("\n"), "utf-8");
				// Regenerate markdown to reflect resolved state (#sf-moobj36p-rlo95i)
				regenerateSelfFeedbackMarkdown(basePath);
				return true;
			}
		}
		return false;
	} catch {
		return false;
	}
}
/**
 * Read unresolved feedback filed while sf was running in other repositories.
 *
 * Purpose: let forge-local triage and inline-fix units consume external
 * observations as sf repair work instead of leaving them stranded in the
 * global upstream log.
 *
 * Consumer: triage-self-feedback and self-feedback-drain.
 */
export function readUpstreamSelfFeedback() {
	return readJsonl(upstreamLogPath());
}
/**
 * Compare two semver strings. Returns positive if a > b, 0 if equal, negative
 * if a < b. Tolerant of pre-release / non-numeric segments by falling back
 * to lexicographic compare for those.
 */
function compareSemver(a, b) {
	const partsA = a.split(".");
	const partsB = b.split(".");
	for (let i = 0; i < Math.max(partsA.length, partsB.length); i++) {
		const pa = partsA[i] ?? "0";
		const pb = partsB[i] ?? "0";
		const na = Number.parseInt(pa, 10);
		const nb = Number.parseInt(pb, 10);
		if (Number.isNaN(na) || Number.isNaN(nb)) {
			if (pa === pb) continue;
			return pa < pb ? -1 : 1;
		}
		if (na !== nb) return na - nb;
	}
	return 0;
}
export function triageBlockedEntries(basePath = process.cwd()) {
	const current = getCurrentSfVersion();
	const retry = [];
	const stillBlocked = [];
	for (const e of getBlockedEntries(basePath)) {
		if (current === "unknown" || e.sfVersion === "unknown") {
			stillBlocked.push(e);
			continue;
		}
		if (compareSemver(current, e.sfVersion) > 0) {
			retry.push(e);
		} else {
			stillBlocked.push(e);
		}
	}
	return { retry, stillBlocked };
}
/**
 * Auto-resolve blocking self-feedback entries whose referenced milestone has
 * already completed (has a SUMMARY.md on disk). Prevents stale blocking
 * entries from accumulating forever when the underlying issue was fixed in a
 * later session (#sf-moqv2k4g-kbg2nq).
 *
 * Returns the array of resolved entry IDs.
 */
export function resolveFeedbackForCompletedMilestones(
	basePath = process.cwd(),
) {
	const resolvedIds = [];
	try {
		const entries = readAllSelfFeedback(basePath);
		for (const e of entries) {
			if (e.resolvedAt || !e.blocking) continue;
			const mid = e.occurredIn?.milestone;
			if (!mid) continue;
			const summaryPath = resolveMilestoneFile(basePath, mid, "SUMMARY");
			if (summaryPath && existsSync(summaryPath)) {
				const didResolve = markResolved(
					e.id,
					{
						reason: `Milestone ${mid} is complete — auto-resolving stale blocking entry`,
						evidence: { kind: "auto-version-bump" },
					},
					basePath,
				);
				if (didResolve) resolvedIds.push(e.id);
			}
		}
	} catch {
		/* non-fatal */
	}
	return resolvedIds;
}
