//! ACP slash command advertising and resolution.

use agent_client_protocol as acp;
use kigi_tools::implementations::skills::skill::format_skill_name;
use kigi_tools::implementations::skills::types::SkillInfo;

/// A built-in slash command.
pub(crate) struct BuiltinCommand {
    pub name: &'static str,
    pub description: &'static str,
    pub argument_hint: Option<&'static str>,
    pub aliases: &'static [&'static str],
    /// Capability the agent must have for this command to be useful.
    /// Filtered by `CommandAvailability::allows()` at advertising time;
    /// commands that map to `BuiltinGate::AlwaysOn` are never gated.
    pub gate: BuiltinGate,
    resolve: fn(args: &str) -> BuiltinAction,
}

/// Capability gate that decides whether a `BuiltinCommand` is advertised
/// and resolvable in a given session.
///
/// Each variant maps to a feature/tool the agent must actually have:
/// - `Memory`: a memory backend is configured (`SessionMemory::is_enabled`).
/// - `Scheduler`: `scheduler_create` is registered.
/// - `Hooks`: a hook registry is loaded.
/// - `Plugins`: a plugin registry is loaded.
/// - `Feedback`: the feedback manager is enabled.
/// - `MemoryConfigured`: memory backend params exist (may be currently
///   disabled). Used for `/memory` so the user can re-enable via toggle.
/// - `Goal`: `resolve_goal()` feature flag is on AND `update_goal` is in the
///   session toolset (see `goal_slash_and_harness_available` in `acp_session.rs`).
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub(crate) enum BuiltinGate {
    AlwaysOn,
    Feedback,
    Memory,
    MemoryConfigured,
    /// Checks `scheduler_create` only. If any future shell-side builtin
    /// needs a separate scheduler-delete gate, add a `SchedulerDelete` variant.
    Scheduler,
    Hooks,
    Plugins,
    Goal,
    /// `resolve_graph()` feature flag is on AND the goal harness is
    /// available (graph nodes execute as goals, so `/graph` needs
    /// everything `/goal` needs).
    Graph,
}

/// All built-in slash commands. Order here = display order in autocomplete.
pub(super) const BUILTIN_COMMANDS: &[BuiltinCommand] = &[
    BuiltinCommand {
        name: "compact",
        description: "Compress conversation history to save context window",
        argument_hint: Some("optional context about what to preserve"),
        aliases: &[],
        gate: BuiltinGate::AlwaysOn,
        resolve: |args| BuiltinAction::Compact {
            user_context: if args.is_empty() {
                None
            } else {
                Some(args.to_string())
            },
        },
    },
    BuiltinCommand {
        name: "always-approve",
        description: "Toggle always-approve mode (skip all permission prompts)",
        argument_hint: Some("on|off"),
        aliases: &["yolo"],
        gate: BuiltinGate::AlwaysOn,
        resolve: |args| BuiltinAction::SetYolo {
            enabled: !matches!(
                args.to_lowercase().as_str(),
                "off" | "false" | "0" | "no" | "disable"
            ),
        },
    },
    BuiltinCommand {
        name: "flush",
        description: "Flush conversation memory to disk now",
        argument_hint: None,
        aliases: &[],
        gate: BuiltinGate::Memory,
        resolve: |_args| BuiltinAction::FlushMemory,
    },
    BuiltinCommand {
        name: "dream",
        description: "Run memory consolidation (merge session logs into organized topics)",
        argument_hint: None,
        aliases: &[],
        gate: BuiltinGate::Memory,
        resolve: |_args| BuiltinAction::Dream,
    },
    BuiltinCommand {
        name: "memory",
        description: "Browse, view, and manage your memories",
        argument_hint: Some("on|off"),
        aliases: &["mem"],
        gate: BuiltinGate::MemoryConfigured,
        resolve: |args| {
            let trimmed = args.trim().to_lowercase();
            match trimmed.as_str() {
                "on" | "enable" => BuiltinAction::MemoryToggle { enabled: true },
                "off" | "disable" => BuiltinAction::MemoryToggle { enabled: false },
                _ => BuiltinAction::MemoryBrowse,
            }
        },
    },
    BuiltinCommand {
        name: "context",
        description: "Show context window usage and session stats",
        argument_hint: None,
        aliases: &[],
        gate: BuiltinGate::AlwaysOn,
        resolve: |_args| BuiltinAction::ContextInfo,
    },
    BuiltinCommand {
        name: "hooks-trust",
        description: "Trust this project for hook execution",
        argument_hint: None,
        aliases: &[],
        gate: BuiltinGate::Hooks,
        resolve: |_args| BuiltinAction::HooksTrust,
    },
    BuiltinCommand {
        name: "hooks-list",
        description: "Show hooks loaded in this session",
        argument_hint: None,
        aliases: &[],
        gate: BuiltinGate::Hooks,
        resolve: |_args| BuiltinAction::HooksList,
    },
    BuiltinCommand {
        name: "hooks-add",
        description: "Add a custom hook file or directory",
        argument_hint: Some("path to hook file or directory"),
        aliases: &[],
        gate: BuiltinGate::Hooks,
        resolve: |args| BuiltinAction::HooksAdd {
            path: args.trim().to_string(),
        },
    },
    BuiltinCommand {
        name: "hooks-remove",
        description: "Remove a custom hook file or directory path",
        argument_hint: Some("path to hook file or directory"),
        aliases: &[],
        gate: BuiltinGate::Hooks,
        resolve: |args| BuiltinAction::HooksRemove {
            path: args.trim().to_string(),
        },
    },
    BuiltinCommand {
        name: "hooks-untrust",
        description: "Remove trust for the current project",
        argument_hint: None,
        aliases: &[],
        gate: BuiltinGate::Hooks,
        resolve: |_args| BuiltinAction::HooksUntrust,
    },
    BuiltinCommand {
        name: "plugins",
        description: "Manage plugins (list, reload, trust, add, remove)",
        argument_hint: Some("list | reload | trust <path> | add <path> | remove <path>"),
        aliases: &["plugin"],
        gate: BuiltinGate::Plugins,
        resolve: |args| {
            let trimmed = args.trim();
            if trimmed.is_empty() || trimmed == "list" {
                BuiltinAction::PluginsList
            } else if trimmed == "reload" {
                BuiltinAction::PluginsReload
            } else if trimmed.starts_with("trust") {
                BuiltinAction::PluginsTrust
            } else if let Some(path) = trimmed.strip_prefix("add ") {
                BuiltinAction::PluginsAdd {
                    path: path.trim().to_string(),
                }
            } else if let Some(path) = trimmed.strip_prefix("remove ") {
                BuiltinAction::PluginsRemove {
                    path: path.trim().to_string(),
                }
            } else if let Some(args) = trimmed.strip_prefix("install ") {
                let args = args.trim();
                let trust = args.ends_with(" --trust") || args == "--trust";
                let source = if trust {
                    args.trim_end_matches(" --trust").trim().to_string()
                } else {
                    args.to_string()
                };
                BuiltinAction::PluginsInstall { source, trust }
            } else if let Some(args) = trimmed.strip_prefix("uninstall ") {
                let args = args.trim();
                let confirm = args.ends_with(" --confirm") || args == "--confirm";
                let name = if confirm {
                    args.trim_end_matches(" --confirm").trim().to_string()
                } else {
                    args.to_string()
                };
                BuiltinAction::PluginsUninstall { name, confirm }
            } else if trimmed == "update" {
                BuiltinAction::PluginsUpdate { name: None }
            } else if let Some(name) = trimmed.strip_prefix("update ") {
                BuiltinAction::PluginsUpdate {
                    name: Some(name.trim().to_string()),
                }
            } else {
                BuiltinAction::PluginsList
            }
        },
    },
    BuiltinCommand {
        name: "reload-plugins",
        description: "Reload plugins from disk (alias for /plugins reload)",
        argument_hint: None,
        aliases: &[],
        gate: BuiltinGate::Plugins,
        resolve: |_args| BuiltinAction::PluginsReload,
    },
    BuiltinCommand {
        name: "session-info",
        description: "Show session details (model, turns, context usage)",
        argument_hint: None,
        aliases: &["status", "info"],
        gate: BuiltinGate::AlwaysOn,
        resolve: |_args| BuiltinAction::SessionInfo,
    },
    BuiltinCommand {
        name: "feedback",
        description: "Send feedback about the current session",
        argument_hint: Some("feedback text"),
        aliases: &[],
        gate: BuiltinGate::Feedback,
        resolve: |args| BuiltinAction::Feedback {
            text: args.trim().to_string(),
        },
    },
    BuiltinCommand {
        name: "goal",
        description: "Set, manage, or check an autonomous goal",
        argument_hint: Some("<objective> [--budget <tokens>] | status | pause | resume | clear"),
        aliases: &[],
        gate: BuiltinGate::Goal,
        resolve: |args| {
            let trimmed = args.trim();
            match trimmed.to_lowercase().as_str() {
                "" | "status" => BuiltinAction::GoalStatus,
                "pause" => BuiltinAction::GoalPause,
                "resume" => BuiltinAction::GoalResume,
                "clear" => BuiltinAction::GoalClear,
                _ => {
                    let (objective, token_budget) = parse_goal_budget(trimmed);
                    BuiltinAction::GoalSet {
                        objective,
                        token_budget,
                    }
                }
            }
        },
    },
    BuiltinCommand {
        name: "graph",
        description: "Decompose an objective into a dependency graph of autonomous goals",
        argument_hint: Some(
            "<objective> [--budget <tokens>] | status | pause | resume [--budget <tokens>] | clear",
        ),
        aliases: &[],
        gate: BuiltinGate::Graph,
        resolve: |args| {
            let trimmed = args.trim();
            match trimmed.to_lowercase().as_str() {
                "" | "status" => BuiltinAction::GraphStatus,
                "show" => BuiltinAction::GraphShow,
                "pause" => BuiltinAction::GraphPause,
                "resume" => BuiltinAction::GraphResume { extra_budget: None },
                "clear" => BuiltinAction::GraphClear,
                _ => {
                    // ANY input starting with `resume` is a resume attempt
                    // and must NEVER fall through to GraphSet — a typo'd
                    // top-up would otherwise silently replace a resumable
                    // BudgetLimited graph. Well-formed `resume --budget
                    // <tokens>` (case-insensitive keywords) carries the
                    // top-up; malformed variants resolve to a plain
                    // resume, whose BudgetLimited arm prints the usage.
                    let lower = trimmed.to_lowercase();
                    if let Some(rest) = lower.strip_prefix("resume") {
                        let extra_budget = rest
                            .trim()
                            .strip_prefix("--budget")
                            .map(str::trim)
                            .filter(|v| !v.is_empty() && v.bytes().all(|b| b.is_ascii_digit()))
                            .and_then(|v| v.parse::<i64>().ok())
                            .filter(|extra| *extra > 0);
                        return BuiltinAction::GraphResume { extra_budget };
                    }
                    let (objective, token_budget) = parse_goal_budget(trimmed);
                    BuiltinAction::GraphSet {
                        objective,
                        token_budget,
                    }
                }
            }
        },
    },
];

