//! MCP server for agnix - AI agent config linter
//!
//! Exposes agnix validation as MCP tools for AI assistants.
//!
//! ## MCP Best Practices Implemented
//!
//! - **Clear tool descriptions**: Each tool has a detailed description explaining
//!   what it does, when to use it, and what it returns
//! - **Rich parameter schemas**: All parameters have descriptions with examples
//! - **Structured outputs**: Returns JSON with predictable schema for easy parsing
//! - **Error handling**: Proper error messages with context
//! - **Server metadata**: Provides name, version, and usage instructions

#[cfg(target_os = "linux")]
#[global_allocator]
static GLOBAL: mimalloc::MiMalloc = mimalloc::MiMalloc;

use agnix_core::{
    config::LintConfig,
    diagnostics::{Diagnostic, DiagnosticLevel},
    validate_file as core_validate_file, validate_project as core_validate_project,
};
use rmcp::{
    ServerHandler, ServiceExt,
    handler::server::{tool::ToolRouter, wrapper::Parameters},
    model::{
        CallToolResult, ContentBlock, ErrorData as McpError, Implementation, ProtocolVersion,
        ServerCapabilities, ServerInfo,
    },
    schemars, tool, tool_handler, tool_router,
    transport::stdio,
};
use serde::{Deserialize, Serialize};
use serde_json::Value;
use std::any::Any;
use std::collections::HashSet;
use std::fmt::Display;
use std::panic::{AssertUnwindSafe, catch_unwind};
use std::path::{Path, PathBuf};

const TOOL_ALIASES: &[(&str, &str)] =
    &[("copilot", "github-copilot"), ("claudecode", "claude-code")];

const COMPAT_TOOL_NAMES: &[&str] = &["generic", "codex"];

/// Input for validate_file tool.
///
/// The `path` field accepts paths inside the server working directory. Path
/// safety is enforced before validation and downstream by `safe_read_file()`.
#[derive(Debug, Deserialize, schemars::JsonSchema)]
#[schemars(description = "Input for validating a single agent configuration file")]
pub struct ValidateFileInput {
    /// Path to the file to validate
    #[schemars(
        description = "Path to an agent configuration file inside the server working directory (e.g., 'SKILL.md', '.claude/settings.json', 'mcp-config.json')"
    )]
    pub path: String,
    /// Tools to validate for (preferred over legacy target)
    #[schemars(
        description = "Tools to validate for. Preferred: JSON array of tool names (e.g. [\"claude-code\", \"cursor\"]). Also accepts comma-separated string (e.g. \"claude-code,cursor\") as a fallback. Uses canonical agnix tool names (case-insensitive), plus compatibility aliases (e.g. \"copilot\", \"claudecode\"). When non-empty, this overrides legacy target."
    )]
    pub tools: Option<ToolsInput>,
    /// Target tool for validation rules
    #[schemars(
        description = "Legacy single target for validation rules (deprecated). Options: 'generic' (default), 'claude-code', 'cursor', 'codex', 'kiro'. Used only when 'tools' is missing or empty."
    )]
    pub target: Option<String>,
}

/// Input for validate_project tool
#[derive(Debug, Deserialize, schemars::JsonSchema)]
#[schemars(description = "Input for validating all agent configs in a project directory")]
pub struct ValidateProjectInput {
    /// Path to the project directory
    #[schemars(
        description = "Path to the project directory to validate (e.g., '.' for current directory)"
    )]
    pub path: String,
    /// Tools to validate for (preferred over legacy target)
    #[schemars(
        description = "Tools to validate for. Preferred: JSON array of tool names (e.g. [\"claude-code\", \"cursor\"]). Also accepts comma-separated string (e.g. \"claude-code,cursor\") as a fallback. Uses canonical agnix tool names (case-insensitive), plus compatibility aliases (e.g. \"copilot\", \"claudecode\"). When non-empty, this overrides legacy target."
    )]
    pub tools: Option<ToolsInput>,
    /// Target tool for validation rules
    #[schemars(
        description = "Legacy single target for validation rules (deprecated). Options: 'generic' (default), 'claude-code', 'cursor', 'codex', 'kiro'. Used only when 'tools' is missing or empty."
    )]
    pub target: Option<String>,
}

/// Tools input for MCP validate tools.
///
/// Supports either JSON array (preferred) or comma-separated string (fallback).
/// Variants are ordered so that serde tries `List` first when deserializing
/// with `#[serde(untagged)]`, and the manual `JsonSchema` impl emits
/// `anyOf` with the array variant first to signal preference to MCP clients.
#[derive(Debug, Deserialize)]
#[serde(untagged)]
pub enum ToolsInput {
    List(Vec<String>),
    Csv(String),
}

