/**
 * Model-related preferences: resolution, fallbacks, profile defaults, and routing.
 *
 * Contains all logic for resolving model configurations from preferences,
 * including per-phase model selection, fallback chains, token profiles,
 * and dynamic routing configuration.
 */
import { existsSync, readFileSync, writeFileSync } from "node:fs";
import { homedir } from "node:os";
import { join } from "node:path";
import { getModels, getProviders } from "@singularity-forge/ai";
import { selectByBenchmarks } from "./benchmark-selector.js";
import { defaultRoutingConfig, MODEL_CAPABILITY_TIER } from "./model-router.js";
import { sfHome } from "./sf-home.js";
import {
	DEFAULT_RUNAWAY_CHANGED_FILES_WARNING,
	DEFAULT_RUNAWAY_DIAGNOSTIC_TURNS,
	DEFAULT_RUNAWAY_ELAPSED_MINUTES,
	DEFAULT_RUNAWAY_TOKEN_WARNING,
	DEFAULT_RUNAWAY_TOOL_CALL_WARNING,
} from "./uok/auto-runaway-guard.js";

// ─── Lazy loader — breaks the preferences.js ↔ preferences-models.js cycle ──
// preferences.js imports resolveProfileDefaults from here, and needs
// loadEffectiveSFPreferences / getGlobalSFPreferencesPath from preferences.js.
// Injecting them via _initPrefsLoader (called at the end of preferences.js
// module evaluation) avoids the static circular import.
let _loadEffectiveSFPreferences;
let _getGlobalSFPreferencesPath;
/** @internal — called once by preferences.js after its exports are defined. */
export function _initPrefsLoader(loadFn, getPathFn) {
	_loadEffectiveSFPreferences = loadFn;
	_getGlobalSFPreferencesPath = getPathFn;
}
function loadEffectiveSFPreferences() {
	return _loadEffectiveSFPreferences();
}
function getGlobalSFPreferencesPath() {
	return _getGlobalSFPreferencesPath();
}

import { getKeyManagerAuthStorage } from "./key-manager.js";
import { lookupDiscoveredModelCost } from "./model-catalog-cache.js";

