//! Request/response transform runner.
//!
//! A transform is a user-supplied subprocess that rewrites a single JSON
//! value: the outbound `tools/call` request params before they are sent,
//! or the response envelope before assertions run. The contract is one
//! shot and deliberately plain so authors do not implement any framing
//! protocol (unlike the  plugin host, which keeps a stateful child
//! and speaks newline-delimited `PluginRequest`/`PluginResponse`):
//!
//! - The host spawns the command once per invocation.
//! - It writes the relevant JSON value to the child's stdin, then closes
//!   stdin so the child sees EOF.
//! - It reads stdout to EOF and parses it as a single JSON value, which
//!   replaces the original.
//!
//! Context that authors need but should not have to parse out of the
//! payload (so stdin stays pure JSON for `jq`) rides on environment
//! variables: `MCPTEST_TRANSFORM_PHASE`, `MCPTEST_TEST_NAME`,
//! `MCPTEST_SERVER`, `MCPTEST_TOOL`, plus any resolved suite vars as
//! `MCPTEST_VAR_<NAME>`. The child also inherits the parent environment,
//! the same passthrough the hooks rely on.
//!
//! A spawn failure, a non-zero exit, a timeout, or unparseable stdout is a
//! typed [`TransformError`] that the executor turns into a failed test with
//! a message naming the phase and command. The payloads are never logged at
//! info level (they may carry secrets); only the phase and command name
//! appear in logs.

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

use serde_json::Value;
use thiserror::Error;

use crate::subprocess::{run_json_subprocess, SubprocessError};
use crate::suite::{TransformCommand, TransformStep};
use crate::transform_jq::{self, JqError};

/// Wall-clock budget for a single transform invocation.
///
/// A transform may cold-start a Node or Python runtime (50-200 ms) and
/// then do real work, so the budget is generous. It mirrors the order of
/// magnitude callers expect from a one-shot helper rather than the tight
/// per-call budget the stateful plugin host promises.
pub const TRANSFORM_TIMEOUT: Duration = Duration::from_secs(30);

/// Which phase a transform invocation belongs to. Carried so error
/// messages and the `MCPTEST_TRANSFORM_PHASE` env var name the phase.
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum TransformPhase {
    /// Rewrites the request params before the tool call is sent.
    Request,
    /// Rewrites the response envelope before assertions run.
    Response,
    /// Rewrites a single extracted value before the matcher judges it
    ///. Unlike `Request`/`Response`, which rewrite the whole
    /// envelope, this phase sees only the value at the assertion's target.
    Assertion,
}

impl TransformPhase {
    /// Lowercase label used in `MCPTEST_TRANSFORM_PHASE` and messages.
    pub fn label(self) -> &'static str {
        match self {
            TransformPhase::Request => "request",
            TransformPhase::Response => "response",
            TransformPhase::Assertion => "assertion",
        }
    }
}

/// Context passed to a transform subprocess via environment variables.
///
/// Kept separate from the payload so stdin stays a single pure JSON value
/// (a `jq` filter sees exactly the request or response, nothing wrapping
/// it). The executor fills `test_name`, `server`, and `tool`; `vars`
/// carries resolved suite variables exported as `MCPTEST_VAR_<NAME>`.
#[derive(Debug, Clone, Default)]
pub struct TransformContext {
    /// Test name, exported as `MCPTEST_TEST_NAME`.
    pub test_name: String,
    /// Server key the test targets, exported as `MCPTEST_SERVER`.
    pub server: String,
    /// Tool name the test invokes, exported as `MCPTEST_TOOL`.
    pub tool: String,
    /// Resolved suite variables, each exported as `MCPTEST_VAR_<NAME>`.
    /// Empty today: the typed suite the executor runs over does not carry
    /// the resolved variable map (it is baked into the YAML upstream), so
    /// the CLI passes none. Threaded here so a future ticket that surfaces
    /// the map only edits the call site, not this contract.
    pub vars: BTreeMap<String, String>,
}

