import { readFileSync } from "node:fs";
import { join } from "node:path";
import type { Api, Model } from "@singularity-forge/ai";
import {
	AuthStorage,
	createAgentSession,
	DefaultResourceLoader,
	InteractiveMode,
	listModels,
	ModelRegistry,
	runPackageCommand,
	runPrintMode,
	runRpcMode,
	SessionManager,
	SettingsManager,
} from "@singularity-forge/coding-agent";
import chalk from "chalk";
import { agentDir, authFilePath, sessionsDir } from "./app-paths.js";
import {
	migrateLegacyFlatSessions,
	parseCliArgs,
	runWebCliBranch,
} from "./cli-web-branch.js";
import { error, formatStructuredError } from "./errors.js";
import { printHelp, printSubcommandHelp } from "./help-text.js";
import { acquireInteractiveSessionLock } from "./interactive-session-lock.js";
import { configureLogger } from "./logger.js";
import { runOnboarding, shouldRunOnboarding } from "./onboarding.js";
import { migratePiCredentials } from "./pi-migration.js";
import { getProjectSessionsDir } from "./project-sessions.js";
import {
	buildResourceLoader,
	getNewerManagedResourceVersion,
	initResources,
} from "./resource-loader.js";
import { loadEffectiveSFPreferences } from "./resources/extensions/sf/preferences.js";
import { isProviderAllowedByLists } from "./resources/extensions/sf/preferences-models.js";
import { bootstrapRtk, SF_RTK_DISABLED_ENV } from "./rtk.js";
import { applySecurityOverrides } from "./security-overrides.js";
import { validateConfiguredModel } from "./startup-model-validation.js";
import { markStartup, printStartupTimings } from "./startup-timings.js";
import { ensureManagedTools } from "./tool-bootstrap.js";
import { checkForUpdates } from "./update-check.js";
import { stopWebMode } from "./web-mode.js";
import { loadStoredEnvKeys } from "./wizard.js";

// ---------------------------------------------------------------------------
// V8 compile cache — Node 26+ can cache compiled bytecode across runs,
// eliminating repeated parse/compile overhead for unchanged modules.
// Must be set early so dynamic imports (extensions, lazy subcommands) benefit.
// ---------------------------------------------------------------------------
if (parseInt(process.versions.node.split(".")[0], 10) >= 22) {
	process.env.NODE_COMPILE_CACHE ??= join(agentDir, ".compile-cache");
}

// ---------------------------------------------------------------------------
// Logger initialization — configure LogTape early so all downstream modules
// emit structured logs instead of raw console.* calls.
// ---------------------------------------------------------------------------
await configureLogger({
	sessionId: process.env.SF_SESSION_ID || `cli-${Date.now()}`,
	mode:
		process.env.SF_AUTONOMOUS === "1" || process.env.NODE_ENV === "production"
			? "autonomous"
			: "dev",
});

function exitIfManagedResourcesAreNewer(currentAgentDir: string): void {
	const currentVersion = process.env.SF_VERSION || "0.0.0";
	const managedVersion = getNewerManagedResourceVersion(
		currentAgentDir,
		currentVersion,
	);
	if (!managedVersion) {
		return;
	}

	process.stderr.write(
		`[sf] ${chalk.yellow("Version mismatch detected")}\n` +
			`[sf] Synced resources are from ${chalk.bold(`v${managedVersion}`)}, but this \`sf\` binary is ${chalk.dim(`v${currentVersion}`)}.\n` +
			`[sf] Run ${chalk.bold("npm install -g singularity-forge@latest")} or ${chalk.bold("sf update")}, then try again.\n`,
	);
	process.exit(1);
}

async function warmDiscoveryBackedProviders(
	modelRegistry: ModelRegistry,
): Promise<void> {
	const providers = ["ollama-cloud", "xiaomi"].filter((provider) =>
		modelRegistry.isProviderRequestReady(provider),
	);
	if (providers.length === 0) return;

	await modelRegistry.discoverModels(providers);
}

// ---------------------------------------------------------------------------
// Shared helpers used by both the print and interactive code paths
// ---------------------------------------------------------------------------

/**
 * Print the non-interactive-mode error and exit. Called both from the early
 * TTY gate (before heavy init) and from the interactive-mode TTY gate right
 * before `InteractiveMode.run()`. The `includeWebHint` variant also lists
 * `sf server` and `headless` as alternatives.
 */
function printNonTtyErrorAndExit(
	missing: string | undefined,
	includeWebHint: boolean,
): never {
	const suffix = missing ? ` but ${missing} not a TTY` : "";
	process.stderr.write(
		`[sf] Error: Interactive mode requires a terminal (TTY)${suffix}.\n`,
	);
	process.stderr.write("[sf] Non-interactive alternatives:\n");
	process.stderr.write(
		"[sf]   sf autonomous                 Autonomous mode via machine surface\n",
	);
	process.stderr.write(
		'[sf]   sf --print "your message"     Single-shot prompt\n',
	);
	if (includeWebHint) {
		process.stderr.write(
			"[sf]   sf server [path]              Full SF server for this repo\n",
		);
	}
	process.stderr.write(
		"[sf]   sf --mode rpc                 Session I/O mode: JSON-RPC over stdin/stdout\n",
	);
	process.stderr.write(
		'[sf]   sf --mode text "message"      Session I/O mode: text print format\n',
	);
	if (includeWebHint) {
		process.stderr.write(
			"[sf]   sf headless autonomous        Machine surface for autonomous mode\n",
		);
	}
	process.exit(1);
}