impl schemars::JsonSchema for ToolsInput {
    fn schema_name() -> std::borrow::Cow<'static, str> {
        "ToolsInput".into()
    }

    fn schema_id() -> std::borrow::Cow<'static, str> {
        concat!(module_path!(), "::ToolsInput").into()
    }

    fn json_schema(_gen: &mut schemars::SchemaGenerator) -> schemars::Schema {
        schemars::json_schema!({
            "anyOf": [
                {
                    "type": "array",
                    "items": { "type": "string" },
                    "description": "Preferred: array of tool names, e.g. [\"claude-code\", \"cursor\"]"
                },
                {
                    "type": "string",
                    "description": "Fallback: comma-separated tool names, e.g. \"claude-code,cursor\""
                }
            ]
        })
    }

    /// Inline this schema at every usage site instead of emitting a `$ref`.
    ///
    /// With `inline_schema = false` (the schemars default), the generator places
    /// `ToolsInput` in `$defs` and emits `$ref` pointers from `ValidateFileInput`
    /// and `ValidateProjectInput`. Some MCP clients do not follow `$ref` when
    /// rendering input-schema choices, which would hide the array-first preference
    /// signal. Inlining guarantees that the `anyOf` with array first is visible
    /// directly at every property site.
    fn inline_schema() -> bool {
        true
    }
}

/// Input for get_rule_docs tool
#[derive(Debug, Deserialize, schemars::JsonSchema)]
#[schemars(description = "Input for looking up a specific validation rule")]
pub struct GetRuleDocsInput {
    /// Rule ID
    #[schemars(
        description = "Rule ID to look up documentation for. Format: PREFIX-NUMBER (e.g., 'AS-004', 'CC-SK-001', 'PE-003', 'MCP-001')"
    )]
    pub rule_id: String,
}

/// Diagnostic output for JSON serialization
#[derive(Debug, Serialize, schemars::JsonSchema)]
struct DiagnosticOutput {
    /// File path where the issue was found
    file: String,
    /// Line number (1-based)
    line: usize,
    /// Column number (1-based)
    column: usize,
    /// Severity level: error, warning, or info
    level: String,
    /// Rule ID (e.g., AS-004)
    rule: String,
    /// Human-readable message describing the issue
    message: String,
    /// Suggested fix or help text
    suggestion: Option<String>,
    /// Whether this issue can be auto-fixed
    fixable: bool,
    /// Rule category from the rules catalog (e.g., "agent-skills")
    #[serde(skip_serializing_if = "Option::is_none")]
    category: Option<String>,
    /// Rule severity from the rules catalog (e.g., "HIGH", "MEDIUM", "LOW").
    /// Named `rule_severity` to avoid confusion with the `level` field.
    #[serde(skip_serializing_if = "Option::is_none")]
    rule_severity: Option<String>,
    /// Tool this rule specifically applies to (e.g., "claude-code")
    #[serde(skip_serializing_if = "Option::is_none")]
    applies_to_tool: Option<String>,
}

impl From<&Diagnostic> for DiagnosticOutput {
    fn from(d: &Diagnostic) -> Self {
        Self::from_diagnostic(d, None)
    }
}

impl DiagnosticOutput {
    fn from_diagnostic(d: &Diagnostic, workspace_root: Option<&Path>) -> Self {
        Self {
            file: workspace_root
                .map(|root| display_path_for_client(&d.file, root))
                .unwrap_or_else(|| d.file.display().to_string()),
            line: d.line,
            column: d.column,
            level: match d.level {
                DiagnosticLevel::Error => "error",
                DiagnosticLevel::Warning => "warning",
                DiagnosticLevel::Info => "info",
            }
            .to_string(),
            rule: d.rule.clone(),
            message: d.message.clone(),
            suggestion: d.suggestion.clone(),
            fixable: !d.fixes.is_empty(),
            category: d.metadata.as_ref().map(|m| m.category.clone()),
            rule_severity: d.metadata.as_ref().map(|m| m.severity.clone()),
            applies_to_tool: d.metadata.as_ref().and_then(|m| m.applies_to_tool.clone()),
        }
    }
}

/// Validation result output
#[derive(Debug, Serialize, schemars::JsonSchema)]
struct ValidationResult {
    /// Path that was validated
    path: String,
    /// Number of files checked
    files_checked: usize,
    /// Number of errors found
    errors: usize,
    /// Number of warnings found
    warnings: usize,
    /// Number of issues that can be auto-fixed
    fixable: usize,
    /// List of diagnostics
    diagnostics: Vec<DiagnosticOutput>,
}

/// Rule info for listing
#[derive(Debug, Serialize, schemars::JsonSchema)]
struct RuleInfo {
    /// Rule ID (e.g., AS-004)
    id: String,
    /// Human-readable name
    name: String,
}

/// Rules list output
#[derive(Debug, Serialize, schemars::JsonSchema)]
struct RulesListOutput {
    /// Total number of rules
    count: usize,
    /// List of rules
    rules: Vec<RuleInfo>,
}

fn parse_target(target: Option<String>) -> agnix_core::config::TargetTool {
    use agnix_core::config::TargetTool;

    match target.as_deref() {
        Some("claude-code") | Some("claudecode") => TargetTool::ClaudeCode,
        Some("cursor") => TargetTool::Cursor,
        Some("codex") => TargetTool::Codex,
        Some("kiro") => TargetTool::Kiro,
        _ => TargetTool::Generic,
    }
}

