/**
 * Scaffold versioning — markers, manifest, and hash helpers (ADR-021 Phase A).
 *
 * SF stamps every file it scaffolds with a first-line HTML comment that
 * records the SF version, the logical template id, the document state, and
 * the sha256 of the body content as written. A sibling JSON manifest at
 * `.sf/scaffold-manifest.json` records the same facts at the project level
 * for fast lookup and to survive marker corruption.
 *
 * The marker is the source of truth; the manifest is a cache. Both can be
 * rebuilt from the other. Phase A only writes them on new files. Phase B
 * adds drift detection. Phase C wires drift into the scaffold pipeline.
 */
import { createHash } from "node:crypto";
import { existsSync, mkdirSync, readFileSync, writeFileSync } from "node:fs";
import { dirname, join } from "node:path";
import { logWarning } from "./workflow-logger.js";
/** Marker on the first line of every scaffold-managed Markdown file. */
export const SF_DOC_MARKER_PREFIX = "<!-- sf-doc:";
export const SF_DOC_MARKER_SUFFIX = "-->";
/** Manifest path under `.sf/`. Single source of truth for filename/location. */
export const SCAFFOLD_MANIFEST_RELPATH = ".sf/scaffold-manifest.json";
// ─── Marker parsing & formatting ─────────────────────────────────────────
const VALID_STATES = ["pending", "editing", "completed", "disabled"];
function isScaffoldDocState(s) {
	return VALID_STATES.includes(s);
}
/**
 * Parse the canonical first-line marker.
 *
 * Accepted format (whitespace-tolerant):
 *   <!-- sf-doc: version=2.75.2 template=AGENTS.md state=pending hash=sha256:abc -->
 *
 * Returns null on any structural mismatch or missing required field. Extra
 * unrecognized key=value pairs are tolerated.
 */
export function parseMarker(line) {
	if (typeof line !== "string") return null;
	const trimmed = line.trim();
	if (!trimmed.startsWith(SF_DOC_MARKER_PREFIX)) return null;
	if (!trimmed.endsWith(SF_DOC_MARKER_SUFFIX)) return null;
	const inner = trimmed
		.slice(
			SF_DOC_MARKER_PREFIX.length,
			trimmed.length - SF_DOC_MARKER_SUFFIX.length,
		)
		.trim();
	if (inner.length === 0) return null;
	const tokens = inner.split(/\s+/);
	const fields = {};
	for (const tok of tokens) {
		const eq = tok.indexOf("=");
		if (eq <= 0) continue;
		const key = tok.slice(0, eq).trim();
		const value = tok.slice(eq + 1).trim();
		if (key.length === 0) continue;
		fields[key] = value;
	}
	const version = fields.version;
	const template = fields.template;
	const hash = fields.hash;
	const stateRaw = fields.state ?? "pending";
	if (!version || !template || !hash) return null;
	if (!isScaffoldDocState(stateRaw)) return null;
	return { version, template, state: stateRaw, hash };
}
/**
 * Emit the canonical single-line marker, terminated with `\n`.
 * The output is round-trip-safe through `parseMarker`.
 */
export function formatMarker(m) {
	return `${SF_DOC_MARKER_PREFIX} version=${m.version} template=${m.template} state=${m.state} hash=${m.hash} ${SF_DOC_MARKER_SUFFIX}\n`;
}
// ─── Hashing ─────────────────────────────────────────────────────────────
/**
 * sha256 of body, returned as `sha256:<hex>`.
 *
 * Bodies are normalized by stripping a single leading `\n` if present; this
 * keeps the hash stable across the marker-prepended and marker-stripped
 * representations of the same content.
 */
export function bodyHash(body) {
	const normalized = body.startsWith("\n") ? body.slice(1) : body;
	const h = createHash("sha256").update(normalized, "utf-8").digest("hex");
	return `sha256:${h}`;
}
// ─── File-level marker IO ────────────────────────────────────────────────
/**
 * Read the file, return the parsed marker (if any) and the body.
 *
 * Body semantics:
 *  - If a marker is present on line 1, body is everything after the first
 *    newline (i.e. line 2 onward), with no leading-newline stripping.
 *  - If no marker, body is the entire file.
 *
 * Failure modes (missing file, unreadable, invalid marker) return
 * `{ marker: null, body: "" }`.
 */