const OPENCODE_FREE_MODEL_IDS = new Set([
	"big-pickle",
	"gpt-5-nano",
	"minimax-m2.5-free",
	"nemotron-3-super-free",
]);
const HIDDEN_MODEL_PROVIDERS = new Set([
	"claude-code",
	"google-vertex",
	"groq",
	"github-copilot",
	"xai",
	"xiaomi-token-plan-ams",
	"xiaomi-token-plan-cn",
	"xiaomi-token-plan-sgp",
]);
function resolveProviderModelAllowList(providerModelAllow, provider) {
	if (!providerModelAllow) return undefined;
	const providerKey = provider.toLowerCase();
	return (
		providerModelAllow[providerKey] ??
		Object.entries(providerModelAllow).find(
			([key]) => key.toLowerCase() === providerKey,
		)?.[1]
	);
}
function providerModelAllowEntryMatches(allowedModel, modelKey) {
	const allowedKey = allowedModel.trim().toLowerCase();
	if (!allowedKey) return false;
	if (allowedKey === modelKey) return true;
	if (allowedKey.startsWith(":")) return modelKey.endsWith(allowedKey);
	if (!allowedKey.includes("*")) return false;
	const pattern = `^${allowedKey
		.split("*")
		.map((part) => part.replace(/[.*+?^${}()|[\]\\]/g, "\\$&"))
		.join(".*")}$`;
	return new RegExp(pattern).test(modelKey);
}
function hasFreeSkuMarker(value) {
	return /(^|[-_:/\s])free($|[-_:/\s])/i.test(value ?? "");
}
function isZeroCost(cost) {
	return (
		!!cost &&
		cost.input === 0 &&
		cost.output === 0 &&
		cost.cacheRead === 0 &&
		cost.cacheWrite === 0
	);
}
function isMistralSelectionModel(modelId) {
	const modelKey = modelId.trim().toLowerCase();
	if (
		modelKey.startsWith("ft:") ||
		modelKey.includes("embed") ||
		modelKey.includes("moderation") ||
		modelKey.includes("ocr") ||
		modelKey.includes("voxtral") ||
		modelKey.includes("transcribe") ||
		modelKey.includes("tts") ||
		modelKey.includes("realtime")
	) {
		return false;
	}
	return true;
}
function isModelAllowedByBuiltInProviderPolicy(provider, modelId, model) {
	const providerKey = provider.toLowerCase();
	const modelKey = modelId.trim().toLowerCase();
	if (HIDDEN_MODEL_PROVIDERS.has(providerKey)) {
		return false;
	}
	if (modelKey.includes("grok") || modelKey.startsWith("x-ai/")) {
		return false;
	}
	if (providerKey === "mistral") {
		return isMistralSelectionModel(modelId);
	}
	if (providerKey === "openrouter") {
		// model?.cost is populated for statically-known models from the upstream SDK catalog.
		// For dynamically-discovered models (absent from the catalog), fall back to the
		// per-provider discovery cache which now carries pricing from /api/v1/models.
		const effectiveCost =
			model?.cost ?? lookupDiscoveredModelCost("openrouter", modelId);
		return (
			providerModelAllowEntryMatches(":free", modelKey) ||
			isZeroCost(effectiveCost)
		);
	}
	if (providerKey === "opencode") {
		return (
			OPENCODE_FREE_MODEL_IDS.has(modelKey) ||
			hasFreeSkuMarker(modelId) ||
			hasFreeSkuMarker(model?.name) ||
			isZeroCost(model?.cost)
		);
	}
	return true;
}
export /**
 * Check if a provider/model pair is allowed by built-in and user-configured policies.
 */ function isProviderModelAllowed(
	provider,
	modelId,
	providerModelAllow,
	providerModelBlock,
	model,
) {
	const modelKey = modelId.trim().toLowerCase();
	if (!isModelAllowedByBuiltInProviderPolicy(provider, modelId, model))
		return false;
	const allowedModels = resolveProviderModelAllowList(
		providerModelAllow,
		provider,
	);
	const allowed =
		allowedModels === undefined
			? true
			: allowedModels.some((allowedModel) =>
					providerModelAllowEntryMatches(allowedModel, modelKey),
				);
	if (!allowed) return false;
	const blockedModels = resolveProviderModelAllowList(
		providerModelBlock,
		provider,
	);
	return !blockedModels?.some((blockedModel) =>
		providerModelAllowEntryMatches(blockedModel, modelKey),
	);
}
/**
 * Filter models by provider/model allow and block lists from user preferences.
 */
export function filterModelsByProviderModelAllow(
	models,
	providerModelAllow,
	providerModelBlock,
) {
	if (
		(!providerModelAllow || Object.keys(providerModelAllow).length === 0) &&
		(!providerModelBlock || Object.keys(providerModelBlock).length === 0) &&
		!models.some(
			(model) =>
				["mistral", "openrouter", "opencode"].includes(
					model.provider.toLowerCase(),
				) || HIDDEN_MODEL_PROVIDERS.has(model.provider.toLowerCase()),
		)
	)
		return [...models];
	return models.filter((model) =>
		isProviderModelAllowed(
			model.provider,
			model.id,
			providerModelAllow,
			providerModelBlock,
			model,
		),
	);
}
/**
 * Check whether a provider/modelId combination is in the operator's
 * enabledModels allowlist from ~/.sf/agent/settings.json.
 *
 * Returns true when:
 *   - enabledModels is undefined/empty (no filter — all allowed)
 *   - any pattern matches "<provider>/<modelId>"
 *
 * Pattern syntax: "provider/*" (all models in a provider) or
 * "provider/specific-model" (exact). Wildcards only at the end of the
 * pattern; intentional to keep policy simple and auditable.
 *
 * @param {string} provider - e.g. "mistral"
 * @param {string} modelId  - e.g. "mistral-large-2512"
 * @param {string[]|undefined} enabledModels
 * @returns {boolean}
 */
export function isModelInEnabledList(provider, modelId, enabledModels) {
	if (!Array.isArray(enabledModels) || enabledModels.length === 0) return true;
	const candidate = `${provider}/${modelId}`;
	for (const pattern of enabledModels) {
		if (typeof pattern !== "string") continue;
		if (pattern === candidate) return true;
		if (pattern.endsWith("/*")) {
			const prefix = pattern.slice(0, -1); // e.g. "mistral/"
			if (candidate.startsWith(prefix)) return true;
		}
	}
	return false;
}
/**
 * Check if a provider is in the allowed list and not in the blocked list.
 */