fn normalize_tool_entry(value: &str) -> Option<String> {
    let trimmed = value.trim();
    if trimmed.is_empty() {
        None
    } else {
        Some(trimmed.to_ascii_lowercase())
    }
}

fn canonicalize_tool(value: &str) -> Option<&'static str> {
    match value {
        v if v.eq_ignore_ascii_case("generic") => Some("generic"),
        v if v.eq_ignore_ascii_case("codex") => Some("codex"),
        _ => TOOL_ALIASES
            .iter()
            .find(|(alias, _)| value.eq_ignore_ascii_case(alias))
            .map(|(_, canonical)| *canonical)
            .or_else(|| agnix_rules::normalize_tool_name(value)),
    }
}

fn supported_tool_names() -> Vec<&'static str> {
    let mut tools = agnix_rules::valid_tools().to_vec();
    for compat in COMPAT_TOOL_NAMES {
        if !tools.contains(compat) {
            tools.push(compat);
        }
    }
    tools.sort_unstable();
    tools
}

fn alias_help() -> String {
    TOOL_ALIASES
        .iter()
        .map(|(alias, canonical)| format!("{} -> {}", alias, canonical))
        .collect::<Vec<_>>()
        .join(", ")
}

fn parse_tools(tools: Option<ToolsInput>) -> Result<Vec<String>, McpError> {
    let raw: Vec<String> = match tools {
        None => Vec::new(),
        Some(ToolsInput::Csv(csv)) => csv.split(',').filter_map(normalize_tool_entry).collect(),
        Some(ToolsInput::List(list)) => list
            .into_iter()
            .filter_map(|entry| normalize_tool_entry(&entry))
            .collect(),
    };

    if raw.is_empty() {
        return Ok(Vec::new());
    }

    let mut seen = HashSet::new();
    let mut normalized = Vec::new();
    for tool in raw {
        let canonical = canonicalize_tool(&tool).ok_or_else(|| {
            make_invalid_params(format!(
                "Unknown tool '{}'. Valid values: {}. Aliases: {}.",
                tool,
                supported_tool_names().join(", "),
                alias_help()
            ))
        })?;
        if seen.insert(canonical) {
            normalized.push(canonical.to_string());
        }
    }

    Ok(normalized)
}

fn apply_tool_selection(
    config: &mut LintConfig,
    tools: Option<ToolsInput>,
    target: Option<String>,
) -> Result<(), McpError> {
    let parsed_tools = parse_tools(tools)?;
    if parsed_tools.is_empty() {
        config.tools_mut().clear();
        config.set_target(parse_target(target));
    } else {
        config.set_target(agnix_core::config::TargetTool::Generic);
        config.set_tools(parsed_tools);
    }

    Ok(())
}

fn diagnostics_to_result(
    path: &str,
    diagnostics: Vec<Diagnostic>,
    files_checked: usize,
    workspace_root: &Path,
) -> ValidationResult {
    let errors = diagnostics
        .iter()
        .filter(|d| matches!(d.level, DiagnosticLevel::Error))
        .count();
    let warnings = diagnostics
        .iter()
        .filter(|d| matches!(d.level, DiagnosticLevel::Warning))
        .count();
    let fixable = diagnostics.iter().filter(|d| !d.fixes.is_empty()).count();

    ValidationResult {
        path: path.to_string(),
        files_checked,
        errors,
        warnings,
        fixable,
        diagnostics: diagnostics
            .iter()
            .map(|d| DiagnosticOutput::from_diagnostic(d, Some(workspace_root)))
            .collect(),
    }
}

fn make_internal_error(msg: String) -> McpError {
    McpError::internal_error(msg, None::<Value>)
}

fn make_invalid_params(msg: String) -> McpError {
    McpError::invalid_params(msg, None::<Value>)
}

fn resolve_path_within_workspace(
    input_path: &str,
    workspace_root: &Path,
) -> Result<PathBuf, String> {
    let canonical_root = workspace_root
        .canonicalize()
        .map_err(|e| format!("Failed to resolve workspace root: {e}"))?;
    let requested = Path::new(input_path);
    let candidate = if requested.is_absolute() {
        requested.to_path_buf()
    } else {
        canonical_root.join(requested)
    };
    let canonical_candidate = candidate
        .canonicalize()
        .map_err(|e| format!("Failed to resolve path: {e}"))?;

    if !canonical_candidate.starts_with(&canonical_root) {
        return Err(format!("Path outside workspace boundary: {input_path}"));
    }

    Ok(canonical_candidate)
}

struct ResolvedMcpPath {
    canonical_path: PathBuf,
    workspace_root: PathBuf,
}

fn resolve_mcp_path_with_root(input_path: &str) -> Result<ResolvedMcpPath, McpError> {
    let workspace_root = std::env::current_dir()
        .map_err(|e| make_internal_error(format!("Failed to read current directory: {e}")))?
        .canonicalize()
        .map_err(|e| make_internal_error(format!("Failed to resolve current directory: {e}")))?;
    let canonical_path =
        resolve_path_within_workspace(input_path, &workspace_root).map_err(make_invalid_params)?;

    Ok(ResolvedMcpPath {
        canonical_path,
        workspace_root,
    })
}

