/**
 * model-role-policy.js -- role intent projection for autonomous model routing.
 *
 * Purpose: preserve durable SF model-routing policy as role-level intent and
 * symbolic constraints so auto model selection remains the only component that
 * resolves concrete provider/model IDs.
 */

/**
 * List the autonomous roles that can carry durable model-routing intent.
 *
 * Purpose: keep role-policy validation aligned with SF's autonomous dispatch
 * vocabulary instead of allowing arbitrary role names to become configuration.
 *
 * Consumer: preference/import projection code before passing policy intent to
 * autonomous model selection.
 */
export const SUPPORTED_MODEL_ROLES = Object.freeze([
	"orchestrator",
	"worker",
	"validator",
	"reviewer",
	"adversary",
]);

/**
 * List symbolic constraints that role policy can persist.
 *
 * Purpose: allow durable policy to express routing needs without persisting
 * provider names, model names, or custom model IDs.
 *
 * Consumer: role-policy validation and autonomous route evidence writers.
 */

/**
 * `lineage-diverse-from-worker` — selected model must NOT share root-vendor
 * lineage with the model that produced the artifact under review (the `worker`
 * role's resolved model). Prevents "looks fine to me" rubber-stamp by
 * lineage-twins. When the worker model is unknown or maps to "unknown" vendor,
 * this constraint is treated as a no-op (fail-open rather than blocking all
 * candidates). Enforcement is best-effort at the model-selection layer.
 *
 * TODO(lineage-diverse-from-worker): wire handler in auto-model-selection.js
 * selectAndApplyModel — after the policy gate resolves `routingEligibleModels`,
 * call resolveWorkerLineage(resolvedWorkerModelId) and filter candidates with
 * !isSameRootVendor(candidate.id, workerVendor). See rootVendorFor() in this
 * file for the canonical vendor mapping.
 */
export const SUPPORTED_MODEL_ROLE_CONSTRAINTS = Object.freeze([
	"coding",
	"review",
	"cheap",
	"long-context",
	"byok-allowed",
	"local-only",
	"strict-json",
	"lineage-diverse-from-worker",
]);

const MODEL_ROLES = new Set(SUPPORTED_MODEL_ROLES);
const MODEL_ROLE_CONSTRAINTS = new Set(SUPPORTED_MODEL_ROLE_CONSTRAINTS);
const CONCRETE_ROUTE_KEYS = new Set([
	"provider",
	"providerId",
	"modelId",
	"model_id",
	"customModelId",
	"custom_model_id",
]);

/**
 * Provide SF's default symbolic routing constraints per autonomous role.
 *
 * Purpose: encode the useful Droid-style role projection while leaving final
 * provider/model choice to SF's existing auto model selection.
 *
 * Consumer: normalizeRolePolicy when a role has no explicit constraints.
 */
export const DEFAULT_MODEL_ROLE_CONSTRAINTS = Object.freeze({
	orchestrator: Object.freeze(["long-context", "strict-json"]),
	worker: Object.freeze(["coding"]),
	validator: Object.freeze(["strict-json", "review"]),
	reviewer: Object.freeze(["review", "lineage-diverse-from-worker"]),
	adversary: Object.freeze(["review", "lineage-diverse-from-worker"]),
});

/**
 * Report invalid durable model-role policy input.
 *
 * Purpose: give preference and projection callers one clear error type when a
 * role policy tries to persist unsupported roles, constraints, or route IDs.
 *
 * Consumer: normalizeRolePolicy and normalizeRolePolicies callers that surface
 * validation failures to users or startup diagnostics.
 */
export class ModelRolePolicyValidationError extends Error {
	constructor(errors) {
		super(`Invalid model role policy: ${errors.join("; ")}`);
		this.name = "ModelRolePolicyValidationError";
		this.errors = errors;
	}
}

function normalizeRoleName(role) {
	return String(role ?? "")
		.trim()
		.toLowerCase();
}

function normalizeConstraintName(constraint) {
	return String(constraint ?? "")
		.trim()
		.toLowerCase()
		.replaceAll("_", "-");
}

function constraintsFromPolicy(role, policy) {
	if (Array.isArray(policy)) return policy;
	if (
		policy &&
		typeof policy === "object" &&
		Array.isArray(policy.constraints)
	) {
		return policy.constraints;
	}
	return DEFAULT_MODEL_ROLE_CONSTRAINTS[role] ?? [];
}

function modeFromPolicy(policy) {
	if (policy === undefined || policy === null) return "auto";
	if (policy === "auto") return "auto";
	if (Array.isArray(policy)) return "auto";
	if (policy && typeof policy === "object") {
		return policy.mode ?? policy.model ?? "auto";
	}
	return policy;
}

