//! Internal error type for the Cordance MCP tools.
//!
//! Every variant maps to a JSON-RPC error code per the MCP 2025-06-18 spec
//! (and the rmcp `ErrorData` constructors that wrap them). Tool authors
//! should produce one of these and call `.into()` to hand back to rmcp.

use rmcp::ErrorData as McpErrorData;
use thiserror::Error;

/// Error produced by a Cordance MCP tool before rmcp wraps it as
/// `CallToolResult { isError: true, ... }` or a JSON-RPC error payload.
///
/// Note: [`Self::TargetOutsideAllowedRoots`] and
/// [`Self::TargetCanonicalisationFailed`] are deliberately payload-less.
/// Round-2 redteam #3 showed that echoing canonical paths back to the MCP
/// peer leaks filesystem layout. The validator logs the offending path via
/// `tracing::warn!` (stderr only — never stdout/JSON-RPC) and returns one of
/// these fixed-shape errors to the peer.
#[derive(Debug, Error)]
pub enum McpToolError {
    /// The caller passed a `target` that escapes the server's allow-listed
    /// roots. JSON-RPC code `-32602` (invalid params).
    ///
    /// No payload by design: the validator never echoes the canonical path
    /// or any portion of the input back to the peer (see module docs).
    #[error("target is outside allowed roots")]
    TargetOutsideAllowedRoots,

    /// The caller passed a `target` (or any path argument) that does not
    /// canonicalise. JSON-RPC code `-32602` (invalid params).
    ///
    /// No payload by design: the validator never echoes the canonical path
    /// or any portion of the input back to the peer (see module docs).
    #[error("target could not be resolved")]
    TargetCanonicalisationFailed,

    /// A path argument was syntactically rejected before canonicalisation
    /// (e.g. UNC prefix, or contained an interior NUL). `-32602`.
    #[error("path argument rejected: {0}")]
    InvalidPathSyntax(String),

    /// A tool argument failed schema validation beyond what serde caught.
    /// `-32602`.
    #[error("invalid argument: {0}")]
    InvalidArgument(String),

    /// An on-disk artifact the tool tried to read is missing. `-32602`.
    #[error("artifact not found: {0}")]
    NotFound(String),

    /// An internal failure: filesystem I/O, JSON encoding, downstream
    /// emitter panic. JSON-RPC code `-32603` (internal error).
    #[error("internal error: {0}")]
    Internal(String),
}

impl McpToolError {
    /// Convert an arbitrary error into a wire-safe `Internal` variant.
    ///
    /// The previous implementation forwarded the full `{e:#}` chain to the
    /// peer (round-3 bughunt #2 / redteam #3). Inner errors routinely contain
    /// absolute filesystem paths (every `std::io::Error` carries the offending
    /// path; `toml::de::Error` from `Config::load_strict` can echo TOML lines
    /// verbatim), which leaks the server's filesystem layout to the MCP peer.
    ///
    /// The full error chain is logged at `warn` level on stderr (the server's
    /// audit channel) and the peer receives a fixed-shape message. Operators
    /// correlate by timestamp and tool name in the log.
    #[allow(clippy::needless_pass_by_value)]
    pub fn from_anyhow(e: anyhow::Error) -> Self {
        tracing::warn!(error = %format!("{e:#}"), "mcp tool internal error");
        Self::Internal("internal error (see server logs)".into())
    }

    /// Convert a `std::io::Error` into a wire-safe `Internal` variant.
    ///
    /// `std::io::Error::Display` includes the OS message and frequently the
    /// offending path (`No such file or directory (os error 2): \\?\C:\...`).
    /// We log the full error to stderr and return a coarse, fixed-shape
    /// classification derived from `ErrorKind` so the peer can disambiguate
    /// not-found vs permission-denied without learning the offending path
    /// (round-3 bughunt #2 / redteam #3).
    #[allow(clippy::needless_pass_by_value)]
    pub fn from_io(e: std::io::Error) -> Self {
        tracing::warn!(error = %e, kind = ?e.kind(), "mcp tool io error");
        let coarse = match e.kind() {
            std::io::ErrorKind::NotFound => "io: not found",
            std::io::ErrorKind::PermissionDenied => "io: permission denied",
            std::io::ErrorKind::InvalidData => "io: invalid data",
            _ => "io: error",
        };
        Self::Internal(coarse.into())
    }

