//! YAML rendering for proposed tests.
//!
//! Why hand-rendered: the block must be paste-ready under a suite's `tools:`
//! key (two-space list indentation, deterministic key order, comments for
//! the volatile leaves and the budget formula). It emits the long form of
//! `expect:` (an `assertions:` list plus a real `max_duration_ms` field)
//! because the engine evaluates both halves of that form since WOR-1280. A
//! generic YAML serializer can produce none of that, so this module renders
//! the small value vocabulary proposals need (JSON-derived maps, arrays,
//! scalars) directly.

use serde_json::Value;

use super::ProposalInput;

/// One proposed assertion before rendering: an executor-syntax target, a
/// single-key matcher value, and the human message the failure will carry.
#[derive(Debug, Clone)]
pub(crate) struct ProposedAssertion {
    /// Assertion target in executor path syntax.
    pub target: String,
    /// Matcher object (exactly one key, per the schema's matcher shape).
    pub matcher: Value,
    /// Failure message explaining what the assertion protects.
    pub message: String,
}

/// Render the complete test entry. The block starts at the two-space list
/// indentation so it pastes directly under `tools:`, lists the assertions
/// in proposal order under the long-form `expect.assertions:`, names the
/// excluded volatile leaves in a comment, and carries the latency budget as
/// a real `max_duration_ms` field, evaluated by the engine since WOR-1280,
/// with the derivation formula in a comment above it.
pub(crate) fn render_test(
    input: &ProposalInput,
    assertions: &[ProposedAssertion],
    excluded_volatile: &[String],
    max_duration_ms: u64,
) -> String {
    let mut out = String::new();
    out.push_str(&format!(
        "  - name: {}\n",
        yaml_string(&format!("{}: proposed assertions", input.tool))
    ));
    out.push_str(&format!("    server: {}\n", yaml_string(&input.server)));
    out.push_str(&format!("    tool: {}\n", yaml_string(&input.tool)));
    out.push_str(&render_args(&input.args));
    if !excluded_volatile.is_empty() {
        out.push_str(&format!(
            "    # volatile across observed calls, not asserted: {}\n",
            excluded_volatile.join(", ")
        ));
    }
    out.push_str("    expect:\n");
    // An empty list renders inline so the YAML stays a list (a bare
    // `assertions:` key would parse as null and fail the schema).
    if assertions.is_empty() {
        out.push_str("      assertions: []\n");
    } else {
        out.push_str("      assertions:\n");
    }
    for assertion in assertions {
        out.push_str(&format!(
            "        - target: {}\n",
            yaml_string(&assertion.target)
        ));
        out.push_str("          matcher:\n");
        out.push_str(&render_node(&assertion.matcher, 12));
        out.push_str(&format!(
            "          message: {}\n",
            yaml_string(&assertion.message)
        ));
    }
    out.push_str(
        "      # latency budget: 2x the slowest observed call, rounded up to the \
         nearest 50 ms, floor 100 ms\n",
    );
    out.push_str(&format!("      max_duration_ms: {max_duration_ms}\n"));
    out
}

/// Render the `args:` line or block. An empty object stays inline (`args: {}`)
/// so the common no-argument tool reads as one line.
fn render_args(args: &Value) -> String {
    match args.as_object() {
        Some(map) if map.is_empty() => "    args: {}\n".to_string(),
        Some(_) => format!("    args:\n{}", render_node(args, 6)),
        // tools/call arguments are an object on the wire; a non-object here
        // is a caller bug, but rendering it inline keeps this total.
        None => format!("    args: {}\n", inline(args)),
    }
}

/// Render one JSON value as block-form YAML lines at the given indentation.
/// Maps and arrays of containers go block-form; scalars and scalar arrays
/// inline. Key order is serde_json's map order (sorted), so output is
/// deterministic.
fn render_node(value: &Value, indent: usize) -> String {
    let pad = " ".repeat(indent);
    let mut out = String::new();
    match value {
        Value::Object(map) => {
            for (key, child) in map {
                if is_inline(child) {
                    out.push_str(&format!("{pad}{}: {}\n", render_key(key), inline(child)));
                } else {
                    out.push_str(&format!("{pad}{}:\n", render_key(key)));
                    out.push_str(&render_node(child, indent + 2));
                }
            }
        }
        Value::Array(items) => {
            for item in items {
                if is_inline(item) {
                    out.push_str(&format!("{pad}- {}\n", inline(item)));
                } else {
                    out.push_str(&format!("{pad}-\n"));
                    out.push_str(&render_node(item, indent + 2));
                }
            }
        }
        scalar => out.push_str(&format!("{pad}{}\n", inline(scalar))),
    }
    out
}

/// Whether a value renders on one line: scalars, empty containers, and
/// arrays of scalars (the `required: ["a", "b"]` shape).
fn is_inline(value: &Value) -> bool {
    match value {
        Value::Object(map) => map.is_empty(),
        Value::Array(items) => items.iter().all(|i| !i.is_object() && !i.is_array()),
        _ => true,
    }
}