fn display_path_for_client(path: &Path, workspace_root: &Path) -> String {
    let display_path = path.strip_prefix(workspace_root).unwrap_or(path);
    if display_path.as_os_str().is_empty() {
        ".".to_string()
    } else {
        display_path.display().to_string()
    }
}

fn sanitize_error_message(error: impl Display, workspace_root: &Path) -> String {
    let mut message = error.to_string();
    let root_display = workspace_root.display().to_string();
    if !root_display.is_empty() {
        message = message.replace(&root_display, ".");
    }
    message
}

fn panic_payload_message(payload: &(dyn Any + Send)) -> String {
    if let Some(msg) = payload.downcast_ref::<&str>() {
        (*msg).to_string()
    } else if let Some(msg) = payload.downcast_ref::<String>() {
        msg.clone()
    } else {
        "unknown panic payload".to_string()
    }
}

fn run_validation_guarded<T, E, F>(
    operation: &str,
    input_path: &str,
    workspace_root: &Path,
    validate: F,
) -> Result<T, McpError>
where
    E: Display,
    F: FnOnce() -> Result<T, E>,
{
    match catch_unwind(AssertUnwindSafe(validate)) {
        Ok(Ok(value)) => Ok(value),
        Ok(Err(error)) => Err(make_invalid_params(format!(
            "Failed to {operation} '{}': {}",
            input_path,
            sanitize_error_message(error, workspace_root)
        ))),
        Err(payload) => Err(make_internal_error(format!(
            "Validation panicked while processing '{}': {}",
            input_path,
            panic_payload_message(payload.as_ref())
        ))),
    }
}

/// Agnix MCP Server - validates AI agent configurations
///
/// Provides tools to validate SKILL.md, CLAUDE.md, AGENTS.md, hooks,
/// MCP configs, and more against all agnix validation rules.

#[derive(Debug, Clone)]
pub struct AgnixServer {
    #[allow(dead_code)] // used by #[tool_router] macro expansion
    tool_router: ToolRouter<AgnixServer>,
}

impl Default for AgnixServer {
    fn default() -> Self {
        Self::new()
    }
}

#[tool_router]
impl AgnixServer {
    pub fn new() -> Self {
        Self {
            tool_router: Self::tool_router(),
        }
    }

    /// Validate a single agent configuration file
    #[tool(
        description = "Validate a single agent configuration file against agnix rules. Supports SKILL.md, CLAUDE.md, AGENTS.md, hooks.json, *.mcp.json, .cursor/rules/*.mdc, and other agent config files. Returns diagnostics with errors, warnings, auto-fix suggestions, and rule IDs for lookup."
    )]
    async fn validate_file(
        &self,
        Parameters(input): Parameters<ValidateFileInput>,
    ) -> Result<CallToolResult, McpError> {
        let mut config = LintConfig::default();
        apply_tool_selection(&mut config, input.tools, input.target)?;

        let resolved_path = resolve_mcp_path_with_root(&input.path)?;

        let outcome = run_validation_guarded(
            "validate file",
            &input.path,
            &resolved_path.workspace_root,
            || core_validate_file(&resolved_path.canonical_path, &config),
        )?;

        let diagnostics = outcome.into_diagnostics();
        let result =
            diagnostics_to_result(&input.path, diagnostics, 1, &resolved_path.workspace_root);
        let json = serde_json::to_string_pretty(&result)
            .map_err(|e| make_internal_error(format!("Failed to serialize result: {}", e)))?;

        Ok(CallToolResult::success(vec![ContentBlock::text(json)]))
    }

    /// Validate all agent configuration files in a project directory
    #[tool(
        description = "Validate all agent configuration files in a project directory. Recursively finds and validates SKILL.md, CLAUDE.md, AGENTS.md, hooks, MCP configs, Cursor rules, and more. Returns aggregated diagnostics for all files."
    )]
    async fn validate_project(
        &self,
        Parameters(input): Parameters<ValidateProjectInput>,
    ) -> Result<CallToolResult, McpError> {
        let mut config = LintConfig::default();
        apply_tool_selection(&mut config, input.tools, input.target)?;

        let resolved_path = resolve_mcp_path_with_root(&input.path)?;

        let validation_result = run_validation_guarded(
            "validate project",
            &input.path,
            &resolved_path.workspace_root,
            || core_validate_project(&resolved_path.canonical_path, &config),
        )?;

        let result = diagnostics_to_result(
            &input.path,
            validation_result.diagnostics,
            validation_result.files_checked,
            &resolved_path.workspace_root,
        );
        let json = serde_json::to_string_pretty(&result)
            .map_err(|e| make_internal_error(format!("Failed to serialize result: {}", e)))?;

        Ok(CallToolResult::success(vec![ContentBlock::text(json)]))
    }

    /// Get all available validation rules
    #[tool(
        description = "List all validation rules available in agnix. Returns rule IDs and names organized by category (AS-* Agent Skills, CC-* Claude Code, MCP-* Model Context Protocol, COP-* Copilot, CUR-* Cursor, etc.)."
    )]
    async fn get_rules(&self) -> Result<CallToolResult, McpError> {
        let rules: Vec<RuleInfo> = agnix_rules::RULES_DATA
            .iter()
            .map(|(id, name)| RuleInfo {
                id: (*id).to_string(),
                name: (*name).to_string(),
            })
            .collect();

        let output = RulesListOutput {
            count: rules.len(),
            rules,
        };

        let json = serde_json::to_string_pretty(&output)
            .map_err(|e| make_internal_error(format!("Failed to serialize rules: {}", e)))?;

        Ok(CallToolResult::success(vec![ContentBlock::text(json)]))
    }

    /// Get documentation for a specific rule
    #[tool(
        description = "Get the name of a specific validation rule by ID. Rule IDs follow patterns like AS-004 (Agent Skills), CC-SK-001 (Claude Code Skills), PE-003 (Prompt Engineering), MCP-001 (Model Context Protocol)."
    )]
    async fn get_rule_docs(
        &self,
        Parameters(input): Parameters<GetRuleDocsInput>,
    ) -> Result<CallToolResult, McpError> {
        let name = agnix_rules::get_rule_name(&input.rule_id).ok_or_else(|| {
            make_invalid_params(format!(
                "Rule not found: {}. Use get_rules to list all available rules.",
                input.rule_id
            ))
        })?;

        let output = RuleInfo {
            id: input.rule_id,
            name: name.to_string(),
        };

        let json = serde_json::to_string_pretty(&output)
            .map_err(|e| make_internal_error(format!("Failed to serialize rule: {}", e)))?;

        Ok(CallToolResult::success(vec![ContentBlock::text(json)]))
    }
}

