//! Context-aware lifecycle hook ABI.
//!
//!  hooks are fire-and-forget commands: they run for side effects
//! and an exit code, but cannot read the run context or return data. This
//! module adds an opt-in richer contract on top, reusing the one-shot
//! JSON subprocess machinery the transform step uses
//! ([`crate::subprocess`]).
//!
//! A hook step is either a plain command string (today's behavior,
//! unchanged) or a map that opts into the context ABI with an explicit
//! `mode: context` marker. A context hook receives the run context as one
//! JSON object on its stdin and may write one JSON patch object to its
//! stdout. The patch is restricted to `{ env, vars, skip }` so a hook can
//! inject computed values or skip a test, but cannot mutate arbitrary suite
//! state. For before phases the patch is applied (env and vars merge, skip
//! marks the affected test(s) skipped); for after phases the hook still
//! receives the test result but its patch is advisory and ignored, because
//! an after hook may not retroactively fail or rewrite a test that already
//! ran. The phase also rides on `MCPTEST_HOOK_PHASE` so a `jq`-style hook
//! can branch without parsing the payload.

use std::collections::BTreeMap;
use std::time::Duration;

use serde_json::{json, Map, Value};
use thiserror::Error;

use crate::subprocess::{run_json_subprocess, SubprocessError};
use crate::suite::TransformCommand;

/// Wall-clock budget for a single context hook invocation. Matches the
/// transform budget: a hook may cold-start a Node or Python runtime before
/// doing real work, so the budget is generous.
pub const HOOK_TIMEOUT: Duration = Duration::from_secs(30);

/// Which lifecycle phase a hook belongs to. Determines the phase label in
/// the context payload and `MCPTEST_HOOK_PHASE`, and whether a stdout patch
/// is applied (before phases) or ignored (after phases).
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum HookPhase {
    /// Runs once before any test. A failure fails the whole run's tests.
    BeforeAll,
    /// Runs once after every test. A failure is reported only.
    AfterAll,
    /// Runs before each test. A failure fails that test.
    BeforeEach,
    /// Runs after each test. A failure is reported only.
    AfterEach,
}

impl HookPhase {
    /// Camel-case label used in the context payload, `MCPTEST_HOOK_PHASE`,
    /// and failure messages. Mirrors the YAML key.
    pub fn label(self) -> &'static str {
        match self {
            HookPhase::BeforeAll => "beforeAll",
            HookPhase::AfterAll => "afterAll",
            HookPhase::BeforeEach => "beforeEach",
            HookPhase::AfterEach => "afterEach",
        }
    }

    /// True for the after phases (`afterAll`, `afterEach`), whose hooks
    /// receive the test result but whose stdout patch is advisory.
    pub fn is_after(self) -> bool {
        matches!(self, HookPhase::AfterAll | HookPhase::AfterEach)
    }
}

/// One hook step: either a plain command or a context-aware
/// command.
///
/// The plain form is a bare YAML string and stays the default for
/// side-effecting setup and teardown. The context form is a map with an
/// explicit `mode: context` marker that opts into the JSON-over-stdio ABI.
#[derive(Debug, Clone, PartialEq, Eq)]
pub enum HookStep {
    /// A fire-and-forget command. Runs for side effects and an exit code;
    /// receives no context on stdin and its stdout is ignored.
    Plain(String),
    /// A command that speaks the context ABI: it reads the run context as
    /// JSON on stdin and may emit a restricted patch on stdout.
    Context {
        /// The command to run, split into program plus argv at parse time.
        command: TransformCommand,
    },
}

/// Context handed to a context hook as one JSON object on stdin.
///
/// `test` is `None` for the All phases (there is no single current test);
/// `result` is `Some` only for the After phases (the test outcome the hook
/// may inspect). `env` and `vars` are the values in effect for the
/// phase, so a beforeEach hook sees vars a beforeAll hook injected.
#[derive(Debug, Clone, Default)]
pub struct HookContext {
    /// Suite name, surfaced under `suite.name`.
    pub suite_name: String,
    /// Current test name for per-test phases, surfaced under `test.name`.
    /// `None` for `beforeAll` / `afterAll`.
    pub test_name: Option<String>,
    /// Environment in effect, surfaced under `env`.
    pub env: BTreeMap<String, String>,
    /// Resolved suite variables, surfaced under `vars`.
    pub vars: BTreeMap<String, String>,
    /// Test result for the After phases, surfaced under `result`. `None`
    /// for the Before phases (the test has not run yet).
    pub result: Option<Value>,
}