/**
 * Print extension load/conflict errors from an extensions result. Downgrades
 * conflicts with built-in tools to warnings (#1347).
 */
function printExtensionErrors(errors: ReadonlyArray<{ error: string }>): void {
	for (const err of errors) {
		const isConflict =
			err.error.includes("supersedes") || err.error.includes("conflicts with");
		const prefix = isConflict ? "Extension conflict" : "Extension load error";
		const guidance = isConflict
			? "Disable one of the conflicting extensions in settings"
			: "Check the extension path and reinstall if necessary";
		process.stderr.write(
			formatStructuredError(
				error(err.error, { operation: "loadExtension", guidance }),
				`[sf] ${prefix}`,
			),
		);
	}
}

/**
 * Re-apply the validated model to the session when `createAgentSession()`
 * reports that it had to use a fallback. Prevents silently overriding the
 * persisted model of resumed conversations (#3534).
 */
async function reapplyValidatedModelOnFallback(
	session: {
		setModel(model: {
			provider: string;
			id: string;
		}): unknown | Promise<unknown>;
	},
	modelRegistry: ModelRegistry,
	settingsManager: SettingsManager,
	fallbackMessage: string | undefined,
): Promise<void> {
	if (!fallbackMessage) return;
	const validatedProvider = settingsManager.getDefaultProvider();
	const validatedModelId = settingsManager.getDefaultModel();
	if (!validatedProvider || !validatedModelId) return;
	const correctModel = modelRegistry
		.getAvailable()
		.find((m) => m.provider === validatedProvider && m.id === validatedModelId);
	if (!correctModel) return;
	try {
		await session.setModel(correctModel);
	} catch {
		// Provider not ready — leave session on its current model
	}
}

const cliFlags = parseCliArgs(process.argv);
const isPrintMode = cliFlags.print || cliFlags.mode !== undefined;

// `sf [subcommand] --help` / `-h` — print help before any subcommand runs.
// loader.ts only catches --help/-h as the *first* arg; here we handle the
// case where it appears later (e.g. `sf update --help`, `sf --foo --help`).
// Prefer subcommand-specific help when the first positional is a known
// subcommand, otherwise fall back to general help.
if (process.argv.includes("--help") || process.argv.includes("-h")) {
	const helpSubcommand = cliFlags.messages[0];
	const version = process.env.SF_VERSION || "0.0.0";
	if (!helpSubcommand || !printSubcommandHelp(helpSubcommand, version)) {
		printHelp(version);
	}
	process.exit(0);
}

// RTK bootstrap — runs once per process, memoized via a module-level promise
// so concurrent callers await the same initialization.
let rtkBootstrapPromise: Promise<void> | undefined;
async function doRtkBootstrap(): Promise<void> {
	// RTK is opt-in via experimental.rtk preference. Default: disabled.
	// Honor SF_RTK_DISABLED if already explicitly set in the environment
	// (env var takes precedence over preferences for manual override).
	if (!process.env[SF_RTK_DISABLED_ENV]) {
		const prefs = loadEffectiveSFPreferences() as {
			preferences?: { experimental?: { rtk?: boolean } };
		};
		const rtkEnabled = prefs?.preferences?.experimental?.rtk === true;
		if (!rtkEnabled) {
			process.env[SF_RTK_DISABLED_ENV] = "1";
		}
	}

	const rtkStatus = await bootstrapRtk();
	markStartup("bootstrapRtk");
	if (
		!rtkStatus.available &&
		rtkStatus.supported &&
		rtkStatus.enabled &&
		rtkStatus.reason
	) {
		process.stderr.write(
			`[sf] Warning: RTK unavailable — continuing without shell-command compression (${rtkStatus.reason}).\n`,
		);
	}
}
function ensureRtkBootstrap(): Promise<void> {
	rtkBootstrapPromise ??= doRtkBootstrap();
	return rtkBootstrapPromise;
}

// `sf update` — update to the latest version via npm
if (cliFlags.messages[0] === "update") {
	const { runUpdate } = await import("./update-cmd.js");
	await runUpdate();
	process.exit(0);
}

