use axum::{
    extract::{Path, State},
    response::IntoResponse,
};
use serde::Deserialize;
use serde_json::Value;
use std::collections::HashMap;

use super::{
    AppState, JsonError, bad_request, internal_err, not_found, sanitize_html, validate_long,
    validate_short,
};

pub(crate) const ROLE_SUBAGENT: &str = "subagent";
pub(crate) const ROLE_MODEL_PROXY: &str = "model-proxy";
pub(crate) const MODEL_MODE_AUTO: &str = "auto";
pub(crate) const MODEL_MODE_ORCHESTRATOR: &str = "orchestrator";

#[derive(Deserialize)]
pub struct CreateSubAgentRequest {
    pub name: String,
    #[serde(default)]
    pub display_name: Option<String>,
    #[serde(default = "default_model")]
    pub model: String,
    #[serde(default = "default_role")]
    pub role: String,
    #[serde(default)]
    pub description: Option<String>,
    #[serde(default)]
    pub skills: Vec<String>,
    #[serde(default)]
    pub fallback_models: Vec<String>,
    #[serde(default)]
    pub personality: Option<Value>,
    #[serde(default = "default_true")]
    pub enabled: bool,
}

fn default_role() -> String {
    ROLE_SUBAGENT.into()
}

fn default_model() -> String {
    MODEL_MODE_AUTO.into()
}
fn default_true() -> bool {
    true
}

#[derive(Deserialize)]
pub struct UpdateSubAgentRequest {
    pub display_name: Option<String>,
    pub model: Option<String>,
    pub role: Option<String>,
    pub description: Option<String>,
    pub skills: Option<Vec<String>>,
    pub fallback_models: Option<Vec<String>>,
    #[serde(default)]
    pub personality: Option<Value>,
    pub enabled: Option<bool>,
}

const MAX_SUBAGENT_NAME_LEN: usize = 128;

pub(crate) fn validate_subagent_name(name: &str) -> Result<(), JsonError> {
    if name.is_empty() {
        return Err(bad_request("subagent name cannot be empty"));
    }
    if name.len() > MAX_SUBAGENT_NAME_LEN {
        return Err(bad_request(format!(
            "subagent name exceeds max length of {MAX_SUBAGENT_NAME_LEN} characters"
        )));
    }
    if !name
        .chars()
        .all(|c| c.is_ascii_alphanumeric() || c == '-' || c == '_')
    {
        return Err(bad_request(
            "subagent name may only contain alphanumeric characters, hyphens, and underscores",
        ));
    }
    Ok(())
}

pub(crate) fn normalize_role(raw: &str) -> Option<&'static str> {
    let v = raw.trim().to_ascii_lowercase();
    match v.as_str() {
        ROLE_SUBAGENT | "specialist" => Some(ROLE_SUBAGENT),
        ROLE_MODEL_PROXY => Some(ROLE_MODEL_PROXY),
        _ => None,
    }
}

pub(crate) fn normalize_skills(skills: &[String]) -> Vec<String> {
    let mut out = std::collections::BTreeSet::new();
    for s in skills {
        let trimmed = s.trim();
        if !trimmed.is_empty() {
            out.insert(trimmed.to_string());
        }
    }
    out.into_iter().collect()
}

pub(crate) fn normalize_model_input(model: &str) -> String {
    model.trim().to_string()
}

pub(crate) fn parse_fallback_models_json(raw: Option<&str>) -> Vec<String> {
    raw.and_then(|s| serde_json::from_str::<Vec<String>>(s).ok())
        .unwrap_or_default()
}

pub(crate) fn normalize_fallback_models(models: &[String], primary_model: &str) -> Vec<String> {
    let primary = primary_model.trim();
    let mut seen = std::collections::HashSet::new();
    let mut out = Vec::new();
    for m in models {
        let trimmed = m.trim();
        if trimmed.is_empty() || trimmed.eq_ignore_ascii_case(primary) {
            continue;
        }
        // Preserve caller order while de-duplicating case-insensitively.
        let key = trimmed.to_ascii_lowercase();
        if seen.insert(key) {
            out.push(trimmed.to_string());
        }
    }
    out
}

pub(crate) fn is_model_mode(model: &str) -> bool {
    matches!(
        model.trim().to_ascii_lowercase().as_str(),
        MODEL_MODE_AUTO | MODEL_MODE_ORCHESTRATOR
    )
}

pub(crate) fn is_concrete_provider_model(model: &str) -> bool {
    let trimmed = model.trim();
    if trimmed.is_empty() {
        return false;
    }
    let Some((provider, model_name)) = trimmed.split_once('/') else {
        return false;
    };
    !provider.trim().is_empty() && !model_name.trim().is_empty()
}

