/**
 * Self-Feedback channel — sf records its own anomalies (caught by runtime
 * detectors or reported via the report_issue 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 { dirname, join } from "node:path";
import { createMemory } from "./memory-store.js";
import { resolveMilestoneFile, sfRuntimeRoot } from "./paths.js";
import {
	insertSelfFeedbackEntry,
	isDbAvailable,
	listSelfFeedbackEntries,
	resolveSelfFeedbackEntry,
} from "./sf-db.js";
import { sfHome } from "./sf-home.js";
import { logWarning } from "./workflow-logger.js";

const SELF_FEEDBACK_HEADER =
	"# SF Self-Feedback\n\n" +
	"Anomalies caught during auto runs (by runtime detectors or via the\n" +
	"`report_issue` 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";
// JSONL marker is a valid JSON object so strict JSONL parsers (e.g. the
// doctor's syntax check) accept line 1. The _meta sentinel keeps the
// "do not hand-edit" intent visible to anyone opening the file.
const JSONL_GENERATED_COMMENT = JSON.stringify({
	_meta: "generated from .sf/sf.db",
	_warning:
		"do not edit directly; use the resolve_issue tool or sf headless triage --apply",
});
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() {
	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 {
				const parsed = JSON.parse(line);
				// Skip meta header lines emitted by regenerateSelfFeedbackJsonl
				// — those are generator metadata, not self-feedback entries.
				// Entries always carry an `id` string; meta lines have `_meta`.
				if (parsed && typeof parsed === "object" && parsed._meta) continue;
				out.push(parsed);
			} catch {
				/* skip malformed lines */
			}
		}
		return out;
	} catch {
		return [];
	}
}
/**
 * Wire-format tag for self-feedback JSONL records.
 *
 * Older entries pre-date this tag and have no `recordType` field; readers
 * treat absence (or "entry") as a regular entry creation. The "resolution"
 * tag was added so JSONL can carry resolution events as well as entries,
 * making it a real append-only audit log instead of a half-event-log that
 * loses resolution history on DB rebuild.
 */
const SELF_FEEDBACK_RECORD_RESOLUTION = "resolution";

function isResolutionRecord(record) {
	return (
		record &&
		record.recordType === SELF_FEEDBACK_RECORD_RESOLUTION &&
		record.entryId
	);
}

function importLegacyJsonlToDb(basePath) {
	if (!isDbAvailable()) return;
	for (const record of readJsonl(projectJsonlPath(basePath))) {
		try {
			if (isResolutionRecord(record)) {
				// Resolution event from the audit log. Apply to the existing DB
				// row if it exists and is not already resolved — never clobbers
				// a credible resolution that landed via a different path.
				resolveSelfFeedbackEntry(record.entryId, {
					reason: record.resolvedReason,
					evidence: record.resolvedEvidence,
					criteriaMet: record.resolvedCriteriaMet,
					resolvedBySfVersion: record.resolvedBySfVersion,
					resolvedAt: record.resolvedAt,
				});
			} else {
				// Legacy / new entry creation record.
				insertSelfFeedbackEntry(record);
			}
		} catch {
			/* non-fatal compatibility import */
		}
	}
}

/**
 * Mirror a self-feedback resolution into the memory subsystem
 * (sf-mp4rp6y2-31jfau). Closures carry knowledge — what kind of issue,
 * what the operator/agent decided, citations to commits or requirements.
 * Writing them as memory entries lets detectors call
 * getRelevantMemoriesRanked(entry.kind) before filing duplicates, and
 * lets reflection passes consult prior closure rationale across cycles.
 *
 * Best-effort: any failure is silently swallowed. The resolution itself
 * already landed; memory write is observability + future detector
 * consumption, not a correctness requirement.
 *
 * Called from markResolved success branch.
 */
