//! Run-level metadata for the canonical output model.
//!
//! Why this module exists: any downstream consumer (the upload reporter,
//! a future hosted collector, a dashboard) needs to know what produced a
//! given run. Without a stable run id, repo coordinates, and CI context,
//! two consecutive uploads look identical and the collector cannot dedupe
//! or attribute them. This module captures that context, best-effort,
//! before the reporters render their output.
//!
//! Collection rules:
//!
//! - [`RunMetadata::collect`] never fails. Unknown fields land as `None`.
//! - Git fields shell out to `git rev-parse` / `git config`. A missing git
//!   binary, a detached HEAD, or a repo with no remote each surface as
//!   `None` instead of an error.
//! - CI detection reads well-known env vars by name only. The values are
//!   never logged or echoed; we only check whether the var is set or use
//!   public identifiers such as `GITHUB_REPOSITORY` that the user controls.
//! - Privacy redaction runs after collection. Redacted fields are removed
//!   from the serialized output entirely (set to `None`); reporters never
//!   see them.

use std::collections::BTreeSet;
use std::process::Command;

use serde::{Deserialize, Serialize};

/// Run-level metadata attached to the canonical output [`super::Report`].
///
/// Populated once at the top of every run; every field except `run_id`
/// is best-effort. The reporter consumes this object as-is; redaction
/// must happen via [`RunMetadata::redacted`] before any reporter sees
/// the data so the privacy policy stays in one place.
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
pub struct RunMetadata {
    /// Stable run identifier (UUIDv7, timestamp-sortable). Always present.
    /// Doubles as the server-side dedup key for the upload reporter.
    pub run_id: String,

    /// RFC 3339 timestamp when the run started, if known.
    #[serde(default, skip_serializing_if = "Option::is_none")]
    pub started_at: Option<String>,

    /// RFC 3339 timestamp when the run ended, if known.
    #[serde(default, skip_serializing_if = "Option::is_none")]
    pub ended_at: Option<String>,

    /// Wall-clock run duration in milliseconds, if known.
    #[serde(default, skip_serializing_if = "Option::is_none")]
    pub duration_ms: Option<u64>,

    /// `CARGO_PKG_VERSION` of the mcptest binary that produced the run.
    pub mcptest_version: String,

    /// MCP protocol version the run negotiated (for example
    /// `"2025-03-26"`). `None` when no handshake completed.
    #[serde(default, skip_serializing_if = "Option::is_none")]
    pub mcp_protocol_version: Option<String>,

    /// Git remote URL (typically the origin remote). `None` when the
    /// working directory is not a git repo or has no remote configured.
    #[serde(default, skip_serializing_if = "Option::is_none")]
    pub repo: Option<String>,

    /// Git branch name (`main`, `feature/foo`). `None` for detached HEAD
    /// or when git is not available.
    #[serde(default, skip_serializing_if = "Option::is_none")]
    pub branch: Option<String>,

    /// Full 40-character git commit SHA. `None` when git is not available.
    #[serde(default, skip_serializing_if = "Option::is_none")]
    pub commit_sha: Option<String>,

    /// First line of the current commit message. `None` when git is not
    /// available.
    #[serde(default, skip_serializing_if = "Option::is_none")]
    pub commit_message: Option<String>,

    /// Host machine name, sourced from `HOSTNAME` (Unix) or
    /// `COMPUTERNAME` (Windows). `None` when neither is set.
    #[serde(default, skip_serializing_if = "Option::is_none")]
    pub hostname: Option<String>,

    /// CI environment identifier. Falls back to `"local"` when no known
    /// CI env var is set. We do not echo secret env values; only the
    /// vendor name is recorded.
    pub runner_environment: String,

    /// Operating system family from `std::env::consts::OS`
    /// (`"linux"`, `"macos"`, `"windows"`).
    pub os: String,

    /// CPU architecture from `std::env::consts::ARCH`
    /// (`"x86_64"`, `"aarch64"`).
    pub arch: String,

    /// SHA-256 of the resolved config plus the on-disk test corpus,
    /// formatted as `sha256:<hex>`. Lets a collector group "the same
    /// suite" across machines. `None` until the runner computes it.
    #[serde(default, skip_serializing_if = "Option::is_none")]
    pub config_hash: Option<String>,