// ---------------------------------------------------------------------------
// Graph subcommand — `sf graph build|status|query|diff`
// ---------------------------------------------------------------------------
if (cliFlags.messages[0] === "graph") {
	const sub = cliFlags.messages[1];
	const {
		buildGraph,
		graphStatus,
		graphQuery,
		graphDiff,
		resolveSFRoot,
		writeGraph,
	} = await import("@singularity-forge/agent-core");

	const projectDir = process.cwd();
	const sfRoot = resolveSFRoot(projectDir);

	if (!sub || sub === "build") {
		try {
			const graph = await buildGraph(projectDir);
			await writeGraph(sfRoot, graph);
			process.stdout.write(
				`Graph built: ${graph.nodes.length} nodes, ${graph.edges.length} edges\n`,
			);
		} catch (err) {
			process.stderr.write(
				formatStructuredError(
					error("graph build failed", {
						operation: "buildGraph",
						file: projectDir,
						guidance:
							"Ensure the project has a valid .sf/ directory, or run 'sf headless init'",
						cause: err,
					}),
					"[sf]",
				),
			);
			process.exit(1);
		}
	} else if (sub === "status") {
		try {
			const result = await graphStatus(projectDir);
			if (!result.exists) {
				process.stdout.write("Graph: not built yet. Run: sf graph build\n");
			} else {
				process.stdout.write(`Graph status:\n`);
				process.stdout.write(`  exists:    ${result.exists}\n`);
				process.stdout.write(`  nodes:     ${result.nodeCount}\n`);
				process.stdout.write(`  edges:     ${result.edgeCount}\n`);
				process.stdout.write(`  stale:     ${result.stale}\n`);
				process.stdout.write(
					`  ageHours:  ${result.ageHours !== undefined ? result.ageHours.toFixed(2) : "n/a"}\n`,
				);
				process.stdout.write(`  lastBuild: ${result.lastBuild ?? "n/a"}\n`);
			}
		} catch (err) {
			process.stderr.write(
				formatStructuredError(
					error("graph status failed", {
						operation: "graphStatus",
						file: projectDir,
						guidance: "Run 'sf graph build' first",
						cause: err,
					}),
					"[sf]",
				),
			);
			process.exit(1);
		}
	} else if (sub === "query") {
		const term = cliFlags.messages[2];
		if (!term) {
			process.stderr.write("Usage: sf graph query <term>\n");
			process.exit(1);
		}
		try {
			const result = await graphQuery(projectDir, term);
			if (result.nodes.length === 0) {
				process.stdout.write(`No nodes found for term: "${term}"\n`);
			} else {
				process.stdout.write(
					`Query results for "${term}" (${result.nodes.length} nodes, ${result.edges.length} edges):\n`,
				);
				for (const node of result.nodes) {
					process.stdout.write(
						`  [${node.type}] ${node.label} (${node.confidence})\n`,
					);
				}
			}
		} catch (err) {
			process.stderr.write(
				formatStructuredError(
					error("graph query failed", {
						operation: "graphQuery",
						file: projectDir,
						guidance: "Run 'sf graph build' first",
						cause: err,
					}),
					"[sf]",
				),
			);
			process.exit(1);
		}
	} else if (sub === "diff") {
		try {
			const result = await graphDiff(projectDir);
			process.stdout.write(`Graph diff:\n`);
			process.stdout.write(`  nodes added:    ${result.nodes.added.length}\n`);
			process.stdout.write(
				`  nodes removed:  ${result.nodes.removed.length}\n`,
			);
			process.stdout.write(
				`  nodes changed:  ${result.nodes.changed.length}\n`,
			);
			process.stdout.write(`  edges added:    ${result.edges.added.length}\n`);
			process.stdout.write(
				`  edges removed:  ${result.edges.removed.length}\n`,
			);
		} catch (err) {
			process.stderr.write(
				formatStructuredError(
					error("graph diff failed", {
						operation: "graphDiff",
						file: projectDir,
						guidance: "Run 'sf graph build' first",
						cause: err,
					}),
					"[sf]",
				),
			);
			process.exit(1);
		}
	} else {
		process.stderr.write(`Unknown graph command: ${sub}\n`);
		process.stderr.write("Commands: build, status, query <term>, diff\n");
		process.exit(1);
	}
	process.exit(0);
}

exitIfManagedResourcesAreNewer(agentDir);

// Early TTY check — must come before heavy initialization to avoid dangling
// handles that prevent process.exit() from completing promptly.
const hasSubcommand = cliFlags.messages.length > 0;
if (
	!process.stdin.isTTY &&
	!isPrintMode &&
	!hasSubcommand &&
	!cliFlags.listModels
) {
	printNonTtyErrorAndExit(undefined, false);
}

const packageCommand = await runPackageCommand({
	appName: "sf",
	args: process.argv.slice(2),
	cwd: process.cwd(),
	agentDir,
	stdout: process.stdout,
	stderr: process.stderr,
	allowedCommands: new Set(["install", "remove", "list"]),
});
if (packageCommand.handled) {
	process.exit(packageCommand.exitCode);
}

// `sf logs tail|follow` — merged live stream from notifications, sessions, activity, and audit logs
if (cliFlags.messages[0] === "logs") {
	const { runLogsCli } = await import("./cli-logs.js");
	const exitCode = await runLogsCli(process.argv.slice(2), {
		basePath: process.cwd(),
	});
	process.exit(exitCode);
}

// `sf status [--live] [--watch]` / `sf dash` — printable aggregate project view
if (cliFlags.messages[0] === "status" || cliFlags.messages[0] === "dash") {
	initResources(agentDir);
	const { runStatusCli } = await import("./cli-status.js");
	const exitCode = await runStatusCli(process.argv.slice(2), {
		basePath: process.cwd(),
	});
	process.exit(exitCode);
}

// `sf stats models` — model outcome summary from .sf/sf.db
if (cliFlags.messages[0] === "stats") {
	const { runStatsCli } = await import("./cli-stats.js");
	const exitCode = await runStatsCli(process.argv.slice(2), {
		basePath: process.cwd(),
	});
	process.exit(exitCode);
}

// `sf config` — replay the setup wizard and exit
if (cliFlags.messages[0] === "config") {
	const authStorage = AuthStorage.create(authFilePath);
	loadStoredEnvKeys(authStorage);
	await runOnboarding(authStorage);
	process.exit(0);
}

// `sf server stop [path|all]` — stop server before anything else
if (cliFlags.messages[0] === "server" && cliFlags.messages[1] === "stop") {
	const webBranch = await runWebCliBranch(cliFlags, {
		stopWebMode,
		stderr: process.stderr,
		baseSessionsDir: sessionsDir,
		agentDir,
	});
	if (webBranch.handled) {
		process.exit(webBranch.exitCode);
	}
}

// `sf server [start] [path]` — launch the full SF operator server
if (cliFlags.messages[0] === "server" && cliFlags.messages[1] !== "stop") {
	await ensureRtkBootstrap();
	const webBranch = await runWebCliBranch(cliFlags, {
		stderr: process.stderr,
		baseSessionsDir: sessionsDir,
		agentDir,
	});
	if (webBranch.handled) {
		process.exit(webBranch.exitCode);
	}
}