pub(crate) fn validate_subagent_model_for_role(role: &str, model: &str) -> Result<(), JsonError> {
    let normalized = normalize_role(role).ok_or_else(|| {
        (
            axum::http::StatusCode::BAD_REQUEST,
            "role must be 'subagent' or 'model-proxy'".to_string(),
        )
    })?;
    if model.trim().is_empty() {
        return Err(bad_request(
            "model cannot be empty; use a concrete provider/model, 'auto', or 'orchestrator'",
        ));
    }
    if normalized == ROLE_MODEL_PROXY && is_model_mode(model) {
        return Err(bad_request(
            "model-proxy entries require a concrete provider/model, not 'auto' or 'orchestrator'",
        ));
    }
    if !is_model_mode(model) && !is_concrete_provider_model(model) {
        return Err(bad_request(
            "model must be provider/model format, or one of: 'auto', 'orchestrator'",
        ));
    }
    Ok(())
}

pub(crate) async fn resolve_taskable_subagent_runtime_model(
    state: &AppState,
    configured_model: &str,
) -> String {
    let model = configured_model.trim().to_ascii_lowercase();
    match model.as_str() {
        MODEL_MODE_AUTO => super::agent::select_routed_model(state, "").await,
        MODEL_MODE_ORCHESTRATOR => {
            let llm = state.llm.read().await;
            llm.router.select_model().to_string()
        }
        _ if is_concrete_provider_model(configured_model) => configured_model.trim().to_string(),
        _ => {
            tracing::warn!(
                configured_model = %configured_model,
                "invalid fixed subagent model; falling back to auto model routing"
            );
            super::agent::select_routed_model(state, "").await
        }
    }
}

pub(crate) fn validate_subagent_contract(
    role: &str,
    model: &str,
    skills: &[String],
    personality: Option<&Value>,
) -> Result<(), JsonError> {
    let normalized = normalize_role(role).ok_or_else(|| {
        (
            axum::http::StatusCode::BAD_REQUEST,
            "role must be 'subagent' or 'model-proxy'".to_string(),
        )
    })?;
    if personality.is_some() {
        return Err(bad_request(
            "personality is not supported for subagents; subagents must be personality-free",
        ));
    }
    if normalized == ROLE_MODEL_PROXY && !skills.is_empty() {
        return Err(bad_request(
            "model-proxy entries cannot own skills; only taskable subagents may have fixed skills",
        ));
    }
    validate_subagent_model_for_role(normalized, model)?;
    Ok(())
}

fn runtime_state_label(state: roboticus_agent::subagents::AgentRunState) -> &'static str {
    match state {
        roboticus_agent::subagents::AgentRunState::Idle => "idle",
        roboticus_agent::subagents::AgentRunState::Starting => "booting",
        roboticus_agent::subagents::AgentRunState::Running => "running",
        roboticus_agent::subagents::AgentRunState::Stopped => "stopped",
        roboticus_agent::subagents::AgentRunState::Error => "error",
    }
}

