//! Hand-rolled YAML emitter for scaffold output.
//!
//! We render YAML by hand (not via `serde_yaml::to_string`) because:
//!
//! 1. `# TODO:` comments must interleave precisely above the node they
//!    annotate. `serde_yaml` strips comments entirely.
//! 2. TODO line numbers are part of the public `--format json`
//!    contract; emitting by hand is the only way to build the line
//!    index without re-parsing our own output.
//! 3. Every map/list iteration uses pre-sorted input so the output is
//!    byte-identical across runs — the ticket's determinism acceptance
//!    criterion.
//!
//! The rendered YAML is always validated by [`super::generate`]'s
//! round-trip through `parser::parse_str`, so emitter bugs surface
//! as hard errors, not silent malformed files.

use super::{BodyShape, ScaffoldRequest, Todo, TodoCategory};

/// Render a [`ScaffoldRequest`] to YAML, interleaving the TODO list
/// as `# TODO:` comments above the annotated nodes. Mutates `todos`
/// in place to backfill final line numbers.
pub fn render(request: &ScaffoldRequest, todos: &mut Vec<Todo>) -> String {
    let mut out = String::new();
    let mut pending: Vec<Todo> = Vec::new();

    // Preamble — stable banner + an env TODO that always applies.
    // The `$schema` comment lets editors with inline schema support
    // (VS Code, JetBrains) pick up validation the moment the file
    // lands on disk.
    line(
        &mut out,
        "# yaml-language-server: $schema=https://raw.githubusercontent.com/NazarKalytiuk/tarn/main/schemas/v1/testfile.json",
    );
    line(&mut out, "# Generated by tarn scaffold (NAZ-411).");
    line(
        &mut out,
        "# Scaffold-quality output: intentionally incomplete — review every",
    );
    line(
        &mut out,
        "# line and every TODO before running against a real API.",
    );

    // Top-level name.
    line(
        &mut out,
        &format!("name: {}", yaml_scalar(&request.file_name)),
    );

    // Always-on env TODO: scaffolds use `{{ env.base_url }}` and we
    // can't know if the user has it wired up yet.
    pending.push(Todo::new(
        TodoCategory::Env,
        "confirm env.base_url is set in tarn.env.yaml (or pass --var base_url=...)",
    ));

    line(&mut out, "steps:");

    // Step name — flush pending env TODO above it.
    flush_todos(&mut out, &mut pending, todos, "");
    line(
        &mut out,
        &format!("  - name: {}", yaml_scalar(&request.step_name)),
    );

    // Request block.
    pending.push(Todo::new(
        TodoCategory::Method,
        format!(
            "confirm method is `{}` (generated from the provided input)",
            request.method
        ),
    ));
    flush_todos(&mut out, &mut pending, todos, "    ");
    line(&mut out, "    request:");
    line(&mut out, &format!("      method: {}", request.method));

    // URL. If the URL contains path-param templates we emit a TODO per
    // parameter above the url line — sorted for determinism.
    let mut params = request.path_params.clone();
    params.sort();
    params.dedup();
    for param in &params {
        pending.push(Todo::new(
            TodoCategory::PathParam,
            format!(
                "supply a real value for path parameter `{}` via `--var {}=...` or inline env",
                param, param
            ),
        ));
    }
    if request.url.trim().is_empty() {
        pending.push(Todo::new(TodoCategory::Url, "set the request URL"));
    }
    flush_todos(&mut out, &mut pending, todos, "      ");
    line(
        &mut out,
        &format!("      url: {}", yaml_scalar(&request.url)),
    );

    // Headers.
    if !request.headers.is_empty() {
        pending.push(Todo::new(
            TodoCategory::Headers,
            "confirm headers — move secret values (tokens, keys) to env",
        ));
        flush_todos(&mut out, &mut pending, todos, "      ");
        line(&mut out, "      headers:");
        // BTreeMap already sorted; emit in-order so output is stable.
        for (name, value) in &request.headers {
            // Sensitive headers get their own TODO right above them
            // so agents can filter on `category:"auth"` and land on
            // the exact line.
            if is_sensitive(name, &request.sensitive_headers) {
                pending.push(Todo::new(
                    TodoCategory::Auth,
                    format!(
                        "`{}` looks sensitive — replace the literal with `{{{{ env.<VAR> }}}}`",
                        name
                    ),
                ));
                flush_todos(&mut out, &mut pending, todos, "        ");
            }
            line(
                &mut out,
                &format!("        {}: {}", yaml_key(name), yaml_scalar(value)),
            );
        }
    }

    // Body.
    if let Some(body) = &request.body {
        match body {
            BodyShape::Json(value) => {
                pending.push(Todo::new(
                    TodoCategory::Body,
                    "fill in or tighten required body fields",
                ));
                flush_todos(&mut out, &mut pending, todos, "      ");
                line(&mut out, "      body:");
                render_json_body(&mut out, value, "        ");
            }
            BodyShape::Raw(text) => {
                pending.push(Todo::new(
                    TodoCategory::Body,
                    "body was not JSON — emitted as a raw string; confirm Content-Type and shape",
                ));
                flush_todos(&mut out, &mut pending, todos, "      ");
                line(
                    &mut out,
                    &format!("      body: {}", yaml_quoted_string(text)),
                );
            }
        }
    }

    // Assertions.
    pending.push(Todo::new(
        TodoCategory::Assertion,
        "tighten assertions — placeholder asserts only status range + body shape",
    ));
    flush_todos(&mut out, &mut pending, todos, "    ");
    line(&mut out, "    assert:");
    let status = request.status_assertion.as_deref().unwrap_or("2xx");
    line(&mut out, &format!("      status: {}", yaml_scalar(status)));
    line(&mut out, "      body:");
    // `$: is_object` is always safe for JSON APIs; for non-JSON it will
    // fail loudly and the user will know to remove it. That's desired
    // scaffold behavior: the assertion is a TODO with teeth.
    line(&mut out, "        \"$\":");
    line(&mut out, "          type: object");

    // Captures.
    if !request.captures.is_empty() {
        pending.push(Todo::new(
            TodoCategory::Capture,
            "verify captured JSONPaths resolve against the real response",
        ));
        flush_todos(&mut out, &mut pending, todos, "    ");
        line(&mut out, "    capture:");
        for (name, path) in &request.captures {
            line(
                &mut out,
                &format!("      {}: {}", yaml_key(name), yaml_scalar(path)),
            );
        }
    }

    out
}