function writeResolutionToMemory(entry, resolution) {
	try {
		if (!entry || typeof entry !== "object") return;
		const evidenceKind = resolution?.evidence?.kind ?? "unknown";
		const reason =
			typeof resolution?.reason === "string" && resolution.reason.trim()
				? resolution.reason.trim()
				: "(no reason recorded)";
		const summary =
			typeof entry.summary === "string" && entry.summary.trim()
				? entry.summary.trim().slice(0, 400)
				: "(no summary)";
		const content = `[${entry.kind}] ${summary}\n→ ${evidenceKind}: ${reason.slice(0, 600)}`;
		const tagSet = new Set();
		if (typeof entry.kind === "string") tagSet.add(entry.kind);
		tagSet.add(`evidence:${evidenceKind}`);
		const commitSha = resolution?.evidence?.commitSha;
		if (typeof commitSha === "string" && commitSha) {
			tagSet.add(`commit:${commitSha.slice(0, 12)}`);
		}
		const requirementId = resolution?.evidence?.requirementId;
		if (typeof requirementId === "string" && requirementId) {
			tagSet.add(`requirement:${requirementId}`);
		}
		createMemory({
			category: "self-feedback-resolution",
			content,
			confidence: 0.9,
			source_unit_type: "self-feedback",
			source_unit_id: entry.id,
			tags: Array.from(tagSet),
		});
	} catch {
		// Best-effort — never fail the resolution path.
	}
}

/**
 * Rewrite `.sf/self-feedback.jsonl` deterministically from all DB entries.
 *
 * DB is canonical; JSONL is a render target. This function replaces whatever
 * is on disk with a fresh projection so the two stores are never out of sync.
 * Each DB entry is serialised as a single JSON line; resolved entries include
 * their full resolution payload. A leading comment line marks the file as
 * generated (safe to skip — readJsonl already ignores non-JSON lines).
 *
 * Consumer: markResolved success branch; backfillSelfFeedbackJsonl.
 */
function regenerateSelfFeedbackJsonl(basePath) {
	try {
		if (!isDbAvailable()) return;
		const entries = listSelfFeedbackEntries();
		const path = projectJsonlPath(basePath);
		ensureDir(path);
		const lines = [JSONL_GENERATED_COMMENT];
		for (const entry of entries) {
			lines.push(JSON.stringify(entry));
		}
		writeFileSync(path, `${lines.join("\n")}\n`, "utf-8");
	} catch {
		/* non-fatal — JSONL is a render target; never block on write failure */
	}
}
/**
 * Enforce JSONL = DB-render on session start (one-shot idempotent).
 *
 * Purpose: when a project has entries in DB that were never written to JSONL
 * (e.g. they were inserted via raw SQL, an older SF version, or an import
 * from upstream), JSONL will be missing those rows. Any path that reads JSONL
 * before DB (or falls back to JSONL after a DB miss) will silently skip them.
 * This function rebuilds JSONL from DB so both stores are consistent.
 *
 * Safe to call on every session start — it writes only when DB is available,
 * is idempotent, and completes in under 10 ms for any realistic entry count.
 *
 * Consumer: session_start bootstrap (register-hooks.js).
 */
export function backfillSelfFeedbackJsonl(basePath = process.cwd()) {
	try {
		if (!isForgeRepo(basePath) || !isDbAvailable()) return false;
		importLegacyJsonlToDb(basePath);
		regenerateSelfFeedbackJsonl(basePath);
		return true;
	} catch {
		return false;
	}
}
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;
	}
}
/**
 * Allowed top-level domains for self-feedback `kind` strings (sf-mp4rxkwt-sfthez).
 *
 * Why an enum + structured shape: free-form `kind` made the threshold-promoter
 * cluster by exact-string and almost never fire — every entry has a unique
 * suffix. With a `domain:family[:specific]` shape, the promoter can cluster
 * by `domain:family` prefix and detectors gain a stable taxonomy. Reflection
 * passes can also reason about whole domains rather than handfuls of unique
 * strings. Existing entries with single-segment kinds remain readable
 * (we only validate NEW writes via recordSelfFeedback) so the migration is
 * non-destructive.
 *
 * Adding a new domain: add it here AND name the family/specific convention
 * you're introducing in the entry's PDD so future operators can find it.
 */
