//! Tracing subscriber initialization and `--log-level` resolution.
//!
//! `mcptest_cli::run` calls [`init`] once at process start, before clap
//! dispatch, so every `tracing::*` event in the workspace lands on stderr.
//! The subscriber is built from an [`EnvFilter`] resolved through a fixed
//! precedence chain. Operators see a clear error if their directive is
//! invalid rather than a silent fall-back to the default.

use std::io::IsTerminal;

use anyhow::{Context, Result};
use tracing_subscriber::EnvFilter;

/// The smallest set of inputs [`init`] needs to decide what the subscriber
/// looks like. Kept as a plain struct so the binary entry point can build
/// it without cloning the entire `GlobalArgs` tree.
#[derive(Debug, Default, Clone)]
pub struct LoggingArgs {
    /// Explicit `--log-level` directive. Overrides every env var below.
    pub log_level: Option<String>,
    /// `--debug` global shortcut. Maps to `mcptest=debug,mcptest_core=debug`.
    pub debug: bool,
    /// `--verbose` global shortcut. Maps to `mcptest=info,mcptest_core=info`.
    pub verbose: bool,
    /// `--no-color` flag. Combined with `NO_COLOR=1` to disable ANSI.
    pub no_color: bool,
    /// `--log-format json` (or `MCPTEST_LOG_FORMAT=json`): emit one JSON object
    /// per log line on stderr for CI log pipelines instead of human text
    /// (WOR-1517).
    pub json: bool,
    /// `--otel-endpoint <url>` (or `OTEL_EXPORTER_OTLP_ENDPOINT`): ship spans to
    /// an OTLP/HTTP collector. Only honored in a build with the `otlp` feature;
    /// otherwise [`init`] fails fast with a clear message rather than silently
    /// ignoring it (WOR-1517).
    pub otel_endpoint: Option<String>,
}

/// Lifetime handle returned by [`init`]. In the default build it carries
/// nothing; with the `otlp` feature it may hold the exporter guard that
/// flushes batched spans when the process exits. The binary entry point keeps
/// it alive for the whole run.
#[derive(Default)]
pub struct LogGuard {
    #[cfg(feature = "otlp")]
    _otel: Option<crate::otel_export::OtelGuard>,
}

/// Build the [`EnvFilter`] from the inputs above following the documented
/// precedence: `--log-level` > `RUST_LOG` > `MCPTEST_LOG` > `--debug` >
/// `--verbose` > `warn`. Returns a typed error when the chosen source
/// produces an invalid directive so the run fails fast.
pub fn resolve_filter(args: &LoggingArgs) -> Result<EnvFilter> {
    if let Some(raw) = args.log_level.as_deref() {
        return EnvFilter::try_new(raw)
            .with_context(|| format!("invalid --log-level directive `{raw}`"));
    }
    if let Ok(raw) = std::env::var("RUST_LOG") {
        if !raw.is_empty() {
            return EnvFilter::try_new(&raw)
                .with_context(|| format!("invalid RUST_LOG directive `{raw}`"));
        }
    }
    if let Ok(raw) = std::env::var("MCPTEST_LOG") {
        if !raw.is_empty() {
            return EnvFilter::try_new(&raw)
                .with_context(|| format!("invalid MCPTEST_LOG directive `{raw}`"));
        }
    }
    if args.debug {
        return EnvFilter::try_new("mcptest=debug,mcptest_core=debug,mcptest_config=debug")
            .context("internal --debug directive failed to parse");
    }
    if args.verbose {
        return EnvFilter::try_new("mcptest=info,mcptest_core=info,mcptest_config=info")
            .context("internal --verbose directive failed to parse");
    }
    EnvFilter::try_new("warn").context("internal default `warn` directive failed to parse")
}

/// Decide whether to emit ANSI escapes. Honors `--no-color`, the standard
/// `NO_COLOR` env var, and finally falls back to a TTY check so piped
/// output stays clean even without the flag.
fn use_ansi(args: &LoggingArgs) -> bool {
    if args.no_color {
        return false;
    }
    if std::env::var_os("NO_COLOR").is_some() {
        return false;
    }
    std::io::stderr().is_terminal()
}