/// The restricted patch a context hook may emit on stdout.
///
/// Every field is optional, so an empty object or no output at all is a
/// valid no-op. For before phases mcptest merges `env` and `vars` into the
/// run context and honors `skip`; for after phases the whole patch is
/// ignored. Keys outside `{ env, vars, skip }` are a [`HookError::BadPatch`]
/// rather than silently allowed, so a hook cannot smuggle in arbitrary
/// suite mutation.
#[derive(Debug, Clone, Default, PartialEq, Eq)]
pub struct HookPatch {
    /// Environment entries to merge into the run context.
    pub env: BTreeMap<String, String>,
    /// Variables to merge into the run context.
    pub vars: BTreeMap<String, String>,
    /// When `Some`, skip the affected test(s) with this reason.
    pub skip: Option<String>,
}

impl HookPatch {
    /// True when the patch carries nothing to apply.
    pub fn is_empty(&self) -> bool {
        self.env.is_empty() && self.vars.is_empty() && self.skip.is_none()
    }
}

/// Typed failure from running a context hook. The caller (the CLI hook
/// runner) renders this onto a [`crate::report::TestFailure`] or a log
/// line per the phase's severity rules.
#[derive(Debug, Error)]
#[non_exhaustive]
pub enum HookError {
    /// The hook subprocess failed (spawn, timeout, non-zero exit, or
    /// unparseable stdout). Carries the phase, the command line, and the
    /// underlying [`SubprocessError`] message.
    #[error("{phase} hook `{command}` {source}")]
    Subprocess {
        /// Phase the hook belongs to.
        phase: &'static str,
        /// Command line that failed.
        command: String,
        /// Underlying one-shot subprocess error.
        #[source]
        source: SubprocessError,
    },
    /// The hook emitted a JSON value that was not an object, or carried a
    /// key outside the allowed `{ env, vars, skip }` set, or gave a field
    /// the wrong type.
    #[error("{phase} hook `{command}` emitted an invalid patch: {detail}")]
    BadPatch {
        /// Phase the hook belongs to.
        phase: &'static str,
        /// Command line that produced the bad patch.
        command: String,
        /// What was wrong with the patch.
        detail: String,
    },
}

/// Render a command as a single space-joined line for messages. Not a
/// faithful shell re-quote, only a readable label (mirrors the transform).
fn command_line(command: &TransformCommand) -> String {
    if command.args.is_empty() {
        command.program.clone()
    } else {
        format!("{} {}", command.program, command.args.join(" "))
    }
}

/// Build the JSON object written to a context hook's stdin.
fn build_stdin(phase: HookPhase, ctx: &HookContext) -> Value {
    let env: Map<String, Value> = ctx
        .env
        .iter()
        .map(|(k, v)| (k.clone(), Value::String(v.clone())))
        .collect();
    let vars: Map<String, Value> = ctx
        .vars
        .iter()
        .map(|(k, v)| (k.clone(), Value::String(v.clone())))
        .collect();
    json!({
        "phase": phase.label(),
        "suite": { "name": ctx.suite_name },
        "test": ctx.test_name.as_ref().map(|n| json!({ "name": n })),
        "env": Value::Object(env),
        "vars": Value::Object(vars),
        "result": ctx.result.clone(),
    })
}

/// Run one context hook and return the patch it emitted.
///
/// Writes the context JSON to the hook's stdin, sets `MCPTEST_HOOK_PHASE`
/// plus the inherited environment, and parses the optional stdout patch.
/// An empty stdout is `Ok(HookPatch::default())`. The caller decides what
/// to do with the patch based on the phase (apply for before, ignore for
/// after) and how to surface a [`HookError`] (fatal for before, logged for
/// after), matching the  severity rules.
pub async fn run_context_hook(
    phase: HookPhase,
    command: &TransformCommand,
    ctx: &HookContext,
) -> Result<HookPatch, HookError> {
    let line = command_line(command);
    let stdin = build_stdin(phase, ctx);
    let env = vec![("MCPTEST_HOOK_PHASE".to_string(), phase.label().to_string())];

    tracing::debug!(
        target: "mcptest_core::hooks",
        phase = phase.label(),
        command = %line,
        "running context hook"
    );

    let out = run_json_subprocess(&command.program, &command.args, &stdin, &env, HOOK_TIMEOUT)
        .await
        .map_err(|source| HookError::Subprocess {
            phase: phase.label(),
            command: line.clone(),
            source,
        })?;

    match out {
        None => Ok(HookPatch::default()),
        Some(value) => parse_patch(phase, &line, value),
    }
}