#[tool_handler]
impl ServerHandler for AgnixServer {
    fn get_info(&self) -> ServerInfo {
        let mut server_impl = Implementation::default();
        server_impl.name = "agnix".into();
        server_impl.version = env!("CARGO_PKG_VERSION").into();

        let mut info = ServerInfo::default();
        info.protocol_version = ProtocolVersion::V_2024_11_05;
        info.capabilities = ServerCapabilities::builder().enable_tools().build();
        info.server_info = server_impl;
        let rule_count = agnix_rules::rule_count();
        info.instructions = Some(format!(
            "Agnix - AI agent configuration linter.\n\n\
             Validates SKILL.md, CLAUDE.md, AGENTS.md, hooks, MCP configs, \
             Cursor rules, and more against {rule_count} rules.\n\n\
             Tools:\n\
             - validate_project: Validate all agent configs in a directory\n\
             - validate_file: Validate a single config file\n\
             - get_rules: List all {rule_count} validation rules\n\
             - get_rule_docs: Get details about a specific rule\n\n\
             Preferred input: tools (array of tool names, or comma-separated string as fallback)\n\
             Legacy fallback: target\n\
             Supported tools are derived from agnix rule metadata"
        ));

        info
    }
}

#[tokio::main]
async fn main() -> anyhow::Result<()> {
    // Initialize logging to stderr (stdout is for MCP protocol)
    tracing_subscriber::fmt()
        .with_env_filter(
            tracing_subscriber::EnvFilter::from_default_env()
                .add_directive(tracing::Level::WARN.into()),
        )
        .with_writer(std::io::stderr)
        .init();

    // Create and run MCP server on stdio
    let server = AgnixServer::new();
    let service = server.serve(stdio()).await?;

    // Wait for shutdown
    service.waiting().await?;

    Ok(())
}

#[cfg(test)]
mod tests {
    use super::{
        ToolsInput, ValidateFileInput, ValidateProjectInput, apply_tool_selection,
        diagnostics_to_result, display_path_for_client, make_internal_error, make_invalid_params,
        parse_tools, resolve_path_within_workspace, run_validation_guarded, sanitize_error_message,
    };
    use agnix_core::LintConfig;
    use agnix_core::config::TargetTool;
    use agnix_core::diagnostics::{Diagnostic, LintError, ValidationError};
    use rmcp::model::ErrorCode;
    use serde_json::json;
    use std::path::PathBuf;

    #[test]
    fn test_parse_tools_csv_trims_and_discards_empty_entries() {
        let tools = parse_tools(Some(ToolsInput::Csv(
            "claude-code, cursor, ,codex,, ".to_string(),
        )))
        .expect("valid tools should parse");
        assert_eq!(tools, vec!["claude-code", "cursor", "codex"]);
    }

    #[test]
    fn test_parse_tools_array_trims_and_discards_empty_entries() {
        let tools = parse_tools(Some(ToolsInput::List(vec![
            " claude-code ".to_string(),
            "".to_string(),
            " cursor".to_string(),
            "   ".to_string(),
        ])))
        .expect("valid tools should parse");
        assert_eq!(tools, vec!["claude-code", "cursor"]);
    }

    #[test]
    fn test_parse_tools_canonicalizes_and_deduplicates_entries() {
        let tools = parse_tools(Some(ToolsInput::List(vec![
            "copilot".to_string(),
            "github-copilot".to_string(),
            "claudecode".to_string(),
            "claude-code".to_string(),
            "cursor".to_string(),
            "CURSOR".to_string(),
        ])))
        .expect("valid tools should parse");
        assert_eq!(tools, vec!["github-copilot", "claude-code", "cursor"]);
    }

