//! The ACP agent: shared state, the connection builder, and the per-method
//! handlers. Handlers are thin — they validate, look up/insert a session, and
//! delegate the turn loop to [`crate::session`].

use std::collections::HashMap;
use std::path::PathBuf;
use std::sync::atomic::{AtomicU64, Ordering};
use std::sync::{Arc, Mutex};

use agent_client_protocol as acp;
use agent_client_protocol::schema::{
    AgentCapabilities, AvailableCommand, AvailableCommandInput, AvailableCommandsUpdate,
    CancelNotification, ContentBlock, CurrentModeUpdate, Implementation, InitializeRequest,
    InitializeResponse, LoadSessionRequest, LoadSessionResponse, McpCapabilities, McpServer,
    NewSessionRequest, NewSessionResponse, PromptRequest, PromptResponse, SessionConfigOption,
    SessionConfigOptionCategory, SessionConfigOptionValue, SessionConfigSelectGroup,
    SessionConfigSelectOption, SessionConfigSelectOptions, SessionId, SessionMode,
    SessionModeState, SessionNotification, SessionUpdate, SetSessionConfigOptionRequest,
    SetSessionConfigOptionResponse, SetSessionModeRequest, SetSessionModeResponse,
    UnstructuredCommandInput,
};
use agent_client_protocol::{Agent, Client as AcpRole, ConnectionTo, Dispatch, Responder, Stdio};
use hagent_core::config::ToolKind;
use hagent_core::protocol::request::ReasoningConfig;
use hagent_core::{
    Client, ConvLogger, Conversation, McpServerSettings, RenderedBlock, Settings, TokenStore,
};

use crate::session::replay_transcript;

/// Reasoning effort levels exposed as ACP session modes (mirrors hagent's
/// `/effort` picker). `off` clears the `reasoning` field.
const EFFORT_LEVELS: [&str; 7] = ["minimal", "low", "medium", "high", "xhigh", "max", "off"];

use crate::session::{SessionHandle, SessionState, run_turn};

/// Monotonic counter for synthesising session ids when logging (which would
/// otherwise supply the id) is disabled.
static SESSION_SEQ: AtomicU64 = AtomicU64::new(0);

/// Process-wide agent state shared across all handler invocations.
pub struct AgentState {
    /// The resolved settings template; each session clones and may diverge it.
    pub settings: Settings,
    /// Config path, for persisting a bootstrapped skill id / extension opt-in.
    pub config_path: Option<PathBuf>,
    /// Whether to upload the file-editing skill on `session/new` (shell + no
    /// cached skill). Suppressed by `--no-bootstrap`.
    pub bootstrap: bool,
    /// Whether the model picker groups options by provider. `false` (a flat
    /// list) is for clients that don't render grouped selects, e.g. JetBrains.
    pub group_models: bool,
    pub token_store: Arc<TokenStore>,
    pub sessions: Mutex<HashMap<SessionId, Arc<SessionHandle>>>,
}

impl AgentState {
    pub fn new(
        settings: Settings,
        config_path: Option<PathBuf>,
        bootstrap: bool,
        group_models: bool,
    ) -> Self {
        Self {
            settings,
            config_path,
            bootstrap,
            group_models,
            token_store: Arc::new(TokenStore::new()),
            sessions: Mutex::new(HashMap::new()),
        }
    }

    fn session(&self, id: &SessionId) -> Option<Arc<SessionHandle>> {
        self.sessions.lock().unwrap().get(id).cloned()
    }
}

