/**
 * Scaffold drift detection (ADR-021 Phase B, extended by ADR-022).
 *
 * Reads the on-disk state of every entry in `SCAFFOLD_FILES`, parses the
 * first-line marker (if any), and classifies each file into one of six
 * buckets. The result is structured and side-effect-free — Phase C wires
 * the report into the scaffold sync pipeline; Phase B is data-plane only.
 *
 * ADR-022 adds profile-aware filtering: files not in the active profile are
 * skipped entirely (not reported as `missing`), and files with
 * `state=disabled` go to the `disabled` bucket rather than any actionable
 * bucket.
 */
import { existsSync, readFileSync } from "node:fs";
import { join } from "node:path";
import { SCAFFOLD_FILES } from "./scaffold-constants.js";
import { resolveActiveProfileSet } from "./scaffold-profiles.js";
import {
	bodyHash,
	extractMarker,
	readScaffoldManifest,
	recordScaffoldApply,
	stampScaffoldFile,
} from "./scaffold-versioning.js";
import { logWarning } from "./workflow-logger.js";

/**
 * Files in `SCAFFOLD_FILES` that intentionally carry no inline marker.
 * Per ADR-021 §2, the `.siftignore` file uses the manifest as its
 * versioning source rather than an HTML/dotfile-comment marker.
 */
const SKIP_MARKER_PATHS = new Set([".siftignore"]);
/** Compare two dotted version strings as numeric tuples. */
function compareVersions(a, b) {
	const norm = (v) =>
		v.split(".").map((part) => {
			const n = Number.parseInt(part, 10);
			return Number.isFinite(n) ? n : 0;
		});
	const aa = norm(a);
	const bb = norm(b);
	const len = Math.max(aa.length, bb.length);
	for (let i = 0; i < len; i++) {
		const av = aa[i] ?? 0;
		const bv = bb[i] ?? 0;
		if (av !== bv) return av < bv ? -1 : 1;
	}
	return 0;
}
function emptyCounts() {
	return {
		missing: 0,
		upgradable: 0,
		"editing-drift": 0,
		untracked: 0,
		current: 0,
		customized: 0,
		disabled: 0,
	};
}

/**
 * Resolve the active profile set for a project.
 *
 * Delegates to resolveActiveProfileSet but only needs the Set — kept as a
 * local shim so migrateLegacyScaffold has a consistent call site.
 */
function resolveProfileSet(profile, manifest, basePath) {
	const { profileSet } = resolveActiveProfileSet(
		basePath ?? null,
		manifest,
		profile,
	);
	return profileSet;
}

/**
 * Classify every `SCAFFOLD_FILES` entry against its on-disk state.
 *
 * Ordering of the report is the same as `SCAFFOLD_FILES` so callers and
 * tests can rely on stable iteration. Failure modes are non-fatal: if a
 * file cannot be read, it is reported as `untracked` rather than aborting
 * the scan.
 *
 * @param {string} basePath - Absolute path to the repo root.
 * @param {string|null} [profile] - Active profile name override. Falls back to
 *   PREFERENCES.md frontmatter → manifest field → auto-detect. Files not in
 *   the active profile are skipped entirely (not reported as `missing`). Files
 *   with `state=disabled` go to the `disabled` bucket.
 */