    #[test]
    fn test_parse_tools_allows_compat_tool_names() {
        let tools = parse_tools(Some(ToolsInput::Csv("generic,codex".to_string())))
            .expect("generic and codex should be accepted for compatibility");
        assert_eq!(tools, vec!["generic", "codex"]);
    }

    #[test]
    fn test_parse_tools_rejects_unknown_tools() {
        let result = parse_tools(Some(ToolsInput::List(vec!["claud-code".to_string()])));
        let err = result.unwrap_err();
        assert_eq!(
            err.code,
            ErrorCode::INVALID_PARAMS,
            "unknown tool rejection must use INVALID_PARAMS (-32602)"
        );
    }

    #[test]
    fn test_make_invalid_params_error_code() {
        let err = make_invalid_params("x".to_string());
        assert_eq!(
            err.code,
            ErrorCode::INVALID_PARAMS,
            "make_invalid_params must produce error code -32602"
        );
    }

    #[test]
    fn test_make_internal_error_error_code() {
        let err = make_internal_error("x".to_string());
        assert_eq!(
            err.code,
            ErrorCode::INTERNAL_ERROR,
            "make_internal_error must produce error code -32603"
        );
    }

    #[test]
    fn test_apply_tool_selection_falls_back_to_target_when_tools_empty() {
        let mut config = LintConfig::default();
        apply_tool_selection(
            &mut config,
            Some(ToolsInput::Csv(" , ".to_string())),
            Some("cursor".to_string()),
        )
        .expect("empty tools should fall back to target");

        assert!(config.tools().is_empty());
        assert_eq!(config.target(), TargetTool::Cursor);
    }

    #[test]
    fn test_apply_tool_selection_falls_back_to_target_when_tools_missing() {
        let mut config = LintConfig::default();
        apply_tool_selection(&mut config, None, Some("claude-code".to_string()))
            .expect("missing tools should fall back to target");

        assert!(config.tools().is_empty());
        assert_eq!(config.target(), TargetTool::ClaudeCode);
    }

    #[test]
    fn test_apply_tool_selection_falls_back_to_target_when_tools_empty_list() {
        let mut config = LintConfig::default();
        apply_tool_selection(
            &mut config,
            Some(ToolsInput::List(vec![])),
            Some("codex".to_string()),
        )
        .expect("empty list should fall back to target");

        assert!(config.tools().is_empty());
        assert_eq!(config.target(), TargetTool::Codex);
    }

    #[test]
    fn test_apply_tool_selection_falls_back_to_target_when_target_is_kiro() {
        let mut config = LintConfig::default();
        apply_tool_selection(
            &mut config,
            Some(ToolsInput::Csv("  ".to_string())),
            Some("kiro".to_string()),
        )
        .expect("empty tools should fall back to kiro target");

        assert!(config.tools().is_empty());
        assert_eq!(config.target(), TargetTool::Kiro);
    }

    #[test]
    fn test_apply_tool_selection_clears_existing_tools_on_fallback() {
        let mut config = LintConfig::default();
        config.set_tools(vec!["cursor".to_string()]);

        apply_tool_selection(
            &mut config,
            Some(ToolsInput::Csv(" ".to_string())),
            Some("claude-code".to_string()),
        )
        .expect("empty tools should trigger fallback and clear stale tools");

        assert!(config.tools().is_empty());
        assert_eq!(config.target(), TargetTool::ClaudeCode);
    }

    #[test]
    fn test_apply_tool_selection_prefers_tools_over_target() {
        let mut config = LintConfig::default();
        config.set_target(TargetTool::Cursor);
        apply_tool_selection(
            &mut config,
            Some(ToolsInput::Csv("claude-code,cursor".to_string())),
            Some("codex".to_string()),
        )
        .expect("valid tools should override target");

        assert_eq!(config.tools(), &["claude-code", "cursor"]);
        // target remains default; tools array drives filtering precedence in core.
        assert_eq!(config.target(), TargetTool::Generic);
    }

    #[test]
    fn test_apply_tool_selection_rejects_unknown_tools() {
        let mut config = LintConfig::default();
        let result = apply_tool_selection(
            &mut config,
            Some(ToolsInput::Csv("unknown-tool".to_string())),
            Some("claude-code".to_string()),
        );
        let err = result.unwrap_err();
        assert_eq!(
            err.code,
            ErrorCode::INVALID_PARAMS,
            "unknown tool rejection must use INVALID_PARAMS (-32602)"
        );
        assert!(config.tools().is_empty());
        assert_eq!(config.target(), TargetTool::Generic);
    }

    #[test]
    fn test_validate_file_input_deserializes_csv_tools_payload() {
        let input: ValidateFileInput = serde_json::from_value(json!({
            "path": "SKILL.md",
            "tools": "claude-code,cursor",
            "target": "codex"
        }))
        .expect("tools CSV payload should deserialize");

        match input.tools {
            Some(ToolsInput::Csv(value)) => assert_eq!(value, "claude-code,cursor"),
            _ => panic!("expected CSV tools variant"),
        }
        assert_eq!(input.target.as_deref(), Some("codex"));
    }