export function isProviderAllowedByLists(
	provider,
	allowedProviders,
	blockedProviders,
) {
	const providerKey = provider.toLowerCase();
	if (blockedProviders?.some((p) => p.toLowerCase() === providerKey)) {
		return false;
	}
	if (!allowedProviders || allowedProviders.length === 0) return true;
	return allowedProviders.some((p) => p.toLowerCase() === providerKey);
}
/**
 * Check if a model ID's provider is in the user's allowed_providers list.
 * Used to filter auto-fill fallbacks so they only come from allowed providers.
 */
function isFallbackProviderAllowed(modelId, prefs) {
	const provider = modelId.includes("/") ? modelId.split("/")[0] : undefined;
	if (!provider) return true; // bare model ID — trust the primary's provider check
	return isProviderAllowedByLists(
		provider,
		prefs?.allowed_providers,
		prefs?.blocked_providers,
	);
}

/**
 * Check if an advisory subagent may use a provider.
 *
 * Purpose: allow advisory-only provider pools without widening the implementor
 * provider set used by planning and execution dispatch.
 *
 * Consumer: advisory subagent dispatch policy when selecting a provider.
 */
export function isProviderAllowedForAdvisor(providerKey, prefs) {
	if (
		prefs.blocked_providers?.some(
			(provider) => provider.toLowerCase() === providerKey.toLowerCase(),
		)
	) {
		return false;
	}
	if (prefs.advisor_allowed_providers?.length) {
		return prefs.advisor_allowed_providers.some(
			(provider) => provider.toLowerCase() === providerKey.toLowerCase(),
		);
	}
	return isProviderAllowedByLists(
		providerKey,
		prefs.allowed_providers,
		prefs.blocked_providers,
	);
}
/**
 * Resolve which model ID to use for a given autonomous mode unit type.
 * Returns undefined if no model preference is set for this unit type.
 */
export function resolveModelForUnit(unitType) {
	const resolved = resolveModelWithFallbacksForUnit(unitType);
	return resolved?.primary;
}
/**
 * Resolve model and fallbacks for a given autonomous mode unit type.
 * Returns the primary model and ordered fallbacks, or undefined if not configured.
 *
 * Supports both legacy string format and extended object format:
 * - Legacy: `planning: claude-opus-4-6`
 * - Extended: `planning: { model: claude-opus-4-6, fallbacks: [glm-5, minimax-m2.5] }`
 */
/**
 * Auto-benchmark model picker when user hasn't pinned `models.<unit>`.
 * Scores candidates using unit-type-specific benchmark profiles and returns top pick plus fallbacks.
 * Works during preference resolution by pulling from pi-ai catalog rather than live registry.
 */