/// Split a trailing `--budget <tokens>` flag off a `/goal` objective.
///
/// Only a TRAILING, standalone flag is consumed: the flag must be its own
/// whitespace-separated token and the value a final all-digit positive
/// token. Anything else stays part of the objective so a goal text that
/// merely mentions the flag is never silently mangled.
fn parse_goal_budget(trimmed: &str) -> (String, Option<i64>) {
    if let Some((head, tail)) = trimmed.rsplit_once("--budget") {
        let value = tail.trim();
        let flag_is_own_token = head.ends_with(char::is_whitespace)
            && tail.starts_with(char::is_whitespace)
            && !value.contains(char::is_whitespace);
        let head = head.trim_end();
        if flag_is_own_token
            && !head.is_empty()
            && !value.is_empty()
            && value.bytes().all(|b| b.is_ascii_digit())
            && let Ok(budget) = value.parse::<i64>()
            && budget > 0
        {
            return (head.to_string(), Some(budget));
        }
    }
    (trimmed.to_string(), None)
}

const PROMPT_COMMANDS: &[BuiltinCommand] = &[BuiltinCommand {
    name: "loop",
    description: "Run a prompt on a recurring interval",
    argument_hint: Some("[interval] <prompt>"),
    aliases: &[],
    gate: BuiltinGate::Scheduler,
    // INVARIANT: resolve() short-circuits any prompt-only command via a
    // PROMPT_COMMANDS lookup before reaching this closure. If a future
    // refactor changes that ordering this `unreachable!` will surface
    // the bug loudly instead of silently dispatching to ContextInfo
    // (which is what the previous sentinel did).
    resolve: |_| unreachable!("/loop is dispatched via the PROMPT_COMMANDS path in resolve()"),
}];

/// A slash command — either built-in or from a SKILL.md file.
pub(super) enum SlashCommand<'a> {
    BuiltIn(&'a BuiltinCommand),
    Skill(&'a SkillInfo),
}

impl<'a> SlashCommand<'a> {
    pub fn name(&self) -> &str {
        match self {
            SlashCommand::BuiltIn(b) => b.name,
            SlashCommand::Skill(s) => &s.name,
        }
    }

    pub fn description(&self) -> &str {
        match self {
            SlashCommand::BuiltIn(b) => b.description,
            SlashCommand::Skill(s) => s.short_description.as_deref().unwrap_or(&s.description),
        }
    }

    pub fn argument_hint(&self) -> Option<&str> {
        match self {
            SlashCommand::BuiltIn(b) => b.argument_hint,
            SlashCommand::Skill(s) => s.argument_hint.as_deref(),
        }
    }
}

/// Builtins first (win on name collisions), then user-invocable skills.
///
/// `availability` filters tool/extension-gated builtins so commands like
/// `/flush` and `/loop` only show up when the agent
/// actually has the backing capability. Always-on builtins are
/// unaffected.
pub(super) fn all_commands<'a>(
    skills: &'a [SkillInfo],
    availability: CommandAvailability,
) -> Vec<SlashCommand<'a>> {
    let mut commands: Vec<SlashCommand<'_>> = BUILTIN_COMMANDS
        .iter()
        .filter(|b| availability.allows(b.gate))
        .map(SlashCommand::BuiltIn)
        .collect();
    commands.extend(
        PROMPT_COMMANDS
            .iter()
            .filter(|b| availability.allows(b.gate))
            .map(SlashCommand::BuiltIn),
    );
    commands.extend(
        skills
            .iter()
            .filter(|s| s.user_invocable && s.enabled)
            .map(SlashCommand::Skill),
    );
    commands
}

/// Per-session capability snapshot used to gate which built-in slash
/// commands the shell advertises and resolves.
///
/// Each field corresponds to a `BuiltinGate` variant. Construct via
/// `CommandAvailability::all_enabled()` for tests, or build it from a
/// live `SessionActor` (see the call site in `acp_session.rs`).
///
/// `Default` returns every gate disabled (fail-closed) so a forgotten
/// initialization advertises only `BuiltinGate::AlwaysOn` commands.
/// In test code, prefer `all_enabled()` when the gating itself isn't
/// under test -- otherwise the test will silently lose coverage of any
/// gated builtin.
#[derive(Debug, Clone, Copy, Default)]
pub(crate) struct CommandAvailability {
    pub feedback: bool,
    /// Memory backend is enabled AND the active toolset includes the
    /// memory read tools. `/flush` and `/dream` only make sense when the
    /// model can later read back what they wrote, so the read-side tool
    /// presence is the right signal -- harnesses that don't register
    /// `memory_search`/`memory_get` get the commands hidden without the
    /// gating layer needing to know about agent_type.
    pub memory: bool,
    /// Memory backend is configured (has `backend_params`) but not
    /// necessarily currently enabled. Gates `/memory` (browse + toggle)
    /// so the user can re-enable memory after toggling it off.
    pub memory_configured: bool,
    pub scheduler: bool,
    pub hooks: bool,
    pub plugins: bool,
    pub goal: bool,
    /// `/graph` gate: the graph feature flag AND the goal harness (nodes
    /// execute as goals) are both available.
    pub graph: bool,
}

impl CommandAvailability {
    /// `true` if commands gated on `gate` should be advertised this session.
    pub fn allows(&self, gate: BuiltinGate) -> bool {
        match gate {
            BuiltinGate::AlwaysOn => true,
            BuiltinGate::Feedback => self.feedback,
            BuiltinGate::Memory => self.memory,
            BuiltinGate::MemoryConfigured => self.memory_configured,
            BuiltinGate::Scheduler => self.scheduler,
            BuiltinGate::Hooks => self.hooks,
            BuiltinGate::Plugins => self.plugins,
            BuiltinGate::Goal => self.goal,
            BuiltinGate::Graph => self.graph,
        }
    }

    /// Test helper: every gate satisfied (matches the legacy "feedback only"
    /// fixture but enables every newly-gated command too).
    #[cfg(test)]
    pub fn all_enabled() -> Self {
        Self {
            feedback: true,
            memory: true,
            memory_configured: true,
            scheduler: true,
            hooks: true,
            plugins: true,
            goal: true,
            graph: true,
        }
    }
}

/// Build the JSON value for `AvailableCommandsUpdate.meta` containing the
/// agent's currently-registered tool names.
///
/// Wire format: `{"tools": ["read_file", "scheduler_create", ...]}`.
/// Pager clients drain this and call `CommandRegistry::set_available_tools`
/// to gate tool-dependent commands like `/loop`.
///
/// Takes `&[String]` rather than `&[&str]` because serde_json copies
/// each entry into the `Value` regardless, so an intermediate
/// `Vec<&str>` adapter would just waste an allocation.
pub(crate) fn build_tools_meta(tool_names: &[String]) -> acp::Meta {
    let mut meta = acp::Meta::new();
    meta.insert("tools".to_owned(), serde_json::json!(tool_names));
    meta
}

/// Build the ACP `AvailableCommand` list for the client autocomplete menu.
///
/// Skills include `scope` and `path` in `_meta` so the client can show
/// where the command comes from (e.g. "project" vs "global") and link
/// to the SKILL.md source.
pub(super) fn available_commands(
    skills: &[SkillInfo],
    availability: CommandAvailability,
) -> Vec<acp::AvailableCommand> {
    // Detect duplicate bare names among user-invocable skills so we can
    // advertise qualified names (e.g. "local:commit") when ambiguous.
    let mut name_counts: std::collections::HashMap<&str, usize> = std::collections::HashMap::new();
    for s in skills.iter().filter(|s| s.user_invocable) {
        *name_counts.entry(&s.name).or_default() += 1;
    }

    // Collect builtin command names so skills that collide are also qualified.
    let builtin_names: std::collections::HashSet<&str> =
        BUILTIN_COMMANDS.iter().map(|b| b.name).collect();

    all_commands(skills, availability)
        .iter()
        .flat_map(|cmd| {
            let entries: Vec<acp::AvailableCommand> = match cmd {
                SlashCommand::BuiltIn(b) => {
                    vec![
                        acp::AvailableCommand::new(
                            b.name.to_string(),
                            cmd.description().to_string(),
                        )
                        .input(cmd.argument_hint().map(|hint| {
                            acp::AvailableCommandInput::Unstructured(
                                acp::UnstructuredCommandInput::new(hint.to_string()),
                            )
                        })),
                    ]
                }
                SlashCommand::Skill(s) => {
                    let meta = serde_json::json!({
                        "scope": s.scope,
                        "path": s.path,
                    })
                    .as_object()
                    .cloned();
                    let qualified = format_skill_name(s);
                    let bare_collides = name_counts.get(s.name.as_str()).copied().unwrap_or(0) > 1
                        || builtin_names.contains(s.name.as_str());
                    let make_entry = |name: String| {
                        acp::AvailableCommand::new(name, cmd.description().to_string())
                            .input(cmd.argument_hint().map(|hint| {
                                acp::AvailableCommandInput::Unstructured(
                                    acp::UnstructuredCommandInput::new(hint.to_string()),
                                )
                            }))
                            .meta(meta.clone())
                    };
                    // Always advertise the qualified name for plugin skills.
                    // Also advertise the bare name if it doesn't collide.
                    if bare_collides {
                        vec![make_entry(qualified)]
                    } else {
                        // No collision — bare name only. The qualified form
                        // would just duplicate the entry in autocomplete.
                        vec![make_entry(s.name.clone())]
                    }
                }
            };
            entries
        })
        .collect()
}

/// Pre-session builtin commands for `InitializeResponse._meta`.
///
/// Advertises every always-on command plus any gated command whose gate
/// is satisfied by `availability`. Pre-session, only config-derived gates
/// (e.g. `goal`, which is driven by the `resolve_goal()` feature flag and
/// not by a live toolset) can be evaluated; runtime/tool-dependent gates
/// stay closed because there's no session context yet. See
/// `MvpAgent::command_availability` for how the pre-session snapshot is
/// built. With `CommandAvailability::default()` (all gates closed) this
/// is equivalent to advertising only `BuiltinGate::AlwaysOn` commands.
pub(crate) fn builtin_commands(availability: CommandAvailability) -> Vec<acp::AvailableCommand> {
    BUILTIN_COMMANDS
        .iter()
        .filter(|cmd| availability.allows(cmd.gate))
        .map(|cmd| {
            acp::AvailableCommand::new(cmd.name.to_string(), cmd.description.to_string()).input(
                cmd.argument_hint.map(|hint| {
                    acp::AvailableCommandInput::Unstructured(acp::UnstructuredCommandInput::new(
                        hint.to_string(),
                    ))
                }),
            )
        })
        .collect()
}

// ── kigi/commands/list ext method ────────────────────────────────

#[derive(serde::Deserialize)]
pub(crate) struct ListCommandsRequest {
    pub cwd: Option<String>,
}

#[derive(serde::Serialize)]
pub(crate) struct ListCommandsResponse {
    pub commands: Vec<acp::AvailableCommand>,
}

/// Build the available commands list, optionally scoped to a working directory.
/// - `Some(cwd)`: full skill discovery (Local + Repo + User) + builtins.
/// - `None`: builtins + global (User-scoped) skills only.
pub(crate) async fn list_commands(
    cwd: Option<&str>,
    skills_config: &kigi_agent::prompt::skills::SkillsConfig,
    plugin_registry: Option<&kigi_agent::plugins::PluginRegistry>,
    availability: CommandAvailability,
    compat: kigi_tools::types::compat::CompatConfig,
) -> ListCommandsResponse {
    let skills = kigi_agent::prompt::skills::list_skills_with_plugins(
        cwd,
        skills_config,
        plugin_registry,
        compat,
    )
    .await;
    ListCommandsResponse {
        commands: available_commands(&skills, availability),
    }
}