pub async fn list_sub_agents(State(state): State<AppState>) -> impl IntoResponse {
    match roboticus_db::agents::list_sub_agents(&state.db) {
        Ok(agents) => {
            let runtime = state.registry.list_agents().await;
            let runtime_by_name: HashMap<String, roboticus_agent::subagents::AgentInstance> =
                runtime
                    .into_iter()
                    .map(|a| (a.id.to_ascii_lowercase(), a))
                    .collect();
            let session_counts = roboticus_db::agents::list_session_counts_by_agent(&state.db)
                .inspect_err(
                    |e| tracing::warn!(error = %e, "failed to load session counts for sub-agents"),
                )
                .unwrap_or_default();
            let mut booting = 0usize;
            let mut running = 0usize;
            let mut errored = 0usize;
            let items: Vec<serde_json::Value> = agents
                .into_iter()
                .map(|a| {
                    let normalized_role = normalize_role(&a.role).unwrap_or(ROLE_SUBAGENT);
                    let runtime_entry = runtime_by_name.get(&a.name.to_ascii_lowercase());
                    let skills = a
                        .skills_json
                        .as_deref()
                        .and_then(|s| serde_json::from_str::<Vec<String>>(s).ok())
                        .unwrap_or_default();
                    let fallback_models =
                        parse_fallback_models_json(a.fallback_models_json.as_deref());
                    let session_count = session_counts
                        .get(&a.name)
                        .copied()
                        .unwrap_or(a.session_count);
                    let runtime_state = if normalized_role == ROLE_MODEL_PROXY {
                        "n/a".to_string()
                    } else if let Some(inst) = runtime_entry {
                        runtime_state_label(inst.state).to_string()
                    } else if a.enabled {
                        "booting".to_string()
                    } else {
                        "stopped".to_string()
                    };
                    let taskable = a.enabled && runtime_state == "running";
                    if normalized_role != ROLE_MODEL_PROXY {
                        match runtime_state.as_str() {
                            "booting" | "idle" => booting += 1,
                            "running" => running += 1,
                            "error" => errored += 1,
                            _ => {}
                        }
                    }
                    let integrity =
                        crate::api::routes::subagent_integrity::assess_subagent_integrity(
                            &a,
                            runtime_entry,
                            session_count,
                        );
                    serde_json::json!({
                        "id": a.id,
                        "name": a.name,
                        "display_name": a.display_name,
                        "model": a.model,
                        "role": normalized_role,
                        "description": a.description,
                        "skills": skills,
                        "fallback_models": fallback_models,
                        "enabled": a.enabled,
                        "session_count": session_count,
                        "last_used_at": a.last_used_at,
                        "runtime_state": runtime_state,
                        "taskable": taskable,
                        "integrity": {
                            "hollow": !integrity.has_fixed_skills,
                            "missing_session": integrity.missing_session,
                            "runtime_registered": integrity.runtime_registered,
                            "runtime_running": integrity.runtime_running,
                            "runtime_state": integrity.runtime_state,
                            "repairable": integrity.repairable,
                            "inferred_skills": integrity.inferred_skills,
                        }
                    })
                })
                .collect();
            let count = items.len();
            Ok(axum::Json(serde_json::json!({
                "agents": items,
                "count": count,
                "runtime_summary": {
                    "booting": booting,
                    "running": running,
                    "error": errored,
                }
            })))
        }
        Err(e) => Err(internal_err(&e)),
    }
}

pub async fn create_sub_agent(
    State(state): State<AppState>,
    axum::Json(body): axum::Json<CreateSubAgentRequest>,
) -> Result<impl IntoResponse, JsonError> {
    validate_short("name", &body.name)?;
    if let Some(ref d) = body.description {
        validate_long("description", d)?;
    }
    let body = CreateSubAgentRequest {
        name: sanitize_html(&body.name),
        description: body.description.as_deref().map(sanitize_html),
        display_name: body.display_name.as_deref().map(sanitize_html),
        ..body
    };
    validate_subagent_name(&body.name)?;
    let role = normalize_role(&body.role)
        .ok_or_else(|| {
            (
                axum::http::StatusCode::BAD_REQUEST,
                "role must be 'subagent' or 'model-proxy'".to_string(),
            )
        })?
        .to_string();
    let model = normalize_model_input(&body.model);
    let skills = normalize_skills(&body.skills);
    let fallback_models = normalize_fallback_models(&body.fallback_models, &model);
    validate_subagent_contract(&role, &model, &skills, body.personality.as_ref())?;
    let agent = roboticus_db::agents::SubAgentRow {
        id: uuid::Uuid::new_v4().to_string(),
        name: body.name.clone(),
        display_name: body.display_name.or_else(|| {
            Some(
                body.name
                    .split('-')
                    .map(|w| {
                        let mut c = w.chars();
                        match c.next() {
                            None => String::new(),
                            Some(f) => f.to_uppercase().to_string() + c.as_str(),
                        }
                    })
                    .collect::<Vec<_>>()
                    .join(" "),
            )
        }),
        model,
        fallback_models_json: Some(
            serde_json::to_string(&fallback_models).unwrap_or_else(|_| "[]".to_string()),
        ),
        role,
        description: body.description,
        skills_json: Some(serde_json::to_string(&skills).unwrap_or_else(|_| "[]".to_string())),
        enabled: body.enabled,
        session_count: 0,
        last_used_at: None,
    };

    match roboticus_db::agents::upsert_sub_agent(&state.db, &agent) {
        Ok(()) => {
            if agent.enabled && agent.role == ROLE_SUBAGENT {
                let config = roboticus_agent::subagents::AgentInstanceConfig {
                    id: agent.name.clone(),
                    name: agent
                        .display_name
                        .clone()
                        .unwrap_or_else(|| agent.name.clone()),
                    model: resolve_taskable_subagent_runtime_model(&state, &agent.model).await,
                    skills,
                    allowed_subagents: vec![],
                    max_concurrent: 4,
                };
                if let Err(e) = state.registry.register(config).await {
                    tracing::error!(agent = %agent.name, error = %e, "failed to register sub-agent in runtime");
                }
                if let Err(e) = state.registry.start_agent(&agent.name).await {
                    tracing::error!(agent = %agent.name, error = %e, "failed to start sub-agent in runtime");
                }
            }
            Ok(axum::Json(serde_json::json!({
                "id": agent.id,
                "name": agent.name,
                "created": true,
            })))
        }
        Err(e) => Err(internal_err(&e)),
    }
}