/// Build the ACP connection and serve it over stdio until the client
/// disconnects. The 0.11+ SDK made connection futures `Send`, so this runs on a
/// plain multi-thread runtime — no `LocalSet`.
pub async fn run(state: Arc<AgentState>) -> acp::Result<()> {
    Agent
        .builder()
        .name("hagent-acp")
        .on_receive_request(
            async move |req: InitializeRequest, responder, _cx: ConnectionTo<AcpRole>| {
                responder.respond(initialize_response(&req))
            },
            acp::on_receive_request!(),
        )
        .on_receive_request(
            {
                let state = state.clone();
                async move |req: NewSessionRequest, responder, cx: ConnectionTo<AcpRole>| {
                    new_session(&state, req, responder, cx).await
                }
            },
            acp::on_receive_request!(),
        )
        .on_receive_request(
            {
                let state = state.clone();
                async move |req: PromptRequest, responder, cx: ConnectionTo<AcpRole>| {
                    prompt(&state, req, responder, cx)
                }
            },
            acp::on_receive_request!(),
        )
        .on_receive_request(
            {
                let state = state.clone();
                async move |req: LoadSessionRequest, responder, cx: ConnectionTo<AcpRole>| {
                    load_session(&state, req, responder, cx).await
                }
            },
            acp::on_receive_request!(),
        )
        .on_receive_request(
            {
                let state = state.clone();
                async move |req: SetSessionModeRequest, responder, cx: ConnectionTo<AcpRole>| {
                    set_session_mode(&state, req, responder, cx)
                }
            },
            acp::on_receive_request!(),
        )
        .on_receive_request(
            {
                let state = state.clone();
                async move |req: SetSessionConfigOptionRequest,
                            responder,
                            cx: ConnectionTo<AcpRole>| {
                    set_config_option(&state, req, responder, cx)
                }
            },
            acp::on_receive_request!(),
        )
        .on_receive_notification(
            {
                let state = state.clone();
                async move |notif: CancelNotification, _cx: ConnectionTo<AcpRole>| {
                    if let Some(session) = state.session(&notif.session_id) {
                        session.trigger_cancel();
                    }
                    Ok(())
                }
            },
            acp::on_receive_notification!(),
        )
        .on_receive_dispatch(
            async move |message: Dispatch, cx: ConnectionTo<AcpRole>| {
                message.respond_with_error(acp::util::internal_error("unhandled message"), cx)
            },
            acp::on_receive_dispatch!(),
        )
        .connect_to(Stdio::new())
        .await
}

/// Static `initialize` response: advertise `loadSession` and remote MCP (the
/// endpoint hosts MCP). Image prompts are gated off until we read the model
/// catalog (conservative default).
fn initialize_response(req: &InitializeRequest) -> InitializeResponse {
    InitializeResponse::new(req.protocol_version)
        .agent_capabilities(
            AgentCapabilities::new()
                .load_session(true)
                .mcp_capabilities(McpCapabilities::new().http(true).sse(true)),
        )
        .agent_info(Implementation::new("hagent-acp", env!("CARGO_PKG_VERSION")))
}

/// `session/new`: clone the settings template, open a logger + client, bootstrap
/// the file-editing skill if needed, and register a fresh session.
async fn new_session(
    state: &Arc<AgentState>,
    req: NewSessionRequest,
    responder: Responder<NewSessionResponse>,
    cx: ConnectionTo<AcpRole>,
) -> acp::Result<()> {
    match build_session(state, &req).await {
        Ok((session_id, handle, modes, config_options)) => {
            state
                .sessions
                .lock()
                .unwrap()
                .insert(session_id.clone(), handle);
            advertise_commands(&cx, &session_id);
            tracing::info!(session = %session_id, "session created");
            responder.respond(
                NewSessionResponse::new(session_id)
                    .modes(modes)
                    .config_options(config_options),
            )
        }
        Err(e) => {
            tracing::error!("session/new failed: {e:#}");
            responder.respond_with_internal_error(e.to_string())
        }
    }
}

