use async_trait::async_trait;
use serde::{Deserialize, Serialize};

use crate::openhuman::agent::tool_policy::GeneratedToolRuntimeContext;

// Re-export the unified ToolResult from the lightweight skills types module so all tools use one type.
pub use crate::openhuman::workflows::types::{ToolContent, ToolResult};

/// Controls where a tool is available.
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum ToolScope {
    /// Available in agent loop, CLI, and RPC.
    All,
    /// Only available in the autonomous agent loop.
    #[allow(dead_code)]
    AgentOnly,
    /// Only available via explicit CLI/RPC invocation (not autonomous agent).
    CliRpcOnly,
}

/// Category of a tool — used by the sub-agent runner to scope which
/// tools a given sub-agent is allowed to see.
///
/// The distinction matters because:
///
/// - **System tools** are built-in Rust implementations (shell, file_read,
///   file_write, cron_*, memory_*, …) that run inside the core process
///   with direct host access.
/// - **Workflow tools** are integration-facing tools that talk to external
///   services (for example Composio-backed SaaS actions).
///
/// The orchestrator uses this category to spawn dedicated tool-execution
/// sub-agents: one scoped to `Workflow` for service integrations (running
/// with the backend's `agentic` model hint), and others scoped to
/// `System` for code/file/host work.
#[derive(Debug, Clone, Copy, PartialEq, Eq, Default, Serialize, Deserialize)]
#[serde(rename_all = "snake_case")]
pub enum ToolCategory {
    /// Built-in Rust tools with direct host access.
    #[default]
    System,
    /// Integration-facing tools that reach external services (Composio-backed
    /// SaaS actions, "runners"). The Rust ident was swept to `Workflow` during
    /// the skills→workflows unification, but this category is NOT a WORKFLOW.md
    /// bundle — it's the integration-tool class the integrations subagent
    /// filters on via `category_filter = "skill"`. The wire format is pinned to
    /// `"skill"` so existing agent definitions keep parsing; the ident is
    /// provisional and gets revisited when the integrations_agent is reworked
    /// (Phase 4 / "runners → Intelligence").
    #[serde(rename = "skill")]
    Workflow,
}

impl std::fmt::Display for ToolCategory {
    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
        match self {
            Self::System => write!(f, "system"),
            Self::Workflow => write!(f, "skill"),
        }
    }
}

/// Permission level required to execute a tool.
///
/// Channels can set a maximum permission level to restrict which tools
/// are available. Tools requiring a level above the channel's maximum
/// are rejected before execution.
#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Serialize, Deserialize, Default)]
pub enum PermissionLevel {
    /// No permission needed (metadata-only operations).
    None = 0,
    /// Read-only operations (file reads, memory recall, listing).
    #[default]
    ReadOnly = 1,
    /// Write operations (file writes, memory store).
    Write = 2,
    /// Command execution (shell, scripts).
    Execute = 3,
    /// Dangerous/destructive operations (hardware, system-level).
    Dangerous = 4,
}

impl std::fmt::Display for PermissionLevel {
    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
        match self {
            Self::None => write!(f, "None"),
            Self::ReadOnly => write!(f, "ReadOnly"),
            Self::Write => write!(f, "Write"),
            Self::Execute => write!(f, "Execute"),
            Self::Dangerous => write!(f, "Dangerous"),
        }
    }
}

/// Per-invocation options threaded from the agent loop into a tool's
/// execution. Lets callers (the harness, orchestrator, RPC dispatcher)
/// hint at how the tool should shape its output without polluting the
/// tool's user-facing parameter schema.
///
/// Tools that opt in override [`Tool::execute_with_options`] and check
/// these flags; tools that ignore the struct keep working unchanged
/// because the trait's default implementation forwards to
/// [`Tool::execute`].
#[derive(Debug, Clone, Copy, Default)]
pub struct ToolCallOptions {
    /// When true, the caller (typically the agent loop) prefers a
    /// markdown rendering of the result for direct LLM consumption,
    /// because markdown is materially cheaper than JSON in tokens.
    /// Tools should populate `ToolResult::markdown_formatted` when
    /// this is set; the harness will pick that field up if present.
    pub prefer_markdown: bool,
}