export function detectScaffoldDrift(basePath, profile) {
	const shipVersion = process.env.SF_VERSION || "0.0.0";
	const items = [];
	const counts = emptyCounts();
	const manifest = readScaffoldManifest(basePath);
	const manifestPresent = manifest.applied.length > 0;
	const { profileSet } = resolveActiveProfileSet(basePath, manifest, profile);
	for (const file of SCAFFOLD_FILES) {
		// Skip files not in the active profile entirely — don't report as missing.
		if (!profileSet.has(file.path)) continue;
		const target = join(basePath, file.path);
		// Files in SKIP_MARKER_PATHS use the manifest as their versioning
		// source instead of an inline marker. For Phase B we treat them as
		// untracked when present (no marker) and missing when absent — Phase
		// C will consult the manifest for these specifically.
		if (SKIP_MARKER_PATHS.has(file.path)) {
			if (!existsSync(target)) {
				items.push({
					path: file.path,
					template: file.path,
					bucket: "missing",
					shipVersion,
				});
				counts.missing += 1;
			} else {
				items.push({
					path: file.path,
					template: file.path,
					bucket: "untracked",
					shipVersion,
				});
				counts.untracked += 1;
			}
			continue;
		}
		if (!existsSync(target)) {
			items.push({
				path: file.path,
				template: file.path,
				bucket: "missing",
				shipVersion,
			});
			counts.missing += 1;
			continue;
		}
		let body;
		let marker;
		try {
			const extracted = extractMarker(target);
			marker = extracted.marker;
			body = extracted.body;
		} catch {
			items.push({
				path: file.path,
				template: file.path,
				bucket: "untracked",
				shipVersion,
			});
			counts.untracked += 1;
			continue;
		}
		if (!marker) {
			// Read whole file as body if extractMarker reported none — we already
			// have the body in `body` per the contract, but be defensive in case
			// a future read failure produces "" + null.
			if (!body) {
				try {
					body = readFileSync(target, "utf-8");
				} catch {
					body = "";
				}
			}
			items.push({
				path: file.path,
				template: file.path,
				bucket: "untracked",
				shipVersion,
			});
			counts.untracked += 1;
			continue;
		}
		// Marker explicitly says completed → never modified by SF.
		if (marker.state === "completed") {
			items.push({
				path: file.path,
				template: file.path,
				bucket: "current",
				currentVersion: marker.version,
				shipVersion,
				hashDrifted: false,
			});
			counts.current += 1;
			continue;
		}
		// Marker says disabled → file is out-of-scope; never touch it.
		if (marker.state === "disabled") {
			items.push({
				path: file.path,
				template: file.path,
				bucket: "disabled",
				currentVersion: marker.version,
				shipVersion,
				hashDrifted: false,
			});
			counts.disabled += 1;
			continue;
		}
		const currentHash = bodyHash(body);
		const hashMatches = currentHash === marker.hash;
		if (!hashMatches) {
			items.push({
				path: file.path,
				template: file.path,
				bucket: "editing-drift",
				currentVersion: marker.version,
				shipVersion,
				hashDrifted: true,
			});
			counts["editing-drift"] += 1;
			continue;
		}
		// Hash matches stamp → file is still in pending state. Decide whether
		// the stamped version is the current shipped version.
		const cmp = compareVersions(marker.version, shipVersion);
		if (cmp < 0) {
			items.push({
				path: file.path,
				template: file.path,
				bucket: "upgradable",
				currentVersion: marker.version,
				shipVersion,
				hashDrifted: false,
			});
			counts.upgradable += 1;
		} else {
			// Stamped version equals (or is somehow ahead of) shipped version
			// and content matches — nothing to do. Bucket as `current` per
			// the spec ("already current"). NOTE: this bucket includes both
			// unedited pending files (hash matches) and user-edited files;
			// use hashDrifted flag to distinguish (false = no user edits).
			items.push({
				path: file.path,
				template: file.path,
				bucket: "current",
				currentVersion: marker.version,
				shipVersion,
				hashDrifted: false,
			});
			counts.current += 1;
		}
	}
	return {
		items,
		countsByBucket: counts,
		manifestPresent,
	};
}
/**
 * Per-template archive of body hashes shipped by prior SF versions.
 *
 * Each entry maps a logical template id (matching `SCAFFOLD_FILES[].path`)
 * to the list of `{version, hash}` pairs it has shipped. This is the table
 * `migrateLegacyScaffold` consults when it encounters a marker-less file
 * on disk: if the file's body hash matches an archive entry, the file is
 * still verbatim from a known prior version and can be promoted to
 * `pending` and stamped (which then makes the next pass classify it as
 * `upgradable`, triggering the silent re-render).
 *
 * Phase C bootstraps the archive with the **current** SF version's hashes,
 * computed lazily from `SCAFFOLD_FILES` on first call. This means a project
 * that sat on the current SF release without scaffold markers (because it
 * predates ADR-021 Phase A) can still be recognised: its files match the
 * current shipping body verbatim, so they get stamped and brought under
 * management without overwriting anything.
 *
 * Future SF releases that change a template body must append the **previous**
 * body hash for that template to this archive (with the version that shipped
 * the previous body). The `migrateLegacyScaffold` flow is forwards-compatible
 * with that growth: more entries simply mean more legacy files match.
 */
export const SCAFFOLD_VERSION_ARCHIVE = {};
let archiveSeededWithCurrent = false;
/**
 * Lazily seed `SCAFFOLD_VERSION_ARCHIVE` with the current SF version's body
 * hashes for every entry in `SCAFFOLD_FILES`. Idempotent — repeated calls
 * are no-ops. Called from `migrateLegacyScaffold` on demand so the archive
 * is always populated for at least the current shipping version.
 */