// ── Slash command resolution ────────────────────────────────────

/// A parsed skill reference from user input.
///
/// Produced by `parse_skill_references()` when scanning user text for known
/// `/{skill_name}` tokens.
#[derive(Debug, Clone, PartialEq, Eq)]
pub(crate) struct ParsedSkillRef {
    /// The skill name (bare or qualified, as typed by the user).
    pub name: String,
    /// Arguments following this skill reference, up to the next skill or end-of-input.
    pub args: String,
    /// The resolved `SkillInfo` path, for loading SKILL.md.
    pub skill_path: String,
    /// Scope-qualified name (e.g. "user:commit"), used for telemetry.
    pub qualified_name: String,
    /// Plugin name if this is a plugin skill.
    pub plugin_name: Option<String>,
}

pub(super) enum SlashCommandOutcome {
    /// Execute directly, no model round-trip.
    Builtin(BuiltinAction),
    /// One or more skills detected in user input.
    ///
    /// The original prompt `blocks` are preserved verbatim — they are NOT
    /// rewritten. The shell's prompt assembly layer will read each skill's
    /// SKILL.md, apply substitutions, and build the `<skill_information>`
    /// envelope alongside the `<user_query>` block.
    InvokeSkill {
        /// The original, unmodified prompt blocks.
        blocks: Vec<acp::ContentBlock>,
        /// Parsed skill references (one per detected `/{skill}` token).
        skills: Vec<ParsedSkillRef>,
    },
}

pub(super) enum BuiltinAction {
    Compact {
        user_context: Option<String>,
    },
    SetYolo {
        enabled: bool,
    },
    FlushMemory,
    Dream,
    ContextInfo,
    HooksTrust,
    HooksList,
    HooksAdd {
        path: String,
    },
    HooksRemove {
        path: String,
    },
    HooksUntrust,
    PluginsList,
    PluginsReload,
    PluginsTrust,
    SessionInfo,
    PluginsAdd {
        path: String,
    },
    PluginsRemove {
        path: String,
    },
    PluginsInstall {
        source: String,
        trust: bool,
    },
    PluginsUninstall {
        name: String,
        confirm: bool,
    },
    PluginsUpdate {
        name: Option<String>,
    },
    Feedback {
        text: String,
    },
    MemoryBrowse,
    MemoryToggle {
        enabled: bool,
    },
    GoalSet {
        objective: String,
        token_budget: Option<i64>,
    },
    GoalStatus,
    GoalPause,
    GoalResume,
    GoalClear,
    GraphSet {
        objective: String,
        token_budget: Option<i64>,
    },
    GraphStatus,
    GraphShow,
    GraphPause,
    GraphResume {
        extra_budget: Option<i64>,
    },
    GraphClear,
}

impl BuiltinAction {
    pub(crate) fn command_name(&self) -> &'static str {
        match self {
            BuiltinAction::Compact { .. } => "compact",
            BuiltinAction::SetYolo { .. } => "yolo",
            BuiltinAction::FlushMemory => "flush",
            BuiltinAction::Dream => "dream",
            BuiltinAction::ContextInfo => "context",
            BuiltinAction::HooksTrust => "hooks-trust",
            BuiltinAction::HooksList => "hooks-list",
            BuiltinAction::HooksAdd { .. } => "hooks-add",
            BuiltinAction::HooksRemove { .. } => "hooks-remove",
            BuiltinAction::HooksUntrust => "hooks-untrust",
            BuiltinAction::PluginsList => "plugins-list",
            BuiltinAction::PluginsReload => "plugins-reload",
            BuiltinAction::PluginsTrust => "plugins-trust",
            BuiltinAction::SessionInfo => "session",
            BuiltinAction::PluginsAdd { .. } => "plugins-add",
            BuiltinAction::PluginsRemove { .. } => "plugins-remove",
            BuiltinAction::PluginsInstall { .. } => "plugins-install",
            BuiltinAction::PluginsUninstall { .. } => "plugins-uninstall",
            BuiltinAction::PluginsUpdate { .. } => "plugins-update",
            BuiltinAction::Feedback { .. } => "feedback",
            BuiltinAction::MemoryBrowse => "memory",
            BuiltinAction::MemoryToggle { .. } => "memory",
            BuiltinAction::GoalSet { .. }
            | BuiltinAction::GoalStatus
            | BuiltinAction::GoalPause
            | BuiltinAction::GoalResume
            | BuiltinAction::GoalClear => "goal",
            BuiltinAction::GraphSet { .. }
            | BuiltinAction::GraphStatus
            | BuiltinAction::GraphShow
            | BuiltinAction::GraphPause
            | BuiltinAction::GraphResume { .. }
            | BuiltinAction::GraphClear => "graph",
        }
    }

    pub(crate) fn args_provided(&self) -> bool {
        match self {
            BuiltinAction::Compact { user_context } => user_context.is_some(),
            BuiltinAction::SetYolo { .. } => true,
            BuiltinAction::FlushMemory => false,
            BuiltinAction::Dream => false,
            BuiltinAction::ContextInfo => false,
            BuiltinAction::HooksTrust => false,
            BuiltinAction::HooksList => false,
            BuiltinAction::HooksAdd { .. } => true,
            BuiltinAction::HooksRemove { .. } => true,
            BuiltinAction::HooksUntrust => false,
            BuiltinAction::PluginsList => false,
            BuiltinAction::PluginsReload => false,
            BuiltinAction::PluginsTrust => false,
            BuiltinAction::SessionInfo => false,
            BuiltinAction::PluginsAdd { .. } => true,
            BuiltinAction::PluginsRemove { .. } => true,
            BuiltinAction::PluginsInstall { .. } => true,
            BuiltinAction::PluginsUninstall { .. } => true,
            BuiltinAction::PluginsUpdate { name } => name.is_some(),
            BuiltinAction::Feedback { text } => !text.is_empty(),
            BuiltinAction::MemoryBrowse => false,
            BuiltinAction::MemoryToggle { .. } => true,
            BuiltinAction::GoalSet { .. } => true,
            BuiltinAction::GoalStatus
            | BuiltinAction::GoalPause
            | BuiltinAction::GoalResume
            | BuiltinAction::GoalClear => false,
            BuiltinAction::GraphSet { .. } => true,
            BuiltinAction::GraphResume { extra_budget } => extra_budget.is_some(),
            BuiltinAction::GraphStatus
            | BuiltinAction::GraphShow
            | BuiltinAction::GraphPause
            | BuiltinAction::GraphClear => false,
        }
    }
}

/// How to rewrite the user's prompt when a slash command resolves to a skill.
///
/// - `RewriteToRun` (default): replace `/foo args` with `"run /foo args"`,
///   matching today's Kigi flow that calls our dedicated `skill` tool.
/// - `Passthrough`: leave the prompt verbatim. Some templates use this —
///   the model is trained to spot a leading `/<name>`, look it up in the
///   `<agent_skills>` listing, and call the Read tool on `fullPath`.
#[derive(Debug, Clone, Copy, Default, PartialEq, Eq)]
pub(crate) enum SkillSlashRewrite {
    #[default]
    RewriteToRun,
    Passthrough,
}

/// Scan user input left-to-right for `/{word}` tokens where `word` matches
/// a **known registered skill name** (bare or qualified).
///
/// Unknown `/words` (like `/api/v2/users`, `/tmp/file`) are NOT treated as
/// skill references — only tokens that resolve to a known skill count.
///
/// Returns `None` when no known skill references are found. Otherwise returns
/// the list of `ParsedSkillRef` entries with each skill's args (the text
/// between one skill token and the next, or end-of-input).
pub(crate) fn parse_skill_references(
    text: &str,
    skills: &[SkillInfo],
    availability: CommandAvailability,
) -> Option<Vec<ParsedSkillRef>> {
    let trimmed = text.trim();
    if trimmed.is_empty() {
        return None;
    }

    let commands = all_commands(skills, availability);

    // Build a map from bare name → SkillInfo reference, tracking ambiguous
    // names (multiple skills sharing the same bare name). Ambiguous bare
    // names are excluded from the map so `/commit` passes through when two
    // skills are both called "commit" in different scopes — the user must
    // use the qualified form `/local:commit` instead.
    let mut skill_map: std::collections::HashMap<&str, Option<&SkillInfo>> =
        std::collections::HashMap::new();
    for cmd in &commands {
        if let SlashCommand::Skill(s) = cmd {
            skill_map
                .entry(&s.name)
                .and_modify(|v| *v = None) // duplicate → mark ambiguous
                .or_insert(Some(s));
        }
    }
    // Remove ambiguous entries so they're never matched by bare name.
    skill_map.retain(|_, v| v.is_some());

    // Collect positions of all /{word} tokens, checking if each matches a known skill.
    struct SkillHit<'a> {
        /// Byte offset of the '/' in the source text.
        offset: usize,
        /// The text as typed by the user (e.g. "commit", "user:commit").
        typed_name: String,
        /// Resolved skill info.
        skill: &'a SkillInfo,
    }

    let mut hits: Vec<SkillHit<'_>> = Vec::new();
    let bytes = trimmed.as_bytes();
    let mut i = 0;

    while i < bytes.len() {
        if bytes[i] != b'/' {
            i += 1;
            continue;
        }
        // Must be at start of text or preceded by whitespace.
        if i > 0 && !bytes[i - 1].is_ascii_whitespace() {
            i += 1;
            continue;
        }
        let start = i + 1; // skip '/'
        if start >= bytes.len() {
            break;
        }
        // Grab the word: everything until whitespace, '/' or end.
        let end = trimmed[start..]
            .find(|c: char| c.is_whitespace())
            .map(|rel| start + rel)
            .unwrap_or(trimmed.len());
        let word = &trimmed[start..end];
        if word.is_empty() {
            i = start;
            continue;
        }

        // Check if it's a builtin — skip those (builtins are not multi-skill candidates).
        let is_builtin = BUILTIN_COMMANDS
            .iter()
            .chain(PROMPT_COMMANDS.iter())
            .any(|b| b.name == word || b.aliases.contains(&word));
        if is_builtin {
            i = end;
            continue;
        }

        // Check bare name in map (only unambiguous entries remain).
        if let Some(Some(skill)) = skill_map.get(word) {
            hits.push(SkillHit {
                offset: i,
                typed_name: word.to_string(),
                skill,
            });
            i = end;
            continue;
        }

        // Check qualified name (e.g. "user:commit").
        let mut found = false;
        for cmd in &commands {
            if let SlashCommand::Skill(s) = cmd
                && format_skill_name(s) == word
            {
                hits.push(SkillHit {
                    offset: i,
                    typed_name: word.to_string(),
                    skill: s,
                });
                found = true;
                break;
            }
        }
        if found {
            i = end;
            continue;
        }

        // Unknown /word — skip.
        i = end;
    }

    if hits.is_empty() {
        return None;
    }

    // Compute args for each hit: text from end-of-skill-token to start of next hit (or end).
    let mut refs = Vec::with_capacity(hits.len());
    for (idx, hit) in hits.iter().enumerate() {
        let word_end = hit.offset + 1 + hit.typed_name.len(); // past the /word
        let args_end = if idx + 1 < hits.len() {
            hits[idx + 1].offset
        } else {
            trimmed.len()
        };
        let args = trimmed[word_end..args_end].trim().to_string();
        refs.push(ParsedSkillRef {
            name: hit.typed_name.clone(),
            args,
            skill_path: hit.skill.path.clone(),
            qualified_name: format_skill_name(hit.skill),
            plugin_name: hit.skill.plugin_name.clone(),
        });
    }

    Some(refs)
}