/// How the harness should bound a single tool invocation in wall-clock time.
///
/// Returned by [`Tool::timeout_policy`]. Separates the common "use the global
/// timeout" case from the scripting-tool cases ("no deadline" / "this exact
/// deadline") so the harness can hard-kill genuinely hang-prone tools while
/// letting a long-but-legitimate script run to completion.
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum ToolTimeout {
    /// Use the global, operator/config-driven tool timeout. Default for most
    /// tools (network, MCP, etc.) — a hung call must not wedge the session.
    Inherit,
    /// Run without any harness-imposed deadline. Scripting tools return this
    /// when the caller did not request an explicit budget.
    Unbounded,
    /// Enforce exactly this many seconds. The harness clamps it to the valid
    /// range (`MIN_TIMEOUT_SECS..=MAX_TIMEOUT_SECS`) defensively.
    Secs(u64),
}

/// Description of a tool for the LLM
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct ToolSpec {
    pub name: String,
    pub description: String,
    pub parameters: serde_json::Value,
}

/// Derive a Title-Cased, human-readable label from a raw tool name.
///
/// Strips common machine prefixes (`composio_`, `mcp_`) and turns
/// snake_case / kebab-case into spaced Title Case:
/// `gmail_read_message` → "Gmail Read Message". Used as the default for
/// [`Tool::display_label`] and as a safety net for any tool that doesn't
/// supply a curated phrase, so a timeline row never shows raw `snake_case`.
pub fn humanize_tool_name(name: &str) -> String {
    let trimmed = name
        .strip_prefix("composio_")
        .or_else(|| name.strip_prefix("mcp_"))
        .unwrap_or(name);

    let mut out = String::with_capacity(trimmed.len());
    let mut capitalize = true;
    for ch in trimmed.chars() {
        if ch == '_' || ch == '-' {
            if !out.is_empty() && !out.ends_with(' ') {
                out.push(' ');
            }
            capitalize = true;
        } else if capitalize {
            out.extend(ch.to_uppercase());
            capitalize = false;
        } else {
            out.push(ch);
        }
    }

    let label = out.trim();
    if label.is_empty() {
        name.to_string()
    } else {
        label.to_string()
    }
}

/// Pull the single most-relevant contextual argument out of a tool-call's
/// args for the timeline "detail" (the bracketed context after the label).
///
/// Scans a prioritized list of common argument keys (recipient, query,
/// path, command, …) and returns the first present, non-empty value as a
/// trimmed, length-capped string — so a row can read "reading messages from
/// steven@gmail.com" / `Read(src/openhuman/wallet/ops.rs)` without every tool
/// hand-writing a [`Tool::display_detail`] override. Returns `None` when no
/// recognized key carries a usable scalar.
pub fn context_detail_from_args(args: &serde_json::Value) -> Option<String> {
    /// Common "what is this acting on" keys, most-specific first.
    const CONTEXT_KEYS: &[&str] = &[
        "to",
        "recipient",
        "recipient_email",
        "to_email",
        "email",
        "query",
        "q",
        "search",
        "search_query",
        "url",
        "file_path",
        "path",
        "command",
        "cmd",
        "subject",
        "title",
        "channel",
        "channel_id",
        "repo",
        "repository",
        "name",
        "id",
    ];

    let obj = args.as_object()?;
    for key in CONTEXT_KEYS {
        let Some(value) = obj.get(*key) else { continue };
        if let Some(rendered) = render_context_value(value) {
            return Some(rendered);
        }
    }
    None
}

/// Render a single arg value to a compact detail string, or `None` when it
/// carries nothing useful (empty string, object, null).
fn render_context_value(value: &serde_json::Value) -> Option<String> {
    /// Max characters for a timeline detail before it is elided.
    const MAX_DETAIL: usize = 80;

    let raw = match value {
        serde_json::Value::String(s) => s.trim().to_string(),
        serde_json::Value::Number(n) => n.to_string(),
        serde_json::Value::Bool(b) => b.to_string(),
        serde_json::Value::Array(items) => items
            .iter()
            .filter_map(|v| v.as_str())
            .collect::<Vec<_>>()
            .join(", "),
        _ => String::new(),
    };
    let raw = raw.split_whitespace().collect::<Vec<_>>().join(" ");
    if raw.is_empty() {
        return None;
    }
    if raw.chars().count() > MAX_DETAIL {
        let truncated: String = raw.chars().take(MAX_DETAIL.saturating_sub(1)).collect();
        Some(format!("{truncated}…"))
    } else {
        Some(raw)
    }
}