// `sf sessions` — list past sessions and pick one to resume
if (cliFlags.messages[0] === "sessions") {
	const cwd = process.cwd();

	let sessions;
	if (cliFlags.allSessions) {
		process.stderr.write(
			chalk.dim("Loading all sessions across all projects...\n"),
		);
		sessions = await SessionManager.listAll();
	} else {
		const safePath = `--${cwd.replace(/^[/\\]/, "").replace(/[/\\:]/g, "-")}--`;
		const projectSessionsDir = join(sessionsDir, safePath);
		process.stderr.write(chalk.dim(`Loading sessions for ${cwd}...\n`));
		sessions = await SessionManager.list(cwd, projectSessionsDir);
	}

	if (sessions.length === 0) {
		process.stderr.write(chalk.yellow("No sessions found.\n"));
		process.exit(0);
	}

	const label = cliFlags.allSessions ? "all projects" : cwd;
	process.stderr.write(
		chalk.bold(`\n  Sessions (${sessions.length}) for ${label}:\n\n`),
	);

	const maxShow = 20;
	const toShow = sessions.slice(0, maxShow);
	for (let i = 0; i < toShow.length; i++) {
		const s = toShow[i];
		const date = s.modified.toLocaleString();
		const msgs = s.messageCount;
		const name = s.name ? ` ${chalk.cyan(s.name)}` : "";
		const preview = s.firstMessage
			? s.firstMessage.replace(/\n/g, " ").substring(0, 80)
			: chalk.dim("(empty)");
		const num = String(i + 1).padStart(3);
		const projectLabel =
			cliFlags.allSessions && s.cwd ? ` ${chalk.yellow(`[${s.cwd}]`)}` : "";
		process.stderr.write(
			`  ${chalk.bold(num)}. ${chalk.green(date)} ${chalk.dim(`(${msgs} msgs)`)}${name}${projectLabel}\n`,
		);
		process.stderr.write(`       ${chalk.dim(preview)}\n\n`);
	}

	if (sessions.length > maxShow) {
		process.stderr.write(
			chalk.dim(`  ... and ${sessions.length - maxShow} more\n\n`),
		);
	}

	// Interactive selection
	const readline = await import("node:readline");
	const rl = readline.createInterface({
		input: process.stdin,
		output: process.stderr,
	});
	const answer = await new Promise<string>((resolve) => {
		rl.question(
			chalk.bold("  Enter session number to resume (or q to quit): "),
			resolve,
		);
	});
	rl.close();

	// Clean up stdin state left by readline.createInterface().
	// Without this, downstream TUI initialization gets corrupted listeners and exhibits
	// duplicate terminal I/O. Match the pattern used after onboarding cleanup.
	process.stdin.removeAllListeners("data");
	process.stdin.removeAllListeners("keypress");
	if (process.stdin.setRawMode) process.stdin.setRawMode(false);
	process.stdin.pause();

	const choice = parseInt(answer, 10);
	if (Number.isNaN(choice) || choice < 1 || choice > toShow.length) {
		process.stderr.write(chalk.dim("Cancelled.\n"));
		process.exit(0);
	}

	const selected = toShow[choice - 1];
	process.stderr.write(
		chalk.green(
			`\nResuming session from ${selected.modified.toLocaleString()}...\n\n`,
		),
	);

	// Mark for the interactive session below to open this specific session
	cliFlags.continue = true;
	cliFlags._selectedSessionPath = selected.path;
}

// `sf headless ...` — machine surface for direct SF commands
if (cliFlags.messages[0] === "headless") {
	await ensureRtkBootstrap();
	// Sync bundled resources before headless runs (#3471). Without this,
	// headless-query loads from src/resources/ while auto/interactive load
	// from ~/.sf/agent/extensions/ — different extension copies diverge.
	initResources(agentDir);
	const { runHeadless, parseHeadlessArgs } = await import("./headless.js");
	await runHeadless(parseHeadlessArgs(process.argv));
	process.exit(0);
}

/**
 * Run a headless command by invoking the headless entrypoint with a synthetic
 * argv. Shared by the `autonomous` shorthand (#2732) and the piped-stdout
 * redirect so they use the same bootstrap + dynamic-import dance.
 */
async function runHeadlessFromAutonomous(
	headlessArgs: string[],
): Promise<never> {
	await ensureRtkBootstrap();
	const { runHeadless, parseHeadlessArgs } = await import("./headless.js");
	const argv = [process.argv[0], process.argv[1], "headless", ...headlessArgs];
	await runHeadless(parseHeadlessArgs(argv));
	process.exit(0);
}

function rawArgsAfterSubcommand(subcommand: string): string[] {
	const args = process.argv.slice(2);
	const index = args.indexOf(subcommand);
	return index >= 0 ? args.slice(index + 1) : [];
}

// `sf autonomous [args...]` — shorthand for autonomous mode via the machine surface (#2732).
if (cliFlags.messages[0] === "autonomous") {
	await runHeadlessFromAutonomous([
		"autonomous",
		...rawArgsAfterSubcommand("autonomous"),
	]);
}

// `sf schedule ...` — first-class non-interactive schedule CLI.
// Keep this before the interactive/TUI path so commands like
// `sf schedule list --json | jq` never fall through to the TTY guard.
if (cliFlags.messages[0] === "schedule") {
	const scheduleModulePath = "./resources/extensions/sf/commands-schedule.js";
	const { handleSchedule } = await import(scheduleModulePath);
	const rawScheduleArgs = process.argv.slice(3);
	const output = (message: string, level = "info") => {
		const stream =
			level === "warning" || level === "error"
				? process.stderr
				: process.stdout;
		stream.write(message.endsWith("\n") ? message : `${message}\n`);
	};
	await handleSchedule(rawScheduleArgs, {
		ui: {
			notify: output,
		},
	});
	process.exit(0);
}