/// Load each parsed skill's SKILL.md, apply substitutions, and build the
/// `<skill_information>` envelope.
///
/// Shared by turn start (prompt assembly in `process_conversation_turn`) and
/// the mid-turn interjection drain, so a skill delivers identically whether
/// it starts a turn or is force-sent into a running one. Returns `None` when
/// no skill content loads (missing files are logged and skipped; the
/// `<skills_referenced>` index still lists every parsed ref).
pub(super) async fn build_skill_information_for_refs(
    parsed_skills: &[ParsedSkillRef],
    slash_skills: &[SkillInfo],
    session_id: &str,
) -> Option<String> {
    use kigi_tools::implementations::skills::skill::{
        SkillRef, SubstitutionContext, apply_substitutions, build_skill_block,
        build_skill_information, load_skill_content,
    };

    let mut skill_blocks: Vec<String> = Vec::new();
    for sk in parsed_skills {
        // Find the SkillInfo by path (more reliable than by name for
        // qualified skills).
        let Some(info) = slash_skills.iter().find(|s| s.path == sk.skill_path) else {
            continue;
        };
        match load_skill_content(info).await {
            Ok(mut content) => {
                let skill_dir = std::path::Path::new(&info.path)
                    .parent()
                    .and_then(|p| p.to_str());
                let args = if sk.args.is_empty() {
                    None
                } else {
                    Some(sk.args.as_str())
                };
                apply_substitutions(
                    &mut content,
                    args,
                    &SubstitutionContext {
                        skill_dir,
                        session_id: Some(session_id),
                        plugin_root: info.plugin_root.as_deref(),
                        plugin_data: info.plugin_data.as_deref(),
                    },
                );
                skill_blocks.push(build_skill_block(&sk.name, &sk.args, &content));
            }
            Err(e) => {
                tracing::warn!(skill = %sk.name, error = %e, "failed to load skill for expansion");
            }
        }
    }

    if skill_blocks.is_empty() {
        return None;
    }
    let refs: Vec<SkillRef<'_>> = parsed_skills
        .iter()
        .map(|sk| SkillRef {
            name: &sk.name,
            path: &sk.skill_path,
        })
        .collect();
    Some(build_skill_information(&skill_blocks, &refs))
}

/// Resolve prompt blocks as a slash command.
/// `Ok(blocks)` = not a command, pass through. `Err(outcome)` = matched.
pub(super) fn resolve(
    prompt_blocks: Vec<acp::ContentBlock>,
    skills: &[SkillInfo],
    availability: CommandAvailability,
    _skill_rewrite: SkillSlashRewrite,
) -> Result<Vec<acp::ContentBlock>, SlashCommandOutcome> {
    let Some((command_name, args)) = parse_slash_prefix(&prompt_blocks) else {
        return Ok(prompt_blocks);
    };

    // Prompt-only commands (e.g. /loop) need a full agent round-trip, not
    // a direct BuiltinAction. They're filtered against the same gate the
    // PROMPT_COMMANDS entry declares -- looking it up here means the gate
    // value lives in exactly one place (the PROMPT_COMMANDS entry) and a
    // future addition just needs the entry, not a parallel branch.
    if let Some(prompt_cmd) = PROMPT_COMMANDS.iter().find(|c| c.name == command_name)
        && availability.allows(prompt_cmd.gate)
    {
        // Dispatch by name so a future PROMPT_COMMANDS entry without a
        // matching arm fails loudly at the call site instead of silently
        // reusing /loop's prompt builder.
        let mut blocks = match prompt_cmd.name {
            "loop" => build_loop_prompt_blocks(args),
            other => {
                unreachable!("prompt-only command /{other} has no resolver wired in resolve()")
            }
        };
        // Annotate with the compact invocation as `displayText` so every client
        // and session replay renders "/loop <args>" instead of the expanded
        // instruction. The pager does this client-side; bare-text clients rely
        // on this server-side annotation.
        let display_text = if args.is_empty() {
            format!("/{command_name}")
        } else {
            format!("/{command_name} {args}")
        };
        if let Some(acp::ContentBlock::Text(tb)) = blocks.first_mut() {
            let map = tb.meta.get_or_insert_with(acp::Meta::new);
            map.insert(
                "displayText".to_string(),
                serde_json::Value::String(display_text),
            );
        }
        // /loop is a prompt-only command — use InvokeSkill with empty skills
        // so the caller forwards the rewritten blocks directly to the model.
        return Err(SlashCommandOutcome::InvokeSkill {
            blocks,
            skills: vec![],
        });
    }

    // Check if the leading /command is a builtin.
    let commands = all_commands(skills, availability);
    let builtin_match = commands
        .iter()
        .find(|c| matches!(c, SlashCommand::BuiltIn(b) if c.name() == command_name || b.aliases.contains(&command_name)));

    if let Some(SlashCommand::BuiltIn(builtin)) = builtin_match {
        let action = (builtin.resolve)(args);
        return Err(SlashCommandOutcome::Builtin(action));
    }

    // Not a builtin — use the multi-skill parser to detect ALL /{skill}
    // references in the full input text, splitting args at skill boundaries.
    let full_text = prompt_blocks
        .iter()
        .find_map(|b| {
            if let acp::ContentBlock::Text(t) = b {
                Some(t.text.as_str())
            } else {
                None
            }
        })
        .unwrap_or("");

    if let Some(parsed_skills) = parse_skill_references(full_text, skills, availability) {
        return Err(SlashCommandOutcome::InvokeSkill {
            blocks: prompt_blocks,
            skills: parsed_skills,
        });
    }

    // No known skill matched — pass through as regular user input.
    Ok(prompt_blocks)
}

/// Extract `(name, args)` if the first text block starts with `/`.
///
/// - `"/compact keep auth"` → `Some(("compact", "keep auth"))`
/// - `"please run /commit"` → `None` (not at start)
fn parse_slash_prefix(prompt_blocks: &[acp::ContentBlock]) -> Option<(&str, &str)> {
    let text = prompt_blocks.iter().find_map(|b| {
        if let acp::ContentBlock::Text(t) = b {
            Some(t.text.as_str())
        } else {
            None
        }
    })?;

    let trimmed = text.trim();
    let without_slash = trimmed.strip_prefix('/')?;

    let (name, args) = match without_slash.find(char::is_whitespace) {
        Some(idx) => (&without_slash[..idx], without_slash[idx..].trim()),
        None => (without_slash, ""),
    };

    if name.is_empty() {
        return None;
    }

    Some((name, args))
}

/// Build the `/loop` prompt blocks for the shell client.
///
/// The wording (usage hint + scheduling instruction) is sourced from
/// `kigi-tools` so it stays identical to the pager's `LoopCommand` and the
/// two front-ends can't drift. Like the pager, there is no host-side interval
/// default: the model derives the cadence from the request and asks when none
/// is given.
fn build_loop_prompt_blocks(args: &str) -> Vec<acp::ContentBlock> {
    use kigi_tools::implementations::kigi::{loop_schedule_instruction, loop_usage_message};

    let text = if args.trim().is_empty() {
        loop_usage_message().to_string()
    } else {
        loop_schedule_instruction(args)
    };

    vec![acp::ContentBlock::Text(acp::TextContent::new(text))]
}

#[cfg(test)]
mod tests {
    use super::*;
    use kigi_tools::implementations::skills::types::SkillScope;

    fn all_gated() -> CommandAvailability {
        CommandAvailability::all_enabled()
    }

    fn text_block(s: &str) -> acp::ContentBlock {
        acp::ContentBlock::Text(acp::TextContent::new(s.to_string()))
    }

    fn make_skill(name: &str, user_invocable: bool) -> SkillInfo {
        SkillInfo {
            name: name.to_string(),
            display_name: None,
            description: format!("A skill called {name}"),
            when_to_use: None,
            short_description: Some(format!("Short: {name}")),
            author: None,
            argument_hint: None,
            path: format!("/path/to/{name}/SKILL.md"),
            scope: SkillScope::Local,
            config_source: None,
            plugin_name: None,
            plugin_version: None,
            plugin_root: None,
            plugin_data: None,
            allowed_tools: None,
            license: None,
            compatibility: None,
            metadata: None,
            model: None,
            effort: None,
            user_invocable,
            disable_model_invocation: false,
            has_user_specified_description: false,
            paths: None,
            enabled: true,
            body: None,
        }
    }

    /// Extract the first parsed skill from an InvokeSkill outcome.
    fn first_skill(outcome: SlashCommandOutcome) -> ParsedSkillRef {
        match outcome {
            SlashCommandOutcome::InvokeSkill { skills, .. } => {
                assert!(!skills.is_empty(), "expected at least one skill");
                skills.into_iter().next().unwrap()
            }
            _ => panic!("expected InvokeSkill"),
        }
    }

    /// Extract original text from InvokeSkill blocks (for prompt-only commands like /loop).
    fn invoke_text(outcome: SlashCommandOutcome) -> String {
        match outcome {
            SlashCommandOutcome::InvokeSkill { blocks, .. } => blocks
                .iter()
                .find_map(|b| match b {
                    acp::ContentBlock::Text(t) => Some(t.text.clone()),
                    _ => None,
                })
                .unwrap(),
            _ => panic!("expected InvokeSkill"),
        }
    }

    // ── description fallback ────────────────────────────────────────

    #[test]
    fn skill_description_falls_back_when_no_short_description() {
        let skill = SkillInfo {
            short_description: None,
            ..make_skill("deploy", true)
        };
        let cmd = SlashCommand::Skill(&skill);
        assert_eq!(cmd.description(), "A skill called deploy");
    }

    // ── parse_slash_prefix ──────────────────────────────────────────

    #[test]
    fn parse_slash_prefix_extracts_name_and_args() {
        assert_eq!(
            parse_slash_prefix(&[text_block("/compact keep auth")]),
            Some(("compact", "keep auth")),
        );
        assert_eq!(
            parse_slash_prefix(&[text_block("/yolo")]),
            Some(("yolo", "")),
        );
    }

    #[test]
    fn parse_slash_prefix_ignores_non_leading_slash() {
        assert_eq!(
            parse_slash_prefix(&[text_block("please run /commit")]),
            None
        );
        assert_eq!(parse_slash_prefix(&[text_block("fix the bug")]), None);
        assert_eq!(parse_slash_prefix(&[text_block("/")]), None);
    }

    #[test]
    fn parse_slash_prefix_trims_whitespace() {
        assert_eq!(
            parse_slash_prefix(&[text_block("  /commit fix typo  ")]),
            Some(("commit", "fix typo")),
        );
    }

    // ── builtin resolve fns ─────────────────────────────────────────