fn flush_todos(out: &mut String, pending: &mut Vec<Todo>, sink: &mut Vec<Todo>, indent: &str) {
    // `drain(..)` in insertion order so the rendered order matches the
    // order mode-specific code pushed the TODOs in. That order is the
    // deterministic contract.
    for mut todo in pending.drain(..) {
        line(out, &format!("{indent}# TODO: {}", todo.message));
        // `matches('\n')` counts newlines up to and including the one
        // the call above appended, giving the 1-based line number for
        // the TODO comment we just wrote.
        let line_no = out.matches('\n').count();
        todo.line = Some(line_no);
        sink.push(todo);
    }
}

fn is_sensitive(name: &str, flagged: &[String]) -> bool {
    let lower = name.to_ascii_lowercase();
    if matches!(
        lower.as_str(),
        "authorization" | "cookie" | "x-api-key" | "x-auth-token"
    ) {
        return true;
    }
    flagged.iter().any(|h| h.eq_ignore_ascii_case(name))
}

/// Render a JSON body as a YAML mapping/sequence rooted at `indent`.
/// We do this by hand (instead of round-tripping through serde_yaml)
/// so nested formatting stays aligned with the rest of the file and
/// key ordering matches the source `serde_json::Value` (which is
/// insertion-ordered via `serde_json::Map`).
fn render_json_body(out: &mut String, value: &serde_json::Value, indent: &str) {
    match value {
        serde_json::Value::Object(map) => {
            if map.is_empty() {
                // Empty object — emit `{}` so the caller's preceding
                // `body:` line has a well-formed value.
                line(out, &format!("{indent}{{}}"));
                return;
            }
            for (k, v) in map {
                render_json_pair(out, k, v, indent);
            }
        }
        serde_json::Value::Array(arr) => {
            if arr.is_empty() {
                line(out, &format!("{indent}[]"));
                return;
            }
            for item in arr {
                match item {
                    serde_json::Value::Object(_) | serde_json::Value::Array(_) => {
                        line(out, &format!("{indent}-"));
                        let next = format!("{indent}  ");
                        render_json_body(out, item, &next);
                    }
                    _ => {
                        line(out, &format!("{indent}- {}", yaml_scalar_for_json(item)));
                    }
                }
            }
        }
        _ => {
            line(out, &format!("{indent}{}", yaml_scalar_for_json(value)));
        }
    }
}