function resolveAutoBenchmarkPickForUnit(unitType, prefs) {
	try {
		const allowed = prefs?.allowed_providers?.map((s) => s.toLowerCase());
		const blocked = prefs?.blocked_providers?.map((s) => s.toLowerCase());
		const auth = getKeyManagerAuthStorage();
		const configuredProviderIds = new Set(
			auth.getConfiguredProviders?.() ??
				getProviders().filter((p) => {
					const creds = auth.getCredentialsForProvider(p);
					return creds.some(
						(c) => c.type === "oauth" || (c.type === "api_key" && c.key),
					);
				}),
		);
		const candidates = [];
		for (const provider of getProviders()) {
			if (!isProviderAllowedByLists(provider, allowed, blocked)) continue;
			if (!configuredProviderIds.has(provider)) continue;
			for (const model of getModels(provider)) {
				if (
					!isProviderModelAllowed(
						provider,
						model.id,
						prefs?.provider_model_allow,
						prefs?.provider_model_block,
						model,
					)
				)
					continue;
				candidates.push({
					provider,
					id: model.id,
					cost: model.cost,
					contextWindow: model.contextWindow,
					maxTokens: model.maxTokens,
					reasoning: model.reasoning,
					input: model.input,
					capabilities: model.capabilities,
				});
			}
		}
		if (candidates.length === 0) return undefined;
		const picked = selectByBenchmarks(unitType, candidates, {
			providerPreference: prefs?.provider_preference,
		});
		if (!picked) return undefined;
		return { primary: picked.primary, fallbacks: picked.fallbacks };
	} catch (err) {
		if (process.env.SF_DEBUG_PREFERENCES_MODELS === "1") {
			console.warn(
				`preferences-models auto benchmark failed: ${err instanceof Error ? err.stack || err.message : String(err)}`,
			);
		}
		return undefined;
	}
}
export function resolveModelWithFallbacksForUnit(unitType, options = {}) {
	const prefs = loadEffectiveSFPreferences();
	const m = prefs?.preferences.models ?? {};
	const autoBenchmark = options.autoBenchmark !== false;
	let phaseConfig;
	switch (unitType) {
		case "research-milestone":
		case "research-slice":
			phaseConfig = m.research;
			break;
		case "plan-milestone":
		case "roadmap-meeting":
		case "plan-slice":
		case "refine-slice":
		case "replan-slice":
			phaseConfig = m.planning;
			break;
		case "discuss-milestone":
		case "discuss-slice":
		// Deep-mode project-level discussion units route to the same model
		// bucket as milestone-level discussion (interactive interview style).
		case "discuss-project":
		case "discuss-requirements":
		// Workflow preferences and research-decision are tiny ask_user_questions
		// style units; they share the discuss bucket because they are
		// conversational rather than research/execution. Falling back to planning
		// when no `discuss` bucket is set keeps parity with the milestone units.
		case "workflow-preferences":
		case "research-decision":
			phaseConfig = m.discuss ?? m.planning;
			break;
		// Deep-mode project research orchestrator. Reads PROJECT.md / REQUIREMENTS.md
		// and fans out research subagents. Routes to the research bucket so it
		// gets the research-tier model when one is configured.
		case "research-project":
			phaseConfig = m.research;
			break;
		case "execute-task":
		case "reactive-execute":
			phaseConfig = m.execution;
			break;
		case "execute-task-simple":
			phaseConfig = m.execution_simple ?? m.execution;
			break;
		case "complete-slice":
		case "complete-milestone":
		case "worktree-merge":
		case "run-uat":
			phaseConfig = m.completion;
			break;
		case "reassess-roadmap":
		case "gate-evaluate":
		case "validate-milestone":
			phaseConfig = m.validation ?? m.planning;
			break;
		case "rewrite-docs":
			phaseConfig = m.validation ?? m.execution ?? m.planning;
			break;
		default:
			// Subagent unit types (e.g., "subagent", "subagent/scout")
			if (unitType === "subagent" || unitType.startsWith("subagent/")) {
				phaseConfig = m.subagent;
				break;
			}
			phaseConfig = undefined;
	}
	if (!phaseConfig) {
		if (!autoBenchmark) return undefined;
		// Auto-benchmark fallback: when the user hasn't pinned a model for this
		// unit type, pick the best-benchmark-scoring model within
		// allowed_providers. Keeps models.* declarative (pin only what you
		// need) and lets the benchmarks drive the rest. Returns undefined if
		// neither pref nor benchmarks can produce a candidate — caller falls
		// through to session model defaults.
		return resolveAutoBenchmarkPickForUnit(unitType, prefs?.preferences);
	}
	// Normalize: string -> { model, fallbacks: [] }
	if (typeof phaseConfig === "string") {
		const auto = autoBenchmark
			? resolveAutoBenchmarkPickForUnit(unitType, prefs?.preferences)
			: undefined;
		const autoFallbacks = (auto?.fallbacks ?? [])
			.filter((id) => id !== phaseConfig)
			.filter((modelId) =>
				isFallbackProviderAllowed(modelId, prefs?.preferences),
			);
		// Don't double-include the user's primary if benchmarks picked it as #1.
		if (auto && auto.primary !== phaseConfig) {
			autoFallbacks.unshift(auto.primary);
		}
		return { primary: phaseConfig, fallbacks: autoFallbacks };
	}
	// When provider is explicitly set, prepend it to the model ID so the
	// resolution code in auto.ts can do an explicit provider match.
	const primary =
		phaseConfig.provider && !phaseConfig.model.includes("/")
			? `${phaseConfig.provider}/${phaseConfig.model}`
			: phaseConfig.model;
	const userFallbacks = phaseConfig.fallbacks ?? [];
	if (userFallbacks.length > 0) {
		// Explicit fallbacks: respect them as-is, no auto-fill.
		return { primary, fallbacks: userFallbacks };
	}
	// Auto-fill empty/missing fallbacks from the benchmark picker so a user
	// who only pinned a primary still gets rate-limit auto-switch behavior
	// out of the box. The auto-pick may include the user's primary as its #1
	// — drop it to avoid `primary == fallbacks[0]` no-op switches.
	const auto = autoBenchmark
		? resolveAutoBenchmarkPickForUnit(unitType, prefs?.preferences)
		: undefined;
	const autoFallbacks = (auto?.fallbacks ?? [])
		.filter((id) => id !== primary)
		.filter((modelId) =>
			isFallbackProviderAllowed(modelId, prefs?.preferences),
		);
	if (auto && auto.primary !== primary) {
		autoFallbacks.unshift(auto.primary);
	}
	return { primary, fallbacks: autoFallbacks };
}
/**
 * Resolve the default session model from SF preferences.
 *
 * Used at autonomous mode bootstrap to override the session model that was
 * determined by settings.json (defaultProvider/defaultModel).  When
 * preferences.yaml (or project preferences) configures an `execution` model
 * we treat that as the session default.  Falls back through execution →
 * planning → first configured model.
 *
 * Accepts an optional `sessionProvider` for bare model IDs that don't
 * include an explicit provider prefix (e.g. `gpt-5.4` instead of
 * `openai-codex/gpt-5.4`).  When a bare ID is found and sessionProvider
 * is available, the session provider is used.  Without sessionProvider,
 * bare IDs are still returned with provider set to the bare ID itself
 * so downstream resolution (resolveModelId) can match it.
 *
 * Returns `{ provider, id }` or `undefined` if no model preference is
 * configured.
 */