function seedArchiveWithCurrentShipVersion() {
	if (archiveSeededWithCurrent) return;
	archiveSeededWithCurrent = true;
	const shipVersion = process.env.SF_VERSION || "0.0.0";
	for (const file of SCAFFOLD_FILES) {
		const hash = bodyHash(file.content);
		const list = SCAFFOLD_VERSION_ARCHIVE[file.path] ?? [];
		// Avoid duplicate entries if a future hand-edited archive entry
		// already mentions this version+hash combination.
		if (!list.some((e) => e.version === shipVersion && e.hash === hash)) {
			list.push({ version: shipVersion, hash });
		}
		SCAFFOLD_VERSION_ARCHIVE[file.path] = list;
	}
}
/**
 * Walk every `SCAFFOLD_FILES` entry (filtered to the active profile) and look
 * for **unmarked** files whose body hash matches a known prior version recorded
 * in `SCAFFOLD_VERSION_ARCHIVE`. Matching files are promoted to `pending` by
 * stamping them with the matched version and recording a manifest entry.
 *
 * Behaviour:
 *  - Files with a marker already present → skipped (some other code path
 *    owns them).
 *  - Files missing on disk → skipped (the missing-file flow handles those).
 *  - Files in `SKIP_MARKER_PATHS` (e.g. `.siftignore`) → skipped here; the
 *    manifest is the versioning source for those.
 *  - Files not in the active profile → skipped; only profile-relevant files
 *    are candidates for legacy promotion.
 *  - Files whose body hash matches an archive entry → stamped with the
 *    matched version, manifest entry recorded, returned in `migrated`.
 *  - Files with no archive match → returned in `skipped`. Treated as
 *    user-customised; SF leaves them alone.
 *
 * Idempotent: a second invocation finds the markers it just wrote and
 * skips them. Failure modes (read error, write error) are swallowed and
 * logged via `logWarning("scaffold", ...)`.
 *
 * @param {string} basePath
 * @param {string|null} [profile] - Active profile; falls back to manifest then `app`.
 */
export function migrateLegacyScaffold(basePath, profile) {
	seedArchiveWithCurrentShipVersion();
	const manifest = readScaffoldManifest(basePath);
	const profileSet = resolveProfileSet(profile, manifest, basePath);
	const migrated = [];
	const skipped = [];
	const appliedAt = new Date().toISOString();
	for (const file of SCAFFOLD_FILES) {
		if (SKIP_MARKER_PATHS.has(file.path)) continue;
		// Only migrate files in the active profile.
		if (!profileSet.has(file.path)) continue;
		const target = join(basePath, file.path);
		if (!existsSync(target)) continue;
		let body;
		let markerPresent = false;
		try {
			const extracted = extractMarker(target);
			markerPresent = extracted.marker !== null;
			body = extracted.body;
		} catch (err) {
			logWarning("scaffold", "failed to read file during legacy migration", {
				file: file.path,
				error: err.message,
			});
			skipped.push(file.path);
			continue;
		}
		// File already managed — not a migration candidate.
		if (markerPresent) continue;
		// extractMarker returns the entire file content as `body` when no
		// marker is present, so this hash is the on-disk body hash.
		let onDiskHash;
		try {
			onDiskHash = bodyHash(body);
		} catch (err) {
			logWarning("scaffold", "failed to hash file during legacy migration", {
				file: file.path,
				error: err.message,
			});
			skipped.push(file.path);
			continue;
		}
		const archive = SCAFFOLD_VERSION_ARCHIVE[file.path] ?? [];
		const match = archive.find((e) => e.hash === onDiskHash);
		if (!match) {
			skipped.push(file.path);
			continue;
		}
		// Promote: stamp with the matched version and record manifest entry.
		try {
			stampScaffoldFile(target, file.path, match.version, "pending");
			const entry = {
				path: file.path,
				template: file.path,
				version: match.version,
				appliedAt,
				stateAtApply: "pending",
				contentHash: onDiskHash,
			};
			recordScaffoldApply(basePath, entry);
			migrated.push(file.path);
		} catch (err) {
			logWarning("scaffold", "failed to stamp legacy-matched file", {
				file: file.path,
				error: err.message,
			});
			skipped.push(file.path);
		}
	}
	return { migrated, skipped };
}