fn render_json_pair(out: &mut String, key: &str, value: &serde_json::Value, indent: &str) {
    match value {
        serde_json::Value::Object(map) if !map.is_empty() => {
            line(out, &format!("{indent}{}:", yaml_key(key)));
            let next = format!("{indent}  ");
            for (k, v) in map {
                render_json_pair(out, k, v, &next);
            }
        }
        serde_json::Value::Array(arr) if !arr.is_empty() => {
            line(out, &format!("{indent}{}:", yaml_key(key)));
            let next = format!("{indent}  ");
            render_json_body(out, value, &next);
            let _ = arr; // bound by pattern guard; explicit drop keeps clippy quiet
        }
        serde_json::Value::Object(_) => {
            line(out, &format!("{indent}{}: {{}}", yaml_key(key)));
        }
        serde_json::Value::Array(_) => {
            line(out, &format!("{indent}{}: []", yaml_key(key)));
        }
        _ => {
            line(
                out,
                &format!("{indent}{}: {}", yaml_key(key), yaml_scalar_for_json(value)),
            );
        }
    }
}

fn yaml_scalar_for_json(value: &serde_json::Value) -> String {
    match value {
        serde_json::Value::Null => "null".to_string(),
        serde_json::Value::Bool(b) => b.to_string(),
        serde_json::Value::Number(n) => n.to_string(),
        serde_json::Value::String(s) => yaml_scalar(s),
        serde_json::Value::Array(_) | serde_json::Value::Object(_) => {
            // Fallback: compact JSON so the parser can still read it
            // if a nested value somehow slips past the structured
            // renderer. In practice `render_json_body` handles these
            // before we reach here.
            value.to_string()
        }
    }
}

/// Quote a value when YAML would otherwise misparse it (leading
/// special char, contains `:` / `#` / `{{`, reserved words, etc.) and
/// return it bare when a naked scalar is safe. Templated values like
/// `{{ env.x }}` are always quoted so the renderer never leaks a
/// stray flow-collection `{` into the parser.
pub(crate) fn yaml_scalar(value: &str) -> String {
    if value.is_empty() {
        return "\"\"".to_string();
    }
    let needs_quote = value.contains("{{")
        || value.contains(": ")
        || value.contains(" #")
        || value.contains('\n')
        || value.contains('"')
        || value.contains('\'')
        || value.contains('\t')
        || value.starts_with(|c: char| "!&*?|>@`%#,[]{}\"'".contains(c))
        || value.ends_with(':')
        || matches!(
            value.to_ascii_lowercase().as_str(),
            "true" | "false" | "null" | "yes" | "no" | "on" | "off" | "~"
        )
        || value.parse::<f64>().is_ok();
    if needs_quote {
        yaml_quoted_string(value)
    } else {
        value.to_string()
    }
}

fn yaml_quoted_string(value: &str) -> String {
    // Double-quoted YAML: escape `\` and `"` and control chars. Good
    // enough for the ASCII subset scaffolds operate on; UTF-8 passes
    // through unchanged.
    let mut out = String::with_capacity(value.len() + 2);
    out.push('"');
    for ch in value.chars() {
        match ch {
            '\\' => out.push_str("\\\\"),
            '"' => out.push_str("\\\""),
            '\n' => out.push_str("\\n"),
            '\r' => out.push_str("\\r"),
            '\t' => out.push_str("\\t"),
            c if (c as u32) < 0x20 => {
                use std::fmt::Write as _;
                let _ = write!(out, "\\x{:02x}", c as u32);
            }
            c => out.push(c),
        }
    }
    out.push('"');
    out
}

/// Emit a mapping key. Mostly the same rules as a scalar, but
/// additionally quote keys that contain `:` or start with `$` / `@`
/// / `[` so JSONPath-style capture names like `$.id` render
/// correctly.
pub(crate) fn yaml_key(key: &str) -> String {
    let needs_quote = key.is_empty()
        || key.contains(':')
        || key.contains(' ')
        || key.contains('#')
        || key.starts_with('$')
        || key.starts_with('@')
        || key.starts_with('[')
        || key.starts_with('"')
        || key.starts_with('\'');
    if needs_quote {
        yaml_quoted_string(key)
    } else {
        key.to_string()
    }
}