    /// Name of the `--profile` the runner used. `None` when no
    /// profile was selected. The enterprise policy evaluator reads
    /// this to enforce "production runs must use profile X" rules
    /// without needing access to the user's shell history.
    #[serde(default, skip_serializing_if = "Option::is_none")]
    pub profile: Option<String>,

    /// Per-server transport + auth posture captured at run start.
    /// Keyed by the YAML server name; values describe which
    /// transport family (`stdio`, `streamable_http`, ...) and auth
    /// scheme (`none`, `bearer`, `oauth2`, `mtls`, ...) the runner
    /// negotiated. Empty when the runner did not record posture.
    /// The enterprise policy layer reads this to enforce
    /// "HTTP-transport servers must use OAuth 2.1 + PKCE" rules.
    #[serde(default, skip_serializing_if = "Vec::is_empty")]
    pub servers: Vec<ServerPosture>,
}

/// Per-server transport + auth posture recorded on the run report.
///
/// One entry per server the suite touched. `transport` carries the
/// transport family (`stdio` for subprocess servers,
/// `streamable_http` / `legacy_sse` for HTTP servers, `cassette`
/// for replayed runs). `auth` carries the authentication scheme
/// the client used to talk to the server (`none`, `bearer`,
/// `oauth2`, `mtls`, `aws_sigv4`, `web_bot_auth`).
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
pub struct ServerPosture {
    /// YAML server name the suite referenced.
    pub name: String,
    /// Transport family identifier.
    pub transport: String,
    /// Authentication scheme identifier.
    pub auth: String,
}

/// Privacy redaction selector for [`RunMetadata`] fields.
///
/// The four pinned fields (`run_id`, `mcptest_version`, `started_at`,
/// `ended_at`) cannot be redacted; everything else can. The reporter
/// applies this via [`RunMetadata::redacted`] before serializing so each
/// reporter (pretty, JSON, JUnit, upload) sees the same redacted view.
#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Hash, Serialize, Deserialize)]
#[serde(rename_all = "snake_case")]
#[non_exhaustive]
pub enum RedactField {
    /// Strip the git remote URL.
    Repo,
    /// Strip the git branch.
    Branch,
    /// Strip the git commit SHA.
    CommitSha,
    /// Strip the first-line commit message.
    CommitMessage,
    /// Strip the OS hostname.
    Hostname,
    /// Strip the runner environment identifier.
    RunnerEnvironment,
    /// Strip the OS family.
    Os,
    /// Strip the CPU architecture.
    Arch,
    /// Strip the negotiated MCP protocol version.
    McpProtocolVersion,
    /// Strip the config hash.
    ConfigHash,
    /// Strip the run duration (does not affect `started_at` / `ended_at`).
    DurationMs,
}

impl ServerPosture {
    /// Build a posture entry from a server name and its
    /// [`crate::suite::ServerSpec`]. The auth scheme is derived from
    /// the spec's transport-level configuration: the bearer-vs-oauth
    /// vs none decision the connector would actually make at runtime.
    pub fn from_spec(name: &str, spec: &crate::suite::ServerSpec) -> Self {
        use crate::suite::ServerSpec;
        let (transport, auth) = match spec {
            ServerSpec::Stdio { .. } => ("stdio", "none"),
            ServerSpec::StreamableHttp { headers, oauth, .. } => {
                ("streamable_http", auth_for_http(headers, oauth.is_some()))
            }
            ServerSpec::LegacySse { headers, oauth, .. } => {
                ("legacy_sse", auth_for_http(headers, oauth.is_some()))
            }
            ServerSpec::Cassette { .. } => ("cassette", "none"),
        };
        Self {
            name: name.to_string(),
            transport: transport.to_string(),
            auth: auth.to_string(),
        }
    }
}

/// Decide the auth identifier for an HTTP-class server.
fn auth_for_http(
    headers: &std::collections::BTreeMap<String, String>,
    has_oauth: bool,
) -> &'static str {
    if has_oauth {
        return "oauth2";
    }
    if headers
        .keys()
        .any(|k| k.eq_ignore_ascii_case("authorization"))
    {
        return "bearer";
    }
    "none"
}