export function resolveDefaultSessionModel(sessionProvider) {
	const prefs = loadEffectiveSFPreferences();
	if (!prefs?.preferences.models) return undefined;
	const m = prefs.preferences.models;
	// Priority: execution → planning → first configured value
	const candidates = [
		m.execution,
		m.planning,
		m.research,
		m.discuss,
		m.completion,
		m.validation,
		m.subagent,
	];
	for (const cfg of candidates) {
		if (!cfg) continue;
		// Normalize to provider + id from the various config shapes
		let provider;
		let id;
		if (typeof cfg === "string") {
			const slashIdx = cfg.indexOf("/");
			if (slashIdx !== -1) {
				provider = cfg.slice(0, slashIdx);
				id = cfg.slice(slashIdx + 1);
			} else {
				// Bare model ID (e.g. "gpt-5.4") — use session provider as context
				provider = sessionProvider;
				id = cfg;
			}
		} else {
			// Object config: { model, provider?, fallbacks? }
			if (cfg.provider) {
				provider = cfg.provider;
			} else if (cfg.model.includes("/")) {
				const slashIdx = cfg.model.indexOf("/");
				provider = cfg.model.slice(0, slashIdx);
				id = cfg.model.slice(slashIdx + 1);
				return { provider, id };
			} else {
				provider = sessionProvider;
			}
			id = cfg.model;
		}
		if (provider && id) {
			return { provider, id };
		}
	}
	return undefined;
}
/**
 * Returns true if `provider` is defined as a custom provider in the user's
 * `~/.sf/agent/models.json` (Ollama, vLLM, LM Studio, OpenAI-compatible
 * proxies, etc.).
 *
 * Used by autonomous mode bootstrap to decide whether the session model
 * (set via `/model`) should override `preferences.yaml`.  Custom providers
 * are never reachable from `preferences.yaml` (which only knows built-in
 * providers), so when the user has explicitly selected one, it must take
 * priority — otherwise autonomous mode tries to start the built-in provider from
 * preferences.yaml and fails with "Not logged in · Please run /login" (#4122).
 *
 * Reads models.json directly with a lightweight JSON parse to avoid
 * pulling in the full model-registry at this call site.  Falls back to
 * `~/.pi/agent/models.json` for parity with `resolveModelsJsonPath()`.
 * Any read or parse error yields `false` (treat as not-custom) so a
 * malformed models.json never breaks the session bootstrap.
 */
