//! Lifecycle hook execution for `mcptest run`.
//!
//! A suite may declare `beforeAll` / `afterAll` (run once around the
//! whole run) and `beforeEach` / `afterEach` (run around each test, the
//! valuable case for stateful MCP session setup and teardown). Each hook
//! step is either a plain shell command string or a context-aware
//! command that opts into the JSON-over-stdio ABI. Steps run in
//! declaration order; the first failure stops that phase and reports it.
//!
//! Failure policy mirrors the ticket: a failing `beforeAll` or
//! `beforeEach` is fatal for the affected test(s), so the caller turns it
//! into a failed test. A failing `afterAll` or `afterEach` is reported but
//! does not by itself fail a test (the test already ran). The before phases
//! gate at their call sites; the after phases only log.
//!
//! Context hooks add two more behaviors for the before phases: an injected
//! `{ env, vars }` patch merges into the run context the following hooks
//! see, and a `{ skip: { reason } }` patch skips the affected test(s). An
//! after hook still receives the test result on stdin, but its patch is
//! advisory and ignored: an after hook may not retroactively fail or rewrite
//! a test that already ran.

use std::process::Command;
use std::sync::Mutex;

use async_trait::async_trait;
use mcptest_core::hooks::{run_context_hook, HookContext, HookPatch, HookPhase, HookStep};
use mcptest_core::runner::{TestExecutor, TestOutcome, TestSpec};
use mcptest_core::suite::Hooks;

/// The command list for `phase` out of a [`Hooks`] block.
pub(crate) fn phase_steps(phase: HookPhase, hooks: &Hooks) -> &[HookStep] {
    match phase {
        HookPhase::BeforeAll => &hooks.before_all,
        HookPhase::AfterAll => &hooks.after_all,
        HookPhase::BeforeEach => &hooks.before_each,
        HookPhase::AfterEach => &hooks.after_each,
    }
}

/// Outcome of running one phase's step list.
#[derive(Debug, Clone, PartialEq, Eq)]
pub(crate) enum HookOutcome {
    /// Every step in the phase succeeded (or the phase was empty). Carries
    /// the merged patch the context hooks emitted (empty for plain hooks).
    Ok(HookPatch),
    /// A step failed. Carries the human-readable reason (which step and how
    /// it failed) for the caller to surface.
    Failed(String),
    /// A before-phase context hook asked to skip the affected test(s).
    /// Carries the reason it gave.
    Skip(String),
}

/// Run the step list for `phase` against `ctx`, stopping at the first
/// failure or skip request.
///
/// Plain steps run for their exit code; context steps feed `ctx` to the
/// hook and collect its patch. For before phases the patches merge into the
/// returned [`HookOutcome::Ok`] payload (and into `ctx` so later steps in
/// the phase see them) and a `skip` short-circuits to [`HookOutcome::Skip`].
/// For after phases the patches are dropped, per the ticket. The first
/// failing step returns [`HookOutcome::Failed`]; later steps do not run.
pub(crate) async fn run_phase(phase: HookPhase, hooks: &Hooks, ctx: &HookContext) -> HookOutcome {
    run_steps(phase, phase_steps(phase, hooks), ctx).await
}

/// Run an explicit step list under `phase`. Split out so tests can drive it
/// without constructing a full [`Hooks`] block.
async fn run_steps(phase: HookPhase, steps: &[HookStep], ctx: &HookContext) -> HookOutcome {
    let mut merged = HookPatch::default();
    // Later steps in the phase see vars/env earlier steps injected.
    let mut effective = ctx.clone();
    for step in steps {
        match step {
            HookStep::Plain(raw) => {
                if let Some(reason) = run_plain(phase, raw) {
                    return HookOutcome::Failed(reason);
                }
            }
            HookStep::Context { command } => {
                let patch = match run_context_hook(phase, command, &effective).await {
                    Ok(patch) => patch,
                    Err(e) => return HookOutcome::Failed(format!("{e}")),
                };
                // After hooks receive the result but their patch is advisory.
                if phase.is_after() {
                    continue;
                }
                if let Some(reason) = patch.skip {
                    return HookOutcome::Skip(reason);
                }
                merge_patch(&mut merged, &mut effective, &patch);
            }
        }
    }
    HookOutcome::Ok(merged)
}