    fn resolve_builtin(name: &str, args: &str) -> Option<BuiltinAction> {
        BUILTIN_COMMANDS
            .iter()
            .chain(PROMPT_COMMANDS.iter())
            .find(|b| b.name == name)
            .map(|b| (b.resolve)(args))
    }

    #[test]
    fn compact_parses_optional_context() {
        assert!(matches!(
            resolve_builtin("compact", ""),
            Some(BuiltinAction::Compact { user_context: None })
        ));
        assert!(matches!(
            resolve_builtin("compact", "keep auth"),
            Some(BuiltinAction::Compact { user_context: Some(ctx) }) if ctx == "keep auth"
        ));
    }

    #[test]
    fn always_approve_parses_on_off() {
        for arg in ["", "on", "true", "1", "yes", "enable"] {
            assert!(
                matches!(
                    resolve_builtin("always-approve", arg),
                    Some(BuiltinAction::SetYolo { enabled: true })
                ),
                "expected on for {arg:?}",
            );
        }
        for arg in ["off", "false", "0", "no", "disable"] {
            assert!(
                matches!(
                    resolve_builtin("always-approve", arg),
                    Some(BuiltinAction::SetYolo { enabled: false })
                ),
                "expected off for {arg:?}",
            );
        }
    }

    #[test]
    fn yolo_alias_resolves_to_always_approve() {
        // /yolo should resolve via alias to the always-approve command
        let blocks = vec![text_block("/yolo on")];
        let outcome = resolve(blocks, &[], all_gated(), SkillSlashRewrite::default()).unwrap_err();
        assert!(matches!(
            outcome,
            SlashCommandOutcome::Builtin(BuiltinAction::SetYolo { enabled: true })
        ));
    }

    // ── resolve ─────────────────────────────────────────────────────

    #[test]
    fn resolve_routes_builtin() {
        let outcome = resolve(
            vec![text_block("/compact preserve auth")],
            &[],
            all_gated(),
            SkillSlashRewrite::default(),
        )
        .unwrap_err();
        assert!(matches!(
            outcome,
            SlashCommandOutcome::Builtin(BuiltinAction::Compact { user_context: Some(ctx) })
            if ctx == "preserve auth"
        ));
    }

    #[test]
    fn status_alias_resolves_to_session_info() {
        let outcome = resolve(
            vec![text_block("/status")],
            &[],
            all_gated(),
            SkillSlashRewrite::default(),
        )
        .unwrap_err();
        assert!(matches!(
            outcome,
            SlashCommandOutcome::Builtin(BuiltinAction::SessionInfo)
        ));
    }

    #[test]
    fn resolve_parses_skill_with_args() {
        let skills = vec![make_skill("commit", true)];
        let outcome = resolve(
            vec![text_block("/commit fix typo")],
            &skills,
            all_gated(),
            SkillSlashRewrite::default(),
        )
        .unwrap_err();
        let skill = first_skill(outcome);
        assert_eq!(skill.name, "commit");
        assert_eq!(skill.args, "fix typo");

        let outcome = resolve(
            vec![text_block("/commit")],
            &skills,
            all_gated(),
            SkillSlashRewrite::default(),
        )
        .unwrap_err();
        let skill = first_skill(outcome);
        assert_eq!(skill.name, "commit");
        assert_eq!(skill.args, "");
    }

    /// `build_skill_information_for_refs` loads the SKILL.md, applies
    /// substitutions, and wraps everything in `<skill_information>`;
    /// unloadable refs are skipped, and no loadable content → `None`.
    /// Shared by turn start and the interjection drain.
    #[tokio::test]
    async fn build_skill_information_for_refs_loads_and_wraps() {
        let dir = tempfile::tempdir().unwrap();
        let path = dir.path().join("SKILL.md");
        std::fs::write(&path, "Body with $ARGUMENTS").unwrap();

        let mut skill = make_skill("commit", true);
        skill.path = path.to_string_lossy().to_string();
        let skills = vec![skill];

        let parsed = parse_skill_references("/commit fix typo", &skills, all_gated())
            .expect("known skill must parse");
        let info = build_skill_information_for_refs(&parsed, &skills, "sid-1")
            .await
            .expect("skill body must load");
        assert!(info.starts_with("<skill_information>"), "got: {info}");
        assert!(
            info.contains("<skill name=\"commit\" args=\"fix typo\">"),
            "got: {info}"
        );
        assert!(
            info.contains("Body with fix typo"),
            "$ARGUMENTS must substitute: {info}"
        );

        // Missing file → logged, skipped, and with nothing loaded: None.
        let missing = vec![make_skill("ghost", true)];
        let parsed = parse_skill_references("/ghost", &missing, all_gated())
            .expect("known skill must parse");
        assert_eq!(
            build_skill_information_for_refs(&parsed, &missing, "sid-1").await,
            None
        );
    }

    #[test]
    fn resolve_loop_annotates_block_with_compact_display_text() {
        let outcome = resolve(
            vec![text_block("/loop 1m echo hello")],
            &[],
            all_gated(),
            SkillSlashRewrite::default(),
        )
        .unwrap_err();
        let blocks = match outcome {
            SlashCommandOutcome::InvokeSkill { blocks, skills } => {
                assert!(skills.is_empty(), "/loop is a prompt-only command");
                blocks
            }
            _ => panic!("expected InvokeSkill for /loop"),
        };
        let acp::ContentBlock::Text(tb) = blocks.first().expect("one block") else {
            panic!("expected a text block");
        };
        assert!(
            tb.text.len() > "/loop 1m echo hello".len(),
            "wire text should be the expanded instruction"
        );
        let display = tb
            .meta
            .as_ref()
            .and_then(|m| m.get("displayText"))
            .and_then(|v| v.as_str());
        assert_eq!(display, Some("/loop 1m echo hello"));
        assert!(
            tb.meta
                .as_ref()
                .and_then(|m| m.get("displayAsSkill"))
                .is_none(),
            "/loop renders as a plain prompt, not a skill"
        );
    }

    #[test]
    fn resolve_loop_without_args_uses_bare_command_display_text() {
        // `/loop` with no args expands to the usage message but should still
        // carry a sensible compact `displayText`.
        let outcome = resolve(
            vec![text_block("/loop")],
            &[],
            all_gated(),
            SkillSlashRewrite::default(),
        )
        .unwrap_err();
        let SlashCommandOutcome::InvokeSkill { blocks, .. } = outcome else {
            panic!("expected InvokeSkill for /loop");
        };
        let acp::ContentBlock::Text(tb) = blocks.first().expect("one block") else {
            panic!("expected a text block");
        };
        assert_eq!(
            tb.meta
                .as_ref()
                .and_then(|m| m.get("displayText"))
                .and_then(|v| v.as_str()),
            Some("/loop")
        );
    }

    #[test]
    fn resolve_passthrough_preserves_original_blocks() {
        // External-harness agents: blocks are passed through verbatim.
        // The prompt assembly layer decides how to format them.
        let skills = vec![make_skill("commit", true)];
        let outcome = resolve(
            vec![text_block("/commit fix typo")],
            &skills,
            all_gated(),
            SkillSlashRewrite::Passthrough,
        )
        .unwrap_err();
        // Original text is preserved in blocks.
        assert_eq!(invoke_text(outcome), "/commit fix typo");

        let outcome = resolve(
            vec![text_block("/commit")],
            &skills,
            all_gated(),
            SkillSlashRewrite::Passthrough,
        )
        .unwrap_err();
        assert_eq!(invoke_text(outcome), "/commit");
    }

    #[test]
    fn resolve_passes_through_normal_prompts() {
        let skills = vec![make_skill("commit", true)];
        assert!(
            resolve(
                vec![text_block("fix the login bug")],
                &skills,
                all_gated(),
                SkillSlashRewrite::default()
            )
            .is_ok()
        );
        assert!(
            resolve(
                vec![text_block("/unknown")],
                &skills,
                all_gated(),
                SkillSlashRewrite::default()
            )
            .is_ok()
        );
    }

    #[test]
    fn resolve_filters_non_invocable_skills() {
        let skills = vec![make_skill("internal-only", false)];
        assert!(
            resolve(
                vec![text_block("/internal-only")],
                &skills,
                all_gated(),
                SkillSlashRewrite::default()
            )
            .is_ok()
        );
    }

    #[test]
    fn resolve_builtin_shadows_same_named_skill() {
        let skills = vec![make_skill("compact", true)];
        let outcome = resolve(
            vec![text_block("/compact")],
            &skills,
            all_gated(),
            SkillSlashRewrite::default(),
        )
        .unwrap_err();
        assert!(matches!(outcome, SlashCommandOutcome::Builtin(_)));
    }

    // ── available_commands (ACP) ─────────────────────────────────────

    #[test]
    fn available_commands_orders_builtins_first() {
        let skills = vec![make_skill("commit", true), make_skill("deploy", true)];
        let commands = available_commands(&skills, all_gated());
        let names: Vec<&str> = commands.iter().map(|c| c.name.as_str()).collect();
        assert_eq!(
            names,
            [
                "compact",
                "always-approve",
                "flush",
                "dream",
                "memory",
                "context",
                "hooks-trust",
                "hooks-list",
                "hooks-add",
                "hooks-remove",
                "hooks-untrust",
                "plugins",
                "reload-plugins",
                "session-info",
                "feedback",
                "goal",
                "graph",
                "loop",
                "commit",
                "deploy",
            ]
        );
    }

    fn advertised_names(availability: CommandAvailability) -> Vec<String> {
        available_commands(&[], availability)
            .into_iter()
            .map(|c| c.name)
            .collect()
    }

    #[test]
    fn availability_filters_memory_commands() {
        // memory=false hides /flush and /dream but NOT /memory (gated on
        // memory_configured instead, so the user can re-enable via toggle).
        let names = advertised_names(CommandAvailability {
            memory: false,
            ..CommandAvailability::all_enabled()
        });
        assert!(!names.iter().any(|n| n == "flush"), "got: {names:?}");
        assert!(!names.iter().any(|n| n == "dream"), "got: {names:?}");
        assert!(
            names.iter().any(|n| n == "memory"),
            "/memory should still be available when memory_configured=true, got: {names:?}"
        );
        assert!(names.iter().any(|n| n == "compact"));

        // memory_configured=false hides /memory too.
        let names2 = advertised_names(CommandAvailability {
            memory: false,
            memory_configured: false,
            ..CommandAvailability::all_enabled()
        });
        assert!(
            !names2.iter().any(|n| n == "memory"),
            "/memory should be hidden when memory_configured=false, got: {names2:?}"
        );
    }

    #[test]
    fn availability_filters_loop_command() {
        let names = advertised_names(CommandAvailability {
            scheduler: false,
            ..CommandAvailability::all_enabled()
        });
        assert!(!names.iter().any(|n| n == "loop"), "got: {names:?}");
    }

    #[test]
    fn availability_filters_hooks_and_plugins() {
        let names = advertised_names(CommandAvailability {
            hooks: false,
            plugins: false,
            ..CommandAvailability::all_enabled()
        });
        for n in [
            "hooks-trust",
            "hooks-list",
            "hooks-add",
            "hooks-remove",
            "hooks-untrust",
            "plugins",
            "reload-plugins",
        ] {
            assert!(
                !names.iter().any(|x| x == n),
                "{n} should be hidden, got: {names:?}",
            );
        }
    }