impl RunMetadata {
    /// Collect run metadata from the current environment, best-effort.
    ///
    /// `mcptest_version` should come from the caller, normally
    /// `crate::version::full()` so the recorded value matches the
    /// running binary (sha + dirty bit included when present).
    ///
    /// Never panics, never returns an error: any field we cannot
    /// determine is left `None`. Callers should treat the absence of a
    /// field the same as "user opted out".
    pub fn collect(mcptest_version: impl Into<String>) -> Self {
        Self {
            run_id: new_uuid_v7(),
            started_at: None,
            ended_at: None,
            duration_ms: None,
            mcptest_version: mcptest_version.into(),
            mcp_protocol_version: None,
            repo: git_remote_origin_url(),
            branch: git_current_branch(),
            commit_sha: git_head_sha(),
            commit_message: git_head_subject(),
            hostname: detect_hostname(),
            runner_environment: detect_runner_environment(),
            os: std::env::consts::OS.to_string(),
            arch: std::env::consts::ARCH.to_string(),
            config_hash: None,
            profile: None,
            servers: Vec::new(),
        }
    }

    /// Return a copy of this metadata with the requested fields stripped.
    ///
    /// Stripped fields land as `None` and are skipped during JSON
    /// serialization, so the collector sees a smaller object rather than
    /// a placeholder. The four pinned fields (`run_id`, `mcptest_version`,
    /// `started_at`, `ended_at`) cannot be redacted because they identify
    /// and timestamp the run; the upload reporter and collector rely on
    /// them being present.
    pub fn redacted(&self, fields: &BTreeSet<RedactField>) -> Self {
        let strip = |f: RedactField| fields.contains(&f);
        Self {
            run_id: self.run_id.clone(),
            started_at: self.started_at.clone(),
            ended_at: self.ended_at.clone(),
            duration_ms: if strip(RedactField::DurationMs) {
                None
            } else {
                self.duration_ms
            },
            mcptest_version: self.mcptest_version.clone(),
            mcp_protocol_version: if strip(RedactField::McpProtocolVersion) {
                None
            } else {
                self.mcp_protocol_version.clone()
            },
            repo: if strip(RedactField::Repo) {
                None
            } else {
                self.repo.clone()
            },
            branch: if strip(RedactField::Branch) {
                None
            } else {
                self.branch.clone()
            },
            commit_sha: if strip(RedactField::CommitSha) {
                None
            } else {
                self.commit_sha.clone()
            },
            commit_message: if strip(RedactField::CommitMessage) {
                None
            } else {
                self.commit_message.clone()
            },
            hostname: if strip(RedactField::Hostname) {
                None
            } else {
                self.hostname.clone()
            },
            runner_environment: if strip(RedactField::RunnerEnvironment) {
                "redacted".to_string()
            } else {
                self.runner_environment.clone()
            },
            os: if strip(RedactField::Os) {
                "redacted".to_string()
            } else {
                self.os.clone()
            },
            arch: if strip(RedactField::Arch) {
                "redacted".to_string()
            } else {
                self.arch.clone()
            },
            config_hash: if strip(RedactField::ConfigHash) {
                None
            } else {
                self.config_hash.clone()
            },
            profile: self.profile.clone(),
            servers: self.servers.clone(),
        }
    }

    /// Build a deterministic fixture used by reporter snapshot tests.
    ///
    /// Stable across versions so snapshot tests can rely on the same
    /// field values without re-recording on every commit.
    pub fn sample() -> Self {
        Self {
            run_id: "01957f6a-3f9b-7c42-a3c5-9d8e1f2a3b4c".to_string(),
            started_at: Some("2026-05-14T15:23:01Z".to_string()),
            ended_at: Some("2026-05-14T15:23:02Z".to_string()),
            duration_ms: Some(1240),
            mcptest_version: "1.0.0".to_string(),
            mcp_protocol_version: Some("2025-03-26".to_string()),
            repo: Some("https://github.com/acme/my-mcp-server".to_string()),
            branch: Some("main".to_string()),
            commit_sha: Some("a3f2c919b4d5e6f7a8b9c0d1e2f3a4b5c6d7e8f9".to_string()),
            commit_message: Some("first commit line only".to_string()),
            hostname: Some("ci-runner-7".to_string()),
            runner_environment: "github-actions".to_string(),
            os: "linux".to_string(),
            arch: "x86_64".to_string(),
            config_hash: Some(
                "sha256:7a3e0000000000000000000000000000000000000000000000000000000000aa"
                    .to_string(),
            ),
            profile: None,
            servers: Vec::new(),
        }
    }
}

