/**
 * 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/BACKLOG.md`           — human-readable summary
 *      • `<basePath>/.sf/self-feedback.jsonl`  — structured source of truth
 *    The jsonl is what reads use. The markdown is for humans browsing the dir.
 *  - 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 the backlog.
 *  - 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,
	writeFileSync,
} from "node:fs";
import { homedir } from "node:os";
import { dirname, join } from "node:path";
import { sfRuntimeRoot } from "./paths.js";

const SF_HOME = process.env.SF_HOME || join(homedir(), ".sf");
const UPSTREAM_LOG = join(SF_HOME, "agent", "upstream-feedback.jsonl");
const BACKLOG_HEADER =
	"# SF Self-Feedback Backlog\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. Source-of-truth records live in `self-feedback.jsonl`.\n\n" +
	"Blocking entries (severity high+) hold their originating unit until\n" +
	"`sf` is bumped past the version recorded with the entry, or the entry\n" +
	"is explicitly resolved.\n\n" +
	"| Timestamp | Kind | Severity | Blocking | sfVersion | Unit | Summary |\n" +
	"|---|---|---|---|---|---|---|\n";

export type SelfFeedbackSeverity = "critical" | "high" | "medium" | "low";

/**
 * Context where a feedback entry occurred (milestone/slice/task/unit).
 */
export interface SelfFeedbackOccurredIn {
	milestone?: string;
	slice?: string;
	task?: string;
	unitType?: string;
}

/**
 * A self-feedback entry reporting an anomaly or issue detected during auto-mode execution.
 */
export interface SelfFeedbackEntry {
	kind: string;
	severity: SelfFeedbackSeverity;
	summary: string;
	evidence?: string;
	suggestedFix?: string;
	/**
	 * Optional reporter-written list of conditions that must be true before the
	 * entry can be considered resolved. When present, a resolver should cite
	 * which criteria they satisfied via markResolved's `criteriaMet` arg.
	 * Without this, "resolved" only means "someone called markResolved" —
	 * which is exactly the half-fix risk the channel is meant to surface.
	 */
	acceptanceCriteria?: string;
	occurredIn?: SelfFeedbackOccurredIn;
	source: "agent" | "detector";
}

/**
 * Structured evidence cited when an entry is marked resolved. One of the
 * non-reason fields should be present so the resolution is traceable.
 *  - `kind: "auto-version-bump"` — auto-resolved by session_start when sf
 *    was bumped past the recorded version. No human action.
 *  - `kind: "agent-fix"` — agent landed a code fix. Cite commitSha and
 *    optionally testPath / summaryNarrative.
 *  - `kind: "human-clear"` — operator manually cleared via doctor or by
 *    editing BACKLOG.md. reason should explain why.
 *  - `kind: "promoted-to-requirement"` — entry was promoted to a REQUIREMENTS
 *    row by the threshold-promotion sweeper.
 */
export type ResolutionEvidence =
	| {
			kind: "auto-version-bump";
			fromVersion: string;
			toVersion: string;
	  }
	| {
			kind: "agent-fix";
			commitSha?: string;
			testPath?: string;
			summaryNarrative?: string;
	  }
	| { kind: "human-clear" }
	| { kind: "promoted-to-requirement"; requirementId: string };

/**
 * Persisted feedback entry with metadata: ID, timestamp, version, and resolution state.
 */
export interface PersistedSelfFeedbackEntry extends SelfFeedbackEntry {
	id: string;
	ts: string;
	basePath: string;
	repoIdentity: "forge" | "external";
	sfVersion: string;
	blocking: boolean;
	resolvedAt?: string;
	resolvedReason?: string;
	resolvedBySfVersion?: string;
	resolvedEvidence?: ResolutionEvidence;
	/** IDs from `acceptanceCriteria` (or hashed lines) the resolver claimed satisfied. Only relevant when the entry had acceptanceCriteria set. */
	resolvedCriteriaMet?: string[];
}

/**
 * Result of recording a self-feedback entry. Contains the persisted entry and blocking status.
 */
export interface RecordResult {
	entry: PersistedSelfFeedbackEntry;
	/** True when callers should treat the originating unit as blocked. */
	blocking: boolean;
}

// ─── Identity & version helpers ────────────────────────────────────────────