// `writeln!` into `String` technically returns a `Result<(), fmt::Error>`
// because of the `std::fmt::Write` trait shape, even though it cannot
// fail for a `String` sink. Rather than sprinkle `let _ =` at every
// call site (clippy's `-D warnings` makes the `must_use` warnings
// fatal), we funnel every emitter write through a tiny panic-free
// helper. Clearer at the call site, and one place to audit.
fn line(out: &mut String, s: &str) {
    out.push_str(s);
    out.push('\n');
}

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

    fn minimal_request() -> ScaffoldRequest {
        let mut r = ScaffoldRequest::new("smoke", "GET /health");
        r.method = "GET".into();
        r.url = "{{ env.base_url }}/health".into();
        r
    }

    #[test]
    fn render_produces_valid_yaml_with_status_and_body_assertion() {
        let mut todos = Vec::new();
        let yaml = render(&minimal_request(), &mut todos);
        assert!(yaml.contains("name: smoke"));
        assert!(yaml.contains("method: GET"));
        assert!(yaml.contains("url: \"{{ env.base_url }}/health\""));
        assert!(yaml.contains("status: 2xx"));
        assert!(yaml.contains("type: object"));
    }

    #[test]
    fn todos_are_anchored_by_line_number() {
        let mut todos = Vec::new();
        let yaml = render(&minimal_request(), &mut todos);
        assert!(!todos.is_empty());
        let lines: Vec<&str> = yaml.lines().collect();
        for todo in &todos {
            let line = todo.line.expect("line populated");
            let text = lines.get(line - 1).copied().unwrap_or("");
            assert!(
                text.trim_start().starts_with("# TODO:"),
                "expected todo line {} to be a TODO, got {:?}",
                line,
                text
            );
        }
    }

    #[test]
    fn sensitive_header_gets_auth_todo() {
        let mut r = minimal_request();
        r.headers
            .insert("Authorization".into(), "Bearer abc".into());
        r.sensitive_headers.push("Authorization".into());
        let mut todos = Vec::new();
        let yaml = render(&r, &mut todos);
        assert!(todos.iter().any(|t| t.category == TodoCategory::Auth));
        // Line-anchor on the Authorization row.
        let auth_line = yaml
            .lines()
            .position(|l| l.contains("Authorization:"))
            .expect("authorization header rendered");
        // The preceding line must be a TODO.
        let prev = yaml.lines().nth(auth_line - 1).unwrap();
        assert!(prev.trim_start().starts_with("# TODO:"));
    }

    #[test]
    fn json_body_is_rendered_as_yaml_mapping() {
        let mut r = minimal_request();
        let mut body = serde_json::Map::new();
        body.insert("email".into(), serde_json::Value::Null);
        body.insert(
            "name".into(),
            serde_json::Value::String("{{ $email }}".into()),
        );
        r.body = Some(BodyShape::Json(serde_json::Value::Object(body)));
        let mut todos = Vec::new();
        let yaml = render(&r, &mut todos);
        assert!(yaml.contains("body:\n"));
        assert!(yaml.contains("email: null"));
        assert!(yaml.contains("name: \"{{ $email }}\""));
    }

    #[test]
    fn captures_are_sorted_alphabetically() {
        let mut r = minimal_request();
        let mut caps = BTreeMap::new();
        caps.insert("uuid".into(), "$.uuid".into());
        caps.insert("id".into(), "$.id".into());
        r.captures = caps;
        let mut todos = Vec::new();
        let yaml = render(&r, &mut todos);
        let id_pos = yaml.find("id: $.id").unwrap();
        let uuid_pos = yaml.find("uuid: $.uuid").unwrap();
        assert!(id_pos < uuid_pos, "captures must render sorted");
    }

    #[test]
    fn yaml_scalar_quotes_templated_values() {
        assert_eq!(yaml_scalar("{{ env.x }}"), "\"{{ env.x }}\"");
    }

    #[test]
    fn yaml_scalar_does_not_quote_plain_path() {
        assert_eq!(yaml_scalar("/health"), "/health");
    }

    #[test]
    fn yaml_scalar_quotes_reserved_words_and_numbers() {
        // `true`/`false`/`null` as bare strings would otherwise
        // deserialize to the wrong Rust type; and a bare number as a
        // YAML value would be typed as int/float, corrupting
        // string-typed request fields.
        assert_eq!(yaml_scalar("true"), "\"true\"");
        assert_eq!(yaml_scalar("42"), "\"42\"");
    }

    #[test]
    fn yaml_key_quotes_dollar_prefixed_names() {
        assert_eq!(yaml_key("$.id"), "\"$.id\"");
        assert_eq!(yaml_key("id"), "id");
    }
}