/// Core tool trait — implement for any capability (built-in or integration-based).
#[async_trait]
pub trait Tool: Send + Sync {
    /// Tool name (used in LLM function calling)
    fn name(&self) -> &str;

    /// Human-readable description
    fn description(&self) -> &str;

    /// JSON schema for parameters
    fn parameters_schema(&self) -> serde_json::Value;

    /// Execute the tool with given arguments.
    /// Returns a unified `ToolResult` (MCP content blocks + error flag).
    async fn execute(&self, args: serde_json::Value) -> anyhow::Result<ToolResult>;

    /// Execute the tool with caller-provided options.
    ///
    /// Default implementation forwards to [`Self::execute`] — existing
    /// tools keep working without changes. Tools that can produce a
    /// compact markdown rendering (saving tokens in the agent loop)
    /// should override this method, inspect
    /// [`ToolCallOptions::prefer_markdown`], and populate
    /// `ToolResult::markdown_formatted` on the returned result.
    async fn execute_with_options(
        &self,
        args: serde_json::Value,
        _options: ToolCallOptions,
    ) -> anyhow::Result<ToolResult> {
        self.execute(args).await
    }

    /// Whether this tool can produce a markdown rendering when
    /// [`ToolCallOptions::prefer_markdown`] is set. Default: `false`.
    /// Tools that override [`Self::execute_with_options`] to honor the
    /// flag should also override this to advertise the capability —
    /// telemetry / agent-loop diagnostics use it to attribute token
    /// savings.
    fn supports_markdown(&self) -> bool {
        false
    }

    /// Permission level required to execute this tool.
    ///
    /// For tools that expose multiple actions with different permission
    /// requirements, this should return the **minimum** level needed by
    /// any action — so the tool is not statically blocked on channels that
    /// could legitimately run the read-only actions. The per-call level is
    /// enforced by [`Self::permission_level_with_args`].
    ///
    /// Channels with a lower maximum permission level will reject this tool
    /// before any per-call check runs.
    /// Default: `ReadOnly`. Override for write/execute/dangerous tools.
    fn permission_level(&self) -> PermissionLevel {
        PermissionLevel::ReadOnly
    }

    /// Args-aware version of [`Self::permission_level`].
    ///
    /// Tools that expose multiple actions with differing permission
    /// requirements (e.g. `schedule list` vs `schedule create`) override
    /// this to return the exact level for the specific call. The agent
    /// harness calls this at call time to enforce the per-action level
    /// against the channel's `allowed_permission`.
    ///
    /// Default: delegates to the arg-less [`Self::permission_level`] so
    /// existing tools keep working without changes.
    fn permission_level_with_args(&self, _args: &serde_json::Value) -> PermissionLevel {
        self.permission_level()
    }

    /// Where this tool may be executed. Default: `All`.
    /// Override to restrict (e.g. `CliRpcOnly` for phone calls).
    fn scope(&self) -> ToolScope {
        ToolScope::All
    }

    /// Category of this tool — `System` for built-in Rust tools (default)
    /// or `Workflow` for integration-facing tools.
    fn category(&self) -> ToolCategory {
        ToolCategory::System
    }

    /// Whether two concurrent invocations of this tool are safe to
    /// run in parallel inside a single LLM iteration.
    ///
    /// Read-only tools that touch no shared mutable state should
    /// return `true` (the agent's tool loop can then `join_all` a
    /// batch of read calls instead of awaiting them serially). Tools
    /// that mutate the workspace, write to disk, or interact with
    /// external services that throttle by caller should leave the
    /// default `false`.
    ///
    /// The argument is provided so a tool can refine the answer per
    /// call (e.g. a generic `bash` tool could allow parallel `ls` /
    /// `cat` invocations and reject parallel `npm install`s) — most
    /// tools will ignore it.
    ///
    /// **Wiring note:** the parallel dispatcher in
    /// `harness::tool_loop` currently runs tool calls serially
    /// regardless of this flag. Annotating tools is still load-
    /// bearing: it lets the dispatch refactor land without
    /// coordinating with every tool author. See the parallel-tool
    /// dispatch follow-up issue.
    fn is_concurrency_safe(&self, _args: &serde_json::Value) -> bool {
        false
    }