export function isCustomProvider(provider) {
	if (!provider) return false;
	const candidates = [
		join(sfHome(), "agent", "models.json"),
		join(homedir(), ".pi", "agent", "models.json"),
	];
	for (const path of candidates) {
		if (!existsSync(path)) continue;
		try {
			const raw = readFileSync(path, "utf-8");
			const parsed = JSON.parse(raw);
			if (parsed?.providers && Object.hasOwn(parsed.providers, provider)) {
				return true;
			}
		} catch {
			// Ignore — malformed models.json must not break bootstrap.
		}
	}
	return false;
}
/**
 * Determines the next fallback model to try when the current model fails.
 * If the current model is not in the configured list, returns the primary model.
 * If the current model is the last in the list, returns undefined (exhausted).
 */
export function getNextFallbackModel(currentModelId, modelConfig) {
	const modelsToTry = [modelConfig.primary, ...modelConfig.fallbacks];
	if (!currentModelId) {
		return modelsToTry[0];
	}
	let foundCurrent = false;
	for (let i = 0; i < modelsToTry.length; i++) {
		const mId = modelsToTry[i];
		// Check for exact match or provider/model suffix match
		if (
			mId === currentModelId ||
			(mId.includes("/") && mId.endsWith(`/${currentModelId}`))
		) {
			foundCurrent = true;
			return modelsToTry[i + 1]; // Return the next one, or undefined if at the end
		}
	}
	// If the current model wasn't in our preference list, default to starting the sequence
	if (!foundCurrent) {
		return modelsToTry[0];
	}
}
/**
 * Validate a model ID string.
 * Returns true if the ID looks like a valid model identifier.
 */
export function validateModelId(modelId) {
	if (!modelId || typeof modelId !== "string") return false;
	const trimmed = modelId.trim();
	if (trimmed.length === 0 || trimmed.length > 256) return false;
	// Allow alphanumeric, hyphens, underscores, dots, slashes, colons
	return /^[a-zA-Z0-9\-_./:]+$/.test(trimmed);
}
/**
 * Update the models section of the global SF preferences file.
 * Performs a safe read-modify-write: reads current content, updates the models
 * YAML block, and writes back. Creates the file if it doesn't exist.
 */
export function updatePreferencesModels(models) {
	const prefsPath = getGlobalSFPreferencesPath();
	let content = "";
	if (existsSync(prefsPath)) {
		content = readFileSync(prefsPath, "utf-8");
	}
	// Build the new models block
	const lines = ["models:"];
	for (const [phase, value] of Object.entries(models)) {
		if (typeof value === "string") {
			lines.push(`  ${phase}: ${value}`);
		} else if (value && typeof value === "object") {
			const config = value;
			lines.push(`  ${phase}:`);
			lines.push(`    model: ${config.model}`);
			if (config.provider) {
				lines.push(`    provider: ${config.provider}`);
			}
			if (config.fallbacks && config.fallbacks.length > 0) {
				lines.push(`    fallbacks:`);
				for (const fb of config.fallbacks) {
					lines.push(`      - ${fb}`);
				}
			}
		}
	}
	const modelsBlock = lines.join("\n");
	// Replace existing models block or append
	const modelsRegex = /^models:[\s\S]*?(?=\n[a-z_]|\n*$)/m;
	if (modelsRegex.test(content)) {
		content = content.replace(modelsRegex, modelsBlock);
	} else {
		content = content.trimEnd() + "\n\n" + modelsBlock + "\n";
	}
	writeFileSync(prefsPath, content, "utf-8");
}
/**
 * Increment the subscription token counter in the global preferences file.
 *
 * When a unit completes on a provider that matches `subscription.provider`,
 * this function atomically updates `subscription.tokens_used_this_month` on
 * disk.  It is best-effort: any error is silently swallowed so that a
 * preference-write failure never disrupts autonomous mode.
 *
 * Pass `provider` (e.g. "anthropic") and `tokensConsumed` from the unit
 * ledger entry.  The function is a no-op when:
 *   - No subscription is configured in effective preferences
 *   - `subscription.provider` does not match the given provider (case-insensitive)
 *   - `tokensConsumed` is 0 or negative
 */