pub async fn update_sub_agent(
    State(state): State<AppState>,
    Path(name): Path<String>,
    axum::Json(body): axum::Json<UpdateSubAgentRequest>,
) -> Result<impl IntoResponse, JsonError> {
    if let Some(ref d) = body.description {
        validate_long("description", d)?;
    }
    if let Some(ref d) = body.display_name {
        validate_short("display_name", d)?;
    }
    let agents = roboticus_db::agents::list_sub_agents(&state.db).map_err(|e| internal_err(&e))?;

    let existing = agents.iter().find(|a| a.name == name).ok_or_else(|| {
        (
            axum::http::StatusCode::NOT_FOUND,
            format!("sub-agent '{name}' not found"),
        )
    })?;

    let merged_role = body
        .role
        .as_deref()
        .or(Some(existing.role.as_str()))
        .unwrap_or(ROLE_SUBAGENT);
    let normalized_role = normalize_role(merged_role).ok_or_else(|| {
        (
            axum::http::StatusCode::BAD_REQUEST,
            "role must be 'subagent' or 'model-proxy'".to_string(),
        )
    })?;
    let merged_skills = body.skills.as_deref().map_or_else(
        || {
            existing
                .skills_json
                .as_deref()
                .and_then(|s| serde_json::from_str::<Vec<String>>(s).ok())
                .unwrap_or_default()
        },
        normalize_skills,
    );
    let merged_model = body
        .model
        .as_deref()
        .map(normalize_model_input)
        .filter(|m| !m.is_empty())
        .unwrap_or_else(|| existing.model.clone());
    let merged_fallback_models = body.fallback_models.as_deref().map_or_else(
        || parse_fallback_models_json(existing.fallback_models_json.as_deref()),
        |v| normalize_fallback_models(v, &merged_model),
    );
    validate_subagent_contract(
        normalized_role,
        &merged_model,
        &merged_skills,
        body.personality.as_ref(),
    )?;

    let updated = roboticus_db::agents::SubAgentRow {
        id: existing.id.clone(),
        name: existing.name.clone(),
        display_name: body.display_name.or(existing.display_name.clone()),
        model: merged_model,
        fallback_models_json: Some(
            serde_json::to_string(&merged_fallback_models).unwrap_or_else(|_| "[]".to_string()),
        ),
        role: normalized_role.to_string(),
        description: body.description.or(existing.description.clone()),
        skills_json: Some(
            serde_json::to_string(&merged_skills).unwrap_or_else(|_| "[]".to_string()),
        ),
        enabled: body.enabled.unwrap_or(existing.enabled),
        session_count: existing.session_count,
        last_used_at: existing.last_used_at.clone(),
    };

    roboticus_db::agents::upsert_sub_agent(&state.db, &updated).map_err(|e| internal_err(&e))?;

    if updated.role == ROLE_SUBAGENT && updated.enabled {
        if state.registry.get_agent(&updated.name).await.is_none() {
            let config = roboticus_agent::subagents::AgentInstanceConfig {
                id: updated.name.clone(),
                name: updated
                    .display_name
                    .clone()
                    .unwrap_or_else(|| updated.name.clone()),
                model: resolve_taskable_subagent_runtime_model(&state, &updated.model).await,
                skills: merged_skills.clone(),
                allowed_subagents: vec![],
                max_concurrent: 4,
            };
            if let Err(e) = state.registry.register(config).await {
                tracing::error!(agent = %updated.name, error = %e, "failed to register sub-agent in runtime");
            }
        }
        if let Err(e) = state.registry.start_agent(&updated.name).await {
            tracing::error!(agent = %updated.name, error = %e, "failed to start sub-agent in runtime");
        }
    } else {
        if let Err(e) = state.registry.stop_agent(&updated.name).await {
            tracing::error!(agent = %updated.name, error = %e, "failed to stop sub-agent in runtime");
        }
        if !state.registry.unregister(&updated.name).await {
            tracing::warn!(agent = %updated.name, "sub-agent was not registered in runtime during unregister");
        }
    }

    Ok(axum::Json(serde_json::json!({
        "updated": true,
        "name": name,
    })))
}

