//! In-process jq transform engine.
//!
//! The request and response transforms accept an inline jq program as an
//! alternative to a subprocess command (see [`crate::transform`]). The
//! inline form runs through this module's pure-Rust jaq engine instead of
//! spawning a child, so the common reshape/rename/unwrap case (`.result.content`,
//! `.arguments.q |= ascii_downcase`) needs no Node, Python, or `jq` on PATH.
//!
//! The contract matches the subprocess form: one JSON value in, one JSON
//! value out. A program that fails to compile is a load-time error
//! [`compile`] surfaces so a typo points at the test. A program that errors
//! at run time, or yields zero or more than one output, is a [`JqError`] the
//! executor turns into a failed transform with a clear message.

use jaq_core::load::{Arena, File, Loader};
use jaq_core::{data, Compiler, Ctx, Filter, Vars};
use jaq_json::Val;
use serde_json::Value;
use thiserror::Error;

/// Typed failure from compiling or running an inline jq transform.
///
/// Kept separate from the subprocess [`crate::transform::TransformError`]
/// because the failure modes differ: there is no spawn, exit code, or
/// stderr, only a compile error (caught at load) and the run-time shapes
/// (engine error, wrong number of outputs). The executor renders the
/// message; the phase framing is added by the transform layer.
#[derive(Debug, Error)]
#[non_exhaustive]
pub enum JqError {
    /// The jq program did not parse or compile. Raised at load time so the
    /// message points the author at the broken program before any run.
    #[error("jq program `{program}` failed to compile: {detail}")]
    Compile {
        /// The offending program, echoed so the author can spot the typo.
        program: String,
        /// One-line summary of the parse or compile failure.
        detail: String,
    },
    /// The jq program errored while running against the input value.
    #[error("jq program `{program}` failed at run time: {detail}")]
    Run {
        /// The program that errored.
        program: String,
        /// The engine's error rendered for the operator.
        detail: String,
    },
    /// The jq program produced a number of outputs other than exactly one.
    /// A transform must replace the value with a single value, so zero
    /// outputs (e.g. `empty` or a failed `select`) or several (e.g. `.[]`)
    /// is a contract violation, not a silent no-op.
    #[error("jq program `{program}` produced {count} outputs (expected exactly one)")]
    OutputCount {
        /// The program with the wrong output cardinality.
        program: String,
        /// How many values the program emitted.
        count: usize,
    },
}

/// Validate that `program` parses and compiles, discarding the result.
///
/// Called at load time so a jq typo fails the suite parse with a clear
/// message rather than tunneling through to a run-time surprise. The
/// compiled filter is not cached on the typed model (it is not `PartialEq`
/// and the model round-trips through plain data), so [`run`] recompiles;
/// jq programs are tiny and a transform runs once per test, so the recompile
/// cost is irrelevant next to the network round trip it sits beside.
pub fn compile(program: &str) -> Result<(), JqError> {
    compile_filter(program).map(|_| ())
}

/// Run `program` against `input` and return its single output value.
///
/// Compiles the program (a compile failure is the same [`JqError::Compile`]
/// [`compile`] raises, so a program validated at load time will not fail
/// here), converts `input` into the engine's value type, runs the filter,
/// and requires exactly one output. Zero or several outputs, or a run-time
/// engine error, is a typed [`JqError`].
pub fn run(program: &str, input: &Value) -> Result<Value, JqError> {
    let filter = compile_filter(program)?;
    let val: Val = serde_json::from_value(input.clone()).map_err(|e| JqError::Run {
        program: program.to_string(),
        detail: e.to_string(),
    })?;

    let ctx = Ctx::<data::JustLut<Val>>::new(&filter.lut, Vars::new([]));
    let mut outputs = Vec::new();
    for result in filter.id.run((ctx, val)).map(jaq_core::unwrap_valr) {
        match result {
            Ok(v) => outputs.push(v),
            Err(e) => {
                return Err(JqError::Run {
                    program: program.to_string(),
                    detail: e.to_string(),
                })
            }
        }
    }

    match outputs.len() {
        1 => val_to_json(program, &outputs[0]),
        count => Err(JqError::OutputCount {
            program: program.to_string(),
            count,
        }),
    }
}