/// Initialize the global tracing subscriber. Safe to call exactly once per
/// process. Returns an error when the filter directive is invalid so the
/// caller can surface a clean message before any subcommand runs.
pub fn init(args: &LoggingArgs) -> Result<LogGuard> {
    let filter = resolve_filter(args)?;

    // WOR-1517: structured JSON logs for CI log pipelines. One JSON object per
    // line on stderr; the ANSI and timer knobs below do not apply.
    let json = args.json
        || std::env::var("MCPTEST_LOG_FORMAT")
            .map(|v| v.eq_ignore_ascii_case("json"))
            .unwrap_or(false);

    // WOR-1517: OTLP span export. The explicit `--otel-endpoint` flag is an
    // intentional request, so we honor it (feature build) or refuse it
    // (default build) rather than ignore it. The ambient
    // OTEL_EXPORTER_OTLP_ENDPOINT only activates the exporter when it is
    // compiled in, so a globally-exported env var never breaks a default run.
    #[cfg(feature = "otlp")]
    {
        let endpoint = args.otel_endpoint.clone().or_else(|| {
            std::env::var("OTEL_EXPORTER_OTLP_ENDPOINT")
                .ok()
                .filter(|s| !s.is_empty())
        });
        if let Some(endpoint) = endpoint {
            return init_with_otlp(args, filter, json, &endpoint);
        }
    }
    #[cfg(not(feature = "otlp"))]
    if args.otel_endpoint.is_some() {
        anyhow::bail!(
            "--otel-endpoint requires a build with the `otlp` feature. Rebuild with \
             `cargo install mcptest --features otlp` (or `cargo build --features otlp`) \
             to enable the OTLP span exporter."
        );
    }

    if json {
        let subscriber = tracing_subscriber::fmt()
            .json()
            .with_writer(std::io::stderr)
            .with_env_filter(filter)
            .with_target(true)
            .finish();
        tracing::subscriber::set_global_default(subscriber)
            .context("failed to install tracing subscriber")?;
        return Ok(LogGuard::default());
    }

    let ansi = use_ansi(args);
    let no_time = std::env::var_os("MCPTEST_LOG_NO_TIME").is_some();

    // Two near-identical builder chains so we can flip the timer off at
    // compile time. `fmt::Subscriber` is generic over its formatter, so a
    // runtime `if` would need boxing; this branch is cleaner.
    if no_time {
        let subscriber = tracing_subscriber::fmt()
            .with_writer(std::io::stderr)
            .with_ansi(ansi)
            .with_env_filter(filter)
            .with_target(true)
            .without_time()
            .finish();
        tracing::subscriber::set_global_default(subscriber)
            .context("failed to install tracing subscriber")?;
    } else {
        let subscriber = tracing_subscriber::fmt()
            .with_writer(std::io::stderr)
            .with_ansi(ansi)
            .with_env_filter(filter)
            .with_target(true)
            .finish();
        tracing::subscriber::set_global_default(subscriber)
            .context("failed to install tracing subscriber")?;
    }
    Ok(LogGuard::default())
}

/// Install a layered subscriber (stderr fmt layer + OTLP exporter layer) when
/// an endpoint is configured. Only compiled with the `otlp` feature; the
/// no-feature build returns a clear error so the flag is never silently inert.
#[cfg(feature = "otlp")]
fn init_with_otlp(
    args: &LoggingArgs,
    filter: EnvFilter,
    json: bool,
    endpoint: &str,
) -> Result<LogGuard> {
    use tracing_subscriber::layer::SubscriberExt;
    use tracing_subscriber::util::SubscriberInitExt;
    use tracing_subscriber::Layer;

    let (otel_layer, guard) = crate::otel_export::build_layer(endpoint)?;
    let fmt_layer = if json {
        tracing_subscriber::fmt::layer()
            .json()
            .with_writer(std::io::stderr)
            .boxed()
    } else {
        tracing_subscriber::fmt::layer()
            .with_ansi(use_ansi(args))
            .with_writer(std::io::stderr)
            .boxed()
    };

    tracing_subscriber::registry()
        .with(filter)
        .with(fmt_layer)
        .with(otel_layer)
        .try_init()
        .context("failed to install tracing subscriber with OTLP exporter")?;
    Ok(LogGuard { _otel: Some(guard) })
}