/// Parse and validate a hook's stdout into a [`HookPatch`]. Rejects a
/// non-object top level, any key outside `{ env, vars, skip }`, and
/// wrong-typed fields, so a hook cannot smuggle in arbitrary mutation.
fn parse_patch(phase: HookPhase, line: &str, value: Value) -> Result<HookPatch, HookError> {
    let bad = |detail: String| HookError::BadPatch {
        phase: phase.label(),
        command: line.to_string(),
        detail,
    };
    let Value::Object(map) = value else {
        return Err(bad("the patch must be a JSON object".to_string()));
    };
    for key in map.keys() {
        if !matches!(key.as_str(), "env" | "vars" | "skip") {
            return Err(bad(format!(
                "unknown patch key `{key}` (allowed: env, vars, skip)"
            )));
        }
    }
    Ok(HookPatch {
        env: string_map(&map, "env").map_err(&bad)?,
        vars: string_map(&map, "vars").map_err(&bad)?,
        skip: skip_reason(&map).map_err(&bad)?,
    })
}

/// Pull a `{string: string}` object out of `map` under `key`. An absent
/// key is an empty map; a non-object or non-string value is an error.
fn string_map(map: &Map<String, Value>, key: &str) -> Result<BTreeMap<String, String>, String> {
    let Some(value) = map.get(key) else {
        return Ok(BTreeMap::new());
    };
    let Value::Object(obj) = value else {
        return Err(format!("`{key}` must be an object of string values"));
    };
    let mut out = BTreeMap::new();
    for (k, v) in obj {
        let Value::String(s) = v else {
            return Err(format!("`{key}.{k}` must be a string"));
        };
        out.insert(k.clone(), s.clone());
    }
    Ok(out)
}