    /// Wire-safe wrapper for tool-site `Internal(format!("...{e}"))` callers.
    ///
    /// Round-4 redteam #3 / bughunt #5: several tool sites bypassed
    /// [`Self::from_anyhow`] by constructing
    /// `Internal(format!("phase failed: {e}"))` directly. Inner errors are
    /// frequently `serde_json::Error::Display`, which embeds line/column AND a
    /// snippet of the offending text — so a corrupted `.cordance/sources.lock`
    /// would leak the absolute paths it contains to the MCP peer.
    ///
    /// This helper logs the inner error at `warn` level (stderr, the server's
    /// audit channel) and returns a fixed-shape message naming only the
    /// `phase` (a compile-time string chosen by the caller). The peer learns
    /// *which* phase failed, never *why* in any path-bearing detail.
    #[allow(clippy::needless_pass_by_value)]
    pub fn internal_redacted(phase: &'static str, inner: impl std::fmt::Display) -> Self {
        tracing::warn!(phase, error = %inner, "mcp tool internal error");
        Self::Internal(format!("{phase}: see server logs"))
    }

    /// Wire-safe wrapper for "an expected artifact is missing" cases.
    ///
    /// Round-4 redteam #2 / bughunt #5: `check.rs` echoed the canonical
    /// absolute path of `.cordance/sources.lock` when the file was missing,
    /// defeating the round-2 payload-less `TargetCanonicalisationFailed`
    /// design. This helper logs the label on stderr and returns a fixed-shape
    /// message that names only the artifact (a compile-time string) and the
    /// remediation (`run cordance pack`). The peer learns *what* is missing,
    /// never *where* the server expected to find it.
    pub fn artifact_missing(label: &'static str) -> Self {
        tracing::warn!(label, "mcp tool: artifact missing");
        Self::NotFound(format!("{label} missing — run cordance pack to produce it"))
    }
}

impl From<McpToolError> for McpErrorData {
    fn from(value: McpToolError) -> Self {
        // For the two payload-less, path-related variants we emit the fixed
        // `Display` string. The peer therefore learns *that* a path failed
        // validation, never *which* path or *what* root it failed against.
        match value {
            McpToolError::TargetOutsideAllowedRoots => {
                Self::invalid_params("target is outside allowed roots", None)
            }
            McpToolError::TargetCanonicalisationFailed => {
                Self::invalid_params("target could not be resolved", None)
            }
            McpToolError::InvalidPathSyntax(msg)
            | McpToolError::InvalidArgument(msg)
            | McpToolError::NotFound(msg) => Self::invalid_params(msg, None),
            McpToolError::Internal(msg) => Self::internal_error(msg, None),
        }
    }
}