/// Peek at the raw argv for the three flags `init` cares about
/// (`--log-level`, `--debug`, `--verbose`, `--no-color`) without invoking
/// clap. The subscriber must come up before clap parses so any errors
/// clap emits land in the operator's stderr too. We accept both the
/// `--log-level=VALUE` and `--log-level VALUE` forms.
pub fn peek_logging_args(argv: &[std::ffi::OsString]) -> LoggingArgs {
    let mut out = LoggingArgs::default();
    let mut i = 1usize; // skip program name in slot 0
    while i < argv.len() {
        let raw = argv[i].to_string_lossy();
        if let Some(val) = raw.strip_prefix("--log-level=") {
            out.log_level = Some(val.to_string());
        } else if raw == "--log-level" {
            if let Some(next) = argv.get(i + 1) {
                out.log_level = Some(next.to_string_lossy().into_owned());
                i += 1;
            }
        } else if raw == "--debug" {
            out.debug = true;
        } else if raw == "--verbose" {
            out.verbose = true;
        } else if raw == "--no-color" {
            out.no_color = true;
        } else if let Some(val) = raw.strip_prefix("--log-format=") {
            out.json = val.eq_ignore_ascii_case("json");
        } else if raw == "--log-format" {
            if let Some(next) = argv.get(i + 1) {
                out.json = next.to_string_lossy().eq_ignore_ascii_case("json");
                i += 1;
            }
        } else if let Some(val) = raw.strip_prefix("--otel-endpoint=") {
            out.otel_endpoint = Some(val.to_string());
        } else if raw == "--otel-endpoint" {
            if let Some(next) = argv.get(i + 1) {
                out.otel_endpoint = Some(next.to_string_lossy().into_owned());
                i += 1;
            }
        }
        i += 1;
    }
    out
}

/// Replace the value of any header whose name matches the redaction
/// pattern. The pattern is `(?i)(token|secret|password|cookie|auth)`,
/// hard-coded to keep this helper allocation-free at the common path.
/// Used in connector and transport log lines so credentials never reach
/// stderr.
pub fn redact_header(name: &str, value: &str) -> String {
    let lower = name.to_ascii_lowercase();
    let sensitive = lower.contains("token")
        || lower.contains("secret")
        || lower.contains("password")
        || lower.contains("cookie")
        || lower.contains("auth");
    if sensitive {
        "***".to_string()
    } else {
        value.to_string()
    }
}

#[cfg(test)]
// `std::env::set_var` / `remove_var` became `unsafe` in the 2024 edition's
// stdlib. EnvGuard serializes on a process-wide mutex so the safety
// contract holds; the workspace `unsafe_code = "warn"` lint is
// suppressed in this test module for that reason.
#[allow(unsafe_code)]
mod tests {
    use super::*;
    use std::sync::Mutex;

    fn args() -> LoggingArgs {
        LoggingArgs::default()
    }