// ---------------------------------------------------------------------------
// Key subcommand — `sf key set|get|remove|list`
// ---------------------------------------------------------------------------
if (cliFlags.messages[0] === "key") {
	const { runKeyCommand } = await import("./cli-key.js");
	// Pass everything after "key" as the sub-argv (process.argv[3..])
	const keyArgs = process.argv.slice(3);
	const exitCode = await runKeyCommand(authFilePath, keyArgs);
	process.exit(exitCode);
}

// Pi's tool bootstrap can mis-detect already-installed fd/rg on some systems
// because spawnSync(..., ["--version"]) returns EPERM despite a zero exit code.
// Provision local managed binaries first so Pi sees them without probing PATH.
ensureManagedTools(join(agentDir, "bin"));
markStartup("ensureManagedTools");

const authStorage = AuthStorage.create(authFilePath);
markStartup("AuthStorage.create");
loadStoredEnvKeys(authStorage);
migratePiCredentials(authStorage);
const settingsManager = SettingsManager.create(process.cwd(), agentDir);
applySecurityOverrides(settingsManager);
markStartup("SettingsManager.create");

// Resolve models.json path with fallback to ~/.pi/agent/models.json
const { resolveModelsJsonPath } = await import("./models-resolver.js");
const modelsJsonPath = resolveModelsJsonPath();

const modelRegistry = new ModelRegistry(
	authStorage,
	modelsJsonPath,
	settingsManager,
);
markStartup("ModelRegistry");
if (!isPrintMode && cliFlags.listModels === undefined) {
	await warmDiscoveryBackedProviders(modelRegistry);
}
markStartup("ModelRegistry.discovery");

// Run onboarding wizard on first launch (no LLM provider configured)
if (
	!isPrintMode &&
	shouldRunOnboarding(authStorage, settingsManager.getDefaultProvider())
) {
	await runOnboarding(authStorage);

	// Clean up stdin state left by @clack/prompts.
	// readline.emitKeypressEvents() adds a permanent data listener and
	// readline.createInterface() may leave stdin paused. Remove stale
	// listeners and pause stdin so the TUI can start with a clean slate.
	process.stdin.removeAllListeners("data");
	process.stdin.removeAllListeners("keypress");
	if (process.stdin.setRawMode) process.stdin.setRawMode(false);
	process.stdin.pause();
}

// Update check — non-blocking banner check; interactive prompt deferred to avoid
// blocking startup. The passive checkForUpdates() prints a banner if an update is
// available (using cached data or a background fetch) without blocking the TUI.
if (!isPrintMode) {
	checkForUpdates().catch(() => {});
}

// Warn if terminal is too narrow for readable output
if (!isPrintMode && process.stdout.columns && process.stdout.columns < 40) {
	process.stderr.write(
		chalk.yellow(
			`[sf] Terminal width is ${process.stdout.columns} columns (minimum recommended: 40). Output may be unreadable.\n`,
		),
	);
}

// --list-models: print the static model catalog quickly by default. Load
// extensions only when discovery or explicit extension paths are requested,
// because syncing/reloading all bundled extensions makes smoke-test and
// orchestration diagnostics pay full startup cost just to list models.
if (cliFlags.listModels !== undefined) {
	exitIfManagedResourcesAreNewer(agentDir);
	const shouldLoadExtensionsForModels =
		cliFlags.discover || cliFlags.extensions.length > 0;
	if (shouldLoadExtensionsForModels) {
		initResources(agentDir);
		const listModelsLoader = new DefaultResourceLoader({
			agentDir,
			additionalExtensionPaths:
				cliFlags.extensions.length > 0 ? cliFlags.extensions : undefined,
		});
		await listModelsLoader.reload();
		const listModelsExtensions = listModelsLoader.getExtensions();
		for (const { name, config } of listModelsExtensions.runtime
			.pendingProviderRegistrations) {
			modelRegistry.registerProvider(name, config);
		}
		listModelsExtensions.runtime.pendingProviderRegistrations = [];
	}

	const searchPattern =
		typeof cliFlags.listModels === "string" ? cliFlags.listModels : undefined;
	// Apply allowed_providers / blocked_providers from SF preferences so the
	// listing matches what autonomous mode would actually be willing to dispatch.
	const sfPrefs = loadEffectiveSFPreferences()?.preferences as
		| {
				allowed_providers?: string[];
				blocked_providers?: string[];
		  }
		| undefined;
	const modelFilter = sfPrefs
		? (model: Model<Api>) =>
				isProviderAllowedByLists(
					model.provider,
					sfPrefs.allowed_providers ?? [],
					sfPrefs.blocked_providers ?? [],
				)
		: undefined;
	// For --discover, eagerly refresh SF-managed providers (opencode, opencode-go,
	// kimi-coding, xiaomi) that the SDK's discovery adapters don't cover.  This
	// writes their model lists into ~/.sf/agent/discovery-cache.json so the SDK's
	// discoverModels() call inside listModels() reads them from cache, making them
	// visible in `--list-models --discover` output even on a cold start.
	if (cliFlags.discover) {
		try {
			const { refreshSfManagedProviders } = await import(
				"./resources/extensions/sf/model-catalog-cache.js"
			);
			const { getKeyManagerAuthStorage } = await import(
				"./resources/extensions/sf/key-manager.js"
			);
			await refreshSfManagedProviders(
				process.cwd(),
				getKeyManagerAuthStorage(),
			);
		} catch {
			// Non-fatal — never block model listing.
		}
	}

	await listModels(modelRegistry, {
		searchPattern,
		discover: cliFlags.discover,
		modelFilter,
	});
	process.exit(0);
}

