/**
 * SF bootstrappers for .gitignore and preferences.yaml
 *
 * Ensures baseline .gitignore exists with universally-correct patterns.
 * Creates an empty preferences.yaml template if it doesn't exist.
 * Both idempotent — non-destructive if already present.
 */
import { execFileSync } from "node:child_process";
import { existsSync, lstatSync, readFileSync, writeFileSync } from "node:fs";
import { join } from "node:path";
import { yamlSafeString } from "./commands-prefs-wizard.js";
import { detectProjectSignals } from "./detection.js";
import { GIT_NO_PROMPT_ENV } from "./git-constants.js";
import { SF_RUNTIME_PATTERNS } from "./git-runtime-patterns.js";
import { nativeLsFiles, nativeRmCached } from "./native-git-bridge.js";
import { sfRoot } from "./paths.js";

export { SF_RUNTIME_PATTERNS } from "./git-runtime-patterns.js";

/**
 * SF runtime/generated exclusion patterns for repos where .sf/ is a LOCAL DIRECTORY.
 * Granular so deliberate human-authored guidance such as .sf/PRINCIPLES.md,
 * .sf/STYLE.md, and .sf/NON-GOALS.md can remain trackable.
 *
 * NOT used when .sf/ is a symlink — symlinks need the blanket SF_SYMLINK_EXCLUSION_PATTERNS
 * because git cannot traverse symlinks to match per-file patterns.
 *
 * Migrated from blanket `.sf` on 2026-05-01; later tightened so
 * .sf/milestones is generated runtime state unless promoted to docs.
 * Previously migrated out of BASELINE_PATTERNS into .git/info/exclude on 2026-04-29.
 */
const SF_RUNTIME_EXCLUSION_PATTERNS = [
	".sf-id",
	".bg-shell/",
	...SF_RUNTIME_PATTERNS,
];
/**
 * SF exclusion patterns for repos where .sf/ is a SYMLINK (external state).
 * Git sees the symlink as an opaque file and cannot traverse it, so granular
 * patterns like .sf/activity/ would never match. The blanket .sf pattern
 * excludes the symlink itself.
 */
const SF_SYMLINK_EXCLUSION_PATTERNS = [".sf", ".sf-id", ".bg-shell/"];
const BASELINE_PATTERNS = [
	// SF-specific patterns now live in SF_RUNTIME_EXCLUSION_PATTERNS, applied
	// to .git/info/exclude via ensureGitInfoExclude() — see comment above.
	// ── OS junk ──
	".DS_Store",
	"Thumbs.db",
	// ── Editor / IDE ──
	"*.swp",
	"*.swo",
	"*~",
	".idea/",
	".vscode/",
	"*.code-workspace",
	// ── Environment / secrets ──
	".env",
	".env.*",
	"!.env.example",
	// ── Node / JS / TS ──
	"node_modules/",
	".next/",
	"dist/",
	"build/",
	// ── Python ──
	"__pycache__/",
	"*.pyc",
	".venv/",
	"venv/",
	// ── Rust ──
	"target/",
	// ── Go ──
	"vendor/",
	// ── Misc build artifacts ──
	"*.log",
	"coverage/",
	".cache/",
	"tmp/",
];
/**
 * Check whether `.sf` is covered by the project's `.gitignore`.
 *
 * Uses `git check-ignore` for accurate evaluation — this respects nested
 * .gitignore files, global gitignore, and negation patterns. Returns true
 * only when git would actually ignore `.sf/`.
 *
 * Returns false (not ignored) if:
 *   - No `.gitignore` exists
 *   - `.sf` is not listed in any active ignore rule
 *   - Not a git repo or git is unavailable
 */
export function isSfGitignored(basePath) {
	// Check both `.sf` and `.sf/` because `.sf/` in .gitignore (trailing
	// slash = directory-only pattern) only matches the directory form. Using
	// both paths covers all gitignore pattern variants.
	for (const path of [".sf", ".sf/"]) {
		try {
			// git check-ignore exits 0 when the path IS ignored, 1 when it is NOT.
			execFileSync("git", ["check-ignore", "-q", path], {
				cwd: basePath,
				stdio: "pipe",
				env: GIT_NO_PROMPT_ENV,
			});
			return true; // exit 0 → .sf is ignored
		} catch {
			// exit 1 → this form is NOT ignored, try the other
		}
	}
	return false; // neither form is ignored (or git unavailable)
}
/**
 * Check whether `.sf/` contains files tracked by git.
 * If so, the project intentionally keeps `.sf/` in version control
 * and we must NOT add `.sf` to `.gitignore` or attempt migration.
 *
 * Returns true if git tracks at least one file under `.sf/`.
 * Returns false (safe to ignore) if:
 *   - Not a git repo
 *   - `.sf/` is a symlink (external state, should be ignored)
 *   - `.sf/` doesn't exist
 *   - No tracked files found under `.sf/`
 */