export function updateSubscriptionTokensUsed(provider, tokensConsumed) {
	if (!provider || tokensConsumed <= 0) return;
	try {
		const prefs = loadEffectiveSFPreferences();
		const sub = prefs?.preferences.subscription;
		if (!sub?.provider) return;
		if (sub.provider.toLowerCase() !== provider.toLowerCase()) return;
		const prefsPath = getGlobalSFPreferencesPath();
		if (!existsSync(prefsPath)) return;
		const content = readFileSync(prefsPath, "utf-8");
		const current = sub.tokens_used_this_month ?? 0;
		const updated = current + tokensConsumed;
		// Replace an existing tokens_used_this_month line or insert after
		// the provider line inside the subscription block.
		const tokensLineRe = /^(\s*tokens_used_this_month\s*:)\s*\d+/m;
		let newContent;
		if (tokensLineRe.test(content)) {
			newContent = content.replace(tokensLineRe, `$1 ${updated}`);
		} else {
			// Insert after the subscription.provider line
			const providerLineRe = /^(\s*provider\s*:.+)$/m;
			if (providerLineRe.test(content)) {
				newContent = content.replace(
					providerLineRe,
					`$1\n  tokens_used_this_month: ${updated}`,
				);
			} else {
				// Can't safely locate insertion point — skip
				return;
			}
		}
		writeFileSync(prefsPath, newContent, "utf-8");
	} catch {
		// Best-effort — never interrupt autonomous mode for a pref write failure
	}
}
/**
 * Resolve the dynamic routing configuration from effective preferences.
 * Returns the merged config with defaults applied.
 */
export function resolveDynamicRoutingConfig() {
	const prefs = loadEffectiveSFPreferences();
	const configured = prefs?.preferences.dynamic_routing;
	if (!configured) return defaultRoutingConfig();
	return {
		...defaultRoutingConfig(),
		...configured,
	};
}
export function resolvePersistModelChanges() {
	const prefs = loadEffectiveSFPreferences();
	return prefs?.preferences.persist_model_changes !== false;
}
export function resolveAutoSupervisorConfig() {
	const prefs = loadEffectiveSFPreferences();
	const configured = prefs?.preferences.auto_supervisor ?? {};
	return {
		soft_timeout_minutes: configured.soft_timeout_minutes ?? 20,
		idle_timeout_minutes: configured.idle_timeout_minutes ?? 10,
		hard_timeout_minutes: configured.hard_timeout_minutes ?? 30,
		solver_max_iterations: Number.isFinite(
			Number(configured.solver_max_iterations),
		)
			? Math.max(1, Math.min(100000, Number(configured.solver_max_iterations)))
			: 25,
		solver_eval_on_autonomous_exit:
			configured.solver_eval_on_autonomous_exit !== false,
		completion_nudge_after: configured.completion_nudge_after ?? 10,
		runaway_guard_enabled: configured.runaway_guard_enabled ?? true,
		runaway_tool_call_warning:
			configured.runaway_tool_call_warning ?? DEFAULT_RUNAWAY_TOOL_CALL_WARNING,
		runaway_token_warning:
			configured.runaway_token_warning ?? DEFAULT_RUNAWAY_TOKEN_WARNING,
		runaway_elapsed_minutes:
			configured.runaway_elapsed_minutes ?? DEFAULT_RUNAWAY_ELAPSED_MINUTES,
		runaway_changed_files_warning:
			configured.runaway_changed_files_warning ??
			DEFAULT_RUNAWAY_CHANGED_FILES_WARNING,
		runaway_diagnostic_turns:
			configured.runaway_diagnostic_turns ?? DEFAULT_RUNAWAY_DIAGNOSTIC_TURNS,
		runaway_hard_pause: configured.runaway_hard_pause ?? true,
		...(configured.model ? { model: configured.model } : {}),
	};
}
// ─── Token Profile Resolution ─────────────────────────────────────────────
const VALID_TOKEN_PROFILES = new Set([
	"budget",
	"balanced",
	"quality",
	"burn-max",
]);
/**
 * Resolve profile defaults for a given token profile tier.
 * Returns a partial SFPreferences that is used as the base layer --
 * explicit user preferences always override these defaults.
 */