pub async fn delete_sub_agent(
    State(state): State<AppState>,
    Path(name): Path<String>,
) -> Result<impl IntoResponse, JsonError> {
    match roboticus_db::agents::delete_sub_agent(&state.db, &name) {
        Ok(true) => {
            if let Err(e) = state.registry.stop_agent(&name).await {
                tracing::error!(agent = %name, error = %e, "failed to stop sub-agent in runtime during delete");
            }
            if !state.registry.unregister(&name).await {
                tracing::warn!(agent = %name, "sub-agent was not registered in runtime during delete");
            }
            Ok(axum::Json(
                serde_json::json!({ "deleted": true, "name": name }),
            ))
        }
        Ok(false) => Err(not_found(format!("sub-agent '{name}' not found"))),
        Err(e) => Err(internal_err(&e)),
    }
}

pub async fn toggle_sub_agent(
    State(state): State<AppState>,
    Path(name): Path<String>,
) -> Result<impl IntoResponse, JsonError> {
    let agents = roboticus_db::agents::list_sub_agents(&state.db).map_err(|e| internal_err(&e))?;

    let existing = agents.iter().find(|a| a.name == name).ok_or_else(|| {
        (
            axum::http::StatusCode::NOT_FOUND,
            format!("sub-agent '{name}' not found"),
        )
    })?;

    let new_enabled = !existing.enabled;
    let mut updated = existing.clone();
    updated.enabled = new_enabled;

    roboticus_db::agents::upsert_sub_agent(&state.db, &updated).map_err(|e| internal_err(&e))?;

    if updated.role == ROLE_SUBAGENT && updated.enabled {
        if state.registry.get_agent(&updated.name).await.is_none() {
            let skills = updated
                .skills_json
                .as_deref()
                .and_then(|s| serde_json::from_str::<Vec<String>>(s).ok())
                .unwrap_or_default();
            let config = roboticus_agent::subagents::AgentInstanceConfig {
                id: updated.name.clone(),
                name: updated
                    .display_name
                    .clone()
                    .unwrap_or_else(|| updated.name.clone()),
                model: resolve_taskable_subagent_runtime_model(&state, &updated.model).await,
                skills,
                allowed_subagents: vec![],
                max_concurrent: 4,
            };
            if let Err(e) = state.registry.register(config).await {
                tracing::error!(agent = %updated.name, error = %e, "failed to register sub-agent in runtime");
            }
        }
        if let Err(e) = state.registry.start_agent(&updated.name).await {
            tracing::error!(agent = %updated.name, error = %e, "failed to start sub-agent in runtime");
        }
    } else {
        if let Err(e) = state.registry.stop_agent(&updated.name).await {
            tracing::error!(agent = %updated.name, error = %e, "failed to stop sub-agent in runtime");
        }
        if !state.registry.unregister(&updated.name).await {
            tracing::warn!(agent = %updated.name, "sub-agent was not registered in runtime during toggle");
        }
    }

    Ok(axum::Json(serde_json::json!({
        "name": name,
        "enabled": new_enabled,
    })))
}

// ── retirement (unused / never-invoked specialists) ─────────────────

#[derive(Deserialize)]
pub struct SubagentRetirementQuery {
    #[serde(default = "default_retirement_min_age")]
    pub min_age_days: i32,
}

fn default_retirement_min_age() -> i32 {
    30
}

#[derive(Deserialize)]
pub struct RetireUnusedSubagentsRequest {
    #[serde(default = "default_retirement_min_age")]
    pub min_age_days: i32,
    #[serde(default)]
    pub dry_run: bool,
    #[serde(default)]
    pub names: Option<Vec<String>>,
}