function isForgeRepo(basePath: string): boolean {
	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(): string {
	if (process.env.SF_VERSION && process.env.SF_VERSION !== "0.0.0") {
		return process.env.SF_VERSION;
	}
	return "unknown";
}

function deriveBlocking(severity: SelfFeedbackSeverity): boolean {
	return severity === "high" || severity === "critical";
}

function newId(): string {
	const ts = Date.now().toString(36);
	const rnd = Math.random().toString(36).slice(2, 8);
	return `sf-${ts}-${rnd}`;
}

// ─── Path helpers ──────────────────────────────────────────────────────────

function projectJsonlPath(basePath: string): string {
	return join(sfRuntimeRoot(basePath), "self-feedback.jsonl");
}

function projectMarkdownPath(basePath: string): string {
	return join(sfRuntimeRoot(basePath), "BACKLOG.md");
}

function ensureDir(path: string): void {
	const dir = dirname(path);
	if (!existsSync(dir)) mkdirSync(dir, { recursive: true });
}

// ─── Writers ───────────────────────────────────────────────────────────────

function appendJsonl(path: string, entry: PersistedSelfFeedbackEntry): void {
	ensureDir(path);
	appendFileSync(path, `${JSON.stringify(entry)}\n`, "utf-8");
}

function appendBacklogRow(
	basePath: string,
	entry: PersistedSelfFeedbackEntry,
): void {
	const path = projectMarkdownPath(basePath);
	ensureDir(path);
	if (!existsSync(path)) writeFileSync(path, BACKLOG_HEADER, "utf-8");
	const unit = formatUnitCell(entry.occurredIn);
	const summary = escapeCell(entry.summary);
	const blocking = entry.blocking ? "yes" : "no";
	const row =
		`| ${entry.ts} | ${entry.kind} | ${entry.severity} | ${blocking} | ${entry.sfVersion} | ${unit} | ${summary} |\n`;
	appendFileSync(path, row, "utf-8");
	if (entry.evidence || entry.suggestedFix) {
		const detail =
			`\n<details><summary>${entry.id} — ${entry.kind}</summary>\n\n` +
			(entry.evidence
				? `**Evidence:**\n\n\`\`\`\n${entry.evidence}\n\`\`\`\n\n`
				: "") +
			(entry.suggestedFix
				? `**Suggested fix:** ${entry.suggestedFix}\n\n`
				: "") +
			`</details>\n`;
		appendFileSync(path, detail, "utf-8");
	}
}

function formatUnitCell(occurred?: SelfFeedbackOccurredIn): string {
	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: string): string {
	return text.replace(/\|/g, "\\|").replace(/\n/g, " ").trim();
}

// ─── Auto-fill from active unit ───────────────────────────────────────────

function readActiveUnit(basePath: string): SelfFeedbackOccurredIn | undefined {
	try {
		const lockPath = join(sfRuntimeRoot(basePath), "auto.lock");
		if (!existsSync(lockPath)) return undefined;
		const lock = JSON.parse(readFileSync(lockPath, "utf-8"));
		const id: string | undefined = 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: SelfFeedbackEntry,
	basePath: string = process.cwd(),
): RecordResult | null {
	try {
		const occurredIn = entry.occurredIn ?? readActiveUnit(basePath);
		const persisted: PersistedSelfFeedbackEntry = {
			...entry,
			occurredIn,
			id: newId(),
			ts: new Date().toISOString(),
			basePath,
			repoIdentity: isForgeRepo(basePath) ? "forge" : "external",
			sfVersion: getCurrentSfVersion(),
			blocking: deriveBlocking(entry.severity),
		};
		if (persisted.repoIdentity === "forge") {
			appendJsonl(projectJsonlPath(basePath), persisted);
			appendBacklogRow(basePath, persisted);
		} else {
			appendJsonl(UPSTREAM_LOG, persisted);
		}
		return { entry: persisted, blocking: persisted.blocking };
	} catch {
		return null;
	}
}

/**
 * Read all entries from the appropriate channel for `basePath`.
 * Reads only the jsonl source-of-truth; the markdown is purely human-facing.
 */
export function readAllSelfFeedback(
	basePath: string = process.cwd(),
): PersistedSelfFeedbackEntry[] {
	const path = isForgeRepo(basePath)
		? projectJsonlPath(basePath)
		: UPSTREAM_LOG;
	try {
		if (!existsSync(path)) return [];
		const out: PersistedSelfFeedbackEntry[] = [];
		for (const line of readFileSync(path, "utf-8").split("\n")) {
			if (!line.trim()) continue;
			try {
				out.push(JSON.parse(line) as PersistedSelfFeedbackEntry);
			} catch {
				/* skip malformed lines */
			}
		}
		return out;
	} catch {
		return [];
	}
}

/**
 * Return blocking entries that have not been resolved.
 * Used by auto's session-start hook to drive retry-on-bump.
 */
export function getBlockedEntries(
	basePath: string = process.cwd(),
): PersistedSelfFeedbackEntry[] {
	return readAllSelfFeedback(basePath).filter(
		(e) => e.blocking && !e.resolvedAt,
	);
}

/**
 * Input for marking a feedback entry as resolved with evidence and reasoning.
 */
export interface ResolutionInput {
	reason: string;
	evidence: ResolutionEvidence;
	criteriaMet?: string[];
}

/**
 * Mark an entry as resolved. Rewrites the jsonl source-of-truth 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.)
 *
 * The corresponding BACKLOG.md row is *not* mutated — markdown is human-
 * authored space; humans can strike-through resolved rows or trim them.
 */
export function markResolved(
	entryId: string,
	resolution: ResolutionInput,
	basePath: string = process.cwd(),
): boolean {
	const path = isForgeRepo(basePath)
		? projectJsonlPath(basePath)
		: UPSTREAM_LOG;
	try {
		if (!existsSync(path)) return false;
		const lines = readFileSync(path, "utf-8").split("\n");
		const out: string[] = [];
		let mutated = false;
		for (const line of lines) {
			if (!line.trim()) {
				out.push(line);
				continue;
			}
			try {
				const e = JSON.parse(line) as PersistedSelfFeedbackEntry;
				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 mutated;
	} catch {
		return false;
	}
}

/**
 * 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: string, b: string): number {
	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;
}

/**
 * For each blocked entry, decide whether the running sf is newer than the
 * one that recorded the entry. If yes, the dispatcher should retry the
 * entry's originating unit; if no, leave it blocked.
 *
 * Returns the entries split by retry-eligibility for the dispatcher to act on.
 */
/**
 * Result of triaging blocked entries by their version eligibility for retry.
 */
export interface BlockedTriage {
	retry: PersistedSelfFeedbackEntry[];
	stillBlocked: PersistedSelfFeedbackEntry[];
}

export function triageBlockedEntries(
	basePath: string = process.cwd(),
): BlockedTriage {
	const current = getCurrentSfVersion();
	const retry: PersistedSelfFeedbackEntry[] = [];
	const stillBlocked: PersistedSelfFeedbackEntry[] = [];
	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 };
}