export function hasGitTrackedSfFiles(basePath) {
	const localSf = join(basePath, ".sf");
	// If .sf doesn't exist or is already a symlink, no tracked files concern
	if (!existsSync(localSf)) return false;
	try {
		if (lstatSync(localSf).isSymbolicLink()) return false;
	} catch {
		return false;
	}
	// Check if git tracks any files under .sf/
	try {
		const tracked = nativeLsFiles(basePath, ".sf");
		if (tracked.length > 0) return true;
		// nativeLsFiles swallows git failures and returns []. An empty result
		// could mean "nothing tracked" OR "git failed silently". Verify git is
		// reachable before trusting the empty result — if it isn't, fail safe
		// by assuming files ARE tracked to prevent data loss.
		execFileSync("git", ["rev-parse", "--git-dir"], {
			cwd: basePath,
			stdio: "pipe",
			env: GIT_NO_PROMPT_ENV,
		});
		return false;
	} catch {
		// git unavailable, index locked, or repo corrupt — fail safe
		return true;
	}
}
/**
 * Ensure basePath/.gitignore contains baseline ignore patterns.
 * Creates the file if missing; appends missing patterns.
 * Returns true if the file was created or modified, false if already complete.
 *
 * **Safety check:** If `.sf/` contains git-tracked files (i.e., the project
 * intentionally keeps `.sf/` in version control), the `.sf` ignore pattern
 * is excluded to prevent data loss. Only the `.sf` pattern is affected —
 * all other baseline patterns are still applied normally.
 */
/**
 * Write sf-specific runtime/generated exclusion patterns (`.sf`, `.sf-id`, `.bg-shell/`)
 * to `.git/info/exclude` — per-clone, never committed, never causes
 * working-tree churn. Idempotent: only writes when something is missing.
 *
 * This replaces the old behavior of appending those patterns to `.gitignore`,
 * which caused two pain points:
 *   1. Every sf run dirtied the working tree because the literal-string
 *      duplicate-check (`existingLines.has(".sf")`) didn't recognize
 *      user-equivalent patterns like `/.sf`.
 *   2. sf was opinionating on a file that should be human-curated.
 *
 * Returns true if the file was modified, false if nothing needed adding or
 * the directory isn't a git repo (no `.git/info`).
 */