/// Assemble a [`SessionHandle`] for a new session (client, logger, conversation,
/// an optional skill bootstrap, and the model/tool config options).
async fn build_session(
    state: &Arc<AgentState>,
    req: &NewSessionRequest,
) -> anyhow::Result<(
    SessionId,
    Arc<SessionHandle>,
    SessionModeState,
    Vec<SessionConfigOption>,
)> {
    let mut settings = state.settings.clone();
    merge_acp_mcp(&mut settings, &req.mcp_servers);

    let logger = match settings.log_dir.as_deref() {
        Some(dir) => match ConvLogger::create(dir, &settings) {
            Ok(l) => Some(l),
            Err(e) => {
                tracing::warn!("conversation logging disabled: {e}");
                None
            }
        },
        None => None,
    };

    let client = Client::new(settings.base_url.clone(), settings.api_key.clone())?
        .with_logger(logger.clone());

    // Bootstrap the bundled file-editing skill when the shell tool is on and no
    // skill is cached for this server (mirrors `maybe_bootstrap_headless`).
    if state.bootstrap && settings.tools.contains(&ToolKind::Shell) && settings.skill_id.is_none() {
        match client.ensure_skill().await {
            Ok(skill) => {
                if let Some(path) = &state.config_path {
                    let _ = hagent_core::persist_skill_id(
                        path,
                        &settings.base_url,
                        settings.api_key.as_deref(),
                        &skill.id,
                    );
                }
                tracing::info!(skill = %skill.id, "file-editing skill mounted");
                settings.skill_id = Some(skill.id);
            }
            Err(e) => tracing::warn!("skill bootstrap failed (continuing without): {e}"),
        }
    }

    let session_id = match &logger {
        Some(l) => SessionId::from(l.conversation_id().to_string()),
        None => SessionId::from(fresh_session_id()),
    };
    let created_ms = hagent_core::convlog::now_ms();
    let model_choices = fetch_model_choices(&client).await;
    let config_options = config_options(&settings, &model_choices, state.group_models);
    let modes = effort_mode_state(&settings);
    let handle = Arc::new(SessionHandle::new(
        client,
        SessionState {
            conv: Conversation::new(),
            settings,
            logger,
            created_ms,
            remembered: HashMap::new(),
            model_choices,
            group_models: state.group_models,
        },
    ));
    Ok((session_id, handle, modes, config_options))
}

/// `session/load`: restore a snapshot, rebuild the client around its resumed
/// logger, register the session, and replay its transcript so the editor
/// repaints history.
async fn load_session(
    state: &Arc<AgentState>,
    req: LoadSessionRequest,
    responder: Responder<LoadSessionResponse>,
    cx: ConnectionTo<AcpRole>,
) -> acp::Result<()> {
    match build_loaded_session(state, &req).await {
        Ok((handle, transcript, modes, config_options)) => {
            state
                .sessions
                .lock()
                .unwrap()
                .insert(req.session_id.clone(), handle);
            replay_transcript(&cx, &req.session_id, &transcript);
            advertise_commands(&cx, &req.session_id);
            tracing::info!(session = %req.session_id, "session loaded");
            responder.respond(
                LoadSessionResponse::new()
                    .modes(modes)
                    .config_options(config_options),
            )
        }
        Err(e) => {
            tracing::error!("session/load failed: {e:#}");
            responder.respond_with_internal_error(e.to_string())
        }
    }
}