/// Detect the CI vendor from public, well-known env var *names* only.
///
/// We intentionally read only the *presence* of vendor env vars (or
/// values that the vendor publishes as part of its docs, like `CI=true`).
/// We never echo, log, or include the values themselves; secret values
/// such as tokens live in different env vars and are not consulted here.
fn detect_runner_environment() -> String {
    // Order matters: vendor-specific vars win over the generic `CI=true`
    // so a GitHub Actions run reports as "github-actions", not "ci".
    let candidates: &[(&str, &str)] = &[
        ("GITHUB_ACTIONS", "github-actions"),
        ("GITLAB_CI", "gitlab-ci"),
        ("CIRCLECI", "circleci"),
        ("BUILDKITE", "buildkite"),
        ("TRAVIS", "travis-ci"),
        ("JENKINS_URL", "jenkins"),
        ("TEAMCITY_VERSION", "teamcity"),
        ("APPVEYOR", "appveyor"),
        ("DRONE", "drone"),
        ("BITBUCKET_BUILD_NUMBER", "bitbucket-pipelines"),
        ("CODEBUILD_BUILD_ID", "aws-codebuild"),
        ("TF_BUILD", "azure-pipelines"),
        ("WOODPECKER", "woodpecker"),
    ];
    for (var, label) in candidates {
        if env_is_truthy(var) {
            return (*label).to_string();
        }
    }
    // Generic CI=true fallback: known to be set by many providers but
    // gives us no vendor info. Better than misreporting as "local".
    if env_is_truthy("CI") {
        return "ci".to_string();
    }
    "local".to_string()
}

/// Read a hostname from `HOSTNAME` (Unix shells) or `COMPUTERNAME`
/// (Windows). Returns `None` when neither is set or both are empty.
fn detect_hostname() -> Option<String> {
    for var in ["HOSTNAME", "COMPUTERNAME"] {
        if let Ok(value) = std::env::var(var) {
            let trimmed = value.trim();
            if !trimmed.is_empty() {
                return Some(trimmed.to_string());
            }
        }
    }
    None
}

/// Return `true` when an env var is set to a non-empty, non-"false" value.
///
/// CI vendors use a mix of `=true`, `=1`, and `=<arbitrary>` to signal
/// presence; we treat empty and the literal strings `false` / `0` as
/// "absent" so a leftover `CI=` from a `--env-file` does not flip the
/// runner environment.
fn env_is_truthy(name: &str) -> bool {
    match std::env::var(name) {
        Ok(value) => {
            let lower = value.trim().to_ascii_lowercase();
            !lower.is_empty() && lower != "false" && lower != "0"
        }
        Err(_) => false,
    }
}

/// Run a git command with the given args. Returns the trimmed stdout on
/// success, or `None` on any failure (binary missing, non-zero exit,
/// non-UTF-8 output). Never panics.
fn run_git(args: &[&str]) -> Option<String> {
    let output = Command::new("git").args(args).output().ok()?;
    if !output.status.success() {
        return None;
    }
    let s = String::from_utf8(output.stdout).ok()?;
    let trimmed = s.trim();
    if trimmed.is_empty() {
        None
    } else {
        Some(trimmed.to_string())
    }
}

fn git_remote_origin_url() -> Option<String> {
    run_git(&["config", "--get", "remote.origin.url"])
}

fn git_current_branch() -> Option<String> {
    // `--abbrev-ref HEAD` returns the literal string "HEAD" on a
    // detached HEAD checkout; treat that as "no branch" per the
    // ticket's best-effort rule.
    let value = run_git(&["rev-parse", "--abbrev-ref", "HEAD"])?;
    if value == "HEAD" {
        None
    } else {
        Some(value)
    }
}

fn git_head_sha() -> Option<String> {
    run_git(&["rev-parse", "HEAD"])
}