// --maintain: refresh model catalogs (TTL-checked) and audit benchmark
// coverage, then exit. Designed for the daemon's periodic maintenance timer:
// idempotent, never spawns a session, never touches user-visible state.
if (cliFlags.maintain) {
	exitIfManagedResourcesAreNewer(agentDir);
	const startedAt = Date.now();
	try {
		const { runModelCatalogRefreshIfStale } = await import(
			"./resources/extensions/sf/model-catalog-cache.js"
		);
		const { runGeminiCatalogRefreshIfStale } = await import(
			"./resources/extensions/sf/gemini-catalog.js"
		);
		const { runOpenaiCodexCatalogRefreshIfStale } = await import(
			"./resources/extensions/sf/openai-codex-catalog.js"
		);
		const { getKeyManagerAuthStorage } = await import(
			"./resources/extensions/sf/key-manager.js"
		);
		const { computeBenchmarkCoverage, writeBenchmarkCoverage } = await import(
			"./resources/extensions/sf/benchmark-coverage.js"
		);
		const { runProviderQuotaRefreshIfStale } = await import(
			"./resources/extensions/sf/provider-quota-cache.js"
		);
		const auth = getKeyManagerAuthStorage();
		await runModelCatalogRefreshIfStale(process.cwd(), auth);
		await runGeminiCatalogRefreshIfStale(process.cwd());
		await runOpenaiCodexCatalogRefreshIfStale(process.cwd());
		await runProviderQuotaRefreshIfStale(process.cwd(), auth);
		const prefs = (loadEffectiveSFPreferences()?.preferences ?? {}) as Record<
			string,
			unknown
		>;
		const coverage = computeBenchmarkCoverage(prefs);
		writeBenchmarkCoverage(coverage);
		// Self-feedback triage drain. The daemon's 6h maintenance timer fires
		// `sf --maintain` in every allowed repo (set via daemon config), so
		// triage runs unattended across the whole project portfolio. Bounded
		// at max=10 per invocation to keep each run finite. Items beyond the
		// bound flush on subsequent maintenance ticks.
		let triageCount = 0;
		try {
			const { handleTriage } = await import("./headless-triage.js");
			const triageResult = await handleTriage(process.cwd(), {
				apply: true,
				json: true, // suppress chatty stdout in the maintenance path
				max: 10,
			});
			triageCount = triageResult.exitCode === 0 ? 10 : 0;
		} catch (err) {
			process.stderr.write(
				`[sf --maintain] triage drain failed (non-fatal): ${
					err instanceof Error ? err.message : String(err)
				}\n`,
			);
		}
		const ms = Date.now() - startedAt;
		process.stdout.write(
			`[sf --maintain] catalog + quota refresh + coverage audit + triage drain done in ${ms}ms — coverage ${coverage.summary.coveredCount}/${coverage.summary.total} (${coverage.uncovered.length} uncovered), triage attempted up to ${triageCount} candidates\n`,
		);
	} catch (err) {
		process.stderr.write(
			`[sf --maintain] failed: ${err instanceof Error ? err.message : String(err)}\n`,
		);
		process.exit(1);
	}
	process.exit(0);
}

// SF always uses quiet startup — the sf extension renders its own branded header
if (!settingsManager.getQuietStartup()) {
	settingsManager.setQuietStartup(true);
}

// Collapse changelog by default — avoid wall of text on updates
if (!settingsManager.getCollapseChangelog()) {
	settingsManager.setCollapseChangelog(true);
}