function validateOne(role, policy) {
	const normalizedRole = normalizeRoleName(role);
	const errors = [];
	if (!MODEL_ROLES.has(normalizedRole)) {
		errors.push(
			`unsupported model role "${String(role)}"; expected one of ${SUPPORTED_MODEL_ROLES.join(", ")}`,
		);
	}
	if (policy && typeof policy === "object" && !Array.isArray(policy)) {
		for (const key of Object.keys(policy)) {
			if (CONCRETE_ROUTE_KEYS.has(key)) {
				errors.push(
					`role "${normalizedRole}" must use mode "auto"; durable policy cannot include "${key}"`,
				);
			}
		}
	}
	const mode = modeFromPolicy(policy);
	if (mode !== "auto") {
		errors.push(
			`role "${normalizedRole}" must use mode "auto"; received "${String(mode)}"`,
		);
	}
	const seen = new Set();
	const constraints = [];
	for (const rawConstraint of constraintsFromPolicy(normalizedRole, policy)) {
		const constraint = normalizeConstraintName(rawConstraint);
		if (!MODEL_ROLE_CONSTRAINTS.has(constraint)) {
			errors.push(
				`role "${normalizedRole}" has unsupported constraint "${String(rawConstraint)}"; expected one of ${SUPPORTED_MODEL_ROLE_CONSTRAINTS.join(", ")}`,
			);
			continue;
		}
		if (!seen.has(constraint)) {
			seen.add(constraint);
			constraints.push(constraint);
		}
	}
	return {
		ok: errors.length === 0,
		errors,
		policy:
			errors.length === 0
				? { role: normalizedRole, mode: "auto", constraints }
				: null,
	};
}

/**
 * Validate one durable autonomous model role policy.
 *
 * Purpose: let callers reject provider-specific routing data before it reaches
 * SF's durable preference or planning state.
 *
 * Consumer: preference projection, Droid-pattern importers, and tests that need
 * non-throwing diagnostics.
 */
export function validateRolePolicy(role, policy = undefined) {
	return validateOne(role, policy);
}

/**
 * Normalize one durable autonomous model role policy.
 *
 * Purpose: produce the canonical role-policy shape consumed by autonomous model
 * selection while guaranteeing concrete route IDs are not persisted.
 *
 * Consumer: startup preference loading and any future projection step that feeds
 * role intent into UOK model route evidence.
 */
export function normalizeRolePolicy(role, policy = undefined) {
	const result = validateOne(role, policy);
	if (!result.ok) throw new ModelRolePolicyValidationError(result.errors);
	return result.policy;
}

/**
 * Normalize a role-to-policy map for autonomous model routing.
 *
 * Purpose: project partial durable policy into a complete role map where every
 * supported autonomous role requests SF's existing auto model selection.
 *
 * Consumer: SF-native policy import/projection code before dispatch starts an
 * autonomous run.
 */
export function normalizeRolePolicies(policies = {}) {
	if (!policies || typeof policies !== "object" || Array.isArray(policies)) {
		throw new ModelRolePolicyValidationError([
			"model role policies must be an object keyed by role",
		]);
	}
	const unknownRoles = Object.keys(policies).filter(
		(role) => !MODEL_ROLES.has(normalizeRoleName(role)),
	);
	if (unknownRoles.length > 0) {
		throw new ModelRolePolicyValidationError(
			unknownRoles.map(
				(role) =>
					`unsupported model role "${role}"; expected one of ${SUPPORTED_MODEL_ROLES.join(", ")}`,
			),
		);
	}
	const normalizedPolicies = Object.fromEntries(
		Object.entries(policies).map(([role, policy]) => [
			normalizeRoleName(role),
			policy,
		]),
	);
	return Object.fromEntries(
		SUPPORTED_MODEL_ROLES.map((role) => [
			role,
			normalizeRolePolicy(role, normalizedPolicies[role]),
		]),
	);
}

/**
 * Map a model ID to its root-vendor label for lineage-diversity checks.
 *
 * Purpose: back the `lineage-diverse-from-worker` constraint — model selection
 * for reviewer/adversary roles must filter candidates that share a vendor with
 * the worker's resolved model.
 *
 * Returns one of: "anthropic" | "openai" | "google" | "moonshot" | "mistral" |
 * "minimax" | "zhipu" | "meituan" | "unknown".  "unknown" means no filter is
 * applied (fail-open).
 *
 * Consumer: lineage-diverse-from-worker handler in the model-selection layer
 * (TODO — see constraint JSDoc above).
 *
 * @param {string} modelId
 * @returns {string}
 */
export function rootVendorFor(modelId) {
	const id = String(modelId ?? "").toLowerCase();
	if (id.startsWith("claude-") || id.startsWith("anthropic/"))
		return "anthropic";
	if (
		id.startsWith("gpt-") ||
		id.startsWith("o1-") ||
		id.startsWith("o3-") ||
		id.startsWith("o4-") ||
		id.startsWith("openai/")
	)
		return "openai";
	if (
		id.startsWith("gemini-") ||
		id.startsWith("gemma-") ||
		id.startsWith("google/") ||
		id.startsWith("google-")
	)
		return "google";
	if (id.startsWith("kimi-") || id.startsWith("moonshotai/")) return "moonshot";
	if (id.startsWith("mistral-") || id.startsWith("mistral/")) return "mistral";
	if (id.startsWith("minimax-") || id.startsWith("minimax/")) return "minimax";
	if (id.startsWith("glm-") || id.startsWith("zai-") || id.startsWith("zhipu/"))
		return "zhipu";
	if (id.startsWith("longcat-")) return "meituan";
	return "unknown";
}

/**
 * Return true when two model IDs share root-vendor lineage.
 *
 * Purpose: allow model-selection layer to implement the
 * `lineage-diverse-from-worker` constraint as a simple predicate.
 *
 * "unknown" vendor is treated as non-matching so it never blocks candidates
 * (fail-open for unrecognised models).
 *
 * @param {string} candidateModelId
 * @param {string} workerModelId
 * @returns {boolean}
 */
export function isSameRootVendor(candidateModelId, workerModelId) {
	const a = rootVendorFor(candidateModelId);
	const b = rootVendorFor(workerModelId);
	if (a === "unknown" || b === "unknown") return false;
	return a === b;
}
