/**
 * md-file-tracker.js — Session-start sha tracker for source-of-truth markdown files.
 *
 * Purpose: detect external edits (hand-edits, git pulls, cross-agent edits) to
 * key markdown files between sessions and surface them as notifications so SF and
 * the operator always know what changed since last time.
 *
 * Consumer: bootstrap/register-hooks.js session_start hook.
 */

import { spawnSync } from "node:child_process";
import { createHash } from "node:crypto";
import { existsSync, readdirSync, readFileSync, statSync } from "node:fs";
import { join, relative } from "node:path";
import {
	deactivateTrackedMdFile,
	getAllTrackedMdFiles,
	getTrackedMdFile,
	upsertTrackedMdFile,
} from "./sf-db/sf-db-md-tracker.js";
import { isDbAvailable } from "./sf-db.js";

// ─── Exclusions ───────────────────────────────────────────────────────────────

/** Uppercase-root md files excluded by design: high churn, low signal. */
const EXCLUDED_ROOT_FILES = new Set([
	"TODO.md",
	"CHANGELOG.md",
	"BUILD_PLAN.md",
]);

// ─── Hashing ─────────────────────────────────────────────────────────────────

function hashFile(absPath) {
	try {
		return createHash("sha256").update(readFileSync(absPath)).digest("hex");
	} catch {
		return null;
	}
}

// ─── Git helpers ──────────────────────────────────────────────────────────────

function getCurrentCommit(cwd) {
	try {
		const r = spawnSync("git", ["rev-parse", "HEAD"], {
			cwd,
			encoding: "utf-8",
		});
		return r.status === 0 ? r.stdout.trim() || null : null;
	} catch {
		return null;
	}
}

function gitDiffForFile(cwd, relpath, sinceCommit) {
	if (!sinceCommit) return null;
	try {
		const r = spawnSync(
			"git",
			["diff", "--unified=3", sinceCommit, "--", relpath],
			{ cwd, encoding: "utf-8" },
		);
		return r.status === 0 ? r.stdout.trim() || null : null;
	} catch {
		return null;
	}
}

// ─── File discovery ───────────────────────────────────────────────────────────

function walkDir(dir, category, repoRoot, out) {
	let entries;
	try {
		entries = readdirSync(dir, { withFileTypes: true });
	} catch {
		return;
	}
	for (const entry of entries) {
		const abs = join(dir, entry.name);
		if (entry.isDirectory()) {
			walkDir(abs, category, repoRoot, out);
		} else if (entry.isFile() && entry.name.endsWith(".md")) {
			out.push({ relpath: relative(repoRoot, abs), absPath: abs, category });
		}
	}
}

/**
 * Discover all candidate source-of-truth markdown files under repoRoot.
 *
 * Purpose: produce the per-session candidate set without shelling out to find.
 *
 * Consumer: detectMdFileDrift().
 */
function discoverTrackedFiles(repoRoot) {
	const results = [];

	// Root-level uppercase .md files
	try {
		for (const entry of readdirSync(repoRoot, { withFileTypes: true })) {
			if (!entry.isFile()) continue;
			const { name } = entry;
			if (!name.endsWith(".md")) continue;
			if (EXCLUDED_ROOT_FILES.has(name)) continue;
			if (/^[A-Z][A-Z_\-0-9]*\.md$/.test(name)) {
				results.push({
					relpath: name,
					absPath: join(repoRoot, name),
					category: "meta",
				});
			}
		}
	} catch {
		/* noop if root unreadable */
	}

	// .github/copilot-instructions.md
	const copilotMd = join(repoRoot, ".github", "copilot-instructions.md");
	if (existsSync(copilotMd)) {
		results.push({
			relpath: ".github/copilot-instructions.md",
			absPath: copilotMd,
			category: "meta",
		});
	}

	// docs/adr/**/*.md
	walkDir(join(repoRoot, "docs", "adr"), "adr", repoRoot, results);
	// docs/plans/**/*.md
	walkDir(join(repoRoot, "docs", "plans"), "plan", repoRoot, results);
	// .sf/repo-map/**/*.md (runtime orientation context; gitignored by default)
	walkDir(join(repoRoot, ".sf", "repo-map"), "repo-map", repoRoot, results);

	return results;
}