/// Restore a session from its on-disk snapshot. Returns the handle, the
/// transcript to replay, the effort mode state, and the config options.
async fn build_loaded_session(
    state: &Arc<AgentState>,
    req: &LoadSessionRequest,
) -> anyhow::Result<(
    Arc<SessionHandle>,
    Vec<RenderedBlock>,
    SessionModeState,
    Vec<SessionConfigOption>,
)> {
    let dir =
        state.settings.log_dir.as_deref().ok_or_else(|| {
            anyhow::anyhow!("conversation logging is disabled; cannot load sessions")
        })?;
    let id = req.session_id.to_string();
    let snapshot = hagent_core::convlog::load_snapshot(dir, &id)?;

    let mut settings = state.settings.clone();
    merge_acp_mcp(&mut settings, &req.mcp_servers);
    let logger = ConvLogger::resume(dir, &id, &settings).ok();
    let client = Client::new(settings.base_url.clone(), settings.api_key.clone())?
        .with_logger(logger.clone());

    let conv = Conversation::restore(&snapshot);
    let transcript = conv.transcript.clone();
    let created_ms = snapshot.created_ms;
    let model_choices = fetch_model_choices(&client).await;
    let config_options = config_options(&settings, &model_choices, state.group_models);
    let modes = effort_mode_state(&settings);
    let handle = Arc::new(SessionHandle::new(
        client,
        SessionState {
            conv,
            settings,
            logger,
            created_ms,
            remembered: HashMap::new(),
            model_choices,
            group_models: state.group_models,
        },
    ));
    Ok((handle, transcript, modes, config_options))
}

/// `session/set_mode`: map the chosen mode id to a reasoning effort and apply it
/// to the session. Spawned off the dispatch loop because acquiring the turn lock
/// would otherwise block message processing while a turn holds it.
fn set_session_mode(
    state: &Arc<AgentState>,
    req: SetSessionModeRequest,
    responder: Responder<SetSessionModeResponse>,
    cx: ConnectionTo<AcpRole>,
) -> acp::Result<()> {
    let Some(session) = state.session(&req.session_id) else {
        return responder
            .respond_with_internal_error(format!("unknown session {}", req.session_id));
    };
    let mode_id = req.mode_id.clone();
    let session_id = req.session_id.clone();
    let cx2 = cx.clone();
    cx.spawn(async move {
        {
            let mut guard = session.state.lock().await;
            apply_effort(&mut guard.settings, &mode_id.to_string());
        }
        let _ = cx2.send_notification(SessionNotification::new(
            session_id,
            SessionUpdate::CurrentModeUpdate(CurrentModeUpdate::new(mode_id)),
        ));
        Ok(())
    })?;
    responder.respond(SetSessionModeResponse::new())
}

/// `session/prompt`: flatten the prompt blocks and spawn the turn off the
/// dispatch loop (so it may issue `request_permission` without deadlocking),
/// responding with the `StopReason` when it settles.
fn prompt(
    state: &Arc<AgentState>,
    req: PromptRequest,
    responder: Responder<PromptResponse>,
    cx: ConnectionTo<AcpRole>,
) -> acp::Result<()> {
    let Some(session) = state.session(&req.session_id) else {
        return responder
            .respond_with_internal_error(format!("unknown session {}", req.session_id));
    };
    let text = flatten_prompt(&req.prompt);
    let session_id = req.session_id.clone();
    let store = state.token_store.clone();
    let cx2 = cx.clone();
    cx.spawn(async move {
        let stop = run_turn(&session, &cx2, &store, session_id, Some(text)).await;
        let _ = responder.respond(PromptResponse::new(stop));
        Ok(())
    })
}

/// Flatten ACP prompt content blocks into the user text we send upstream. Only
/// `Text` is consumed today; richer blocks (resource links, images) are a later
/// phase gated on prompt capabilities.
fn flatten_prompt(blocks: &[ContentBlock]) -> String {
    let mut out = String::new();
    for block in blocks {
        if let ContentBlock::Text(t) = block {
            if !out.is_empty() {
                out.push('\n');
            }
            out.push_str(&t.text);
        }
    }
    out
}

fn fresh_session_id() -> String {
    format!(
        "acp-{}-{}",
        hagent_core::convlog::now_ms(),
        SESSION_SEQ.fetch_add(1, Ordering::Relaxed)
    )
}