const ALLOWED_KIND_DOMAINS = new Set([
	"gap",
	"architecture-defect",
	"architectural-risk",
	"inconsistency",
	"runaway-loop",
	"runaway-guard-hard-pause",
	"schema-drift",
	"janitor-gap",
	"upstream-rollup",
	"reflection",
	"copilot-parity-gaps",
	"gap-audit-orphan-prompt",
	"gap-audit-orphan-command",
	"flow-audit",
	"executor-refused",
	"solver-missing-checkpoint",
	"self-feedback-resolution",
	// Public report_issue prompt kinds. These are single-segment legacy domains
	// because agents already receive them as valid stable identifiers.
	"prompt-quality-issue",
	"improvement-idea",
	"agent-friction",
	"design-thought",
	"missing-feature",
	"brittle-predicate",
	"git-empty-pathspec",
	"advisory-downgrade",
	// adversarial-finding: R075 autonomous-adversarial-review domain. Without
	// this, R075 challenge units have no kind to file their findings under
	// (gap reported by sf-mp9u4i25-fczmcj on 2026-05-17).
	"adversarial-finding",
]);

const KIND_SEGMENT_RE = /^[a-z][a-z0-9]*(?:-[a-z0-9]+)*$/;

/**
 * Validate a kind string. Accepts:
 *   `domain`              — single-segment legacy shape, requires the
 *                            domain to be in the allow-list. We accept this
 *                            so existing detectors that file e.g.
 *                            `runaway-guard-hard-pause` keep working until
 *                            they're migrated to a `domain:family` form.
 *   `domain:family`       — two-segment canonical shape. Both segments
 *                            kebab-case. Domain must be in the allow-list.
 *   `domain:family:specific` — three-segment specific shape, all kebab.
 *
 * Returns `null` when valid; an error message string when not. The string
 * surface is so recordSelfFeedback callers (and tests) can see WHY a write
 * was refused instead of guessing.
 */
function validateKind(kind) {
	if (typeof kind !== "string" || kind.length === 0) {
		return "kind must be a non-empty string";
	}
	const segments = kind.split(":");
	if (segments.length < 1 || segments.length > 3) {
		return `kind "${kind}" must have 1, 2, or 3 colon-separated segments (got ${segments.length})`;
	}
	for (const seg of segments) {
		if (!KIND_SEGMENT_RE.test(seg)) {
			return `kind "${kind}" segment "${seg}" must be kebab-case (lowercase letters/digits, internal hyphens)`;
		}
	}
	if (!ALLOWED_KIND_DOMAINS.has(segments[0])) {
		return `kind "${kind}" uses unknown domain "${segments[0]}"; valid domains: ${Array.from(ALLOWED_KIND_DOMAINS).sort().join(", ")}`;
	}
	return null;
}

// ─── 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.
 *
 * Kind validation (sf-mp4rxkwt-sfthez): the entry's `kind` MUST satisfy
 * validateKind — see ALLOWED_KIND_DOMAINS + KIND_SEGMENT_RE. Writes with
 * malformed kinds return null and emit a warning to workflow-logger.
 */
export function recordSelfFeedback(entry, basePath = process.cwd()) {
	const kindError = validateKind(entry?.kind);
	if (kindError) {
		try {
			logWarning("self-feedback", `recordSelfFeedback rejected: ${kindError}`);
		} catch {
			// best-effort — never block on logger failure
		}
		return null;
	}
	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,
	);
}
/**
 * Accepted resolution evidence kinds. Anything outside this set is rejected
 * at the writer layer (sf-mp4qoby4-meiir7): the credibility check at the
 * READER side (self-feedback-drain.js) was previously the only gate, and an
 * agent that wrote DB rows directly via raw SQL or the wrong tool could
 * bypass it, landing resolutions like `{file, line}` or `null` that the
 * reader would then either trust (legacy carve-out) or quietly re-open. The
 * writer-layer check makes the policy belt-and-suspenders for any caller
 * going through markResolved or resolveSelfFeedbackEntry. Raw SQL bypass is
 * a known limit documented in the entry; full coverage needs a DB CHECK
 * constraint (schema migration, separate work).
 */
const ACCEPTED_EVIDENCE_KINDS = new Set([
	"agent-fix",
	"human-clear",
	"promoted-to-requirement",
	"auto-version-bump",
	// "agent-fix-unverified" is reserved for outcomes-verification (a future
	// follow-up): if a caller wants to mark a resolution as agent-attributed
	// but explicitly NOT verified, it should write this kind so the
	// credibility check re-opens the entry until verification lands.
	"agent-fix-unverified",
]);