// ---------------------------------------------------------------------------
// Print / subagent mode — single-shot execution, no TTY required
// ---------------------------------------------------------------------------
if (isPrintMode) {
	await ensureRtkBootstrap();
	const sessionManager = cliFlags.noSession
		? SessionManager.inMemory()
		: SessionManager.create(process.cwd());

	// Read --append-system-prompt file content (subagent writes agent system prompts to temp files)
	let appendSystemPrompt: string | undefined;
	if (cliFlags.appendSystemPrompt) {
		try {
			appendSystemPrompt = readFileSync(cliFlags.appendSystemPrompt, "utf-8");
		} catch {
			// If it's not a file path, treat it as literal text
			appendSystemPrompt = cliFlags.appendSystemPrompt;
		}
	}

	exitIfManagedResourcesAreNewer(agentDir);
	initResources(agentDir);
	markStartup("initResources");
	// Route print mode through buildResourceLoader so the SF extension registry
	// filter (extensionPathsTransform) is applied consistently with TUI mode.
	// Constructing DefaultResourceLoader directly bypassed the filter and let
	// disabled bundled extensions (e.g. `ollama` superseded by `@0xkobold/pi-ollama`)
	// leak through and emit `/ollama` command conflicts on every print invocation.
	const resourceLoader = buildResourceLoader(agentDir, {
		additionalExtensionPaths:
			cliFlags.extensions.length > 0 ? cliFlags.extensions : undefined,
		appendSystemPrompt,
	});
	await resourceLoader.reload();
	markStartup("resourceLoader.reload");

	// Print mode is a one-shot invocation. The --model flag is a transient
	// override (e.g. verification smoke tests like `sf -p --model longcat/X "reply ok"`)
	// and MUST NOT mutate the persisted defaultProvider/defaultModel in settings.json (#4251).
	// We disable persistence at session construction so every downstream path
	// (setModel override, fallback reapply, validation repair) is gated in one place.
	const { session, extensionsResult, modelFallbackMessage } =
		await createAgentSession({
			authStorage,
			modelRegistry,
			settingsManager,
			sessionManager,
			resourceLoader,
			isClaudeCodeReady: () =>
				modelRegistry.isProviderRequestReady("claude-code"),
			persistModelChanges: false,
		});
	markStartup("createAgentSession");

	// In print mode we still repair a genuinely stale default so the session has a
	// usable model, BUT when the caller explicitly passed --model we skip validation
	// entirely — the CLI already said which model to use, and repairing the default
	// would overwrite settings.json with a fallback the user didn't ask for (#4251).
	if (!cliFlags.model) {
		validateConfiguredModel(modelRegistry, settingsManager);
		await reapplyValidatedModelOnFallback(
			session,
			modelRegistry,
			settingsManager,
			modelFallbackMessage,
		);
	}
	printExtensionErrors(extensionsResult.errors);

	// Apply --model override if specified. persist: false is redundant given
	// persistModelChanges above, but we pass it explicitly so the intent is
	// visible at the call site and survives future refactors.
	if (cliFlags.model) {
		const available = modelRegistry.getAvailable();
		const match =
			available.find((m) => m.id === cliFlags.model) ||
			available.find((m) => `${m.provider}/${m.id}` === cliFlags.model);
		if (match) {
			session.setModel(match, { persist: false });
		}
	}

	const mode = cliFlags.mode || "text";

	if (mode === "rpc") {
		printStartupTimings();
		await runRpcMode(session);
		process.exit(0);
	}

	printStartupTimings();
	await runPrintMode(session, {
		mode: mode as "text" | "json",
		messages: cliFlags.messages,
	});
	process.exit(0);
}

// ---------------------------------------------------------------------------
// Worktree subcommand — `sf worktree <list|merge|clean|remove>`
// ---------------------------------------------------------------------------
if (cliFlags.messages[0] === "worktree" || cliFlags.messages[0] === "wt") {
	const { handleList, handleMerge, handleClean, handleRemove } = await import(
		"./worktree-cli.js"
	);
	const sub = cliFlags.messages[1];
	const subArgs = cliFlags.messages.slice(2);

	if (!sub || sub === "list") {
		await handleList(process.cwd());
	} else if (sub === "merge") {
		await handleMerge(process.cwd(), subArgs);
	} else if (sub === "clean") {
		await handleClean(process.cwd());
	} else if (sub === "remove" || sub === "rm") {
		await handleRemove(process.cwd(), subArgs);
	} else {
		process.stderr.write(`Unknown worktree command: ${sub}\n`);
		process.stderr.write(
			"Commands: list, merge [name], clean, remove <name>\n",
		);
	}
	process.exit(0);
}

// ---------------------------------------------------------------------------
// Worktree flag (-w) — create/resume a worktree for the interactive session
// ---------------------------------------------------------------------------
if (cliFlags.worktree) {
	const { handleWorktreeFlag } = await import("./worktree-cli.js");
	await handleWorktreeFlag(cliFlags.worktree);
}

// ---------------------------------------------------------------------------
// Active worktree banner — remind user of unmerged worktrees on normal launch
// ---------------------------------------------------------------------------
if (!cliFlags.worktree && !isPrintMode) {
	try {
		const { handleStatusBanner } = await import("./worktree-cli.js");
		await handleStatusBanner(process.cwd());
	} catch {
		/* non-fatal */
	}
}

// ---------------------------------------------------------------------------
// Scheduled items banner — remind user of due follow-ups
// ---------------------------------------------------------------------------
if (!cliFlags.worktree && !isPrintMode) {
	try {
		const { showScheduleBanner } = await import(
			"./resources/extensions/sf/schedule-launch-banner.js"
		);
		await showScheduleBanner(process.cwd());
	} catch {
		/* non-fatal */
	}
}

// ---------------------------------------------------------------------------
// Autonomous redirect: autonomous mode with piped stdout -> machine surface (#2732)
// When stdout is not a TTY (e.g. `sf autonomous | cat`),
// the TUI cannot render and the process hangs. Redirect to the machine surface
// which handles non-interactive output gracefully.
// ---------------------------------------------------------------------------
if (cliFlags.messages[0] === "autonomous" && !process.stdout.isTTY) {
	process.stderr.write(
		"[forge] stdout is not a terminal — running autonomous mode through the machine surface.\n",
	);
	await runHeadlessFromAutonomous([
		"autonomous",
		...rawArgsAfterSubcommand("autonomous"),
	]);
}

// ---------------------------------------------------------------------------
// Interactive mode — normal TTY session
// ---------------------------------------------------------------------------

await ensureRtkBootstrap();

// Per-directory session storage — same encoding as the upstream SDK so that
// /resume only shows sessions from the current working directory.
const cwd = process.cwd();
const projectSessionsDir = getProjectSessionsDir(cwd);

// Migrate legacy flat sessions: before per-directory scoping, all .jsonl session
// files lived directly in ~/.sf/sessions/. Move them into the correct per-cwd
// subdirectory so /resume can find them.
migrateLegacyFlatSessions(sessionsDir, projectSessionsDir);

const sessionManager = cliFlags._selectedSessionPath
	? SessionManager.open(cliFlags._selectedSessionPath, projectSessionsDir)
	: cliFlags.continue
		? SessionManager.continueRecent(cwd, projectSessionsDir)
		: SessionManager.create(cwd, projectSessionsDir);