/// Merge ACP-client-provided MCP servers (HTTP/SSE) into the config-derived set;
/// config.toml / .mcp.json wins on a label conflict (mirrors hagent's merge).
/// Stdio servers are skipped — the endpoint executes server-side and can't reach
/// a stdio process on the editor's machine.
fn merge_acp_mcp(settings: &mut Settings, servers: &[McpServer]) {
    let mut skipped = 0usize;
    for server in servers {
        match acp_mcp_to_settings(server) {
            Some(s) => {
                if !settings
                    .mcp_servers
                    .iter()
                    .any(|e| e.server_label == s.server_label)
                {
                    settings.mcp_servers.push(s);
                }
            }
            None => skipped += 1,
        }
    }
    if skipped > 0 {
        tracing::warn!("{skipped} ACP-provided stdio MCP server(s) ignored (HTTP/SSE only)");
    }
}

/// The session's reasoning effort exposed as ACP session modes, with the current
/// effort selected. `off` (no reasoning) is the current mode when `reasoning` is
/// unset.
fn effort_mode_state(settings: &Settings) -> SessionModeState {
    let current = settings
        .reasoning
        .as_ref()
        .and_then(|r| r.effort.clone())
        .unwrap_or_else(|| "off".to_string());
    let modes = EFFORT_LEVELS
        .iter()
        .map(|e| SessionMode::new(*e, *e))
        .collect();
    SessionModeState::new(current, modes)
}

/// Apply a reasoning-effort mode id to the session settings. `off` clears the
/// `reasoning` field; any other level sets it (preserving the summary verbosity,
/// defaulting to `auto`). Also called by the `/effort` slash command.
pub(crate) fn apply_effort(settings: &mut Settings, effort: &str) {
    if effort == "off" {
        settings.reasoning = None;
    } else {
        let summary = settings
            .reasoning
            .as_ref()
            .and_then(|r| r.summary.clone())
            .or_else(|| Some("auto".to_string()));
        settings.reasoning = Some(ReasoningConfig {
            effort: Some(effort.to_string()),
            summary,
        });
    }
}

/// The tool config-option ids, paired with their `ToolKind` and label.
const TOOL_OPTIONS: [(&str, ToolKind, &str); 4] = [
    ("tool:shell", ToolKind::Shell, "Shell tool"),
    ("tool:web_search", ToolKind::WebSearch, "Web search tool"),
    ("tool:file_search", ToolKind::FileSearch, "File search tool"),
    ("tool:mcp", ToolKind::Mcp, "MCP tool"),
];

/// Build the model dropdown options. When `grouped`, options are grouped by
/// provider (the id's first path segment, e.g. `openrouter`, `azure`,
/// `anthropic`; ids without a `/` go in an `other` group, alphabetical, labels
/// drop the provider prefix). Otherwise a flat list with full ids as labels —
/// for clients that don't render grouped selects (e.g. JetBrains). The current
/// model is always included, even if the server didn't list it.
fn model_options(
    model_choices: &[String],
    current: &str,
    grouped: bool,
) -> SessionConfigSelectOptions {
    let mut models: Vec<&str> = model_choices.iter().map(String::as_str).collect();
    if !models.contains(&current) {
        models.insert(0, current);
    }

    if !grouped {
        let flat: Vec<SessionConfigSelectOption> = models
            .into_iter()
            .map(|id| SessionConfigSelectOption::new(id.to_string(), id.to_string()))
            .collect();
        return flat.into();
    }

    let mut groups: std::collections::BTreeMap<&str, Vec<SessionConfigSelectOption>> =
        std::collections::BTreeMap::new();
    for id in models {
        let (provider, label) = id.split_once('/').unwrap_or(("other", id));
        groups
            .entry(provider)
            .or_default()
            .push(SessionConfigSelectOption::new(
                id.to_string(),
                label.to_string(),
            ));
    }

    let groups: Vec<SessionConfigSelectGroup> = groups
        .into_iter()
        .map(|(provider, options)| {
            SessionConfigSelectGroup::new(provider.to_string(), provider.to_string(), options)
        })
        .collect();
    groups.into()
}