    /// Whether this tool produces an externally-observable side effect
    /// (outbound Slack/Telegram/email/calendar/webhook write, etc.).
    ///
    /// When `true`, the agent harness routes the call through the
    /// `ApprovalGate` before `execute()` runs. Local file writes,
    /// memory writes, and TTS `reply_speech` stay `false` — they are
    /// either reversible inside the user's machine or considered
    /// internal per issue #1339.
    ///
    /// Default: `false`. Override on tools that talk to external
    /// services on the user's behalf.
    fn external_effect(&self) -> bool {
        false
    }

    /// Args-aware version of [`Self::external_effect`]. Tools whose
    /// classification depends on the call arguments (e.g. the
    /// `composio` tool gates `action="execute"` but lets
    /// `action="list"` / `action="connect"` flow through unprompted)
    /// override this method to peek at `args`.
    ///
    /// The harness calls this method (not the arg-less variant) at
    /// the gate-decision point, so most tools that need per-call
    /// gating should override here rather than [`Self::external_effect`].
    /// Default: defer to the arg-less classification so existing
    /// overrides keep working without changes.
    fn external_effect_with_args(&self, _args: &serde_json::Value) -> bool {
        self.external_effect()
    }

    /// Optional generated-tool runtime metadata for policy enforcement.
    ///
    /// Generated or externally supplied tools can override this to let
    /// the agent policy layer apply provider/capability/risk rules before
    /// execution. Built-in tools leave it unset.
    fn generated_runtime_context(
        &self,
        _args: &serde_json::Value,
    ) -> Option<GeneratedToolRuntimeContext> {
        None
    }

    /// Per-tool cap on the character length of the result body sent
    /// back to the model.
    ///
    /// When `Some(cap)` and the tool's `output_for_llm` exceeds it,
    /// the agent's tool loop truncates the body and appends a marker
    /// before threading the value into history — protecting the
    /// context window from one chatty tool. When `None` (the
    /// default), no per-tool cap applies and the global
    /// `PayloadSummarizer` (if any) handles oversize bodies.
    ///
    /// Set this on tools whose output is *bounded but unpredictable*
    /// (`bash`, `web_fetch`, etc.); leave it unset on tools where
    /// callers genuinely want full content (`read_file`, `grep`).
    fn max_result_size_chars(&self) -> Option<usize> {
        None
    }

    /// How the harness should bound this invocation in wall-clock time.
    ///
    /// The harness wraps every `execute()` in a deadline. Most tools want the
    /// global, operator/config-driven timeout ([`ToolTimeout::Inherit`]) so a
    /// hung network/MCP call can't wedge a session. Scripting tools (`shell`,
    /// `node_exec`, `npm_exec`) instead return [`ToolTimeout::Unbounded`] when
    /// the caller did not request a budget — a build / solver / test run
    /// legitimately takes minutes and must not be hard-killed by a default cap
    /// (issue #4023) — and [`ToolTimeout::Secs`] only when an explicit
    /// `timeout_secs` was supplied.
    ///
    /// Default: [`ToolTimeout::Inherit`] (use the global timeout).
    fn timeout_policy(&self, _args: &serde_json::Value) -> ToolTimeout {
        ToolTimeout::Inherit
    }

    /// Get the full spec for LLM registration
    fn spec(&self) -> ToolSpec {
        ToolSpec {
            name: self.name().to_string(),
            description: self.description().to_string(),
            parameters: self.parameters_schema(),
        }
    }

    /// Short, human-readable verb phrase describing this call for the chat
    /// "agent processing" timeline (e.g. "Reading messages", "Running
    /// command", "Reading file"). The default derives a Title-Cased label
    /// from [`Self::name`] via [`humanize_tool_name`]; dynamic / integration
    /// tools (Composio, MCP, generated) and high-value built-ins override to
    /// return a curated phrase so a row never reads as raw `snake_case`.
    ///
    /// Paired with [`Self::display_detail`] (the specific argument), the UI
    /// renders `label (detail)` — Claude-style `Read(path)` /
    /// "reading messages from steven@gmail.com".
    fn display_label(&self, _args: &serde_json::Value) -> Option<String> {
        Some(humanize_tool_name(self.name()))
    }