    #[test]
    fn test_validate_file_input_deserializes_array_tools_payload() {
        let input: ValidateFileInput = serde_json::from_value(json!({
            "path": "SKILL.md",
            "tools": ["claude-code", "cursor"]
        }))
        .expect("tools array payload should deserialize");

        match input.tools {
            Some(ToolsInput::List(values)) => {
                assert_eq!(values, vec!["claude-code", "cursor"]);
            }
            _ => panic!("expected array tools variant"),
        }
        assert!(input.target.is_none());
    }

    #[test]
    fn test_validate_project_input_deserializes_csv_tools_payload() {
        let input: ValidateProjectInput = serde_json::from_value(json!({
            "path": ".",
            "tools": "claude-code,cursor"
        }))
        .expect("project CSV tools payload should deserialize");

        match input.tools {
            Some(ToolsInput::Csv(value)) => assert_eq!(value, "claude-code,cursor"),
            _ => panic!("expected CSV tools variant"),
        }
    }

    #[test]
    fn test_validate_project_input_deserializes_array_tools_payload() {
        let input: ValidateProjectInput = serde_json::from_value(json!({
            "path": ".",
            "tools": ["claude-code", "cursor"]
        }))
        .expect("project array tools payload should deserialize");

        match input.tools {
            Some(ToolsInput::List(values)) => {
                assert_eq!(values, vec!["claude-code", "cursor"]);
            }
            _ => panic!("expected array tools variant"),
        }
    }

    #[test]
    fn test_resolve_path_within_workspace_accepts_child_file() {
        let temp = tempfile::TempDir::new().unwrap();
        let file = temp.path().join("SKILL.md");
        std::fs::write(&file, "---\nname: test\n---\n").unwrap();

        let resolved = resolve_path_within_workspace("SKILL.md", temp.path())
            .expect("workspace child path should resolve");

        assert_eq!(resolved, file.canonicalize().unwrap());
    }

    #[test]
    fn test_resolve_path_within_workspace_rejects_parent_traversal() {
        let workspace = tempfile::TempDir::new().unwrap();
        let outside = tempfile::TempDir::new().unwrap();
        let outside_file = outside.path().join("secret.md");
        std::fs::write(&outside_file, "secret").unwrap();
        let traversal = format!(
            "../{}/secret.md",
            outside.path().file_name().unwrap().to_string_lossy()
        );

        let err = resolve_path_within_workspace(&traversal, workspace.path())
            .expect_err("parent traversal must be rejected");

        assert!(err.contains("outside workspace boundary"));
    }

    #[test]
    fn test_resolve_path_within_workspace_rejects_absolute_outside_path() {
        let workspace = tempfile::TempDir::new().unwrap();
        let outside = tempfile::NamedTempFile::new().unwrap();

        let err = resolve_path_within_workspace(outside.path().to_str().unwrap(), workspace.path())
            .expect_err("absolute outside paths must be rejected");

        assert!(err.contains("outside workspace boundary"));
    }

    #[test]
    fn test_diagnostics_to_result_relativizes_workspace_paths() {
        let workspace = tempfile::TempDir::new().unwrap();
        let skill_path = workspace.path().join("SKILL.md");
        std::fs::write(&skill_path, "---\nname: test\n---\n").unwrap();
        let canonical_skill = skill_path.canonicalize().unwrap();
        let diagnostic = Diagnostic::error(canonical_skill, 1, 1, "AS-001", "test diagnostic");

        let result = diagnostics_to_result(
            "SKILL.md",
            vec![diagnostic],
            1,
            &workspace.path().canonicalize().unwrap(),
        );

        assert_eq!(result.path, "SKILL.md");
        assert_eq!(result.diagnostics[0].file, "SKILL.md");
    }

    #[test]
    fn test_display_path_for_client_uses_workspace_relative_path() {
        let workspace = tempfile::TempDir::new().unwrap();
        let nested = workspace.path().join("nested").join("SKILL.md");
        let display = display_path_for_client(&nested, workspace.path());

        assert_eq!(
            display,
            PathBuf::from("nested")
                .join("SKILL.md")
                .display()
                .to_string()
        );
    }

    #[test]
    fn test_sanitize_error_message_redacts_workspace_root() {
        let workspace = tempfile::TempDir::new().unwrap();
        let message = format!(
            "failed to read {}",
            workspace.path().join("secret").join("SKILL.md").display()
        );

        let sanitized = sanitize_error_message(message, workspace.path());

        assert!(!sanitized.contains(&workspace.path().display().to_string()));
        assert!(sanitized.contains("."));
    }

    #[test]
    fn test_run_validation_guarded_converts_panic_to_mcp_error() {
        let workspace = tempfile::TempDir::new().unwrap();
        let err = run_validation_guarded(
            "validate file",
            "SKILL.md",
            workspace.path(),
            || -> Result<(), String> { panic!("intentional mcp panic") },
        )
        .expect_err("panic should be converted to an MCP error");

        assert_eq!(err.code, ErrorCode::INTERNAL_ERROR);
        assert!(err.message.contains("intentional mcp panic"));
    }