/// Convenience alias for tool bodies.
pub type McpToolResult<T> = Result<T, McpToolError>;

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

    #[test]
    fn target_outside_roots_is_invalid_params() {
        let e = McpToolError::TargetOutsideAllowedRoots;
        let wire: McpErrorData = e.into();
        assert_eq!(wire.code, ErrorCode::INVALID_PARAMS);
    }

    #[test]
    fn target_outside_roots_wire_message_omits_paths() {
        // Round-2 redteam #3: the wire message must not echo any path the
        // peer could read or guess from.
        let e = McpToolError::TargetOutsideAllowedRoots;
        let wire: McpErrorData = e.into();
        let msg = wire.message.to_string();
        assert!(
            !msg.contains('/') && !msg.contains('\\'),
            "wire message must not contain any path separator: {msg:?}"
        );
        assert_eq!(msg, "target is outside allowed roots");
    }

    #[test]
    fn target_canonicalisation_failed_wire_message_omits_paths() {
        let e = McpToolError::TargetCanonicalisationFailed;
        let wire: McpErrorData = e.into();
        let msg = wire.message.to_string();
        assert!(
            !msg.contains('/') && !msg.contains('\\'),
            "wire message must not contain any path separator: {msg:?}"
        );
        assert_eq!(msg, "target could not be resolved");
    }

    #[test]
    fn internal_is_internal_error_code() {
        let e = McpToolError::Internal("disk full".into());
        let wire: McpErrorData = e.into();
        assert_eq!(wire.code, ErrorCode::INTERNAL_ERROR);
    }

    #[test]
    fn from_anyhow_returns_fixed_shape_internal() {
        // The shape is `Internal(<fixed string>)` and contains no payload
        // from the inner error. The fixed string mentions the log channel so
        // operators know where to look.
        let e = McpToolError::from_anyhow(anyhow::anyhow!("upstream broke"));
        match e {
            McpToolError::Internal(msg) => {
                assert!(
                    !msg.contains("upstream broke"),
                    "inner anyhow message leaked to wire: {msg}"
                );
                assert!(
                    msg.contains("server logs"),
                    "fixed-shape message should hint at log correlation: {msg}"
                );
            }
            other => panic!("expected Internal, got {other:?}"),
        }
    }

    #[test]
    fn from_anyhow_does_not_leak_inner_message() {
        // Round-3 bughunt #2 / redteam #3: inner messages routinely embed
        // absolute paths and TOML content. The wire `Display` must contain
        // neither.
        let inner_path = "/secret/path/to/config.toml";
        let inner = anyhow::anyhow!("loading {inner_path} failed");
        let err = McpToolError::from_anyhow(inner);
        let wire = format!("{err}");
        assert!(
            !wire.contains("/secret"),
            "wire error leaked path: {wire}"
        );
        assert!(
            !wire.contains("config.toml"),
            "wire error leaked filename: {wire}"
        );
    }

    #[test]
    fn from_anyhow_wire_message_omits_paths() {
        // Belt-and-braces: the rendered ErrorData payload must also be free
        // of path separators that could only have come from the inner error.
        let inner = anyhow::anyhow!(
            "failed to read /etc/passwd: permission denied while processing C:\\Users\\foo"
        );
        let err = McpToolError::from_anyhow(inner);
        let wire: McpErrorData = err.into();
        let msg = wire.message.to_string();
        assert!(
            !msg.contains("/etc/passwd"),
            "posix path leaked through to wire: {msg}"
        );
        assert!(
            !msg.contains("C:\\Users"),
            "windows path leaked through to wire: {msg}"
        );
        assert_eq!(wire.code, ErrorCode::INTERNAL_ERROR);
    }

    #[test]
    fn from_io_does_not_leak_inner_message() {
        // std::io::Error::Display embeds the offending path on most
        // platforms. Make sure the wrapper strips it.
        let inner = std::io::Error::new(
            std::io::ErrorKind::NotFound,
            "/secret/path/missing",
        );
        let err = McpToolError::from_io(inner);
        let wire = format!("{err}");
        assert!(
            !wire.contains("/secret"),
            "io error leaked path: {wire}"
        );
        assert!(
            wire.contains("not found"),
            "io kind should still surface: {wire}"
        );
    }

    #[test]
    fn from_io_classifies_permission_denied() {
        let inner = std::io::Error::new(
            std::io::ErrorKind::PermissionDenied,
            "/root/.ssh/id_rsa",
        );
        let err = McpToolError::from_io(inner);
        let wire = format!("{err}");
        assert!(
            !wire.contains("/root"),
            "io error leaked path: {wire}"
        );
        assert!(
            !wire.contains("id_rsa"),
            "io error leaked filename: {wire}"
        );
        assert!(
            wire.contains("permission denied"),
            "io kind should surface as 'permission denied': {wire}"
        );
    }

    #[test]
    fn from_io_falls_back_to_generic_for_unmapped_kinds() {
        // ErrorKind::Other doesn't map to a specific message; ensure the
        // generic fallback still leaks nothing.
        let inner = std::io::Error::other("/var/log/secret.log was bad");
        let err = McpToolError::from_io(inner);
        let wire = format!("{err}");
        assert!(
            !wire.contains("/var/log"),
            "fallback path leaked: {wire}"
        );
        assert!(
            !wire.contains("secret.log"),
            "fallback filename leaked: {wire}"
        );
        assert!(
            wire.contains("io:"),
            "fallback should still announce it was an io error: {wire}"
        );
    }

    #[test]
    fn internal_redacted_carries_phase_and_log_hint() {
        // The wire message must name the phase the caller chose (a
        // compile-time string) and the literal "see server logs" pointer the
        // operator uses to correlate; it must NOT carry any byte of the inner
        // error.
        let err = McpToolError::internal_redacted(
            "parse sources.lock",
            "expected value at line 17 column 3",
        );
        let wire = format!("{err}");
        assert!(
            wire.contains("parse sources.lock"),
            "phase name should surface to the peer: {wire}"
        );
        assert!(
            wire.contains("see server logs"),
            "log-correlation hint should surface: {wire}"
        );
        assert!(
            !wire.contains("line 17"),
            "inner serde-style detail leaked: {wire}"
        );
        assert!(
            !wire.contains("column 3"),
            "inner serde-style detail leaked: {wire}"
        );
        assert!(
            !wire.contains("expected value"),
            "inner error text leaked: {wire}"
        );
    }

    #[test]
    fn internal_redacted_wire_message_has_no_path_separators() {
        // The phase strings are compile-time literals and never contain `/`
        // or `\`; the inner error may. The wire message is built solely from
        // the phase, so it must contain neither.
        let err = McpToolError::internal_redacted(
            "load sources.lock",
            "/etc/passwd: permission denied while parsing C:\\Users\\alice\\config.toml",
        );
        let wire: McpErrorData = err.into();
        let msg = wire.message.to_string();
        assert!(
            !msg.contains('/'),
            "forward slash leaked into wire message: {msg:?}"
        );
        assert!(
            !msg.contains('\\'),
            "backslash leaked into wire message: {msg:?}"
        );
    }

    #[test]
    fn internal_redacted_does_not_leak_embedded_secrets() {
        // Belt-and-braces: an inner error containing any of the canonical
        // secret-shaped strings (POSIX path, Windows path, PEM banner) must
        // not appear in the wire message.
        let inner = format!(
            "{} | {} | {}",
            "/etc/passwd",
            "C:\\Users\\alice\\.ssh\\id_rsa",
            "-----BEGIN PRIVATE KEY-----",
        );
        let err = McpToolError::internal_redacted("dummy phase", inner);
        let wire = format!("{err}");
        assert!(
            !wire.contains("/etc/passwd"),
            "posix path leaked through redacted wrapper: {wire}"
        );
        assert!(
            !wire.contains("C:\\Users"),
            "windows path leaked through redacted wrapper: {wire}"
        );
        assert!(
            !wire.contains("BEGIN PRIVATE KEY"),
            "PEM banner leaked through redacted wrapper: {wire}"
        );
    }

    #[test]
    fn internal_redacted_routes_through_internal_error_code() {
        let err = McpToolError::internal_redacted("phase", "anything");
        let wire: McpErrorData = err.into();
        assert_eq!(wire.code, ErrorCode::INTERNAL_ERROR);
    }

    #[test]
    fn artifact_missing_wire_message_has_no_path_bytes() {
        // Round-4 redteam #2: when an artifact is absent the peer learns
        // *what* is missing (a compile-time label) and the remediation —
        // never the server's canonical filesystem layout.
        let err = McpToolError::artifact_missing("sources.lock");
        let wire: McpErrorData = err.into();
        let msg = wire.message.to_string();
        assert!(
            !msg.contains('/'),
            "forward slash leaked into artifact_missing wire message: {msg:?}"
        );
        assert!(
            !msg.contains('\\'),
            "backslash leaked into artifact_missing wire message: {msg:?}"
        );
        assert!(
            msg.contains("sources.lock"),
            "label should surface to the peer: {msg:?}"
        );
        assert!(
            msg.contains("cordance pack"),
            "remediation hint should surface: {msg:?}"
        );
    }

    #[test]
    fn artifact_missing_routes_through_invalid_params() {
        // `NotFound` maps to `invalid_params` (-32602), matching the existing
        // `From<McpToolError>` arm. This pins the wire code so a future
        // refactor cannot quietly demote the error to `internal_error`.
        let err = McpToolError::artifact_missing("sources.lock");
        let wire: McpErrorData = err.into();
        assert_eq!(wire.code, ErrorCode::INVALID_PARAMS);
    }
}