/// Pull the optional `skip.reason` string out of the patch. `skip` may be
/// absent (no skip) or an object carrying a string `reason`.
fn skip_reason(map: &Map<String, Value>) -> Result<Option<String>, String> {
    let Some(value) = map.get("skip") else {
        return Ok(None);
    };
    let Value::Object(obj) = value else {
        return Err("`skip` must be an object like { reason: \"...\" }".to_string());
    };
    match obj.get("reason") {
        Some(Value::String(reason)) => Ok(Some(reason.clone())),
        Some(_) => Err("`skip.reason` must be a string".to_string()),
        None => Err("`skip` must carry a string `reason`".to_string()),
    }
}

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

    fn sh(script: &str) -> TransformCommand {
        TransformCommand {
            program: "/bin/sh".to_string(),
            args: vec!["-c".to_string(), script.to_string()],
        }
    }

    fn ctx() -> HookContext {
        HookContext {
            suite_name: "demo".to_string(),
            test_name: Some("t".to_string()),
            ..HookContext::default()
        }
    }

    #[tokio::test]
    async fn before_each_hook_injecting_vars_returns_them() {
        let cmd = sh(r#"cat >/dev/null; echo '{"vars":{"token":"abc"}}'"#);
        let patch = run_context_hook(HookPhase::BeforeEach, &cmd, &ctx())
            .await
            .expect("hook runs");
        assert_eq!(patch.vars.get("token"), Some(&"abc".to_string()));
        assert!(patch.skip.is_none());
    }

    #[tokio::test]
    async fn skip_patch_carries_the_reason() {
        let cmd = sh(r#"cat >/dev/null; echo '{"skip":{"reason":"no creds"}}'"#);
        let patch = run_context_hook(HookPhase::BeforeEach, &cmd, &ctx())
            .await
            .expect("hook runs");
        assert_eq!(patch.skip.as_deref(), Some("no creds"));
    }

    #[tokio::test]
    async fn empty_stdout_is_an_empty_patch() {
        let cmd = sh("cat >/dev/null; true");
        let patch = run_context_hook(HookPhase::BeforeAll, &cmd, &ctx())
            .await
            .expect("hook runs");
        assert!(patch.is_empty());
    }

    #[tokio::test]
    async fn unknown_patch_key_is_rejected() {
        let cmd = sh(r#"cat >/dev/null; echo '{"servers":{"x":"y"}}'"#);
        let err = run_context_hook(HookPhase::BeforeEach, &cmd, &ctx())
            .await
            .expect_err("unknown key fails");
        match err {
            HookError::BadPatch { detail, .. } => {
                assert!(detail.contains("unknown patch key"), "{detail}");
                assert!(detail.contains("servers"), "{detail}");
            }
            other => panic!("expected BadPatch, got {other:?}"),
        }
    }

    #[tokio::test]
    async fn non_object_patch_is_rejected() {
        let cmd = sh(r#"cat >/dev/null; echo '[1,2,3]'"#);
        let err = run_context_hook(HookPhase::BeforeEach, &cmd, &ctx())
            .await
            .expect_err("array patch fails");
        assert!(matches!(err, HookError::BadPatch { .. }), "{err:?}");
    }

    #[tokio::test]
    async fn after_hook_receives_the_result_on_stdin() {
        // The hook proves it read `result` from stdin: when the payload
        // names a passing verdict it emits a fixed marker var, otherwise an
        // empty patch. The grep keeps the emitted JSON well formed.
        let cmd = sh(
            r#"in=$(cat); printf '%s' "$in" | grep -q '"verdict":"pass"' && echo '{"vars":{"seen":"pass"}}' || echo '{}'"#,
        );
        let mut c = ctx();
        c.result = Some(json!({"verdict": "pass"}));
        let patch = run_context_hook(HookPhase::AfterEach, &cmd, &c)
            .await
            .expect("hook runs");
        assert_eq!(patch.vars.get("seen"), Some(&"pass".to_string()));
    }

    #[tokio::test]
    async fn phase_rides_on_the_env_var() {
        let cmd = sh(r#"cat >/dev/null; printf '{"vars":{"p":"%s"}}' "$MCPTEST_HOOK_PHASE""#);
        let patch = run_context_hook(HookPhase::AfterAll, &cmd, &ctx())
            .await
            .expect("hook runs");
        assert_eq!(patch.vars.get("p"), Some(&"afterAll".to_string()));
    }

    #[tokio::test]
    async fn context_payload_carries_phase_suite_test_and_vars() {
        // Reflect the whole stdin back out so we can assert its shape.
        let cmd = sh("cat");
        let mut c = ctx();
        c.vars.insert("region".to_string(), "us".to_string());
        // The reflected value is not a valid patch (it has `phase`/`suite`),
        // so we only assert the build_stdin shape directly here.
        let built = build_stdin(HookPhase::BeforeEach, &c);
        assert_eq!(built["phase"], json!("beforeEach"));
        assert_eq!(built["suite"]["name"], json!("demo"));
        assert_eq!(built["test"]["name"], json!("t"));
        assert_eq!(built["vars"]["region"], json!("us"));
        assert_eq!(built["result"], Value::Null);
        // And that `cat` round-trips it (proves stdin delivery), even though
        // the result is rejected as a patch.
        let err = run_context_hook(HookPhase::BeforeEach, &cmd, &c)
            .await
            .expect_err("reflected context is not a valid patch");
        assert!(matches!(err, HookError::BadPatch { .. }), "{err:?}");
    }

    #[tokio::test]
    async fn all_phase_context_has_null_test() {
        let c = HookContext {
            suite_name: "demo".to_string(),
            test_name: None,
            ..HookContext::default()
        };
        let built = build_stdin(HookPhase::BeforeAll, &c);
        assert_eq!(built["test"], Value::Null);
    }

    #[tokio::test]
    async fn non_zero_exit_is_a_subprocess_error() {
        let cmd = sh("exit 2");
        let err = run_context_hook(HookPhase::BeforeEach, &cmd, &ctx())
            .await
            .expect_err("non-zero fails");
        assert!(matches!(err, HookError::Subprocess { .. }), "{err:?}");
    }

    #[test]
    fn after_phases_are_flagged() {
        assert!(HookPhase::AfterAll.is_after());
        assert!(HookPhase::AfterEach.is_after());
        assert!(!HookPhase::BeforeAll.is_after());
        assert!(!HookPhase::BeforeEach.is_after());
    }
}