    /// Serialize every env-touching test in this module so concurrent
    /// `cargo test` workers do not race on `RUST_LOG` / `MCPTEST_LOG`.
    /// Without this lock, one test's `EnvGuard::set` can be observed by
    /// another test that just ran `EnvGuard::unset`.
    fn env_lock() -> std::sync::MutexGuard<'static, ()> {
        static M: Mutex<()> = Mutex::new(());
        // PoisonError is fine: a panicking sibling test should not
        // cascade-fail the next test, so we just take the guard.
        M.lock().unwrap_or_else(|e| e.into_inner())
    }

    #[test]
    fn resolve_filter_defaults_to_warn() {
        let _lock = env_lock();
        let _g1 = EnvGuard::unset("RUST_LOG");
        let _g2 = EnvGuard::unset("MCPTEST_LOG");
        let f = resolve_filter(&args()).unwrap();
        let rendered = format!("{f}");
        assert!(rendered.contains("warn"), "expected warn, got {rendered}");
    }

    #[test]
    fn resolve_filter_rejects_invalid_directive() {
        let _lock = env_lock();
        let mut a = args();
        a.log_level = Some("not-a-level=banana".into());
        let err = resolve_filter(&a).unwrap_err();
        let msg = format!("{err:#}");
        assert!(msg.contains("invalid --log-level"), "got {msg}");
    }

    #[test]
    fn resolve_filter_log_level_overrides_rust_log() {
        let _lock = env_lock();
        let _g1 = EnvGuard::set("RUST_LOG", "info");
        let mut a = args();
        a.log_level = Some("debug".into());
        let f = resolve_filter(&a).unwrap();
        let rendered = format!("{f}");
        assert!(rendered.contains("debug"), "got {rendered}");
    }

    #[test]
    fn resolve_filter_debug_flag_maps_to_debug() {
        let _lock = env_lock();
        let _g1 = EnvGuard::unset("RUST_LOG");
        let _g2 = EnvGuard::unset("MCPTEST_LOG");
        let mut a = args();
        a.debug = true;
        let f = resolve_filter(&a).unwrap();
        let rendered = format!("{f}");
        assert!(rendered.contains("debug"), "got {rendered}");
    }

    #[test]
    fn peek_detects_json_log_format() {
        let json: Vec<std::ffi::OsString> = ["mcptest", "run", "--log-format", "json"]
            .iter()
            .map(Into::into)
            .collect();
        assert!(peek_logging_args(&json).json);
        let json_eq: Vec<std::ffi::OsString> = ["mcptest", "--log-format=json", "run"]
            .iter()
            .map(Into::into)
            .collect();
        assert!(peek_logging_args(&json_eq).json);
        let plain: Vec<std::ffi::OsString> = ["mcptest", "run"].iter().map(Into::into).collect();
        assert!(!peek_logging_args(&plain).json);
        let pretty: Vec<std::ffi::OsString> = ["mcptest", "run", "--log-format", "pretty"]
            .iter()
            .map(Into::into)
            .collect();
        assert!(!peek_logging_args(&pretty).json);
    }

    #[test]
    fn peek_detects_otel_endpoint() {
        let spaced: Vec<std::ffi::OsString> =
            ["mcptest", "run", "--otel-endpoint", "http://localhost:4318"]
                .iter()
                .map(Into::into)
                .collect();
        assert_eq!(
            peek_logging_args(&spaced).otel_endpoint.as_deref(),
            Some("http://localhost:4318")
        );
        let eq: Vec<std::ffi::OsString> =
            ["mcptest", "--otel-endpoint=http://collector:4318", "run"]
                .iter()
                .map(Into::into)
                .collect();
        assert_eq!(
            peek_logging_args(&eq).otel_endpoint.as_deref(),
            Some("http://collector:4318")
        );
        let plain: Vec<std::ffi::OsString> = ["mcptest", "run"].iter().map(Into::into).collect();
        assert!(peek_logging_args(&plain).otel_endpoint.is_none());
    }

    #[test]
    fn redact_header_masks_authorization() {
        assert_eq!(redact_header("Authorization", "Bearer abc"), "***");
        assert_eq!(redact_header("x-api-token", "sk_live_xyz"), "***");
        assert_eq!(redact_header("Cookie", "session=1"), "***");
        assert_eq!(
            redact_header("Content-Type", "application/json"),
            "application/json"
        );
    }

    /// Scoped env var override for tests. Restores the original value
    /// when dropped so tests run in any order without leaking state.
    struct EnvGuard {
        key: &'static str,
        prev: Option<std::ffi::OsString>,
    }

    impl EnvGuard {
        fn set(key: &'static str, val: &str) -> Self {
            let prev = std::env::var_os(key);
            // SAFETY: every env-touching test in this module holds the
            // process-wide `env_lock()` mutex for its whole body, so no other
            // thread reads or writes the environment while this call runs.
            // That mutex serialization is what upholds the 2024-edition
            // contract on `set_var`, not the test runner's thread count.
            unsafe { std::env::set_var(key, val) };
            Self { key, prev }
        }
        fn unset(key: &'static str) -> Self {
            let prev = std::env::var_os(key);
            unsafe { std::env::remove_var(key) };
            Self { key, prev }
        }
    }

    impl Drop for EnvGuard {
        fn drop(&mut self) {
            unsafe {
                match self.prev.take() {
                    Some(v) => std::env::set_var(self.key, v),
                    None => std::env::remove_var(self.key),
                }
            }
        }
    }
}