function isAcceptedEvidence(evidence) {
	if (!evidence || typeof evidence !== "object" || Array.isArray(evidence))
		return false;
	const kind = evidence.kind;
	return typeof kind === "string" && ACCEPTED_EVIDENCE_KINDS.has(kind);
}

/**
 * Outcomes-verification AC2 (sf-mp4vxusa-pn2tnd): extract file/path
 * mentions from a free-form acceptanceCriteria string. We accept two
 * shapes that operators tend to use:
 *
 *   1. backticked code spans: `src/foo.js`, `tests/bar.test.mjs`
 *   2. bare path-like tokens: src/foo.js, packages/ai/src/types.ts,
 *      docs/dev/ADR-019.md (any token containing a slash + dotted
 *      extension that doesn't start with a numeric digit)
 *
 * Returns an array of unique normalized paths (no leading ./, lowercased
 * comparison is the caller's job — we keep original case here). Returns
 * [] when no extractable paths found. Empty AC also returns [].
 */
function extractFilesFromAcceptanceCriteria(acText) {
	if (typeof acText !== "string" || !acText.trim()) return [];
	const found = new Set();
	// Backticked spans first — most reliable signal
	const backtickRe = /`([^`\n]{1,200})`/g;
	let m;
	while ((m = backtickRe.exec(acText)) !== null) {
		const candidate = m[1].trim();
		if (looksLikePath(candidate)) found.add(normalizePath(candidate));
	}
	// Then bare path-like tokens. Word-boundary on either side; require at
	// least one slash and a dotted extension to avoid sucking in plain
	// english words.
	const pathRe =
		/(?:^|[\s,([])([a-zA-Z][a-zA-Z0-9._\-/]*\/[a-zA-Z0-9._\-/]+\.[a-zA-Z0-9]+)(?=[\s,)\]:;.]|$)/g;
	while ((m = pathRe.exec(acText)) !== null) {
		const candidate = m[1].trim();
		if (looksLikePath(candidate)) found.add(normalizePath(candidate));
	}
	return Array.from(found);
}

function looksLikePath(s) {
	if (!s || s.length < 3) return false;
	if (/\s/.test(s)) return false;
	if (!s.includes("/")) return false;
	if (!/\.[a-zA-Z0-9]+$/.test(s)) return false;
	// Reject URLs and protocol-looking strings
	if (/^https?:\/\//i.test(s)) return false;
	// Reject leading digits (version-like strings)
	if (/^\d/.test(s)) return false;
	return true;
}

function normalizePath(p) {
	return p.replace(/^\.\//, "").replace(/^\/+/, "");
}

/**
 * Outcomes-verification AC2 (sf-mp4vxusa-pn2tnd): return the list of files
 * touched by a given commit. Best-effort — returns null when git is
 * unavailable or the commit is missing, so callers downgrade gracefully.
 *
 * Consumer: markResolved when evidence.kind === "agent-fix" and the
 * resolved entry has AC mentioning specific files.
 */
function getCommitTouchedFiles(commitSha, basePath) {
	if (typeof commitSha !== "string" || !commitSha.trim()) return null;
	let execFileSync;
	try {
		const cp = require("node:child_process");
		execFileSync = cp.execFileSync;
	} catch {
		return null;
	}
	try {
		const out = execFileSync(
			"git",
			["diff-tree", "--no-commit-id", "--name-only", "-r", commitSha.trim()],
			{
				cwd: basePath,
				encoding: "utf-8",
				stdio: ["ignore", "pipe", "pipe"],
				timeout: 5000,
			},
		);
		return out
			.split("\n")
			.map((s) => s.trim())
			.filter(Boolean)
			.map(normalizePath);
	} catch {
		return null;
	}
}

/**
 * Outcomes-verification AC1 (sf-mp4rxkwn-jmp039): when an agent-fix
 * resolution cites a commit_sha, verify the commit actually exists in the
 * repo. Without this check, an agent can stamp any string as a commit and
 * the writer-layer constraint will accept it.
 *
 * Returns one of:
 *   "verified"      — git exists and the commit is in the repo
 *   "missing"       — git exists, commit lookup failed (e.g. NONEXISTENT_SHA)
 *   "ungrokable"    — git unavailable or basePath isn't a git repo; we
 *                     cannot verify and must not punish (don't make every
 *                     non-git working tree start failing resolutions)
 *
 * Caller policy: reject on "missing"; accept on "verified" and
 * "ungrokable". The "ungrokable" carve-out is the only practical one — an
 * SF self-feedback ledger living outside a git repo is a real (rare) case
 * and the agent-fix-unverified kind exists for explicit "I edited but
 * can't cite a verified commit" workflows.
 */
function verifyCommitExists(commitSha, basePath) {
	if (typeof commitSha !== "string" || !commitSha.trim()) return "ungrokable";
	const sha = commitSha.trim();
	let execFileSync;
	try {
		const cp = require("node:child_process");
		execFileSync = cp.execFileSync;
	} catch {
		return "ungrokable";
	}
	try {
		execFileSync("git", ["rev-parse", "--verify", `${sha}^{commit}`], {
			cwd: basePath,
			stdio: ["ignore", "pipe", "pipe"],
			timeout: 5000,
		});
		return "verified";
	} catch {
		// rev-parse exits non-zero when the commit doesn't exist OR when
		// the directory isn't a git repo. Distinguish via a second probe:
		// `git rev-parse --git-dir` exits 0 when inside a repo.
		try {
			execFileSync("git", ["rev-parse", "--git-dir"], {
				cwd: basePath,
				stdio: ["ignore", "pipe", "pipe"],
				timeout: 5000,
			});
			return "missing"; // in a repo but the SHA is unknown
		} catch {
			return "ungrokable"; // not in a repo or git unavailable
		}
	}
}

/**
 * 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` with one of the accepted kinds
 * (sf-mp4qoby4-meiir7 — writer-layer constraint):
 *  - `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
 *  - `agent-fix-unverified` for outcomes-verification-pending resolutions
 *
 * Resolutions without an accepted evidence kind are REJECTED (return false)
 * — no DB write, no JSONL write, no markdown regeneration.
 *
 * 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 (!isAcceptedEvidence(resolution?.evidence)) {
		// Writer-layer constraint: refuse non-canonical resolutions. The
		// caller can recover by re-submitting with a valid evidence kind.
		return false;
	}
	// Outcomes-verification AC1 (sf-mp4rxkwn-jmp039): when an agent-fix
	// resolution cites a commit_sha, verify the commit actually exists in
	// the repo. Reject when git can confirm the commit is missing; accept
	// when git is unavailable or basePath isn't a git repo (the
	// agent-fix-unverified kind exists for that explicit case).
	if (
		resolution.evidence.kind === "agent-fix" &&
		typeof resolution.evidence.commitSha === "string" &&
		resolution.evidence.commitSha.trim().length > 0
	) {
		const status = verifyCommitExists(resolution.evidence.commitSha, basePath);
		if (status === "missing") return false;
		// Outcomes-verification AC2 (sf-mp4vxusa-pn2tnd): if the entry has
		// acceptanceCriteria mentioning specific files, the cited commit
		// must touch at least one of them. Skip silently when AC has no
		// extractable file mentions (operator AC was prose-only — there's
		// nothing to verify against and we don't want false rejections).
		// Skip when git can't enumerate the commit's files ("ungrokable" or
		// out-of-repo) — same carve-out as AC1.
		if (status === "verified" && isForgeRepo(basePath) && isDbAvailable()) {
			try {
				const entry = listSelfFeedbackEntries().find((e) => e.id === entryId);
				const acText =
					entry?.acceptanceCriteria ??
					entry?.full_json?.acceptanceCriteria ??
					"";
				const expectedFiles = extractFilesFromAcceptanceCriteria(acText);
				if (expectedFiles.length > 0) {
					const touched = getCommitTouchedFiles(
						resolution.evidence.commitSha,
						basePath,
					);
					if (Array.isArray(touched) && touched.length > 0) {
						const touchedSet = new Set(touched);
						// Match by exact path OR by basename to tolerate the
						// common AC pattern of saying "src/foo.js" when the
						// actual change was in "packages/x/src/foo.js" (and vice
						// versa). Exact match wins; basename match catches the
						// usual operator informality without over-trusting.
						const touchedBasenames = new Set(
							touched.map((p) => p.split("/").pop()),
						);
						const hit = expectedFiles.some((f) => {
							if (touchedSet.has(f)) return true;
							const basename = f.split("/").pop();
							return basename ? touchedBasenames.has(basename) : false;
						});
						if (!hit) return false;
					}
				}
			} catch {
				// Verification is best-effort — never block resolution on a
				// verification probe throwing. The AC1 commit-exists check
				// has already accepted the SHA at this point.
			}
		}
	}
	if (isForgeRepo(basePath) && isDbAvailable()) {
		try {
			// Import any legacy JSONL entries that haven't reached DB yet, so
			// entries inserted via older paths are always resolvable here.
			importLegacyJsonlToDb(basePath);
			const resolvedAt = new Date().toISOString();
			const mutated = resolveSelfFeedbackEntry(entryId, {
				...resolution,
				resolvedBySfVersion: getCurrentSfVersion(),
				resolvedAt,
			});
			if (mutated) {
				// DB is now canonical. Regenerate both JSONL and Markdown from DB
				// so both render targets reflect the resolved state. JSONL is no
				// longer appended-to; it is fully rewritten from DB state so that
				// entries inserted directly into DB (without a prior JSONL write)
				// are not invisible to JSONL readers after resolution.
				regenerateSelfFeedbackJsonl(basePath);
				regenerateSelfFeedbackMarkdown(basePath);
				// Mirror the closure into memory-store (sf-mp4rp6y2-31jfau) so
				// detectors and reflection passes can consult prior closure
				// rationale via getRelevantMemoriesRanked. Best-effort.
				try {
					const entry = listSelfFeedbackEntries().find((e) => e.id === entryId);
					if (entry) writeResolutionToMemory(entry, resolution);
				} catch {
					// memory mirror is observability; never fail the resolution
				}
			}
			return mutated;
		} catch {
			return false;
		}
	}
	// Legacy fallback: no DB available. Mutate JSONL in-place (upstream log
	// only — forge repos always have DB in modern deployments).
	if (!isForgeRepo(basePath)) {
		try {
			const path = upstreamLogPath();
			if (!existsSync(path)) return false;
			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");
				return true;
			}
			return false;
		} catch {
			return false;
		}
	}
	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;
}
/**
 * Self-feedback kinds that represent operator intent rather than mechanical
 * symptoms. Auto-version-bump close MUST NOT fire on these — operator-filed
 * design / capability / spec direction does NOT expire when SF's version
 * bumps. Mechanical symptoms (runaway-loop:idle-halt, prompt-quality-issue,
 * silent-worker-failure) ARE retry-eligible because the underlying bug may
 * have been fixed in the bumped version.
 *
 * Observed 2026-05-17: operator filed `improvement-idea` (R124 proposal) via
 * `sf headless feedback add`; entry written with empty sfVersion; resolver
 * compareSemver'd "" against "2.75.4" → close as "version-bumped" within 54
 * seconds. Two defects, both fixed here:
 *   1) empty/null/undefined sfVersion treated as comparable (now treated as
 *      unknown).
 *   2) operator-direction kinds NOT exempt from the close path (now skipped).
 */
const OPERATOR_DIRECTION_KINDS = new Set([
	"improvement-idea",
	"architecture-defect",
	"missing-feature",
	"gap",
]);

function isMissingVersion(v) {
	return (
		v === undefined ||
		v === null ||
		v === "" ||
		v === "unknown" ||
		v === "null" ||
		v === "undefined"
	);
}

export function triageBlockedEntries(basePath = process.cwd()) {
	const current = getCurrentSfVersion();
	const retry = [];
	const stillBlocked = [];
	for (const e of getBlockedEntries(basePath)) {
		if (OPERATOR_DIRECTION_KINDS.has(e.kind)) {
			stillBlocked.push(e);
			continue;
		}
		if (isMissingVersion(current) || isMissingVersion(e.sfVersion)) {
			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;
}