/// Typed failure from running a transform. Each variant maps to a clear
/// test-failure message; the executor renders it without parsing strings.
#[derive(Debug, Error)]
#[non_exhaustive]
pub enum TransformError {
    /// The transform command could not be spawned (binary missing,
    /// permission denied).
    #[error("{phase} transform `{command}` failed to spawn: {source}")]
    Spawn {
        /// Phase the transform belongs to.
        phase: &'static str,
        /// Command line that failed.
        command: String,
        /// Underlying OS error.
        #[source]
        source: std::io::Error,
    },
    /// The transform did not finish within [`TRANSFORM_TIMEOUT`].
    #[error("{phase} transform `{command}` timed out after {timeout:?}")]
    Timeout {
        /// Phase the transform belongs to.
        phase: &'static str,
        /// Command line that hung.
        command: String,
        /// Budget the run exceeded.
        timeout: Duration,
    },
    /// The transform exited non-zero. Carries the stderr tail for
    /// diagnosis (already secret-shaped output should be redacted by the
    /// caller before display, but stderr is the author's own log).
    #[error("{phase} transform `{command}` exited with {code}: {stderr}")]
    NonZeroExit {
        /// Phase the transform belongs to.
        phase: &'static str,
        /// Command line that failed.
        command: String,
        /// Exit code, or a signal description when there was no code.
        code: String,
        /// Trimmed stderr from the child for the operator.
        stderr: String,
    },
    /// The transform's stdout was not a single parseable JSON value.
    #[error("{phase} transform `{command}` emitted unparseable JSON: {source}")]
    BadOutput {
        /// Phase the transform belongs to.
        phase: &'static str,
        /// Command line that produced the bad output.
        command: String,
        /// Underlying parse error.
        #[source]
        source: serde_json::Error,
    },
    /// An I/O error writing stdin or collecting the child's output.
    #[error("{phase} transform `{command}` I/O error: {source}")]
    Io {
        /// Phase the transform belongs to.
        phase: &'static str,
        /// Command line that failed.
        command: String,
        /// Underlying OS error.
        #[source]
        source: std::io::Error,
    },
    /// An inline jq transform failed at run time. A compile error
    /// is caught at load time (it is a [`crate::suite::ParseError`]), so the
    /// run-time variant only carries the engine error or a wrong output
    /// count from [`crate::transform_jq`].
    #[error("{phase} transform failed: {source}")]
    Jq {
        /// Phase the transform belongs to.
        phase: &'static str,
        /// Underlying jq engine failure.
        #[source]
        source: JqError,
    },
}

/// Run one transform phase against `input` and return its replacement.
///
/// Dispatches on the [`TransformStep`] form: a [`TransformStep::Command`]
/// runs through [`run_transform`] (one-shot subprocess), and a
/// [`TransformStep::Jq`] runs in-process via [`crate::transform_jq`]. Both
/// honor the same one-JSON-in, one-JSON-out contract, so the executor calls
/// this once per phase and does not care which form the author chose.
pub async fn run_transform_step(
    phase: TransformPhase,
    step: &TransformStep,
    input: &Value,
    ctx: &TransformContext,
) -> Result<Value, TransformError> {
    match step {
        TransformStep::Command(command) => run_transform(phase, command, input, ctx).await,
        // The jq engine is synchronous and CPU-bound but tiny, so running it
        // inline on the async task is fine; it never blocks on I/O the way the
        // subprocess form does.
        TransformStep::Jq(program) => {
            transform_jq::run(program, input).map_err(|source| TransformError::Jq {
                phase: phase.label(),
                source,
            })
        }
    }
}

/// Run one transform command against `input` and return its replacement.
///
/// Spawns `command` once, writes `input` as JSON to stdin, closes stdin,
/// waits up to [`TRANSFORM_TIMEOUT`] for the child to exit, and parses its
/// stdout as one JSON value. The context fields plus any `vars` are set as
/// `MCPTEST_*` environment variables; the child also inherits the parent
/// environment. Any spawn, timeout, exit, or parse failure is a typed
/// [`TransformError`].
pub async fn run_transform(
    phase: TransformPhase,
    command: &TransformCommand,
    input: &Value,
    ctx: &TransformContext,
) -> Result<Value, TransformError> {
    let label = phase.label();
    let line = command_line(command);
    tracing::debug!(
        target: "mcptest_core::transform",
        phase = label,
        command = %line,
        "running transform"
    );

    let mut env = vec![
        ("MCPTEST_TRANSFORM_PHASE".to_string(), label.to_string()),
        ("MCPTEST_TEST_NAME".to_string(), ctx.test_name.clone()),
        ("MCPTEST_SERVER".to_string(), ctx.server.clone()),
        ("MCPTEST_TOOL".to_string(), ctx.tool.clone()),
    ];
    for (name, value) in &ctx.vars {
        env.push((format!("MCPTEST_VAR_{name}"), value.clone()));
    }

    let out = run_json_subprocess(
        &command.program,
        &command.args,
        input,
        &env,
        TRANSFORM_TIMEOUT,
    )
    .await
    .map_err(|e| transform_error(label, line, e))?;

    // A transform must replace the value, so an empty stdout (no JSON at
    // all) is a contract violation, not a silent no-op like a hook patch.
    out.ok_or_else(|| TransformError::BadOutput {
        phase: label,
        command: command_line(command),
        source: empty_output_error(),
    })
}