export function extractMarker(filePath) {
	let content;
	try {
		if (!existsSync(filePath)) return { marker: null, body: "" };
		content = readFileSync(filePath, "utf-8");
	} catch {
		return { marker: null, body: "" };
	}
	const newlineIdx = content.indexOf("\n");
	const firstLine = newlineIdx === -1 ? content : content.slice(0, newlineIdx);
	const marker = parseMarker(firstLine);
	if (!marker) {
		return { marker: null, body: content };
	}
	const body = newlineIdx === -1 ? "" : content.slice(newlineIdx + 1);
	return { marker, body };
}
/**
 * Stamp `path` with a fresh marker and write the file.
 *
 * Behavior:
 *  - If the file does not exist, the existing-content path is taken with body=""
 *    — but in practice `stampScaffoldFile` is called immediately after writing
 *    initial content, so the body is the just-written content read back.
 *  - If line 1 is already a marker, it is replaced; the body is preserved
 *    verbatim (including any internal blank lines).
 *  - If line 1 is not a marker, the marker is prepended with a newline.
 *
 * The body hash recorded in the marker is computed against the body **as it
 * appears after the marker line** in the final file (i.e. excluding the
 * marker itself). `bodyHash` normalizes a leading newline; the marker's
 * trailing `\n` does not pollute the hash.
 */
export function stampScaffoldFile(path, template, version, state = "pending") {
	let body;
	if (existsSync(path)) {
		// If the existing file already has a marker, drop it — we are restamping.
		const { marker: existing, body: existingBody } = extractMarker(path);
		body = existing ? existingBody : readFileSync(path, "utf-8");
	} else {
		body = "";
	}
	const hash = bodyHash(body);
	const marker = { version, template, state, hash };
	const out = formatMarker(marker) + body;
	mkdirSync(dirname(path), { recursive: true });
	writeFileSync(path, out, "utf-8");
}
// ─── Manifest IO ─────────────────────────────────────────────────────────
function manifestPath(basePath) {
	return join(basePath, SCAFFOLD_MANIFEST_RELPATH);
}
/**
 * Type guard to validate that an unknown value is a ScaffoldManifestEntry.
 * Used to filter unparsed JSON into strongly-typed entries.
 */
function isScaffoldManifestEntry(e) {
	return (
		!!e &&
		typeof e === "object" &&
		typeof e.path === "string" &&
		typeof e.template === "string" &&
		typeof e.version === "string" &&
		typeof e.appliedAt === "string" &&
		typeof e.contentHash === "string"
	);
}
/**
 * Read `.sf/scaffold-manifest.json`. Never throws. Missing or corrupt files
 * yield an empty manifest at the current schema version.
 */
export function readScaffoldManifest(basePath) {
	const fp = manifestPath(basePath);
	if (!existsSync(fp)) {
		return { schemaVersion: 1, profile: null, applied: [] };
	}
	let content;
	try {
		content = readFileSync(fp, "utf-8");
	} catch {
		return { schemaVersion: 1, profile: null, applied: [] };
	}
	let parsed;
	try {
		parsed = JSON.parse(content);
	} catch {
		return { schemaVersion: 1, profile: null, applied: [] };
	}
	if (
		!parsed ||
		typeof parsed !== "object" ||
		parsed.schemaVersion !== 1 ||
		!Array.isArray(parsed.applied)
	) {
		return { schemaVersion: 1, profile: null, applied: [] };
	}
	const applied = parsed.applied.filter(isScaffoldManifestEntry);
	const profile = typeof parsed.profile === "string" ? parsed.profile : null;
	return { schemaVersion: 1, profile, applied };
}
/**
 * Write the manifest. Never throws — write failures are logged via
 * `workflow-logger` under the `scaffold` component and swallowed.
 */
export function writeScaffoldManifest(basePath, manifest) {
	const fp = manifestPath(basePath);
	try {
		mkdirSync(dirname(fp), { recursive: true });
		writeFileSync(fp, JSON.stringify(manifest, null, 2) + "\n", "utf-8");
	} catch (err) {
		logWarning("scaffold", "failed to write scaffold manifest", {
			error: err.message,
		});
	}
}
/**
 * Record a scaffold application. Reads the manifest, removes any prior entry
 * with the same `path`, appends the new one, writes back. Idempotent.
 * Preserves the `profile` field from the existing manifest.
 */
export function recordScaffoldApply(basePath, entry) {
	const manifest = readScaffoldManifest(basePath);
	const filtered = manifest.applied.filter((e) => e.path !== entry.path);
	filtered.push(entry);
	writeScaffoldManifest(basePath, {
		schemaVersion: 1,
		profile: manifest.profile,
		applied: filtered,
	});
}