if (!process.stdin.isTTY || !process.stdout.isTTY) {
	const missing =
		!process.stdin.isTTY && !process.stdout.isTTY
			? "stdin and stdout are"
			: !process.stdin.isTTY
				? "stdin is"
				: "stdout is";
	printNonTtyErrorAndExit(missing, true);
}

const interactiveLock = acquireInteractiveSessionLock(
	cwd,
	sessionManager.getSessionFile(),
);
if (!interactiveLock.acquired) {
	process.stderr.write(`${interactiveLock.message}\n`);
	process.exit(1);
}

exitIfManagedResourcesAreNewer(agentDir);
initResources(agentDir);
markStartup("initResources");

// Warm the sift index in the background so it's ready when needed.
try {
	const { ensureSiftIndexWarmup } = await import(
		"./resources/extensions/sf/code-intelligence.js"
	);
	const { loadEffectiveSFPreferences } = await import(
		"./resources/extensions/sf/preferences.js"
	);
	ensureSiftIndexWarmup(
		process.cwd(),
		(loadEffectiveSFPreferences()?.preferences as any)?.codebase,
		{ force: true },
	);
} catch {
	/* non-fatal — sift warmup is best-effort */
}

// Overlap resource loading with session manager setup — both are independent.
// resourceLoader.reload() is the most expensive step (jiti compilation), so
// starting it early shaves ~50-200ms off interactive startup.
const resourceLoader = buildResourceLoader(agentDir);
const resourceLoadPromise = resourceLoader.reload();

// While resources load, let session manager finish any async I/O it needs.
// Then await the resource promise before creating the agent session.
await resourceLoadPromise;
markStartup("resourceLoader.reload");

// Interactive mode explicitly opts into persistence so user model picks (via
// /model, Ctrl+P, interactive selector) write back to settings.json. The
// AgentSessionConfig.persistModelChanges default is false (#4251) so SDK
// consumers don't silently mutate user settings; CLI interactive paths opt in.
const {
	session,
	extensionsResult,
	modelFallbackMessage: interactiveFallbackMsg,
} = await createAgentSession({
	authStorage,
	modelRegistry,
	settingsManager,
	sessionManager,
	resourceLoader,
	isClaudeCodeReady: () => modelRegistry.isProviderRequestReady("claude-code"),
	persistModelChanges: true,
});
markStartup("createAgentSession");

// Validate configured model AFTER extensions have registered their models (#2626).
// Before this, extension-provided models (e.g. claude-code/*) were not yet in the
// registry, causing the user's valid choice to be silently overwritten.
validateConfiguredModel(modelRegistry, settingsManager);
await reapplyValidatedModelOnFallback(
	session,
	modelRegistry,
	settingsManager,
	interactiveFallbackMsg,
);
printExtensionErrors(extensionsResult.errors);

// Restore scoped models from settings on startup.
// The upstream InteractiveMode reads enabledModels from settings when /scoped-models is opened,
// but doesn't apply them to the session at startup — so Ctrl+P cycles all models instead of
// just the saved selection until the user re-runs /scoped-models.
const enabledModelPatterns = settingsManager.getEnabledModels();
if (enabledModelPatterns && enabledModelPatterns.length > 0) {
	const availableModels = modelRegistry.getAvailable();
	const scopedModels: Array<{ model: (typeof availableModels)[number] }> = [];
	const seen = new Set<string>();

	for (const pattern of enabledModelPatterns) {
		// Patterns are "provider/modelId" exact strings saved by /scoped-models
		const slashIdx = pattern.indexOf("/");
		if (slashIdx !== -1) {
			const provider = pattern.substring(0, slashIdx);
			const modelId = pattern.substring(slashIdx + 1);
			const model = availableModels.find(
				(m) => m.provider === provider && m.id === modelId,
			);
			if (model) {
				const key = `${model.provider}/${model.id}`;
				if (!seen.has(key)) {
					seen.add(key);
					scopedModels.push({ model });
				}
			}
		} else {
			// Fallback: match by model id alone
			const model = availableModels.find((m) => m.id === pattern);
			if (model) {
				const key = `${model.provider}/${model.id}`;
				if (!seen.has(key)) {
					seen.add(key);
					scopedModels.push({ model });
				}
			}
		}
	}

	// Only apply if we resolved some models and it's a genuine subset
	if (scopedModels.length > 0 && scopedModels.length < availableModels.length) {
		session.setScopedModels(scopedModels);
	}
}

// Welcome screen — shown on every fresh interactive session before TUI takes over.
// Skip when the first-run banner was already printed in loader.ts (prevents double banner).
if (!process.env.SF_FIRST_RUN_BANNER) {
	const { printWelcomeScreen } = await import("./welcome-screen.js");
	let remoteChannel: string | undefined;
	try {
		const { resolveRemoteConfig } = await import(
			"./resources/extensions/remote-questions/config.js"
		);
		const rc = resolveRemoteConfig() as { channel?: string } | null;
		if (rc) remoteChannel = rc.channel;
	} catch {
		/* non-fatal */
	}
	printWelcomeScreen({
		version: process.env.SF_VERSION || "0.0.0",
		modelName: settingsManager.getDefaultModel() || undefined,
		provider: settingsManager.getDefaultProvider() || undefined,
		remoteChannel,
	});
}

const interactiveMode = new InteractiveMode(session);
markStartup("InteractiveMode");
printStartupTimings();
try {
	await interactiveMode.run();
} finally {
	interactiveLock.release();
}