/// Parse and compile `program` into a runnable filter, loading the jq core,
/// standard, and JSON definitions so the full jq language is available.
fn compile_filter(program: &str) -> Result<Filter<data::JustLut<Val>>, JqError> {
    let defs = jaq_core::defs()
        .chain(jaq_std::defs())
        .chain(jaq_json::defs());
    let funs = jaq_core::funs()
        .chain(jaq_std::funs())
        .chain(jaq_json::funs());

    let loader = Loader::new(defs);
    let arena = Arena::default();
    let modules = loader
        .load(
            &arena,
            File {
                code: program,
                path: (),
            },
        )
        .map_err(|errs| JqError::Compile {
            program: program.to_string(),
            detail: format!("could not load program ({} error(s))", errs.len()),
        })?;

    Compiler::default()
        .with_funs(funs)
        .compile(modules)
        .map_err(|errs| JqError::Compile {
            program: program.to_string(),
            detail: format!("undefined symbol(s) ({} error(s))", errs.len()),
        })
}

/// Convert the engine's output value back into a [`serde_json::Value`].
///
/// `Val` only implements `Deserialize`, so the round-trip goes through its
/// canonical JSON `Display` form, which is valid JSON (quotes, escaping).
fn val_to_json(program: &str, val: &Val) -> Result<Value, JqError> {
    serde_json::from_str(&val.to_string()).map_err(|e| JqError::Run {
        program: program.to_string(),
        detail: e.to_string(),
    })
}

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

    #[test]
    fn unwraps_a_nested_value() {
        let out = run(
            ".result.content[0].text",
            &json!({"result": {"content": [{"text": "hi"}]}}),
        )
        .expect("program runs");
        assert_eq!(out, json!("hi"));
    }

    #[test]
    fn reshapes_an_object() {
        let out = run(
            "{name: .name, q: (.arguments.q | ascii_downcase)}",
            &json!({"name": "echo", "arguments": {"q": "RAW"}}),
        )
        .expect("program runs");
        assert_eq!(out, json!({"name": "echo", "q": "raw"}));
    }

    #[test]
    fn passes_the_identity_value_through() {
        let out = run(".", &json!({"x": 1})).expect("identity runs");
        assert_eq!(out, json!({"x": 1}));
    }

    #[test]
    fn a_syntax_error_is_a_compile_error() {
        let err = compile(".result.content[").expect_err("unterminated index fails");
        assert!(matches!(err, JqError::Compile { .. }), "{err:?}");
    }

    #[test]
    fn an_unknown_function_is_a_compile_error() {
        let err = compile("no_such_filter").expect_err("undefined filter fails");
        assert!(matches!(err, JqError::Compile { .. }), "{err:?}");
    }

    #[test]
    fn zero_outputs_is_an_output_count_error() {
        let err = run("empty", &json!({"x": 1})).expect_err("empty yields nothing");
        match err {
            JqError::OutputCount { count, .. } => assert_eq!(count, 0),
            other => panic!("expected OutputCount, got {other:?}"),
        }
    }

    #[test]
    fn multiple_outputs_is_an_output_count_error() {
        let err = run(".[]", &json!([1, 2, 3])).expect_err("stream yields three");
        match err {
            JqError::OutputCount { count, .. } => assert_eq!(count, 3),
            other => panic!("expected OutputCount, got {other:?}"),
        }
    }

    #[test]
    fn a_runtime_type_error_fails_clearly() {
        // Adding an object to a number is a run-time jq error.
        let err = run(". + 1", &json!({"a": 1})).expect_err("object plus number fails");
        assert!(matches!(err, JqError::Run { .. }), "{err:?}");
    }
}