export function ensureGitInfoExclude(basePath) {
	const gitInfoDir = join(basePath, ".git", "info");
	if (!existsSync(gitInfoDir)) return false;
	const excludePath = join(gitInfoDir, "exclude");
	const existing = existsSync(excludePath)
		? readFileSync(excludePath, "utf-8")
		: "";
	// Determine whether .sf is a symlink (external state) or a local directory.
	// Symlink: git cannot traverse it, so only the blanket .sf pattern works.
	// Directory: use granular patterns so deliberate human-authored .sf guidance
	//            can be tracked while generated runtime state stays ignored.
	const sfIsSymlink = (() => {
		const localSf = join(basePath, ".sf");
		try {
			return existsSync(localSf) && lstatSync(localSf).isSymbolicLink();
		} catch {
			return false;
		}
	})();
	const targetPatterns = sfIsSymlink
		? SF_SYMLINK_EXCLUSION_PATTERNS
		: SF_RUNTIME_EXCLUSION_PATTERNS;
	// Patterns to remove: whatever the OTHER mode would have written.
	// This handles transitions (symlink↔directory) by cleaning up stale entries.
	const stalePatterns = sfIsSymlink
		? SF_RUNTIME_EXCLUSION_PATTERNS
		: SF_SYMLINK_EXCLUSION_PATTERNS;
	const existingLines = existing.split("\n").map((l) => l.trim());
	const existingSet = new Set(
		existingLines.filter((l) => l && !l.startsWith("#")),
	);
	const missing = targetPatterns.filter((p) => !existingSet.has(p));
	const toRemove = new Set(stalePatterns.filter((p) => existingSet.has(p)));
	if (missing.length === 0 && toRemove.size === 0) return false;
	let content = existing
		.split("\n")
		.filter((l) => !toRemove.has(l.trim()))
		.join("\n");
	if (missing.length > 0) {
		const block = [
			"",
			"# ── SF runtime/generated exclusion (managed by sf, per-clone) ──",
			...missing,
			"",
		].join("\n");
		const prefix = content && !content.endsWith("\n") ? "\n" : "";
		content = content + prefix + block;
	}
	writeFileSync(excludePath, content, "utf-8");
	return true;
}
export function ensureGitignore(basePath, options) {
	// SF-specific runtime patterns go to .git/info/exclude regardless of
	// manage_gitignore — it's per-clone and never affects committed files.
	ensureGitInfoExclude(basePath);
	// If manage_gitignore is explicitly false, do not touch .gitignore at all
	if (options?.manageGitignore === false) return false;
	const gitignorePath = join(basePath, ".gitignore");
	let existing = "";
	if (existsSync(gitignorePath)) {
		existing = readFileSync(gitignorePath, "utf-8");
	}
	// Parse existing lines (trimmed, ignoring comments and blanks)
	const existingLines = new Set(
		existing
			.split("\n")
			.map((l) => l.trim())
			.filter((l) => l && !l.startsWith("#")),
	);
	// Determine which patterns to apply. If .sf/ has tracked files,
	// exclude the ".sf" pattern to prevent deleting tracked state.
	const sfIsTracked = hasGitTrackedSfFiles(basePath);
	const patternsToApply = sfIsTracked
		? BASELINE_PATTERNS.filter((p) => p !== ".sf")
		: BASELINE_PATTERNS;
	// Find patterns not yet present
	const missing = patternsToApply.filter((p) => !existingLines.has(p));
	if (missing.length === 0) return false;
	// Build the block to append
	const block = [
		"",
		"# ── SF baseline (auto-generated) ──",
		...missing,
		"",
	].join("\n");
	// Ensure existing content ends with a newline before appending
	const prefix = existing && !existing.endsWith("\n") ? "\n" : "";
	writeFileSync(gitignorePath, existing + prefix + block, "utf-8");
	return true;
}
/**
 * Remove BASELINE_PATTERNS runtime paths from the git index if they are
 * currently tracked. This fixes repos that started tracking these files
 * before the .gitignore rule was added — git continues tracking files
 * already in the index even after .gitignore is updated.
 *
 * Only removes from the index (`--cached`), never from disk. Idempotent.
 *
 * Note: These are strictly runtime/generated paths (activity logs, lock files,
 * metrics, STATE.md, milestone workspaces). They are always safe to untrack,
 * even when the project intentionally keeps human-authored `.sf/` guidance in
 * version control.
 */
export function untrackRuntimeFiles(basePath) {
	const runtimePaths = SF_RUNTIME_PATTERNS;
	for (const pattern of runtimePaths) {
		// Use -r for directory patterns (trailing slash), strip the slash for the command
		const target = pattern.endsWith("/") ? pattern.slice(0, -1) : pattern;
		try {
			nativeRmCached(basePath, [target]);
		} catch {
			// File not tracked or doesn't exist — expected, ignore
		}
	}
}
/**
 * Ensure basePath/.sf/preferences.yaml exists as a template.
 * Creates the file with YAML only if it doesn't exist.
 * Returns true if created, false if already exists.
 */
export function ensurePreferences(basePath) {
	const yamlPath = join(sfRoot(basePath), "preferences.yaml");
	if (existsSync(yamlPath)) {
		return false;
	}
	// Auto-detect project type and seed verification_commands.
	let verifySection = "";
	try {
		const signals = detectProjectSignals(basePath);
		if (signals.verificationCommands.length > 0) {
			const lines = signals.verificationCommands.map(
				(c) => `  - ${yamlSafeString(c)}`,
			);
			verifySection = `verification_commands:\n${lines.join("\n")}\n`;
		}
	} catch {
		// fall through to bare template
	}
	const sfVersion = process.env.SF_VERSION || "0.0.0";
	const template = `# SF preferences — see ~/.sf/agent/extensions/sf/docs/preferences-reference.md for docs
version: 1
last_synced_with_sf: ${yamlSafeString(sfVersion)}
sf_template_state: pending
${verifySection}always_use_skills: []
prefer_skills: []
avoid_skills: []
skill_rules: []
custom_instructions: []
models: {}
skill_discovery: {}
auto_supervisor: {}
`;
	writeFileSync(yamlPath, template, "utf-8");
	return true;
}