/// Shared implementation for HTTP and virtual orchestration tools.
pub(crate) async fn execute_retire_unused_subagents_tool(
    state: &AppState,
    min_age_days: i32,
    dry_run: bool,
    names: Option<Vec<String>>,
) -> Result<serde_json::Value, String> {
    let candidates =
        roboticus_db::agents::list_subagent_retirement_candidates(&state.db, min_age_days)
            .map_err(|e| e.to_string())?;
    let eligible_count = candidates.iter().filter(|c| c.eligible).count();

    let mut to_retire: Vec<String> = candidates
        .iter()
        .filter(|c| c.eligible)
        .filter(|c| {
            names
                .as_ref()
                .map(|ns| ns.iter().any(|n| n == &c.name))
                .unwrap_or(true)
        })
        .map(|c| c.name.clone())
        .collect();
    to_retire.sort();
    to_retire.dedup();

    if dry_run {
        return Ok(serde_json::json!({
            "dry_run": true,
            "min_age_days": min_age_days,
            "candidates": candidates,
            "eligible_count": eligible_count,
            "would_retire": to_retire,
            "count": to_retire.len(),
        }));
    }

    let agents = roboticus_db::agents::list_sub_agents(&state.db).map_err(|e| e.to_string())?;
    let mut to_disable: Vec<String> = Vec::new();
    for name in &to_retire {
        let Some(existing) = agents.iter().find(|a| a.name == *name) else {
            continue;
        };
        if normalize_role(&existing.role) != Some(ROLE_SUBAGENT) {
            continue;
        }
        if !existing.enabled {
            continue;
        }
        to_disable.push(name.clone());
    }
    to_disable.sort();
    to_disable.dedup();

    roboticus_db::agents::disable_subagents_by_name(&state.db, &to_disable)
        .map_err(|e| e.to_string())?;

    let mut retired = Vec::new();
    for name in &to_disable {
        if let Err(e) = state.registry.stop_agent(name).await {
            tracing::error!(agent = %name, error = %e, "failed to stop sub-agent during retirement");
        }
        if !state.registry.unregister(name).await {
            tracing::warn!(agent = %name, "sub-agent was not registered during retirement unregister");
        }
        retired.push(name.clone());
    }

    Ok(serde_json::json!({
        "dry_run": false,
        "min_age_days": min_age_days,
        "candidates": candidates,
        "eligible_count": eligible_count,
        "retired": retired,
        "count": retired.len(),
    }))
}

pub async fn get_subagent_retirement_candidates(
    State(state): State<AppState>,
    axum::extract::Query(query): axum::extract::Query<SubagentRetirementQuery>,
) -> Result<impl IntoResponse, JsonError> {
    let candidates =
        roboticus_db::agents::list_subagent_retirement_candidates(&state.db, query.min_age_days)
            .map_err(|e| internal_err(&e))?;
    let eligible_count = candidates.iter().filter(|c| c.eligible).count();
    Ok(axum::Json(serde_json::json!({
        "min_age_days": query.min_age_days,
        "candidates": candidates,
        "eligible_count": eligible_count,
    })))
}

pub async fn retire_unused_subagents(
    State(state): State<AppState>,
    axum::Json(body): axum::Json<RetireUnusedSubagentsRequest>,
) -> Result<impl IntoResponse, JsonError> {
    let v =
        execute_retire_unused_subagents_tool(&state, body.min_age_days, body.dry_run, body.names)
            .await
            .map_err(|e| internal_err(&e))?;
    Ok(axum::Json(v))
}

#[cfg(test)]
mod tests {
    use super::*;

    // ── default_role / default_model / default_true ──────────────

    #[test]
    fn default_role_is_subagent() {
        assert_eq!(default_role(), "subagent");
    }

    #[test]
    fn default_model_is_auto() {
        assert_eq!(default_model(), "auto");
    }

    #[test]
    fn default_true_returns_true() {
        assert!(default_true());
    }

    // ── validate_subagent_name ──────────────────────────────────

    #[test]
    fn validate_subagent_name_accepts_valid_names() {
        assert!(validate_subagent_name("geo-specialist").is_ok());
        assert!(validate_subagent_name("agent_1").is_ok());
        assert!(validate_subagent_name("A").is_ok());
        assert!(validate_subagent_name("abc-def_123").is_ok());
    }

    #[test]
    fn validate_subagent_name_rejects_empty() {
        let err = validate_subagent_name("").unwrap_err();
        assert_eq!(err.0, axum::http::StatusCode::BAD_REQUEST);
        assert!(err.1.contains("empty"));
    }

    #[test]
    fn validate_subagent_name_rejects_too_long() {
        let long_name = "a".repeat(MAX_SUBAGENT_NAME_LEN + 1);
        let err = validate_subagent_name(&long_name).unwrap_err();
        assert_eq!(err.0, axum::http::StatusCode::BAD_REQUEST);
        assert!(err.1.contains("max length"));
    }

    #[test]
    fn validate_subagent_name_rejects_special_chars() {
        let err = validate_subagent_name("agent name").unwrap_err();
        assert!(err.1.contains("alphanumeric"));

        let err = validate_subagent_name("agent.name").unwrap_err();
        assert!(err.1.contains("alphanumeric"));

        let err = validate_subagent_name("agent/name").unwrap_err();
        assert!(err.1.contains("alphanumeric"));
    }

    #[test]
    fn validate_subagent_name_at_boundary_length() {
        let exactly_max = "a".repeat(MAX_SUBAGENT_NAME_LEN);
        assert!(validate_subagent_name(&exactly_max).is_ok());
    }

    // ── normalize_role ──────────────────────────────────────────