/// Build the session config options the editor renders: a Model selector
/// (from the server's `/v1/models`, grouped by provider unless `group_models` is
/// false) and a boolean toggle per server-side tool.
pub(crate) fn config_options(
    settings: &Settings,
    model_choices: &[String],
    group_models: bool,
) -> Vec<SessionConfigOption> {
    let mut options = Vec::new();

    options.push(
        SessionConfigOption::select(
            "model",
            "Model",
            settings.model.clone(),
            model_options(model_choices, &settings.model, group_models),
        )
        .category(SessionConfigOptionCategory::Model),
    );

    for (id, kind, label) in TOOL_OPTIONS {
        options.push(
            SessionConfigOption::boolean(id, label, settings.tools.contains(&kind))
                .category(SessionConfigOptionCategory::Other("tools".to_string())),
        );
    }
    options
}

/// The slash-commands advertised to the editor (handled locally in `run_turn`).
fn available_commands() -> Vec<AvailableCommand> {
    vec![
        AvailableCommand::new("model", "Switch the model for this session").input(
            AvailableCommandInput::Unstructured(UnstructuredCommandInput::new("model id")),
        ),
        AvailableCommand::new(
            "effort",
            "Set reasoning effort (minimal|low|medium|high|xhigh|max|off)",
        )
        .input(AvailableCommandInput::Unstructured(
            UnstructuredCommandInput::new("level"),
        )),
        AvailableCommand::new(
            "status",
            "Show the current model, effort, tools, and token usage",
        ),
    ]
}

/// Fetch the model ids the server offers (best-effort; empty on failure).
async fn fetch_model_choices(client: &Client) -> Vec<String> {
    match client.list_models().await {
        Ok(models) => models.into_iter().map(|m| m.id).collect(),
        Err(e) => {
            tracing::warn!("couldn't list models for the picker: {e}");
            Vec::new()
        }
    }
}

/// Advertise the available slash commands for a session (deferred via `spawn` so
/// the notification follows the session response on the wire).
fn advertise_commands(cx: &ConnectionTo<AcpRole>, session_id: &SessionId) {
    let cx2 = cx.clone();
    let sid = session_id.clone();
    let _ = cx.spawn(async move {
        let _ = cx2.send_notification(SessionNotification::new(
            sid,
            SessionUpdate::AvailableCommandsUpdate(AvailableCommandsUpdate::new(
                available_commands(),
            )),
        ));
        Ok(())
    });
}

/// `session/set_config_option`: apply a model or tool-toggle change and respond
/// with the full, updated option set. Spawned off the dispatch loop (acquiring
/// the turn lock could otherwise block message processing during a turn).
fn set_config_option(
    state: &Arc<AgentState>,
    req: SetSessionConfigOptionRequest,
    responder: Responder<SetSessionConfigOptionResponse>,
    cx: ConnectionTo<AcpRole>,
) -> acp::Result<()> {
    let Some(session) = state.session(&req.session_id) else {
        return responder
            .respond_with_internal_error(format!("unknown session {}", req.session_id));
    };
    cx.spawn(async move {
        let options = {
            let mut guard = session.state.lock().await;
            apply_config_option(&mut guard, &req.config_id.to_string(), &req.value);
            config_options(&guard.settings, &guard.model_choices, guard.group_models)
        };
        let _ = responder.respond(SetSessionConfigOptionResponse::new(options));
        Ok(())
    })
}

/// Apply one config-option change to the session state.
fn apply_config_option(st: &mut SessionState, config_id: &str, value: &SessionConfigOptionValue) {
    if config_id == "model" {
        if let Some(id) = value.as_value_id() {
            st.settings.model = id.to_string();
        }
        return;
    }
    if let Some((_, kind, _)) = TOOL_OPTIONS.iter().find(|(id, _, _)| *id == config_id)
        && let Some(enabled) = value.as_bool()
    {
        let present = st.settings.tools.contains(kind);
        if enabled && !present {
            st.settings.tools.push(*kind);
        } else if !enabled && present {
            st.settings.tools.retain(|t| t != kind);
        }
    }
}