    #[test]
    fn availability_filters_goal_command() {
        let names = advertised_names(CommandAvailability {
            goal: false,
            ..CommandAvailability::all_enabled()
        });
        assert!(!names.iter().any(|n| n == "goal"), "got: {names:?}");
    }

    #[test]
    fn availability_filters_graph_command() {
        let names = advertised_names(CommandAvailability {
            graph: false,
            ..CommandAvailability::all_enabled()
        });
        assert!(!names.iter().any(|n| n == "graph"), "got: {names:?}");
    }

    #[test]
    fn graph_does_not_resolve_when_gate_off() {
        let availability = CommandAvailability {
            graph: false,
            ..CommandAvailability::all_enabled()
        };
        assert!(
            resolve(
                vec![text_block("/graph status")],
                &[],
                availability,
                SkillSlashRewrite::default(),
            )
            .is_ok(),
            "expected pass-through (Ok), got an outcome",
        );
    }

    #[test]
    fn graph_resolves_subcommands_and_budget() {
        let set = resolve_builtin("graph", "ship the feature --budget 5000")
            .expect("/graph must resolve");
        match set {
            BuiltinAction::GraphSet {
                objective,
                token_budget,
            } => {
                assert_eq!(objective, "ship the feature");
                assert_eq!(token_budget, Some(5000));
            }
            other => panic!("expected GraphSet, got /{}", other.command_name()),
        }
        assert!(matches!(
            resolve_builtin("graph", ""),
            Some(BuiltinAction::GraphStatus)
        ));
        assert!(matches!(
            resolve_builtin("graph", "show"),
            Some(BuiltinAction::GraphShow)
        ));
        assert!(matches!(
            resolve_builtin("graph", "pause"),
            Some(BuiltinAction::GraphPause)
        ));
        assert!(matches!(
            resolve_builtin("graph", "resume"),
            Some(BuiltinAction::GraphResume { extra_budget: None })
        ));
        assert!(matches!(
            resolve_builtin("graph", "resume --budget 800"),
            Some(BuiltinAction::GraphResume {
                extra_budget: Some(800)
            })
        ));
        // Malformed top-ups resolve to a PLAIN resume — never to
        // GraphSet, which would silently replace a resumable graph; the
        // BudgetLimited resume arm then prints the usage hint.
        assert!(matches!(
            resolve_builtin("graph", "resume --budget nope"),
            Some(BuiltinAction::GraphResume { extra_budget: None })
        ));
        assert!(matches!(
            resolve_builtin("graph", "Resume --Budget 800"),
            Some(BuiltinAction::GraphResume {
                extra_budget: Some(800)
            })
        ));
        assert!(matches!(
            resolve_builtin("graph", "clear"),
            Some(BuiltinAction::GraphClear)
        ));
    }

    #[test]
    fn goal_does_not_resolve_when_update_goal_unavailable() {
        let availability = CommandAvailability {
            goal: false,
            ..CommandAvailability::all_enabled()
        };
        assert!(
            resolve(
                vec![text_block("/goal status")],
                &[],
                availability,
                SkillSlashRewrite::default(),
            )
            .is_ok(),
            "expected pass-through (Ok), got an outcome",
        );
    }

    #[test]
    fn loop_does_not_resolve_when_scheduler_unavailable() {
        // Without the scheduler gate the shell should not route /loop --
        // it would otherwise produce a useless "call scheduler_create"
        // prompt the model can't act on.
        let availability = CommandAvailability {
            scheduler: false,
            ..CommandAvailability::all_enabled()
        };
        assert!(
            resolve(
                vec![text_block("/loop 5m do thing")],
                &[],
                availability,
                SkillSlashRewrite::default(),
            )
            .is_ok(),
            "expected pass-through (Ok), got an outcome",
        );
    }

    /// Extract the text of the first block produced by `build_loop_prompt_blocks`.
    fn loop_text(args: &str) -> String {
        match build_loop_prompt_blocks(args).into_iter().next() {
            Some(acp::ContentBlock::Text(t)) => t.text,
            other => panic!("expected a text block, got {other:?}"),
        }
    }

    #[test]
    fn loop_usage_has_no_10m_default() {
        // The shell client must not advertise a silent 10m default.
        let usage = loop_text("");
        assert!(usage.contains("Usage: /loop"), "got: {usage}");
        assert!(
            !usage.contains("10m"),
            "usage must not claim a default: {usage}"
        );
    }

    #[test]
    fn loop_instruction_derives_interval_without_default_or_inline_execute() {
        let instr = loop_text("every 30 minutes do x");
        assert!(
            !instr.contains("10m"),
            "instruction must not default: {instr}"
        );
        assert!(instr.contains("30 minutes"));
        assert!(instr.contains("<number><unit>"));
        assert!(instr.contains("ask the user how often"));
        assert!(instr.contains("Do NOT execute the prompt inline"));
        assert!(
            !instr.contains("immediately execute the parsed prompt"),
            "stale inline-execute wording must be gone: {instr}"
        );
        assert!(instr.contains("every 30 minutes do x"));
    }

    #[test]
    fn loop_prompt_matches_pager_wording() {
        // The shell and pager must stay textually identical so they don't drift.
        use kigi_tools::implementations::kigi::{loop_schedule_instruction, loop_usage_message};
        assert_eq!(loop_text(""), loop_usage_message());
        assert_eq!(
            loop_text("2h run tests"),
            loop_schedule_instruction("2h run tests")
        );
    }

    #[test]
    fn build_tools_meta_serialises_tool_names() {
        let names = vec!["scheduler_create".to_string(), "web_search".to_string()];
        let v = build_tools_meta(&names);
        assert_eq!(
            serde_json::Value::Object(v),
            serde_json::json!({"tools": ["scheduler_create", "web_search"]})
        );
    }

    #[test]
    fn pre_session_builtin_commands_excludes_gated_entries() {
        // The pre-session list (advertised in InitializeResponse._meta)
        // with a default (fail-closed) availability must not include any
        // gated command -- we don't know the toolset yet at that point.
        let names: Vec<String> = builtin_commands(CommandAvailability::default())
            .into_iter()
            .map(|c| c.name)
            .collect();
        for forbidden in [
            "flush",
            "dream",
            "memory",
            "feedback",
            "goal",
            "graph",
            "hooks-list",
            "plugins",
            "reload-plugins",
        ] {
            assert!(
                !names.iter().any(|n| n == forbidden),
                "{forbidden} should be excluded pre-session, got: {names:?}",
            );
        }
        // Always-on commands are still present.
        for required in ["compact", "always-approve", "context", "session-info"] {
            assert!(
                names.iter().any(|n| n == required),
                "{required} should be present, got: {names:?}",
            );
        }
    }

    #[test]
    fn pre_session_builtin_commands_advertises_goal_when_flag_enabled() {
        // `/goal` is gated on a config feature flag known at initialize
        // time (not a live toolset), so when the pre-session availability
        // enables it the command must be advertised -- otherwise it would
        // only show up after the first user turn created a session.
        let availability = CommandAvailability {
            goal: true,
            ..CommandAvailability::default()
        };
        let names: Vec<String> = builtin_commands(availability)
            .into_iter()
            .map(|c| c.name)
            .collect();
        assert!(
            names.iter().any(|n| n == "goal"),
            "goal should be advertised pre-session when the flag is on, got: {names:?}",
        );
        // Runtime/tool-dependent gates stay closed pre-session.
        for forbidden in ["flush", "dream", "memory", "feedback", "plugins"] {
            assert!(
                !names.iter().any(|n| n == forbidden),
                "{forbidden} should stay excluded pre-session, got: {names:?}",
            );
        }
    }

    #[test]
    fn available_commands_populates_acp_fields() {
        let skills = vec![make_skill("commit", true)];
        let commands = available_commands(&skills, all_gated());

        let builtin = commands.iter().find(|c| c.name == "compact").unwrap();
        assert!(builtin.input.is_some());

        let flush = commands.iter().find(|c| c.name == "flush").unwrap();
        assert!(flush.input.is_none()); // no argument_hint

        let skill = commands.iter().find(|c| c.name == "commit").unwrap();
        assert_eq!(skill.description, "Short: commit");
    }

    // ── /flush ─────────────────────────────────────────────────────

    #[test]
    fn flush_resolves_to_builtin_action() {
        assert!(matches!(
            resolve_builtin("flush", ""),
            Some(BuiltinAction::FlushMemory)
        ));
        // Args are ignored — still resolves to FlushMemory
        assert!(matches!(
            resolve_builtin("flush", "some extra args"),
            Some(BuiltinAction::FlushMemory)
        ));
    }

    #[test]
    fn resolve_routes_flush_builtin() {
        let outcome = resolve(
            vec![text_block("/flush")],
            &[],
            all_gated(),
            SkillSlashRewrite::default(),
        )
        .unwrap_err();
        assert!(matches!(
            outcome,
            SlashCommandOutcome::Builtin(BuiltinAction::FlushMemory)
        ));
    }

    #[test]
    fn flush_builtin_shadows_same_named_skill() {
        let skills = vec![make_skill("flush", true)];
        let outcome = resolve(
            vec![text_block("/flush")],
            &skills,
            all_gated(),
            SkillSlashRewrite::default(),
        )
        .unwrap_err();
        assert!(matches!(outcome, SlashCommandOutcome::Builtin(_)));
    }

    // ── /dream ─────────────────────────────────────────────────────

    #[test]
    fn dream_resolves_to_builtin_action() {
        assert!(matches!(
            resolve_builtin("dream", ""),
            Some(BuiltinAction::Dream)
        ));
        assert!(matches!(
            resolve_builtin("dream", "extra args"),
            Some(BuiltinAction::Dream)
        ));
    }

    #[test]
    fn resolve_routes_dream_builtin() {
        let outcome = resolve(
            vec![text_block("/dream")],
            &[],
            all_gated(),
            SkillSlashRewrite::default(),
        )
        .unwrap_err();
        assert!(matches!(
            outcome,
            SlashCommandOutcome::Builtin(BuiltinAction::Dream)
        ));
    }

    #[test]
    fn dream_builtin_shadows_same_named_skill() {
        let skills = vec![make_skill("dream", true)];
        let outcome = resolve(
            vec![text_block("/dream")],
            &skills,
            all_gated(),
            SkillSlashRewrite::default(),
        )
        .unwrap_err();
        assert!(matches!(outcome, SlashCommandOutcome::Builtin(_)));
    }

    // ── ambiguous skill names ─────────────────────────────────────

    fn make_scoped_skill(name: &str, scope: SkillScope) -> SkillInfo {
        SkillInfo {
            name: name.to_string(),
            display_name: None,
            description: format!("A {scope:?} skill called {name}"),
            when_to_use: None,
            short_description: Some(format!("Short: {name}")),
            author: None,
            argument_hint: None,
            path: format!("/path/to/{name}/{scope:?}/SKILL.md"),
            scope,
            config_source: None,
            plugin_name: None,
            plugin_version: None,
            plugin_root: None,
            plugin_data: None,
            allowed_tools: None,
            license: None,
            compatibility: None,
            metadata: None,
            model: None,
            effort: None,
            user_invocable: true,
            disable_model_invocation: false,
            has_user_specified_description: false,
            paths: None,
            enabled: true,
            body: None,
        }
    }