export function resolveProfileDefaults(profile) {
	switch (profile) {
		case "budget":
			return {
				models: {
					planning: "claude-sonnet-4-5-20250514",
					execution: "claude-sonnet-4-5-20250514",
					execution_simple: "claude-haiku-4-5-20250414",
					completion: "claude-haiku-4-5-20250414",
					subagent: "claude-haiku-4-5-20250414",
				},
				phases: {
					skip_research: true,
					skip_reassess: true,
					skip_slice_research: true,
					skip_milestone_validation: true,
				},
			};
		case "balanced":
			return {
				models: {
					subagent: "claude-sonnet-4-5-20250514",
				},
				phases: {
					skip_research: true,
					skip_reassess: true,
					skip_slice_research: true,
				},
			};
		case "quality":
			return {
				models: {},
				phases: {
					skip_research: true,
					skip_slice_research: true,
					skip_reassess: true,
				},
			};
		case "burn-max":
			return {
				// Quality-first profile: keep user-selected models, disable downgrade routing.
				// Policy constraints still apply at dispatch time.
				dynamic_routing: {
					enabled: false,
				},
				context_selection: "full",
				phases: {
					skip_research: false,
					skip_slice_research: false,
					skip_reassess: false,
					skip_milestone_validation: false,
					reassess_after_slice: true,
				},
			};
	}
}
/**
 * Resolve the effective token profile from preferences.
 * Returns "balanced" when no profile is set (D046).
 */
export function resolveEffectiveProfile() {
	const prefs = loadEffectiveSFPreferences();
	const profile = prefs?.preferences.token_profile;
	if (profile && VALID_TOKEN_PROFILES.has(profile)) return profile;
	return "balanced";
}
/**
 * Resolve the inline level from the active token profile.
 * budget -> minimal, balanced -> standard, quality/burn-max -> full.
 */
export function resolveInlineLevel() {
	const profile = resolveEffectiveProfile();
	switch (profile) {
		case "budget":
			return "minimal";
		case "balanced":
			return "standard";
		case "quality":
			return "full";
		case "burn-max":
			return "full";
	}
}
/**
 * Resolve the context selection mode from the active token profile.
 * budget -> "smart", balanced/quality/burn-max -> "full".
 * Explicit preference always wins.
 */
export function resolveContextSelection() {
	const prefs = loadEffectiveSFPreferences();
	if (prefs?.preferences.context_selection)
		return prefs.preferences.context_selection;
	const profile = resolveEffectiveProfile();
	return profile === "budget" ? "smart" : "full";
}
/**
 * Resolve the search provider preference from preferences.md.
 * Returns undefined if not configured (caller falls back to existing behavior).
 */
export function resolveSearchProviderFromPreferences() {
	const prefs = loadEffectiveSFPreferences();
	return prefs?.preferences.search_provider;
}

/**
 * Returns true if modelId belongs to the "heavy" capability tier.
 *
 * Purpose: enforce `modelMode === "fast"` constraints on subagent dispatch by
 * blocking high-cost/high-latency models from being used in fast-mode contexts.
 *
 * Primary: looks up the bare model ID (strips provider prefix) in MODEL_CAPABILITY_TIER
 * from model-router.js — the single authoritative tier map for the entire system.
 * Fallback: word-boundary regex patterns for models not yet in the tier map, so new
 * additions to model-router.js are automatically picked up without editing this file.
 *
 * Consumer: subagent-inheritance.js when checking modelMode === "fast" gate.
 */
export function isHeavyModelId(modelId) {
	if (!modelId || typeof modelId !== "string") return false;
	// Strip provider prefix (e.g. "anthropic/claude-opus-4-6" → "claude-opus-4-6")
	const bare = modelId.includes("/")
		? modelId.split("/").slice(1).join("/")
		: modelId;
	const tier = MODEL_CAPABILITY_TIER[bare] ?? MODEL_CAPABILITY_TIER[modelId];
	if (tier !== undefined) return tier === "heavy";
	// Fallback regex for models not yet in the tier map
	return /\bopus\b|\bo1\b|\bo3\b|\bgpt-4-turbo\b|\bgpt-5\b|\bdeepseek-reasoner\b/i.test(
		modelId,
	);
}