/// Convert an ACP-provided MCP server (HTTP/SSE only) into a `hagent-core`
/// `McpServerSettings`, reusing `McpServerConfig::into_settings` for header/auth
/// resolution. Returns `None` for stdio (and other) transports.
fn acp_mcp_to_settings(server: &McpServer) -> Option<McpServerSettings> {
    let (name, url, headers) = match server {
        McpServer::Http(h) => (h.name.as_str(), h.url.as_str(), &h.headers),
        McpServer::Sse(s) => (s.name.as_str(), s.url.as_str(), &s.headers),
        _ => return None,
    };
    let header_map: std::collections::HashMap<String, String> = headers
        .iter()
        .map(|h| (h.name.clone(), h.value.clone()))
        .collect();
    let value = serde_json::json!({
        "server_label": slug_label(name),
        "server_url": url,
        "server_description": name,
        "headers": header_map,
    });
    let config: hagent_core::McpServerConfig = serde_json::from_value(value).ok()?;
    config.into_settings().ok()
}

/// Slugify a display name into a Hadrian-friendly server label (lowercase,
/// `[a-z0-9_-]`, spaces/dashes collapsed). Mirrors `mcp::sanitize_label`, which
/// is crate-private in hagent-core.
fn slug_label(raw: &str) -> String {
    let mut out = String::new();
    let mut prev_dash = false;
    for ch in raw.trim().chars() {
        let c = ch.to_ascii_lowercase();
        if c.is_ascii_alphanumeric() || c == '_' {
            out.push(c);
            prev_dash = false;
        } else if (c == ' ' || c == '-') && !out.is_empty() && !prev_dash {
            out.push('-');
            prev_dash = true;
        }
    }
    let s = out.trim_end_matches('-').to_string();
    if s.is_empty() {
        "server".to_string()
    } else {
        s
    }
}

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

    #[test]
    fn model_options_flat_lists_full_ids() {
        let models = vec!["openai/gpt-5.2".to_string(), "test/smoke".to_string()];
        let SessionConfigSelectOptions::Ungrouped(opts) =
            model_options(&models, "test/smoke", false)
        else {
            panic!("expected ungrouped options");
        };
        let labels: Vec<&str> = opts.iter().map(|o| o.name.as_str()).collect();
        assert_eq!(labels, vec!["openai/gpt-5.2", "test/smoke"]);
    }

    #[test]
    fn model_options_are_grouped_by_provider() {
        let models = vec![
            "openrouter/deepseek/deepseek-v4".to_string(),
            "openai/gpt-5.2".to_string(),
            "openrouter/anthropic/claude".to_string(),
        ];
        // current model isn't in the list — it must still appear (under "test").
        let SessionConfigSelectOptions::Grouped(groups) =
            model_options(&models, "test/smoke", true)
        else {
            panic!("expected grouped options");
        };

        // Groups are alphabetical; the current model's provider is included.
        let names: Vec<&str> = groups.iter().map(|g| g.name.as_str()).collect();
        assert_eq!(names, vec!["openai", "openrouter", "test"]);

        // Labels drop the provider prefix; values keep the full id.
        let openrouter = groups.iter().find(|g| g.name == "openrouter").unwrap();
        assert_eq!(openrouter.options.len(), 2);
        assert_eq!(openrouter.options[0].name, "deepseek/deepseek-v4");
        assert_eq!(
            openrouter.options[0].value.to_string(),
            "openrouter/deepseek/deepseek-v4"
        );

        let test = groups.iter().find(|g| g.name == "test").unwrap();
        assert_eq!(test.options[0].name, "smoke");
        assert_eq!(test.options[0].value.to_string(), "test/smoke");
    }
}