    #[test]
    fn resolve_ambiguous_bare_name_passes_through() {
        // Two skills share the bare name "commit" in different scopes.
        let skills = vec![
            make_scoped_skill("commit", SkillScope::Local),
            make_scoped_skill("commit", SkillScope::User),
        ];
        // Bare "/commit" is ambiguous -- should pass through (not first-match).
        assert!(
            resolve(
                vec![text_block("/commit")],
                &skills,
                all_gated(),
                SkillSlashRewrite::default()
            )
            .is_ok()
        );
    }

    #[test]
    fn resolve_qualified_skill_name() {
        let skills = vec![
            make_scoped_skill("commit", SkillScope::Local),
            make_scoped_skill("commit", SkillScope::User),
        ];

        // Qualified "/local:commit" resolves unambiguously.
        let outcome = resolve(
            vec![text_block("/local:commit fix typo")],
            &skills,
            all_gated(),
            SkillSlashRewrite::default(),
        )
        .unwrap_err();
        let skill = first_skill(outcome);
        assert_eq!(skill.name, "local:commit");
        assert_eq!(skill.args, "fix typo");

        // Qualified "/user:commit" resolves unambiguously.
        let outcome = resolve(
            vec![text_block("/user:commit")],
            &skills,
            all_gated(),
            SkillSlashRewrite::default(),
        )
        .unwrap_err();
        let skill = first_skill(outcome);
        assert_eq!(skill.name, "user:commit");
        assert_eq!(skill.args, "");
    }

    #[test]
    fn available_commands_uses_qualified_names_for_duplicates() {
        let skills = vec![
            make_scoped_skill("commit", SkillScope::Local),
            make_scoped_skill("commit", SkillScope::User),
            make_skill("deploy", true),
        ];
        let commands = available_commands(&skills, all_gated());
        let names: Vec<&str> = commands.iter().map(|c| c.name.as_str()).collect();
        // Duplicate "commit" skills should use qualified names.
        assert!(names.contains(&"local:commit"));
        assert!(names.contains(&"user:commit"));
        // Unique "deploy" keeps bare name only (no duplicate qualified form).
        assert!(names.contains(&"deploy"));
        assert!(
            !names.contains(&"local:deploy"),
            "non-colliding skill should NOT get a qualified duplicate, got: {names:?}"
        );
        // Bare "commit" should NOT appear.
        assert!(!names.contains(&"commit"));
    }

    // ── builtin/skill name collisions ─────────────────────────────

    #[test]
    fn available_commands_qualifies_builtin_colliding_skill() {
        // A skill named "compact" collides with the builtin /compact.
        let skills = vec![
            make_scoped_skill("compact", SkillScope::Local),
            make_skill("deploy", true),
        ];
        let commands = available_commands(&skills, all_gated());
        let names: Vec<&str> = commands.iter().map(|c| c.name.as_str()).collect();
        // The skill should be advertised under its qualified name.
        assert!(
            names.contains(&"local:compact"),
            "builtin-colliding skill should use qualified name, got: {names:?}"
        );
        // The bare "compact" entry should be the builtin, not the skill.
        let compact_cmd = commands.iter().find(|c| c.name == "compact").unwrap();
        assert!(
            compact_cmd.meta.is_none(),
            "bare 'compact' should be the builtin (no meta)"
        );
        // Non-colliding skill keeps bare name.
        assert!(names.contains(&"deploy"));
    }

    #[test]
    fn resolve_qualified_builtin_colliding_skill() {
        // A skill named "compact" collides with the builtin.
        let skills = vec![make_scoped_skill("compact", SkillScope::Local)];

        // Bare "/compact" should resolve to the builtin, not the skill.
        let outcome = resolve(
            vec![text_block("/compact")],
            &skills,
            all_gated(),
            SkillSlashRewrite::default(),
        )
        .unwrap_err();
        assert!(matches!(outcome, SlashCommandOutcome::Builtin(_)));

        // Qualified "/local:compact" should resolve to the skill.
        let outcome = resolve(
            vec![text_block("/local:compact")],
            &skills,
            all_gated(),
            SkillSlashRewrite::default(),
        )
        .unwrap_err();
        let skill = first_skill(outcome);
        assert_eq!(skill.name, "local:compact");
        assert_eq!(skill.args, "");
    }

    #[test]
    fn feedback_does_not_resolve_when_disabled() {
        // /feedback should pass through as unrecognized when the feature is off.
        assert!(
            resolve(
                vec![text_block("/feedback hello")],
                &[],
                CommandAvailability::default(),
                SkillSlashRewrite::default()
            )
            .is_ok()
        );
    }

    #[test]
    fn feedback_resolves_when_enabled() {
        let outcome = resolve(
            vec![text_block("/feedback hello")],
            &[],
            all_gated(),
            SkillSlashRewrite::default(),
        )
        .unwrap_err();
        assert!(matches!(
            outcome,
            SlashCommandOutcome::Builtin(BuiltinAction::Feedback { ref text }) if text == "hello"
        ));
    }

    /// Collect the advertised command names for the given availability.
    fn advertised_names_with(availability: CommandAvailability) -> Vec<String> {
        available_commands(&[], availability)
            .into_iter()
            .map(|c| c.name)
            .collect()
    }

    /// `CommandAvailability::default()` must be fail-closed: every gated
    /// command is hidden, only `BuiltinGate::AlwaysOn` survives. The
    /// pre-session `MvpAgent::command_availability()` builds on this value
    /// (only flipping config-derived gates like `goal` on), so a
    /// regression here would re-expose `/flush`, `/loop`, etc. on the home
    /// screen for harnesses that won't actually run them.
    #[test]
    fn default_availability_is_fail_closed_on_every_gate() {
        let names = advertised_names_with(CommandAvailability::default());
        for forbidden in [
            "flush",
            "dream",
            "feedback",
            "goal",
            "graph",
            "loop",
            "hooks-list",
            "hooks-trust",
            "hooks-untrust",
            "hooks-add",
            "hooks-remove",
            "plugins",
            "reload-plugins",
        ] {
            assert!(
                !names.iter().any(|n| n == forbidden),
                "{forbidden} must not be advertised under default fail-closed availability, got: {names:?}",
            );
        }
        for required in ["compact", "always-approve", "context", "session-info"] {
            assert!(
                names.iter().any(|n| n == required),
                "AlwaysOn {required} must always be advertised, got: {names:?}",
            );
        }
    }

    /// `/flush` is a memory-write that's only useful when the model can
    /// later read back what it wrote. The shell's
    /// `build_command_availability()` ANDs `memory.is_enabled()` with
    /// `memory_search`/`memory_get` registration; the gate itself just
    /// reads `availability.memory`. Lock both halves so a future change
    /// to either side is forced through this test.
    #[test]
    fn flush_hidden_when_memory_gate_off_visible_when_on() {
        let off = advertised_names_with(CommandAvailability::default());
        assert!(!off.iter().any(|n| n == "flush"), "got: {off:?}");
        assert!(!off.iter().any(|n| n == "dream"), "got: {off:?}");
        // /memory is gated on memory_configured, not memory — hidden here
        // because Default sets both to false.
        assert!(!off.iter().any(|n| n == "memory"), "got: {off:?}");

        let on = advertised_names_with(CommandAvailability {
            memory: true,
            memory_configured: true,
            ..CommandAvailability::default()
        });
        assert!(on.iter().any(|n| n == "flush"), "got: {on:?}");
        assert!(on.iter().any(|n| n == "dream"), "got: {on:?}");
        assert!(on.iter().any(|n| n == "memory"), "got: {on:?}");
    }

    // ── /memory ─────────────────────────────────────────────────────

    #[test]
    fn memory_bare_resolves_to_browse() {
        assert!(matches!(
            resolve_builtin("memory", ""),
            Some(BuiltinAction::MemoryBrowse)
        ));
        // Any unrecognized arg also falls through to browse
        assert!(matches!(
            resolve_builtin("memory", "status"),
            Some(BuiltinAction::MemoryBrowse)
        ));
    }

    #[test]
    fn memory_on_off_resolves_to_toggle() {
        for (arg, expected) in [
            ("on", true),
            ("enable", true),
            ("ON", true),
            ("Enable", true),
            ("off", false),
            ("disable", false),
            ("OFF", false),
            ("Disable", false),
        ] {
            assert!(
                matches!(
                    resolve_builtin("memory", arg),
                    Some(BuiltinAction::MemoryToggle { enabled }) if enabled == expected
                ),
                "expected toggle({expected}) for {arg:?}",
            );
        }
    }

    #[test]
    fn mem_alias_resolves_to_memory_browse() {
        let outcome = resolve(
            vec![text_block("/mem")],
            &[],
            all_gated(),
            SkillSlashRewrite::default(),
        )
        .unwrap_err();
        assert!(matches!(
            outcome,
            SlashCommandOutcome::Builtin(BuiltinAction::MemoryBrowse)
        ));
    }

    #[test]
    fn mem_alias_resolves_toggle_with_args() {
        let outcome = resolve(
            vec![text_block("/mem off")],
            &[],
            all_gated(),
            SkillSlashRewrite::default(),
        )
        .unwrap_err();
        assert!(matches!(
            outcome,
            SlashCommandOutcome::Builtin(BuiltinAction::MemoryToggle { enabled: false })
        ));
    }

    #[test]
    fn memory_resolves_when_disabled_but_configured() {
        // memory=false but memory_configured=true: /memory must still work
        // so the user can re-enable via the toggle.
        let availability = CommandAvailability {
            memory: false,
            ..CommandAvailability::all_enabled()
        };
        let outcome = resolve(
            vec![text_block("/memory")],
            &[],
            availability,
            SkillSlashRewrite::default(),
        );
        assert!(
            outcome.is_err(),
            "expected /memory to resolve when memory_configured=true",
        );
    }

    #[test]
    fn memory_not_resolved_when_not_configured() {
        let availability = CommandAvailability {
            memory: false,
            memory_configured: false,
            ..CommandAvailability::all_enabled()
        };
        assert!(
            resolve(
                vec![text_block("/memory")],
                &[],
                availability,
                SkillSlashRewrite::default(),
            )
            .is_ok(),
            "expected pass-through (Ok) when memory_configured is false",
        );
    }

    // ── parse_skill_references ──────────────────────────────────────

    #[test]
    fn parse_skill_refs_single_skill() {
        let skills = vec![make_skill("commit", true)];
        let refs = parse_skill_references("/commit fix typo", &skills, all_gated()).unwrap();
        assert_eq!(refs.len(), 1);
        assert_eq!(refs[0].name, "commit");
        assert_eq!(refs[0].args, "fix typo");
    }

    #[test]
    fn parse_skill_refs_single_no_args() {
        let skills = vec![make_skill("commit", true)];
        let refs = parse_skill_references("/commit", &skills, all_gated()).unwrap();
        assert_eq!(refs.len(), 1);
        assert_eq!(refs[0].name, "commit");
        assert_eq!(refs[0].args, "");
    }

    #[test]
    fn parse_skill_refs_multi_skill() {
        let skills = vec![make_skill("review", true), make_skill("lint", true)];
        let refs = parse_skill_references("/review fix auth /lint --strict", &skills, all_gated())
            .unwrap();
        assert_eq!(refs.len(), 2);
        assert_eq!(refs[0].name, "review");
        assert_eq!(refs[0].args, "fix auth");
        assert_eq!(refs[1].name, "lint");
        assert_eq!(refs[1].args, "--strict");
    }