// ─── Main API ─────────────────────────────────────────────────────────────────

/**
 * Scan tracked markdown files, detect drift from last session, and return a
 * report of changed/new/deleted files. Updates the DB in-place.
 *
 * Purpose: give SF and the operator visibility into which source-of-truth docs
 * changed between sessions so nothing silently drifts.
 *
 * Consumer: bootstrap/register-hooks.js session_start.
 *
 * @param {string} repoRoot  Absolute path to the project root.
 * @returns {{ changed: DriftEntry[], added: DriftEntry[], deleted: string[] }}
 */
export function detectMdFileDrift(repoRoot) {
	if (!isDbAvailable()) return { changed: [], added: [], deleted: [] };

	const headCommit = getCurrentCommit(repoRoot);
	const candidates = discoverTrackedFiles(repoRoot);
	const seen = new Set();
	const changed = [];
	const added = [];

	for (const { relpath, absPath, category } of candidates) {
		seen.add(relpath);
		const sha = hashFile(absPath);
		if (!sha) continue;
		let sizeBytes = 0;
		try {
			sizeBytes = statSync(absPath).size;
		} catch {
			/* noop */
		}

		const existing = getTrackedMdFile(relpath);
		if (!existing) {
			upsertTrackedMdFile({
				relpath,
				sha256: sha,
				sizeBytes,
				lastSeenCommit: headCommit,
				category,
			});
			added.push({ relpath, category });
		} else if (existing.sha256 !== sha) {
			const diff = gitDiffForFile(repoRoot, relpath, existing.last_seen_commit);
			upsertTrackedMdFile({
				relpath,
				sha256: sha,
				sizeBytes,
				lastSeenCommit: headCommit,
				category,
			});
			changed.push({
				relpath,
				category,
				prevCommit: existing.last_seen_commit,
				diff,
			});
		} else {
			// Unchanged — refresh timestamp and commit pointer so we stay current.
			upsertTrackedMdFile({
				relpath,
				sha256: sha,
				sizeBytes,
				lastSeenCommit: headCommit,
				category,
			});
		}
	}

	// Detect deletions among previously-tracked files.
	const deleted = [];
	for (const row of getAllTrackedMdFiles()) {
		if (!seen.has(row.relpath) && !existsSync(join(repoRoot, row.relpath))) {
			deactivateTrackedMdFile(row.relpath);
			deleted.push(row.relpath);
		}
	}

	return { changed, added, deleted };
}

/**
 * Format a drift report as a human-readable notification string.
 *
 * Purpose: keep UI-layer formatting out of the tracker core so the logic can
 * be tested without a UI dependency.
 *
 * Consumer: bootstrap/register-hooks.js after detectMdFileDrift().
 */
export function formatDriftReport({ changed, added, deleted }) {
	const lines = [];
	if (changed.length > 0) {
		lines.push(
			`${changed.length} tracked md file${changed.length === 1 ? "" : "s"} changed since last session:`,
		);
		for (const { relpath } of changed) lines.push(`  • ${relpath}`);
	}
	if (added.length > 0) {
		lines.push(
			`${added.length} new tracked md file${added.length === 1 ? "" : "s"} now tracked:`,
		);
		for (const { relpath } of added) lines.push(`  • ${relpath}`);
	}
	if (deleted.length > 0) {
		lines.push(
			`${deleted.length} previously tracked md file${deleted.length === 1 ? "" : "s"} removed:`,
		);
		for (const relpath of deleted) lines.push(`  • ${relpath}`);
	}
	return lines.join("\n");
}