fn git_head_subject() -> Option<String> {
    // `log -1 --pretty=%s` returns the subject (first line) of the
    // current HEAD commit. We never use the body so a noisy multi-
    // paragraph commit body cannot accidentally leak into telemetry.
    run_git(&["log", "-1", "--pretty=%s"])
}

/// Generate a UUIDv7 string for the run id.
///
/// Delegates to [`uuid::Uuid::now_v7`], the audited time-sortable
/// constructor (RFC 9562 section 5.7). The returned canonical hex form
/// keeps the first 48 bits monotonic with wall-clock time so a collector
/// indexing runs by id still gets chronological order.
fn new_uuid_v7() -> String {
    uuid::Uuid::now_v7().to_string()
}

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

    // -- UUIDv7 smoke ------------------------------------------------

    #[test]
    fn uuid_v7_is_time_sortable() {
        // Back-to-back calls: the second id must sort greater-equal as
        // a string because the first 48 bits of UUIDv7 encode unix-ms
        // and dominate the lexical order. The `uuid` crate's own test
        // suite covers the layout (version nibble, variant bits, hex
        // grouping); we only assert the property the call site depends
        // on (a monotonic prefix for downstream collectors).
        let earlier = new_uuid_v7();
        let later = new_uuid_v7();
        assert!(
            later >= earlier,
            "expected {later} >= {earlier} (uuidv7 is time-sortable)"
        );
    }

    #[test]
    fn uuid_v7_is_unique_across_back_to_back_calls() {
        let a = new_uuid_v7();
        let b = new_uuid_v7();
        assert_ne!(a, b);
    }

    #[test]
    fn uuid_v7_sorts_lexicographically_by_time() {
        // Sample once, sleep a millisecond, sample again. The later id
        // must compare strictly greater because the timestamp prefix
        // dominates the lexical order.
        let earlier = new_uuid_v7();
        std::thread::sleep(std::time::Duration::from_millis(2));
        let later = new_uuid_v7();
        assert!(
            later > earlier,
            "expected {later} > {earlier} (uuidv7 is time-sortable)"
        );
    }

    // -- collect() shape --------------------------------------------

    #[test]
    fn collect_populates_required_fields() {
        let m = RunMetadata::collect("1.2.3");
        assert_eq!(m.mcptest_version, "1.2.3");
        assert!(!m.run_id.is_empty());
        assert!(!m.os.is_empty());
        assert!(!m.arch.is_empty());
        assert!(!m.runner_environment.is_empty());
    }

    #[test]
    fn collect_never_returns_an_error_when_git_is_missing() {
        // Force the git lookups to miss by pointing PATH at an empty
        // tempdir. The call must still produce a complete struct.
        let tmp = tempfile::tempdir().expect("tempdir");
        with_env(
            &[("PATH", Some(tmp.path().to_string_lossy().as_ref()))],
            || {
                let m = RunMetadata::collect("x");
                assert_eq!(m.repo, None);
                assert_eq!(m.branch, None);
                assert_eq!(m.commit_sha, None);
                assert_eq!(m.commit_message, None);
            },
        );
    }

    // -- runner_environment detection -------------------------------

    #[test]
    fn detects_github_actions() {
        with_env(&[("GITHUB_ACTIONS", Some("true"))], || {
            assert_eq!(detect_runner_environment(), "github-actions");
        });
    }

    #[test]
    fn detects_gitlab_ci() {
        with_env(
            &[
                ("GITHUB_ACTIONS", None),
                ("GITLAB_CI", Some("true")),
                ("CIRCLECI", None),
                ("BUILDKITE", None),
                ("TRAVIS", None),
                ("JENKINS_URL", None),
                ("TEAMCITY_VERSION", None),
                ("APPVEYOR", None),
                ("DRONE", None),
                ("BITBUCKET_BUILD_NUMBER", None),
                ("CODEBUILD_BUILD_ID", None),
                ("TF_BUILD", None),
                ("WOODPECKER", None),
                ("CI", Some("true")),
            ],
            || {
                assert_eq!(detect_runner_environment(), "gitlab-ci");
            },
        );
    }

    #[test]
    fn detects_circleci() {
        with_env(
            &[
                ("GITHUB_ACTIONS", None),
                ("GITLAB_CI", None),
                ("CIRCLECI", Some("true")),
                ("CI", Some("true")),
            ],
            || {
                assert_eq!(detect_runner_environment(), "circleci");
            },
        );
    }

    #[test]
    fn detects_buildkite() {
        with_env(
            &[
                ("GITHUB_ACTIONS", None),
                ("GITLAB_CI", None),
                ("CIRCLECI", None),
                ("BUILDKITE", Some("true")),
            ],
            || {
                assert_eq!(detect_runner_environment(), "buildkite");
            },
        );
    }

    #[test]
    fn detects_travis_ci() {
        with_env(
            &[
                ("GITHUB_ACTIONS", None),
                ("GITLAB_CI", None),
                ("CIRCLECI", None),
                ("BUILDKITE", None),
                ("TRAVIS", Some("true")),
            ],
            || {
                assert_eq!(detect_runner_environment(), "travis-ci");
            },
        );
    }

    #[test]
    fn detects_jenkins() {
        with_env(
            &[
                ("GITHUB_ACTIONS", None),
                ("GITLAB_CI", None),
                ("CIRCLECI", None),
                ("BUILDKITE", None),
                ("TRAVIS", None),
                ("JENKINS_URL", Some("https://jenkins.example.com")),
            ],
            || {
                assert_eq!(detect_runner_environment(), "jenkins");
            },
        );
    }

    #[test]
    fn detects_teamcity() {
        with_env(
            &[
                ("GITHUB_ACTIONS", None),
                ("GITLAB_CI", None),
                ("CIRCLECI", None),
                ("BUILDKITE", None),
                ("TRAVIS", None),
                ("JENKINS_URL", None),
                ("TEAMCITY_VERSION", Some("2024.03")),
            ],
            || {
                assert_eq!(detect_runner_environment(), "teamcity");
            },
        );
    }

    #[test]
    fn detects_azure_pipelines() {
        with_env(
            &[
                ("GITHUB_ACTIONS", None),
                ("GITLAB_CI", None),
                ("CIRCLECI", None),
                ("BUILDKITE", None),
                ("TRAVIS", None),
                ("JENKINS_URL", None),
                ("TEAMCITY_VERSION", None),
                ("APPVEYOR", None),
                ("DRONE", None),
                ("BITBUCKET_BUILD_NUMBER", None),
                ("CODEBUILD_BUILD_ID", None),
                ("TF_BUILD", Some("True")),
            ],
            || {
                assert_eq!(detect_runner_environment(), "azure-pipelines");
            },
        );
    }

    #[test]
    fn falls_back_to_generic_ci_when_only_ci_is_set() {
        with_env(
            &[
                ("GITHUB_ACTIONS", None),
                ("GITLAB_CI", None),
                ("CIRCLECI", None),
                ("BUILDKITE", None),
                ("TRAVIS", None),
                ("JENKINS_URL", None),
                ("TEAMCITY_VERSION", None),
                ("APPVEYOR", None),
                ("DRONE", None),
                ("BITBUCKET_BUILD_NUMBER", None),
                ("CODEBUILD_BUILD_ID", None),
                ("TF_BUILD", None),
                ("WOODPECKER", None),
                ("CI", Some("true")),
            ],
            || {
                assert_eq!(detect_runner_environment(), "ci");
            },
        );
    }

    #[test]
    fn defaults_to_local_when_no_ci_var_is_set() {
        with_env(
            &[
                ("GITHUB_ACTIONS", None),
                ("GITLAB_CI", None),
                ("CIRCLECI", None),
                ("BUILDKITE", None),
                ("TRAVIS", None),
                ("JENKINS_URL", None),
                ("TEAMCITY_VERSION", None),
                ("APPVEYOR", None),
                ("DRONE", None),
                ("BITBUCKET_BUILD_NUMBER", None),
                ("CODEBUILD_BUILD_ID", None),
                ("TF_BUILD", None),
                ("WOODPECKER", None),
                ("CI", None),
            ],
            || {
                assert_eq!(detect_runner_environment(), "local");
            },
        );
    }

    #[test]
    fn treats_ci_false_as_absent() {
        with_env(
            &[
                ("GITHUB_ACTIONS", Some("false")),
                ("GITLAB_CI", None),
                ("CIRCLECI", None),
                ("BUILDKITE", None),
                ("TRAVIS", None),
                ("JENKINS_URL", None),
                ("TEAMCITY_VERSION", None),
                ("APPVEYOR", None),
                ("DRONE", None),
                ("BITBUCKET_BUILD_NUMBER", None),
                ("CODEBUILD_BUILD_ID", None),
                ("TF_BUILD", None),
                ("WOODPECKER", None),
                ("CI", None),
            ],
            || {
                assert_eq!(detect_runner_environment(), "local");
            },
        );
    }

    #[test]
    fn treats_ci_empty_as_absent() {
        with_env(
            &[
                ("GITHUB_ACTIONS", None),
                ("GITLAB_CI", None),
                ("CIRCLECI", None),
                ("BUILDKITE", None),
                ("TRAVIS", None),
                ("JENKINS_URL", None),
                ("TEAMCITY_VERSION", None),
                ("APPVEYOR", None),
                ("DRONE", None),
                ("BITBUCKET_BUILD_NUMBER", None),
                ("CODEBUILD_BUILD_ID", None),
                ("TF_BUILD", None),
                ("WOODPECKER", None),
                ("CI", Some("")),
            ],
            || {
                assert_eq!(detect_runner_environment(), "local");
            },
        );
    }

    // -- hostname detection ----------------------------------------

    #[test]
    fn hostname_prefers_hostname_env() {
        with_env(
            &[
                ("HOSTNAME", Some("ci-runner-7")),
                ("COMPUTERNAME", Some("WIN-BOX")),
            ],
            || {
                assert_eq!(detect_hostname(), Some("ci-runner-7".to_string()));
            },
        );
    }

    #[test]
    fn hostname_falls_back_to_computername_on_windows_shells() {
        with_env(
            &[("HOSTNAME", None), ("COMPUTERNAME", Some("WIN-BOX"))],
            || {
                assert_eq!(detect_hostname(), Some("WIN-BOX".to_string()));
            },
        );
    }

    #[test]
    fn hostname_is_none_when_both_vars_are_absent() {
        with_env(&[("HOSTNAME", None), ("COMPUTERNAME", None)], || {
            assert_eq!(detect_hostname(), None);
        });
    }

    #[test]
    fn hostname_is_none_when_var_is_empty() {
        with_env(&[("HOSTNAME", Some("   ")), ("COMPUTERNAME", None)], || {
            assert_eq!(detect_hostname(), None);
        });
    }

    // -- redaction --------------------------------------------------

    fn fully_populated() -> RunMetadata {
        RunMetadata::sample()
    }

    #[test]
    fn redact_removes_repo_field_entirely() {
        let m = fully_populated();
        let mut fields = BTreeSet::new();
        fields.insert(RedactField::Repo);
        let out = m.redacted(&fields);
        assert_eq!(out.repo, None);
        // Other fields untouched.
        assert_eq!(out.branch, m.branch);
        assert_eq!(out.commit_sha, m.commit_sha);
    }

    #[test]
    fn redact_removes_hostname_branch_and_commit_message() {
        let m = fully_populated();
        let fields: BTreeSet<RedactField> = [
            RedactField::Hostname,
            RedactField::Branch,
            RedactField::CommitMessage,
        ]
        .into_iter()
        .collect();
        let out = m.redacted(&fields);
        assert_eq!(out.hostname, None);
        assert_eq!(out.branch, None);
        assert_eq!(out.commit_message, None);
    }

    #[test]
    fn redact_replaces_pinned_string_fields_with_placeholder() {
        let m = fully_populated();
        let fields: BTreeSet<RedactField> = [
            RedactField::Os,
            RedactField::Arch,
            RedactField::RunnerEnvironment,
        ]
        .into_iter()
        .collect();
        let out = m.redacted(&fields);
        assert_eq!(out.os, "redacted");
        assert_eq!(out.arch, "redacted");
        assert_eq!(out.runner_environment, "redacted");
    }

    #[test]
    fn redact_never_strips_run_id_version_or_timestamps() {
        let m = fully_populated();
        // Even with every variant set, run_id, mcptest_version,
        // started_at, ended_at survive.
        let fields: BTreeSet<RedactField> = [
            RedactField::Repo,
            RedactField::Branch,
            RedactField::CommitSha,
            RedactField::CommitMessage,
            RedactField::Hostname,
            RedactField::RunnerEnvironment,
            RedactField::Os,
            RedactField::Arch,
            RedactField::McpProtocolVersion,
            RedactField::ConfigHash,
            RedactField::DurationMs,
        ]
        .into_iter()
        .collect();
        let out = m.redacted(&fields);
        assert_eq!(out.run_id, m.run_id);
        assert_eq!(out.mcptest_version, m.mcptest_version);
        assert_eq!(out.started_at, m.started_at);
        assert_eq!(out.ended_at, m.ended_at);
    }

    #[test]
    fn redact_empty_set_is_a_passthrough() {
        let m = fully_populated();
        let out = m.redacted(&BTreeSet::new());
        assert_eq!(out, m);
    }

    // -- json round-trip -------------------------------------------

    #[test]
    fn json_round_trip_is_lossless() {
        let m = RunMetadata::sample();
        let s = serde_json::to_string(&m).expect("encode");
        let back: RunMetadata = serde_json::from_str(&s).expect("decode");
        assert_eq!(m, back);
    }

    #[test]
    fn json_omits_none_optional_fields() {
        let mut m = RunMetadata::sample();
        m.repo = None;
        m.branch = None;
        m.commit_sha = None;
        m.commit_message = None;
        m.hostname = None;
        m.mcp_protocol_version = None;
        m.config_hash = None;
        m.duration_ms = None;
        let value: serde_json::Value = serde_json::to_value(&m).expect("encode");
        assert!(value.get("repo").is_none());
        assert!(value.get("branch").is_none());
        assert!(value.get("commit_sha").is_none());
        assert!(value.get("commit_message").is_none());
        assert!(value.get("hostname").is_none());
        assert!(value.get("mcp_protocol_version").is_none());
        assert!(value.get("config_hash").is_none());
        assert!(value.get("duration_ms").is_none());
    }

    #[test]
    fn secret_env_values_are_not_echoed_into_runner_environment() {
        // We never read the value of secret-shaped env vars; we only
        // check the *name* of vendor markers. Stuffing a "secret" into
        // a known marker still results in the vendor label, not the
        // raw value.
        with_env(
            &[("GITHUB_ACTIONS", Some("ghp_super_secret_dont_log"))],
            || {
                let label = detect_runner_environment();
                assert_eq!(label, "github-actions");
                assert!(!label.contains("ghp_"));
                assert!(!label.contains("secret"));
            },
        );
    }
}