    #[test]
    fn test_run_validation_guarded_sanitizes_validation_error() {
        let workspace = tempfile::TempDir::new().unwrap();
        let rooted_path = workspace.path().join("secret").join("SKILL.md");
        let err = run_validation_guarded("validate file", "SKILL.md", workspace.path(), || {
            Err::<(), LintError>(ValidationError::RootNotFound { path: rooted_path }.into())
        })
        .expect_err("validation error should become an MCP invalid params error");

        assert_eq!(err.code, ErrorCode::INVALID_PARAMS);
        assert!(
            !err.message
                .contains(&workspace.path().display().to_string())
        );
    }

    #[test]
    fn test_tools_input_schema_prefers_array() {
        let schema =
            rmcp::schemars::SchemaGenerator::default().into_root_schema_for::<ToolsInput>();
        let json = serde_json::to_value(&schema).expect("schema should serialize");
        let any_of = json
            .get("anyOf")
            .and_then(|v| v.as_array())
            .expect("schema should have anyOf array");

        assert_eq!(any_of.len(), 2, "anyOf must have exactly two entries");

        assert_eq!(
            any_of[0].get("type").and_then(|v| v.as_str()),
            Some("array"),
            "first anyOf entry must be the array variant"
        );
        assert_eq!(
            any_of[1].get("type").and_then(|v| v.as_str()),
            Some("string"),
            "second anyOf entry must be the string variant"
        );

        // Verify items constraint so MCP clients know array elements are strings
        assert_eq!(
            any_of[0]
                .get("items")
                .and_then(|v| v.get("type"))
                .and_then(|v| v.as_str()),
            Some("string"),
            "array variant must have items.type == 'string'"
        );
    }

    #[test]
    fn test_tools_input_schema_has_variant_descriptions() {
        let schema =
            rmcp::schemars::SchemaGenerator::default().into_root_schema_for::<ToolsInput>();
        let json = serde_json::to_value(&schema).expect("schema should serialize");
        let any_of = json
            .get("anyOf")
            .and_then(|v| v.as_array())
            .expect("schema should have anyOf array");

        let array_desc = any_of[0]
            .get("description")
            .and_then(|v| v.as_str())
            .expect("array variant should have description");
        assert!(
            array_desc.contains("Preferred"),
            "array variant description should contain 'Preferred', got: {}",
            array_desc
        );

        let string_desc = any_of[1]
            .get("description")
            .and_then(|v| v.as_str())
            .expect("string variant should have description");
        assert!(
            string_desc.contains("Fallback"),
            "string variant description should contain 'Fallback', got: {}",
            string_desc
        );
    }

    #[test]
    fn test_tools_input_deserialization_after_reorder() {
        // JSON array should deserialize to List variant
        let list: ToolsInput =
            serde_json::from_value(json!(["claude-code", "cursor"])).expect("array should parse");
        match list {
            ToolsInput::List(values) => assert_eq!(values, vec!["claude-code", "cursor"]),
            ToolsInput::Csv(_) => panic!("expected List variant for JSON array input"),
        }

        // JSON string should deserialize to Csv variant
        let csv: ToolsInput =
            serde_json::from_value(json!("claude-code,cursor")).expect("string should parse");
        match csv {
            ToolsInput::Csv(value) => assert_eq!(value, "claude-code,cursor"),
            ToolsInput::List(_) => panic!("expected Csv variant for JSON string input"),
        }
    }

    #[test]
    fn test_validate_file_input_schema_tools_description() {
        let schema =
            rmcp::schemars::SchemaGenerator::default().into_root_schema_for::<ValidateFileInput>();
        let json = serde_json::to_value(&schema).expect("schema should serialize");
        let json_str = serde_json::to_string(&json).unwrap();
        // The tools field description must mention Preferred/Fallback so MCP clients
        // see clear guidance on the expected input format in ValidateFileInput.
        assert!(
            json_str.contains("Preferred"),
            "ValidateFileInput schema must mention 'Preferred' for tools field"
        );
        assert!(
            json_str.contains("fallback"),
            "ValidateFileInput schema must mention 'fallback' for tools field"
        );
    }

    #[test]
    fn test_validate_project_input_schema_tools_description() {
        let schema = rmcp::schemars::SchemaGenerator::default()
            .into_root_schema_for::<ValidateProjectInput>();
        let json = serde_json::to_value(&schema).expect("schema should serialize");
        let json_str = serde_json::to_string(&json).unwrap();
        // The tools field description must mention Preferred/Fallback so MCP clients
        // see clear guidance on the expected input format in ValidateProjectInput.
        assert!(
            json_str.contains("Preferred"),
            "ValidateProjectInput schema must mention 'Preferred' for tools field"
        );
        assert!(
            json_str.contains("fallback"),
            "ValidateProjectInput schema must mention 'fallback' for tools field"
        );
    }
}