/// Merge a before-phase patch into the running accumulator and into the
/// effective context the next step in the phase sees.
fn merge_patch(merged: &mut HookPatch, effective: &mut HookContext, patch: &HookPatch) {
    for (k, v) in &patch.env {
        merged.env.insert(k.clone(), v.clone());
        effective.env.insert(k.clone(), v.clone());
    }
    for (k, v) in &patch.vars {
        merged.vars.insert(k.clone(), v.clone());
        effective.vars.insert(k.clone(), v.clone());
    }
}

/// Spawn one plain hook command, returning `Some(reason)` on failure. A
/// parse error, a spawn error, and a non-zero exit all render as a reason
/// naming the phase and command.
fn run_plain(phase: HookPhase, raw: &str) -> Option<String> {
    let parts = match shell_words::split(raw) {
        Ok(parts) if !parts.is_empty() => parts,
        Ok(_) => return Some(format!("{} hook is an empty command", phase.label())),
        Err(e) => {
            return Some(format!(
                "{} hook `{raw}` could not be parsed: {e}",
                phase.label()
            ))
        }
    };
    let (program, args) = parts.split_first()?;
    match Command::new(program).args(args).status() {
        Ok(status) if status.success() => None,
        Ok(status) => Some(format!(
            "{} hook `{raw}` exited with {}",
            phase.label(),
            status
                .code()
                .map(|c| c.to_string())
                .unwrap_or_else(|| "a signal".to_string())
        )),
        Err(e) => Some(format!(
            "{} hook `{raw}` failed to spawn: {e}",
            phase.label()
        )),
    }
}

/// A [`TestExecutor`] that brackets each test with `beforeEach` and
/// `afterEach` hooks.
///
/// Wraps the real executor. Before delegating, it runs `beforeEach`; a
/// failure there fails the test without dispatching it, and a context
/// hook's skip request skips the test instead. After the test, it runs
/// `afterEach` with the test result on the context; a failure there is
/// reported but does not flip a passing test to fail, per the ticket. The
/// CLI forces serial execution when this wrapper is installed so the
/// bracket ordering holds for stateful MCP sessions. The suite name and the
/// beforeAll-injected env/vars seed every per-test hook's context.
pub(crate) struct HookedExecutor<E: TestExecutor> {
    inner: E,
    suite_name: String,
    before_each: Vec<HookStep>,
    after_each: Vec<HookStep>,
    /// Env and vars injected by `beforeAll` (and accumulated across tests),
    /// seeded into each per-test hook context. Shared so the serial bracket
    /// can read it without threading it through every call.
    seed: Mutex<HookPatch>,
}

impl<E: TestExecutor> HookedExecutor<E> {
    /// Wrap `inner` with the per-test hook lists from `hooks`, seeding the
    /// per-test context with the suite name and any `beforeAll` patch.
    pub(crate) fn new(inner: E, suite_name: &str, hooks: &Hooks, seed: HookPatch) -> Self {
        Self {
            inner,
            suite_name: suite_name.to_string(),
            before_each: hooks.before_each.clone(),
            after_each: hooks.after_each.clone(),
            seed: Mutex::new(seed),
        }
    }

    /// Build the per-test hook context from the suite name, the current
    /// seed (beforeAll patch plus prior per-test injections), the test
    /// name, and the optional test result.
    fn context(&self, test: &str, result: Option<serde_json::Value>) -> HookContext {
        let seed = self.seed.lock().unwrap_or_else(|p| p.into_inner());
        HookContext {
            suite_name: self.suite_name.clone(),
            test_name: Some(test.to_string()),
            env: seed.env.clone(),
            vars: seed.vars.clone(),
            result,
        }
    }
}

#[async_trait]
impl<E: TestExecutor> TestExecutor for HookedExecutor<E> {
    async fn execute_one(&self, test: &TestSpec) -> TestOutcome {
        let ctx = self.context(&test.name, None);
        match run_steps(HookPhase::BeforeEach, &self.before_each, &ctx).await {
            HookOutcome::Failed(reason) => {
                // A failed setup blocks the test; do not dispatch it.
                return TestOutcome::fail(format!(
                    "beforeEach hook failed for `{}`: {reason}",
                    test.name
                ));
            }
            HookOutcome::Skip(reason) => {
                tracing::info!(target: "mcptest::cli", test = %test.name, "skipped by beforeEach hook: {reason}");
                eprintln!(
                    "mcptest run: `{}` skipped by beforeEach hook: {reason}",
                    test.name
                );
                return TestOutcome::skip();
            }
            HookOutcome::Ok(patch) => {
                // Accumulate this test's injections so later tests' hooks
                // see them, matching the serial bracket the runner forces.
                let mut seed = self.seed.lock().unwrap_or_else(|p| p.into_inner());
                for (k, v) in patch.env {
                    seed.env.insert(k, v);
                }
                for (k, v) in patch.vars {
                    seed.vars.insert(k, v);
                }
            }
        }

        let outcome = self.inner.execute_one(test).await;

        // afterEach runs regardless of the verdict and receives the result,
        // but its patch is advisory; its failure is logged, not fatal.
        let result = test_result_json(&outcome);
        let after_ctx = self.context(&test.name, Some(result));
        if let HookOutcome::Failed(reason) =
            run_steps(HookPhase::AfterEach, &self.after_each, &after_ctx).await
        {
            tracing::warn!(target: "mcptest::cli", test = %test.name, "{reason}");
            eprintln!(
                "mcptest run: afterEach hook reported a failure for `{}`: {reason}",
                test.name
            );
        }
        outcome
    }
}