/// One-line rendering for the [`is_inline`] shapes. Strings are double-quoted
/// with YAML escapes; everything else uses its JSON form, which YAML accepts
/// as flow style.
fn inline(value: &Value) -> String {
    match value {
        Value::String(s) => yaml_string(s),
        Value::Array(items) => {
            let parts: Vec<String> = items.iter().map(inline).collect();
            format!("[{}]", parts.join(", "))
        }
        other => other.to_string(),
    }
}

/// Map keys render bare when they are plain identifiers and quoted otherwise,
/// so generated YAML stays readable without ever being ambiguous.
fn render_key(key: &str) -> String {
    let plain = !key.is_empty()
        && key
            .chars()
            .all(|c| c.is_ascii_alphanumeric() || c == '_' || c == '-');
    if plain {
        key.to_string()
    } else {
        yaml_string(key)
    }
}

/// Double-quoted YAML scalar with the escapes a double-quoted scalar needs.
/// Control characters use their escape forms so an observed value containing
/// a newline still renders on one line.
fn yaml_string(s: &str) -> String {
    let mut out = String::with_capacity(s.len() + 2);
    out.push('"');
    for c in s.chars() {
        match c {
            '\\' => out.push_str("\\\\"),
            '"' => out.push_str("\\\""),
            '\n' => out.push_str("\\n"),
            '\r' => out.push_str("\\r"),
            '\t' => out.push_str("\\t"),
            other => out.push(other),
        }
    }
    out.push('"');
    out
}

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

    fn parse_block(yaml: &str) -> Value {
        let doc = format!("tools:\n{yaml}");
        let parsed: serde_yaml::Value = serde_yaml::from_str(&doc).expect("block parses");
        serde_json::to_value(parsed).expect("yaml to json")["tools"][0].clone()
    }

    fn sample_input(args: Value) -> ProposalInput {
        ProposalInput {
            server: "target".to_string(),
            tool: "echo".to_string(),
            args,
        }
    }

    #[test]
    fn rendered_block_round_trips_through_a_yaml_parser() {
        let assertions = vec![ProposedAssertion {
            target: "result.isError".to_string(),
            matcher: json!({ "not": { "exact": true } }),
            message: "must not error".to_string(),
        }];
        let yaml = render_test(&sample_input(json!({})), &assertions, &[], 100);
        let test = parse_block(&yaml);
        assert_eq!(test["name"], "echo: proposed assertions");
        assert_eq!(test["server"], "target");
        assert_eq!(test["tool"], "echo");
        assert_eq!(test["args"], json!({}));
        assert_eq!(
            test["expect"]["assertions"][0]["matcher"]["not"]["exact"],
            true
        );
        assert_eq!(test["expect"]["max_duration_ms"], 100);
    }

    #[test]
    fn nested_args_render_as_block_yaml() {
        let args = json!({ "filter": { "status": "open" }, "tags": ["a", "b"] });
        let yaml = render_test(&sample_input(args.clone()), &[], &[], 100);
        let test = parse_block(&yaml);
        assert_eq!(test["args"], args);
    }

    #[test]
    fn schema_matcher_renders_inline_scalar_arrays_for_required() {
        let assertions = vec![ProposedAssertion {
            target: "result.content".to_string(),
            matcher: json!({
                "schema": {
                    "type": "array",
                    "items": {
                        "type": "object",
                        "required": ["text", "type"],
                        "properties": { "text": { "type": "string" } }
                    }
                }
            }),
            message: "shape".to_string(),
        }];
        let yaml = render_test(&sample_input(json!({})), &assertions, &[], 100);
        assert!(yaml.contains("required: [\"text\", \"type\"]"), "{yaml}");
        let test = parse_block(&yaml);
        assert_eq!(
            test["expect"]["assertions"][0]["matcher"]["schema"]["items"]["required"],
            json!(["text", "type"])
        );
    }

    #[test]
    fn volatile_comment_and_real_latency_budget_field_are_rendered() {
        let yaml = render_test(
            &sample_input(json!({})),
            &[],
            &["result.serial".to_string()],
            250,
        );
        assert!(yaml.contains("# volatile across observed calls, not asserted: result.serial"));
        // The budget is a real, evaluated field now (WOR-1280), with the
        // derivation formula kept as a comment above it.
        assert!(yaml.contains("# latency budget: 2x the slowest observed call"));
        let test = parse_block(&yaml);
        assert_eq!(test["expect"]["max_duration_ms"], 250);
        assert_eq!(test["expect"]["assertions"], json!([]));
    }

    #[test]
    fn strings_with_escapes_survive_the_round_trip() {
        let assertions = vec![ProposedAssertion {
            target: "result.content[0].text".to_string(),
            matcher: json!({ "exact": "line one\nsay \"hi\"\tend" }),
            message: "escapes".to_string(),
        }];
        let yaml = render_test(&sample_input(json!({})), &assertions, &[], 100);
        let test = parse_block(&yaml);
        assert_eq!(
            test["expect"]["assertions"][0]["matcher"]["exact"],
            "line one\nsay \"hi\"\tend"
        );
    }
}