    /// The specific, contextual argument for this call — the file path, email
    /// address, command, or query pulled from `args` — shown in brackets
    /// after [`Self::display_label`]. The default pulls the most-relevant
    /// common argument via [`context_detail_from_args`], so any tool reads
    /// like "reading messages from steven@gmail.com" / `Read(path)` without a
    /// hand-written override. Tools whose meaningful arg sits under an unusual
    /// key override to surface the right value.
    fn display_detail(&self, args: &serde_json::Value) -> Option<String> {
        context_detail_from_args(args)
    }
}

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

    struct DummyTool;

    #[async_trait]
    impl Tool for DummyTool {
        fn name(&self) -> &str {
            "dummy_tool"
        }

        fn description(&self) -> &str {
            "A deterministic test tool"
        }

        fn parameters_schema(&self) -> serde_json::Value {
            serde_json::json!({
                "type": "object",
                "properties": {
                    "value": { "type": "string" }
                }
            })
        }

        async fn execute(&self, args: serde_json::Value) -> anyhow::Result<ToolResult> {
            let text = args
                .get("value")
                .and_then(serde_json::Value::as_str)
                .unwrap_or_default()
                .to_string();
            Ok(ToolResult::success(text))
        }
    }

    #[test]
    fn spec_uses_tool_metadata_and_schema() {
        let tool = DummyTool;
        let spec = tool.spec();

        assert_eq!(spec.name, "dummy_tool");
        assert_eq!(spec.description, "A deterministic test tool");
        assert_eq!(spec.parameters["type"], "object");
        assert_eq!(spec.parameters["properties"]["value"]["type"], "string");
    }

    #[tokio::test]
    async fn execute_returns_expected_output() {
        let tool = DummyTool;
        let result = tool
            .execute(serde_json::json!({ "value": "hello-tool" }))
            .await
            .unwrap();

        assert!(!result.is_error);
        assert_eq!(result.output(), "hello-tool");
    }

    #[test]
    fn tool_result_serialization_roundtrip() {
        let result = ToolResult::error("boom");

        let json = serde_json::to_string(&result).unwrap();
        let parsed: ToolResult = serde_json::from_str(&json).unwrap();

        assert!(parsed.is_error);
        assert_eq!(parsed.output(), "boom");
    }

    // ── Default trait-method values ────────────────────────────────

    #[test]
    fn default_permission_level_is_read_only() {
        let tool = DummyTool;
        assert_eq!(tool.permission_level(), PermissionLevel::ReadOnly);
    }

    #[test]
    fn default_scope_is_all() {
        let tool = DummyTool;
        assert_eq!(tool.scope(), ToolScope::All);
    }

    #[test]
    fn default_category_is_system() {
        let tool = DummyTool;
        assert_eq!(tool.category(), ToolCategory::System);
    }

    #[test]
    fn default_is_concurrency_safe_is_false() {
        let tool = DummyTool;
        assert!(!tool.is_concurrency_safe(&serde_json::Value::Null));
    }

    #[test]
    fn default_max_result_size_chars_is_none() {
        let tool = DummyTool;
        assert!(tool.max_result_size_chars().is_none());
    }

    #[test]
    fn default_external_effect_is_false() {
        let tool = DummyTool;
        assert!(!tool.external_effect());
    }

    // ── display_label / display_detail ─────────────────────────────

    #[test]
    fn default_display_label_humanizes_tool_name() {
        let tool = DummyTool;
        assert_eq!(
            tool.display_label(&serde_json::Value::Null).as_deref(),
            Some("Dummy Tool")
        );
    }

    #[test]
    fn default_display_detail_pulls_context_arg() {
        let tool = DummyTool;
        // No object args → nothing to surface.
        assert!(tool.display_detail(&serde_json::Value::Null).is_none());
        // A recognized key becomes the bracketed context.
        assert_eq!(
            tool.display_detail(&serde_json::json!({ "path": "src/main.rs" }))
                .as_deref(),
            Some("src/main.rs")
        );
    }

    #[test]
    fn context_detail_prefers_specific_keys_and_truncates() {
        // `to` outranks `name` for a messaging-style call.
        assert_eq!(
            context_detail_from_args(&serde_json::json!({
                "name": "ignored", "to": "steven@gmail.com"
            }))
            .as_deref(),
            Some("steven@gmail.com")
        );
        // Long values are elided to keep the row compact.
        let long = "x".repeat(200);
        let detail = context_detail_from_args(&serde_json::json!({ "query": long })).unwrap();
        assert!(detail.chars().count() <= 80);
        assert!(detail.ends_with('…'));
        // Whitespace is collapsed.
        assert_eq!(
            context_detail_from_args(&serde_json::json!({ "command": "  ls   -la  " })).as_deref(),
            Some("ls -la")
        );
    }

    #[test]
    fn humanize_tool_name_title_cases_snake_case() {
        assert_eq!(
            humanize_tool_name("gmail_read_message"),
            "Gmail Read Message"
        );
        assert_eq!(humanize_tool_name("web_fetch"), "Web Fetch");
        assert_eq!(humanize_tool_name("shell"), "Shell");
    }

    #[test]
    fn humanize_tool_name_strips_machine_prefixes() {
        // Composio / MCP wrappers prefix the raw action name; the timeline
        // label should read as the action, not the transport.
        assert_eq!(
            humanize_tool_name("composio_gmail_send_email"),
            "Gmail Send Email"
        );
        assert_eq!(
            humanize_tool_name("mcp_notion_create_page"),
            "Notion Create Page"
        );
    }

    #[test]
    fn humanize_tool_name_handles_kebab_and_empty() {
        assert_eq!(humanize_tool_name("read-diff"), "Read Diff");
        // Degenerate input never panics and never yields an empty label.
        assert_eq!(humanize_tool_name(""), "");
        assert_eq!(humanize_tool_name("___"), "___");
    }

    // ── PermissionLevel ordering ───────────────────────────────────

    #[test]
    fn permission_level_is_totally_ordered_from_none_to_dangerous() {
        // The runtime compares PermissionLevel as `<` to reject tools whose
        // required level exceeds the channel max, so the ordering is a
        // load-bearing invariant.
        assert!(PermissionLevel::None < PermissionLevel::ReadOnly);
        assert!(PermissionLevel::ReadOnly < PermissionLevel::Write);
        assert!(PermissionLevel::Write < PermissionLevel::Execute);
        assert!(PermissionLevel::Execute < PermissionLevel::Dangerous);
    }

    #[test]
    fn permission_level_default_is_read_only() {
        assert_eq!(PermissionLevel::default(), PermissionLevel::ReadOnly);
    }

    #[test]
    fn permission_level_display_matches_variant_name() {
        assert_eq!(PermissionLevel::None.to_string(), "None");
        assert_eq!(PermissionLevel::ReadOnly.to_string(), "ReadOnly");
        assert_eq!(PermissionLevel::Write.to_string(), "Write");
        assert_eq!(PermissionLevel::Execute.to_string(), "Execute");
        assert_eq!(PermissionLevel::Dangerous.to_string(), "Dangerous");
    }

    #[test]
    fn permission_level_round_trips_as_json_number() {
        for level in [
            PermissionLevel::None,
            PermissionLevel::ReadOnly,
            PermissionLevel::Write,
            PermissionLevel::Execute,
            PermissionLevel::Dangerous,
        ] {
            let s = serde_json::to_string(&level).unwrap();
            let back: PermissionLevel = serde_json::from_str(&s).unwrap();
            assert_eq!(back, level);
        }
    }

    // ── ToolCategory ───────────────────────────────────────────────

    #[test]
    fn tool_category_default_is_system() {
        assert_eq!(ToolCategory::default(), ToolCategory::System);
    }

    #[test]
    fn tool_category_display_is_lowercase() {
        assert_eq!(ToolCategory::System.to_string(), "system");
        assert_eq!(ToolCategory::Workflow.to_string(), "skill");
    }

    #[test]
    fn tool_category_serde_uses_snake_case() {
        // The runtime relies on snake_case JSON for `category` in agent
        // definitions — catch any rename that would break user-facing
        // definition files.
        let s = serde_json::to_string(&ToolCategory::System).unwrap();
        assert_eq!(s, "\"system\"");
        let s = serde_json::to_string(&ToolCategory::Workflow).unwrap();
        assert_eq!(s, "\"skill\"");
        let back: ToolCategory = serde_json::from_str("\"skill\"").unwrap();
        assert_eq!(back, ToolCategory::Workflow);
    }

    // ── ToolScope ──────────────────────────────────────────────────

    #[test]
    fn tool_scope_variants_are_distinct() {
        assert_ne!(ToolScope::All, ToolScope::AgentOnly);
        assert_ne!(ToolScope::All, ToolScope::CliRpcOnly);
        assert_ne!(ToolScope::AgentOnly, ToolScope::CliRpcOnly);
    }
}