    #[test]
    fn normalize_role_accepts_subagent_and_specialist() {
        assert_eq!(normalize_role("subagent"), Some(ROLE_SUBAGENT));
        assert_eq!(normalize_role("specialist"), Some(ROLE_SUBAGENT));
        assert_eq!(normalize_role("  Subagent  "), Some(ROLE_SUBAGENT));
        assert_eq!(normalize_role("SPECIALIST"), Some(ROLE_SUBAGENT));
    }

    #[test]
    fn normalize_role_accepts_model_proxy() {
        assert_eq!(normalize_role("model-proxy"), Some(ROLE_MODEL_PROXY));
        assert_eq!(normalize_role("MODEL-PROXY"), Some(ROLE_MODEL_PROXY));
    }

    #[test]
    fn normalize_role_rejects_unknown() {
        assert_eq!(normalize_role("orchestrator"), None);
        assert_eq!(normalize_role(""), None);
        assert_eq!(normalize_role("worker"), None);
    }

    // ── normalize_skills ────────────────────────────────────────

    #[test]
    fn normalize_skills_deduplicates_and_sorts() {
        let skills = vec![
            "risk".to_string(),
            "geo".to_string(),
            "risk".to_string(),
            "  geo  ".to_string(),
        ];
        let out = normalize_skills(&skills);
        assert_eq!(out, vec!["geo", "risk"]);
    }

    #[test]
    fn normalize_skills_removes_empty_and_whitespace() {
        let skills = vec!["".to_string(), "  ".to_string(), "analysis".to_string()];
        let out = normalize_skills(&skills);
        assert_eq!(out, vec!["analysis"]);
    }

    #[test]
    fn normalize_skills_empty_input() {
        assert!(normalize_skills(&[]).is_empty());
    }

    #[test]
    fn normalize_fallback_models_preserves_order() {
        let models = vec![
            "moonshot/kimi-k2-turbo-preview".to_string(),
            "openrouter/openai/gpt-4o".to_string(),
            "moonshot/kimi-k2-turbo-preview".to_string(),
        ];
        let out = normalize_fallback_models(&models, "openai/gpt-5.3-codex");
        assert_eq!(
            out,
            vec!["moonshot/kimi-k2-turbo-preview", "openrouter/openai/gpt-4o"]
        );
    }

    #[test]
    fn normalize_fallback_models_drops_primary_case_insensitively() {
        let models = vec![
            "moonshot/kimi-k2-turbo-preview".to_string(),
            "MOONSHOT/KIMI-K2-TURBO-PREVIEW".to_string(),
            "openrouter/openai/gpt-4o".to_string(),
        ];
        let out = normalize_fallback_models(&models, "moonshot/kimi-k2-turbo-preview");
        assert_eq!(out, vec!["openrouter/openai/gpt-4o"]);
    }

    // ── normalize_model_input ───────────────────────────────────

    #[test]
    fn normalize_model_input_trims_whitespace() {
        assert_eq!(normalize_model_input("  openai/gpt-4o  "), "openai/gpt-4o");
        assert_eq!(normalize_model_input("auto"), "auto");
    }

    // ── is_model_mode ───────────────────────────────────────────

    #[test]
    fn is_model_mode_detects_auto_and_orchestrator() {
        assert!(is_model_mode("auto"));
        assert!(is_model_mode("AUTO"));
        assert!(is_model_mode("orchestrator"));
        assert!(is_model_mode("ORCHESTRATOR"));
        assert!(is_model_mode("  auto  "));
    }

    #[test]
    fn is_model_mode_rejects_concrete_models() {
        assert!(!is_model_mode("openai/gpt-4o"));
        assert!(!is_model_mode("anthropic/claude-sonnet-4-20250514"));
        assert!(!is_model_mode(""));
    }

    #[test]
    fn is_concrete_provider_model_requires_provider_slash_model() {
        assert!(is_concrete_provider_model("openai/gpt-4o"));
        assert!(is_concrete_provider_model("ollama/qwen3:8b"));
        assert!(!is_concrete_provider_model("orca-ata"));
        assert!(!is_concrete_provider_model("openai/"));
        assert!(!is_concrete_provider_model("/gpt-4o"));
    }

    // ── validate_subagent_contract ──────────────────────────────

    #[test]
    fn validate_contract_accepts_valid_subagent() {
        assert!(
            validate_subagent_contract(
                "subagent",
                "auto",
                &["geo".to_string(), "risk".to_string()],
                None
            )
            .is_ok()
        );
    }

    #[test]
    fn validate_contract_accepts_valid_model_proxy() {
        assert!(validate_subagent_contract("model-proxy", "openai/gpt-4o", &[], None).is_ok());
    }