    #[test]
    fn parse_skill_refs_ignores_unknown_slash() {
        let skills = vec![make_skill("commit", true)];
        // /api/v2/users is not a known skill — should be ignored.
        let result = parse_skill_references("check /api/v2/users", &skills, all_gated());
        assert!(result.is_none());
    }

    #[test]
    fn parse_skill_refs_ignores_builtins() {
        let skills = vec![make_skill("commit", true)];
        // /compact is a builtin — should NOT appear in skill refs.
        let result = parse_skill_references("/compact", &skills, all_gated());
        assert!(result.is_none());
    }

    #[test]
    fn parse_skill_refs_empty_text() {
        let skills = vec![make_skill("commit", true)];
        assert!(parse_skill_references("", &skills, all_gated()).is_none());
    }

    #[test]
    fn parse_skill_refs_no_slash() {
        let skills = vec![make_skill("commit", true)];
        assert!(parse_skill_references("just some text", &skills, all_gated()).is_none());
    }

    #[test]
    fn parse_skill_refs_non_invocable_skill_ignored() {
        let skills = vec![make_skill("internal-only", false)];
        assert!(parse_skill_references("/internal-only", &skills, all_gated()).is_none());
    }

    #[test]
    fn parse_skill_refs_qualified_name() {
        let skills = vec![
            make_scoped_skill("commit", SkillScope::Local),
            make_scoped_skill("commit", SkillScope::User),
        ];
        let refs = parse_skill_references("/local:commit fix typo", &skills, all_gated()).unwrap();
        assert_eq!(refs.len(), 1);
        assert_eq!(refs[0].name, "local:commit");
        assert_eq!(refs[0].args, "fix typo");
        assert_eq!(refs[0].qualified_name, "local:commit");
    }

    #[test]
    fn parse_skill_refs_text_before_first_skill() {
        // Text before the first skill reference is part of user query,
        // not consumed as args.
        let skills = vec![make_skill("commit", true)];
        let refs =
            parse_skill_references("please do /commit fix typo", &skills, all_gated()).unwrap();
        assert_eq!(refs.len(), 1);
        assert_eq!(refs[0].name, "commit");
        assert_eq!(refs[0].args, "fix typo");
    }

    // ── /goal command resolution ─────────────────────────────────

    fn resolve_goal(args: &str) -> BuiltinAction {
        let blocks = vec![text_block(&format!("/goal {args}"))];
        match resolve(blocks, &[], all_gated(), SkillSlashRewrite::default()).unwrap_err() {
            SlashCommandOutcome::Builtin(action) => action,
            _ => panic!("expected Builtin outcome"),
        }
    }

    #[test]
    fn goal_empty_resolves_to_status() {
        assert!(matches!(resolve_goal(""), BuiltinAction::GoalStatus));
    }

    #[test]
    fn goal_status_keyword_resolves_to_status() {
        assert!(matches!(resolve_goal("status"), BuiltinAction::GoalStatus));
        assert!(matches!(resolve_goal("STATUS"), BuiltinAction::GoalStatus));
    }

    #[test]
    fn goal_pause_resolves_to_pause() {
        assert!(matches!(resolve_goal("pause"), BuiltinAction::GoalPause));
        assert!(matches!(resolve_goal("PAUSE"), BuiltinAction::GoalPause));
    }

    #[test]
    fn goal_resume_resolves_to_resume() {
        assert!(matches!(resolve_goal("resume"), BuiltinAction::GoalResume));
    }

    #[test]
    fn goal_clear_resolves_to_clear() {
        assert!(matches!(resolve_goal("clear"), BuiltinAction::GoalClear));
    }

    #[test]
    fn goal_objective_resolves_to_set() {
        match resolve_goal("implement auth module") {
            BuiltinAction::GoalSet {
                objective,
                token_budget,
            } => {
                assert_eq!(objective, "implement auth module");
                assert_eq!(token_budget, None);
            }
            other => panic!("expected GoalSet, got {}", other.command_name()),
        }
    }

    #[test]
    fn goal_set_preserves_original_casing() {
        match resolve_goal("Fix BUG in AuthManager") {
            BuiltinAction::GoalSet { objective, .. } => {
                assert_eq!(objective, "Fix BUG in AuthManager");
            }
            other => panic!("expected GoalSet, got {}", other.command_name()),
        }
    }

    #[test]
    fn goal_set_trailing_budget_flag_parses() {
        match resolve_goal("implement X --budget 500000") {
            BuiltinAction::GoalSet {
                objective,
                token_budget,
            } => {
                assert_eq!(objective, "implement X");
                assert_eq!(token_budget, Some(500_000));
            }
            other => panic!("expected GoalSet, got {}", other.command_name()),
        }
    }

    #[test]
    fn goal_set_budget_accepts_boundary_and_extra_whitespace() {
        for (text, objective, budget) in [
            ("do x --budget 1", "do x", 1),
            ("do x --budget   77", "do x", 77),
            ("do x \t --budget 500000", "do x", 500_000),
        ] {
            match resolve_goal(text) {
                BuiltinAction::GoalSet {
                    objective: o,
                    token_budget,
                } => {
                    assert_eq!(o, objective);
                    assert_eq!(token_budget, Some(budget), "for {text:?}");
                }
                other => panic!("expected GoalSet, got {}", other.command_name()),
            }
        }
    }

    #[test]
    fn goal_set_malformed_budget_stays_in_objective() {
        // Non-numeric, missing, non-positive, glued, signed, overflowing,
        // or mid-text values must not be consumed as a budget.
        for text in [
            "implement X --budget abc",
            "implement X --budget",
            "implement X --budget 0",
            "implement X --budget -5",
            "implement X --budget +5",
            "implement X --budget 99999999999999999999",
            "implement X --budget5",
            "implement X --budget500000",
            "tune my-fund--budget 100",
            "fix the --budget flag parsing bug",
            "--budget 500000",
        ] {
            match resolve_goal(text) {
                BuiltinAction::GoalSet {
                    objective,
                    token_budget,
                } => {
                    assert_eq!(objective, text, "objective must be preserved verbatim");
                    assert_eq!(token_budget, None, "no budget must be parsed from {text:?}");
                }
                other => panic!("expected GoalSet, got {}", other.command_name()),
            }
        }
    }

    #[test]
    fn goal_command_name_is_goal() {
        assert_eq!(BuiltinAction::GoalStatus.command_name(), "goal");
        assert_eq!(BuiltinAction::GoalPause.command_name(), "goal");
        assert_eq!(BuiltinAction::GoalResume.command_name(), "goal");
        assert_eq!(BuiltinAction::GoalClear.command_name(), "goal");
        assert_eq!(
            BuiltinAction::GoalSet {
                objective: "x".into(),
                token_budget: None,
            }
            .command_name(),
            "goal"
        );
    }

    #[test]
    fn goal_args_provided() {
        assert!(
            BuiltinAction::GoalSet {
                objective: "x".into(),
                token_budget: None,
            }
            .args_provided()
        );
        assert!(!BuiltinAction::GoalStatus.args_provided());
        assert!(!BuiltinAction::GoalPause.args_provided());
        assert!(!BuiltinAction::GoalResume.args_provided());
        assert!(!BuiltinAction::GoalClear.args_provided());
    }

    // ── GoalTracker handler-level interaction tests ──────────────
    // These test the exact tracker state transitions that the slash
    // command handlers perform, without constructing a full SessionActor.

    #[test]
    fn goal_tracker_status_with_no_goal_returns_none() {
        use crate::session::goal_tracker::GoalTracker;
        let tracker = GoalTracker::new(std::path::PathBuf::from("/tmp/test"));
        assert!(tracker.snapshot().is_none());
        assert!(tracker.status().is_none());
    }

    #[test]
    fn goal_tracker_create_sets_active() {
        use crate::session::goal_tracker::{GoalStatus, GoalTracker};
        let mut tracker = GoalTracker::new(std::path::PathBuf::from("/tmp/test"));
        tracker.create_goal("g1".into(), "obj".into(), None, 0, "now".into(), None);
        assert_eq!(tracker.status(), Some(GoalStatus::Active));
        assert_eq!(tracker.objective(), Some("obj"));
    }

    #[test]
    fn goal_tracker_pause_only_when_active() {
        use crate::session::goal_tracker::{GoalPauseReason, GoalStatus, GoalTracker};
        let mut tracker = GoalTracker::new(std::path::PathBuf::from("/tmp/test"));
        // No goal — pause returns false
        assert!(!tracker.pause(GoalPauseReason::User));

        tracker.create_goal("g1".into(), "obj".into(), None, 0, "now".into(), None);
        assert!(tracker.pause(GoalPauseReason::User));
        assert_eq!(tracker.status(), Some(GoalStatus::UserPaused));
        // Already paused — pause returns false
        assert!(!tracker.pause(GoalPauseReason::User));
    }

    #[test]
    fn goal_tracker_resume_only_when_paused() {
        use crate::session::goal_tracker::{GoalPauseReason, GoalStatus, GoalTracker};
        let mut tracker = GoalTracker::new(std::path::PathBuf::from("/tmp/test"));
        tracker.create_goal("g1".into(), "obj".into(), None, 0, "now".into(), None);
        // Active — resume returns false
        assert!(!tracker.resume());
        tracker.pause(GoalPauseReason::User);
        assert!(tracker.resume());
        assert_eq!(tracker.status(), Some(GoalStatus::Active));
    }

    #[test]
    fn goal_tracker_clear_removes_orchestration() {
        use crate::session::goal_tracker::GoalTracker;
        let mut tracker = GoalTracker::new(std::path::PathBuf::from("/tmp/test"));
        tracker.create_goal("g1".into(), "obj".into(), None, 0, "now".into(), None);
        assert!(tracker.snapshot().is_some());
        tracker.clear();
        assert!(tracker.snapshot().is_none());
    }

    #[test]
    fn goal_tracker_create_replaces_existing() {
        use crate::session::goal_tracker::GoalTracker;
        let mut tracker = GoalTracker::new(std::path::PathBuf::from("/tmp/test"));
        tracker.create_goal("g1".into(), "first".into(), None, 0, "now".into(), None);
        tracker.create_goal("g2".into(), "second".into(), None, 0, "now".into(), None);
        assert_eq!(tracker.objective(), Some("second"));
    }

    #[test]
    fn goal_tracker_account_elapsed_flushes_delta() {
        use crate::session::goal_tracker::GoalTracker;
        let mut tracker = GoalTracker::new(std::path::PathBuf::from("/tmp/test"));
        tracker.create_goal("g1".into(), "obj".into(), None, 0, "now".into(), None);
        // After create_goal, elapsed_ms is 0 but active_since is set.
        let before = tracker.snapshot().unwrap().elapsed_ms;
        assert_eq!(before, 0);
        // account_elapsed flushes pending wall-clock time.
        std::thread::sleep(std::time::Duration::from_millis(5));
        tracker.account_elapsed();
        let after = tracker.snapshot().unwrap().elapsed_ms;
        assert!(after > 0, "elapsed should be > 0 after account_elapsed");
    }
}