// Tiny env-var harness for the tests above. Lives inline because
// every other place in the crate that needs this rolls its own; one
// more would create a small footgun if another test mutated env vars
// concurrently. Tests in this module run serially via a mutex.
#[cfg(test)]
mod serial_test_helpers {
    use std::sync::Mutex;

    /// Process-wide mutex that serializes env-var mutations across
    /// tests in this module. Without it, parallel tests would race on
    /// `std::env::set_var` and produce flaky results.
    static ENV_LOCK: Mutex<()> = Mutex::new(());

    /// Run `body` with the given env vars set (or removed when the
    /// option is `None`). Original values are restored when `body`
    /// returns, including the panic path so a failed assertion does
    /// not leak state into subsequent tests.
    pub fn with_env<F: FnOnce()>(vars: &[(&str, Option<&str>)], body: F) {
        // Hold the lock for the full duration of the test so the
        // restore step also runs under the lock.
        let guard = ENV_LOCK.lock().unwrap_or_else(|e| e.into_inner());
        let saved: Vec<(String, Option<String>)> = vars
            .iter()
            .map(|(k, _)| (k.to_string(), std::env::var(k).ok()))
            .collect();
        for (k, v) in vars {
            match v {
                Some(val) => std::env::set_var(k, val),
                None => std::env::remove_var(k),
            }
        }
        // Catch unwinding panics so we always restore env state.
        let result = std::panic::catch_unwind(std::panic::AssertUnwindSafe(body));
        for (k, original) in &saved {
            match original {
                Some(val) => std::env::set_var(k, val),
                None => std::env::remove_var(k),
            }
        }
        drop(guard);
        if let Err(err) = result {
            std::panic::resume_unwind(err);
        }
    }
}