    #[test]
    fn validate_contract_rejects_unknown_role() {
        let err = validate_subagent_contract("worker", "auto", &[], None).unwrap_err();
        assert!(err.1.contains("role must be"));
    }

    #[test]
    fn validate_contract_rejects_personality() {
        let personality = serde_json::json!({"tone": "formal"});
        let err =
            validate_subagent_contract("subagent", "auto", &[], Some(&personality)).unwrap_err();
        assert!(err.1.contains("personality"));
    }

    #[test]
    fn validate_contract_rejects_model_proxy_with_skills() {
        let err =
            validate_subagent_contract("model-proxy", "openai/gpt-4o", &["geo".to_string()], None)
                .unwrap_err();
        assert!(err.1.contains("cannot own skills"));
    }

    #[test]
    fn validate_contract_rejects_empty_model() {
        let err = validate_subagent_contract("subagent", "  ", &[], None).unwrap_err();
        assert!(err.1.contains("model cannot be empty"));
    }

    #[test]
    fn validate_contract_rejects_model_proxy_with_auto() {
        let err = validate_subagent_contract("model-proxy", "auto", &[], None).unwrap_err();
        assert!(err.1.contains("concrete provider/model"));
    }

    #[test]
    fn validate_contract_rejects_model_proxy_with_orchestrator() {
        let err = validate_subagent_contract("model-proxy", "orchestrator", &[], None).unwrap_err();
        assert!(err.1.contains("concrete provider/model"));
    }

    #[test]
    fn validate_contract_rejects_invalid_fixed_model_identifier() {
        let err = validate_subagent_contract("subagent", "orca-ata", &[], None).unwrap_err();
        assert!(err.1.contains("provider/model format"));
    }

    // ── runtime_state_label ─────────────────────────────────────

    #[test]
    fn runtime_state_label_maps_all_states() {
        use roboticus_agent::subagents::AgentRunState;
        assert_eq!(runtime_state_label(AgentRunState::Idle), "idle");
        assert_eq!(runtime_state_label(AgentRunState::Starting), "booting");
        assert_eq!(runtime_state_label(AgentRunState::Running), "running");
        assert_eq!(runtime_state_label(AgentRunState::Stopped), "stopped");
        assert_eq!(runtime_state_label(AgentRunState::Error), "error");
    }

    // ── CreateSubAgentRequest deserialization ────────────────────

    #[test]
    fn create_request_defaults() {
        let json = serde_json::json!({
            "name": "test-agent"
        });
        let req: CreateSubAgentRequest = serde_json::from_value(json).unwrap();
        assert_eq!(req.name, "test-agent");
        assert_eq!(req.model, "auto");
        assert_eq!(req.role, "subagent");
        assert!(req.enabled);
        assert!(req.skills.is_empty());
        assert!(req.display_name.is_none());
        assert!(req.description.is_none());
        assert!(req.personality.is_none());
    }

    #[test]
    fn create_request_with_all_fields() {
        let json = serde_json::json!({
            "name": "geo-specialist",
            "display_name": "Geo Specialist",
            "model": "openai/gpt-4o",
            "role": "model-proxy",
            "description": "proxy for openai",
            "skills": [],
            "enabled": false
        });
        let req: CreateSubAgentRequest = serde_json::from_value(json).unwrap();
        assert_eq!(req.name, "geo-specialist");
        assert_eq!(req.display_name.as_deref(), Some("Geo Specialist"));
        assert_eq!(req.model, "openai/gpt-4o");
        assert_eq!(req.role, "model-proxy");
        assert!(!req.enabled);
    }

    // ── UpdateSubAgentRequest deserialization ────────────────────

    #[test]
    fn update_request_all_none() {
        let json = serde_json::json!({});
        let req: UpdateSubAgentRequest = serde_json::from_value(json).unwrap();
        assert!(req.display_name.is_none());
        assert!(req.model.is_none());
        assert!(req.role.is_none());
        assert!(req.description.is_none());
        assert!(req.skills.is_none());
        assert!(req.personality.is_none());
        assert!(req.enabled.is_none());
    }

    #[test]
    fn update_request_partial_fields() {
        let json = serde_json::json!({
            "model": "anthropic/claude-sonnet-4-20250514",
            "enabled": false
        });
        let req: UpdateSubAgentRequest = serde_json::from_value(json).unwrap();
        assert_eq!(
            req.model.as_deref(),
            Some("anthropic/claude-sonnet-4-20250514")
        );
        assert_eq!(req.enabled, Some(false));
        assert!(req.role.is_none());
    }
}