/// Build the parse error used when a transform emits no JSON at all. A
/// transform's whole job is to produce a replacement value, so silence is a
/// failure (distinct from a hook patch, where silence means "no patch").
fn empty_output_error() -> serde_json::Error {
    match serde_json::from_str::<Value>("") {
        Err(e) => e,
        Ok(_) => unreachable!("empty input never parses as JSON"),
    }
}

/// Map a generic [`SubprocessError`] onto the phase-and-command-framed
/// [`TransformError`] the executor renders.
fn transform_error(phase: &'static str, command: String, err: SubprocessError) -> TransformError {
    match err {
        SubprocessError::Spawn { source } => TransformError::Spawn {
            phase,
            command,
            source,
        },
        SubprocessError::Timeout { timeout } => TransformError::Timeout {
            phase,
            command,
            timeout,
        },
        SubprocessError::NonZeroExit { code, stderr } => TransformError::NonZeroExit {
            phase,
            command,
            code,
            stderr,
        },
        SubprocessError::BadOutput { source } => TransformError::BadOutput {
            phase,
            command,
            source,
        },
        SubprocessError::Io { source } => TransformError::Io {
            phase,
            command,
            source,
        },
    }
}

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

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

    /// Build a `/bin/sh -c <script>` transform command for the tests.
    fn sh(script: &str) -> TransformCommand {
        TransformCommand {
            program: "/bin/sh".to_string(),
            args: vec!["-c".to_string(), script.to_string()],
        }
    }

    fn ctx() -> TransformContext {
        TransformContext {
            test_name: "t".to_string(),
            server: "fs".to_string(),
            tool: "echo".to_string(),
            vars: BTreeMap::new(),
        }
    }

    #[tokio::test]
    async fn request_transform_replaces_arguments() {
        // jq-style mutation done in pure shell: read stdin, emit a new value.
        let cmd = sh(r#"cat >/dev/null; echo '{"name":"echo","arguments":{"q":"hi"}}'"#);
        let input = json!({"name": "echo", "arguments": {"q": "raw"}});
        let out = run_transform(TransformPhase::Request, &cmd, &input, &ctx())
            .await
            .expect("transform succeeds");
        assert_eq!(out, json!({"name": "echo", "arguments": {"q": "hi"}}));
    }

    #[tokio::test]
    async fn response_transform_mutates_result() {
        // Echo a normalized envelope back, dropping a volatile id.
        let cmd = sh(r#"cat >/dev/null; echo '{"result":{"ok":true}}'"#);
        let input = json!({"result": {"ok": true, "id": "abc-123"}});
        let out = run_transform(TransformPhase::Response, &cmd, &input, &ctx())
            .await
            .expect("transform succeeds");
        assert_eq!(out, json!({"result": {"ok": true}}));
    }

    #[tokio::test]
    async fn context_env_vars_are_visible_to_the_script() {
        // The script reflects the phase + test name back as the new value.
        let cmd = sh(
            r#"cat >/dev/null; printf '{"phase":"%s","test":"%s"}' "$MCPTEST_TRANSFORM_PHASE" "$MCPTEST_TEST_NAME""#,
        );
        let out = run_transform(TransformPhase::Request, &cmd, &json!({}), &ctx())
            .await
            .expect("transform succeeds");
        assert_eq!(out, json!({"phase": "request", "test": "t"}));
    }

    #[tokio::test]
    async fn resolved_vars_are_exported_with_prefix() {
        let mut c = ctx();
        c.vars.insert("REGION".to_string(), "us-west".to_string());
        let cmd = sh(r#"cat >/dev/null; printf '{"region":"%s"}' "$MCPTEST_VAR_REGION""#);
        let out = run_transform(TransformPhase::Request, &cmd, &json!({}), &c)
            .await
            .expect("transform succeeds");
        assert_eq!(out, json!({"region": "us-west"}));
    }

    #[tokio::test]
    async fn non_zero_exit_fails_with_clear_error() {
        let cmd = sh(r#"echo 'boom' >&2; exit 3"#);
        let err = run_transform(TransformPhase::Response, &cmd, &json!({}), &ctx())
            .await
            .expect_err("non-zero exit must fail");
        match err {
            TransformError::NonZeroExit {
                phase,
                code,
                stderr,
                ..
            } => {
                assert_eq!(phase, "response");
                assert_eq!(code, "3");
                assert!(stderr.contains("boom"), "stderr captured: {stderr}");
            }
            other => panic!("expected NonZeroExit, got {other:?}"),
        }
    }

    #[tokio::test]
    async fn unparseable_stdout_fails_clearly() {
        let cmd = sh(r#"cat >/dev/null; echo 'not json'"#);
        let err = run_transform(TransformPhase::Request, &cmd, &json!({}), &ctx())
            .await
            .expect_err("bad output must fail");
        assert!(
            matches!(
                err,
                TransformError::BadOutput {
                    phase: "request",
                    ..
                }
            ),
            "expected BadOutput, got {err:?}"
        );
    }

    #[tokio::test]
    async fn missing_binary_is_a_spawn_error() {
        let cmd = TransformCommand {
            program: "this-binary-does-not-exist-mcptest".to_string(),
            args: Vec::new(),
        };
        let err = run_transform(TransformPhase::Request, &cmd, &json!({}), &ctx())
            .await
            .expect_err("missing binary must fail");
        assert!(
            matches!(
                err,
                TransformError::Spawn {
                    phase: "request",
                    ..
                }
            ),
            "expected Spawn, got {err:?}"
        );
    }

    #[test]
    fn phase_labels_are_lowercase() {
        assert_eq!(TransformPhase::Request.label(), "request");
        assert_eq!(TransformPhase::Response.label(), "response");
        assert_eq!(TransformPhase::Assertion.label(), "assertion");
    }

    #[test]
    fn command_line_joins_program_and_args() {
        let cmd = TransformCommand {
            program: "node".to_string(),
            args: vec!["./wrap.js".to_string()],
        };
        assert_eq!(command_line(&cmd), "node ./wrap.js");
        let bare = TransformCommand {
            program: "jq".to_string(),
            args: Vec::new(),
        };
        assert_eq!(command_line(&bare), "jq");
    }
}

// The inline jq dispatch runs in-process, so these tests need no subprocess
// and run on every platform (unlike the unix-gated subprocess tests above).
#[cfg(test)]
mod jq_step_tests {
    use super::*;
    use serde_json::json;

    fn ctx() -> TransformContext {
        TransformContext::default()
    }

    #[tokio::test]
    async fn request_jq_step_mutates_the_outbound_args() {
        // Lowercase the tool-call argument before it is sent.
        let step = TransformStep::Jq(".arguments.q |= ascii_downcase".to_string());
        let input = json!({"name": "echo", "arguments": {"q": "RAW"}});
        let out = run_transform_step(TransformPhase::Request, &step, &input, &ctx())
            .await
            .expect("request jq succeeds");
        assert_eq!(out, json!({"name": "echo", "arguments": {"q": "raw"}}));
    }

    #[tokio::test]
    async fn response_jq_step_unwraps_the_envelope() {
        let step = TransformStep::Jq(".result.content[0].text".to_string());
        let input = json!({"result": {"content": [{"text": "hi"}]}});
        let out = run_transform_step(TransformPhase::Response, &step, &input, &ctx())
            .await
            .expect("response jq succeeds");
        assert_eq!(out, json!("hi"));
    }

    #[tokio::test]
    async fn jq_step_zero_output_fails_with_the_phase_label() {
        let step = TransformStep::Jq("empty".to_string());
        let err = run_transform_step(TransformPhase::Response, &step, &json!({}), &ctx())
            .await
            .expect_err("zero outputs fails");
        match err {
            TransformError::Jq { phase, source } => {
                assert_eq!(phase, "response");
                assert!(
                    source.to_string().contains("expected exactly one"),
                    "{source}"
                );
            }
            other => panic!("expected Jq error, got {other:?}"),
        }
    }
}