/// Render a test outcome as the compact `result` JSON an after hook reads
/// on stdin. Kept minimal (verdict plus the failure message when present)
/// so the contract is stable and free of internal report shapes.
fn test_result_json(outcome: &TestOutcome) -> serde_json::Value {
    use mcptest_core::report::TestStatus;
    let verdict = match outcome.status {
        TestStatus::Pass => "pass",
        TestStatus::Fail => "fail",
        TestStatus::Skip => "skip",
        // `TestStatus` is non-exhaustive; a future variant reads as unknown
        // to the hook rather than failing the run.
        _ => "unknown",
    };
    let mut map = serde_json::Map::new();
    map.insert(
        "verdict".to_string(),
        serde_json::Value::String(verdict.to_string()),
    );
    if let Some(failure) = &outcome.failure {
        map.insert(
            "message".to_string(),
            serde_json::Value::String(failure.message.clone()),
        );
    }
    serde_json::Value::Object(map)
}

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

    /// A bare suite-level context with no test (the All phases).
    fn suite_ctx() -> HookContext {
        HookContext {
            suite_name: "demo".to_string(),
            ..HookContext::default()
        }
    }

    #[tokio::test]
    async fn empty_step_list_succeeds() {
        assert_eq!(
            run_steps(HookPhase::BeforeAll, &[], &suite_ctx()).await,
            HookOutcome::Ok(HookPatch::default())
        );
    }

    #[tokio::test]
    async fn successful_plain_command_returns_ok() {
        let steps = vec![HookStep::Plain("true".to_string())];
        assert_eq!(
            run_steps(HookPhase::BeforeEach, &steps, &suite_ctx()).await,
            HookOutcome::Ok(HookPatch::default())
        );
    }

    #[tokio::test]
    async fn non_zero_exit_is_reported_as_failed() {
        let steps = vec![HookStep::Plain("false".to_string())];
        match run_steps(HookPhase::BeforeAll, &steps, &suite_ctx()).await {
            HookOutcome::Failed(reason) => {
                assert!(reason.contains("beforeAll"), "{reason}");
                assert!(reason.contains("exited"), "{reason}");
            }
            other => panic!("expected the false command to fail, got {other:?}"),
        }
    }

    #[tokio::test]
    async fn first_failure_stops_the_phase() {
        let steps = vec![
            HookStep::Plain("false".to_string()),
            HookStep::Plain("true".to_string()),
        ];
        let outcome = run_steps(HookPhase::BeforeEach, &steps, &suite_ctx()).await;
        assert!(matches!(outcome, HookOutcome::Failed(_)));
    }

    #[tokio::test]
    async fn unparseable_command_is_failed_not_panic() {
        let steps = vec![HookStep::Plain("\"unterminated".to_string())];
        match run_steps(HookPhase::AfterAll, &steps, &suite_ctx()).await {
            HookOutcome::Failed(reason) => {
                assert!(reason.contains("could not be parsed"), "{reason}")
            }
            other => panic!("expected a parse failure, got {other:?}"),
        }
    }

    #[tokio::test]
    async fn run_phase_reads_the_right_list() {
        let hooks = Hooks {
            before_each: vec![HookStep::Plain("true".to_string())],
            before_all: vec![HookStep::Plain("false".to_string())],
            ..Hooks::default()
        };
        assert_eq!(
            run_phase(HookPhase::BeforeEach, &hooks, &suite_ctx()).await,
            HookOutcome::Ok(HookPatch::default())
        );
        assert!(matches!(
            run_phase(HookPhase::BeforeAll, &hooks, &suite_ctx()).await,
            HookOutcome::Failed(_)
        ));
    }

    #[cfg(unix)]
    fn ctx_step(script: &str) -> HookStep {
        HookStep::Context {
            command: mcptest_core::suite::TransformCommand {
                program: "/bin/sh".to_string(),
                args: vec!["-c".to_string(), script.to_string()],
            },
        }
    }

    #[cfg(unix)]
    #[tokio::test]
    async fn before_each_context_hook_injects_vars() {
        let steps = vec![ctx_step(
            r#"cat >/dev/null; echo '{"vars":{"token":"xyz"}}'"#,
        )];
        match run_steps(HookPhase::BeforeEach, &steps, &suite_ctx()).await {
            HookOutcome::Ok(patch) => {
                assert_eq!(patch.vars.get("token"), Some(&"xyz".to_string()))
            }
            other => panic!("expected Ok with vars, got {other:?}"),
        }
    }

    #[cfg(unix)]
    #[tokio::test]
    async fn before_each_context_hook_can_skip() {
        let steps = vec![ctx_step(
            r#"cat >/dev/null; echo '{"skip":{"reason":"no creds"}}'"#,
        )];
        match run_steps(HookPhase::BeforeEach, &steps, &suite_ctx()).await {
            HookOutcome::Skip(reason) => assert_eq!(reason, "no creds"),
            other => panic!("expected Skip, got {other:?}"),
        }
    }

    #[cfg(unix)]
    #[tokio::test]
    async fn after_phase_context_patch_is_ignored() {
        // The after hook emits a vars patch, but after patches are advisory,
        // so the phase returns an empty Ok payload.
        let steps = vec![ctx_step(
            r#"cat >/dev/null; echo '{"vars":{"ignored":"yes"}}'"#,
        )];
        let mut ctx = suite_ctx();
        ctx.result = Some(serde_json::json!({"verdict": "pass"}));
        match run_steps(HookPhase::AfterEach, &steps, &ctx).await {
            HookOutcome::Ok(patch) => assert!(patch.is_empty(), "{patch:?}"),
            other => panic!("expected empty Ok, got {other:?}"),
        }
    }

    /// Inner executor that always passes, used to isolate the hook wrapper.
    struct AlwaysPass;

    #[async_trait]
    impl TestExecutor for AlwaysPass {
        async fn execute_one(&self, _test: &TestSpec) -> TestOutcome {
            TestOutcome::pass()
        }
    }

    #[tokio::test]
    async fn before_each_failure_fails_the_test_without_dispatch() {
        let hooks = Hooks {
            before_each: vec![HookStep::Plain("false".to_string())],
            ..Hooks::default()
        };
        let exec = HookedExecutor::new(AlwaysPass, "demo", &hooks, HookPatch::default());
        let outcome = exec.execute_one(&TestSpec::new("t")).await;
        assert_eq!(outcome.status, mcptest_core::report::TestStatus::Fail);
        let failure = outcome.failure.expect("failure payload");
        assert!(
            failure.message.contains("beforeEach"),
            "{}",
            failure.message
        );
    }

    #[cfg(unix)]
    #[tokio::test]
    async fn before_each_skip_marks_the_test_skipped() {
        let hooks = Hooks {
            before_each: vec![ctx_step(
                r#"cat >/dev/null; echo '{"skip":{"reason":"gated"}}'"#,
            )],
            ..Hooks::default()
        };
        let exec = HookedExecutor::new(AlwaysPass, "demo", &hooks, HookPatch::default());
        let outcome = exec.execute_one(&TestSpec::new("t")).await;
        assert_eq!(outcome.status, mcptest_core::report::TestStatus::Skip);
    }

    #[cfg(unix)]
    #[tokio::test]
    async fn after_each_context_hook_receives_the_result() {
        // The after hook fails only if the result it read names a pass; the
        // failure is logged (not fatal), so the test still passes. We assert
        // the verdict reached the hook by having it echo back via exit code.
        let hooks = Hooks {
            after_each: vec![ctx_step(
                r#"in=$(cat); printf '%s' "$in" | grep -q '"verdict":"pass"' && exit 9 || exit 0"#,
            )],
            ..Hooks::default()
        };
        let exec = HookedExecutor::new(AlwaysPass, "demo", &hooks, HookPatch::default());
        let outcome = exec.execute_one(&TestSpec::new("t")).await;
        // afterEach failure is advisory: the passing test stays a pass.
        assert_eq!(outcome.status, mcptest_core::report::TestStatus::Pass);
    }

    #[tokio::test]
    async fn after_each_failure_does_not_flip_a_passing_test() {
        let hooks = Hooks {
            after_each: vec![HookStep::Plain("false".to_string())],
            ..Hooks::default()
        };
        let exec = HookedExecutor::new(AlwaysPass, "demo", &hooks, HookPatch::default());
        let outcome = exec.execute_one(&TestSpec::new("t")).await;
        assert_eq!(outcome.status, mcptest_core::report::TestStatus::Pass);
    }

    #[tokio::test]
    async fn clean_hooks_pass_the_inner_outcome_through() {
        let hooks = Hooks {
            before_each: vec![HookStep::Plain("true".to_string())],
            after_each: vec![HookStep::Plain("true".to_string())],
            ..Hooks::default()
        };
        let exec = HookedExecutor::new(AlwaysPass, "demo", &hooks, HookPatch::default());
        let outcome = exec.execute_one(&TestSpec::new("t")).await;
        assert_eq!(outcome.status, mcptest_core::report::TestStatus::Pass);
    }

    /// Inner executor that appends a marker to a shared log file so the
    /// ordering test can interleave it with hook side effects. Only the
    /// `#[cfg(unix)]` bracket-order test uses it, so gate it to match (dead
    /// code on Windows otherwise).
    #[cfg(unix)]
    struct LoggingExec {
        log: std::path::PathBuf,
    }

    #[cfg(unix)]
    #[async_trait]
    impl TestExecutor for LoggingExec {
        async fn execute_one(&self, _test: &TestSpec) -> TestOutcome {
            append_marker(&self.log, "test");
            TestOutcome::pass()
        }
    }

    #[cfg(unix)]
    fn append_marker(path: &std::path::Path, marker: &str) {
        use std::io::Write;
        let mut f = std::fs::OpenOptions::new()
            .create(true)
            .append(true)
            .open(path)
            .expect("open log");
        writeln!(f, "{marker}").expect("write marker");
    }

    /// A shell command that appends a marker line to `log`. Quoting keeps
    /// the redirection inside a single `sh -c` argument.
    #[cfg(unix)]
    fn echo_cmd(log: &std::path::Path, marker: &str) -> HookStep {
        HookStep::Plain(format!("sh -c 'echo {marker} >> {}'", log.display()))
    }

    // Uses `sh` and a Unix-style path redirection (a Windows path mangles
    // inside `sh -c`). The hook ordering it verifies is platform-independent
    // and covered on Unix CI, so gate the test rather than make the shell
    // harness cross-platform. Matches the other sh-based tests above.
    #[cfg(unix)]
    #[tokio::test]
    async fn full_bracket_order_is_before_all_each_test_each_all() {
        // Verify the documented ordering across two tests: beforeAll runs
        // once, then for each test beforeEach -> test -> afterEach, then
        // afterAll runs once. The markers land in the log in that order.
        let dir = std::env::temp_dir();
        let log = dir.join(format!("wor836-hook-order-{}.log", std::process::id()));
        let _ = std::fs::remove_file(&log);

        let hooks = Hooks {
            before_all: vec![echo_cmd(&log, "beforeAll")],
            after_all: vec![echo_cmd(&log, "afterAll")],
            before_each: vec![echo_cmd(&log, "beforeEach")],
            after_each: vec![echo_cmd(&log, "afterEach")],
        };

        assert!(matches!(
            run_phase(HookPhase::BeforeAll, &hooks, &suite_ctx()).await,
            HookOutcome::Ok(_)
        ));
        let exec = HookedExecutor::new(
            LoggingExec { log: log.clone() },
            "demo",
            &hooks,
            HookPatch::default(),
        );
        exec.execute_one(&TestSpec::new("a")).await;
        exec.execute_one(&TestSpec::new("b")).await;
        assert!(matches!(
            run_phase(HookPhase::AfterAll, &hooks, &suite_ctx()).await,
            HookOutcome::Ok(_)
        ));

        let contents = std::fs::read_to_string(&log).expect("read log");
        let lines: Vec<&str> = contents.lines().collect();
        assert_eq!(
            lines,
            vec![
                "beforeAll",
                "beforeEach",
                "test",
                "afterEach",
                "beforeEach",
                "test",
                "afterEach",
                "afterAll",
            ],
            "hook ordering must be beforeAll -> (beforeEach -> test -> afterEach)* -> afterAll"
        );
        let _ = std::fs::remove_file(&log);
    }
}
