//! Comment-preserving YAML edit substrate.
//!
//! Wraps [`yaml_edit`] (rowan-backed lossless syntax tree) so callers that
//! mutate `.team/*.yaml` keep the user's comments, blank-line clusters, and
//! key ordering intact across save. The previous `serde_yaml::Value`
//! round-trip stripped all of that on every write — see the dogfood
//! `.team/projects/teamctl.yaml` regressions on the PR #54 + PR #55
//! cascades for the class this closes.
//!
//! ## Surface
//!
//! - [`load`] / [`save`] — IO with `anyhow` context.
//! - Re-exports of [`yaml_edit::Document`], [`yaml_edit::Mapping`],
//!   [`yaml_edit::Sequence`], and [`YamlPath`] so callers can drive the
//!   editor directly for round-trip + leaf updates.
//! - [`set_nested_mapping`] — bounded line-anchored helper for the one
//!   pattern yaml-edit 0.2.x can't do natively: insert or replace a
//!   properly-indented sub-block at a known parent path.
//!
//! ## Why the bounded helper
//!
//! `yaml_edit::Document::set_path` creates intermediate mappings via
//! `MappingBuilder::new().build_document().as_mapping()` and inserts them
//! with `mapping.set(key, &empty_mapping)`. The empty mapping has zero
//! base-indent, and the resulting nested entries serialize at column 0
//! instead of indenting under the parent (see `path::set_path_on_mapping`,
//! registry source line 401-435 of yaml-edit 0.2.1). Filed upstream for
//! a fix; until then [`set_nested_mapping`] handles the create-nested
//! pattern via line-anchored splice into the source string before the
//! Document re-parse. Substrate consumers never see the splice.
//!
//! Per-pm scope lock (msg 1969): the helper handles ONE pattern only
//! ("insert a properly-indented sub-block at a known parent path"). If a
//! future T-077-E verb needs a different yaml-edit-gap workaround,
//! escalate; do NOT generalize this helper.

use std::fs;
use std::path::Path;

use anyhow::{anyhow, Context, Result};

pub use yaml_edit::path::YamlPath;
pub use yaml_edit::{Document, Mapping, Sequence};

/// Read `path` and parse it as an editable YAML document.
///
/// The returned [`Document`] retains the source's comments, blank-line
/// clusters, and key ordering; mutations applied to it preserve everything
/// outside the touched range.
pub fn load(path: &Path) -> Result<Document> {
    let raw = fs::read_to_string(path).with_context(|| format!("read {}", path.display()))?;
    raw.parse::<Document>()
        .with_context(|| format!("parse {}", path.display()))
}

/// Serialize `doc` and write it to `path`, replacing any previous contents.
///
/// `Document`'s `Display` impl emits the underlying syntax tree verbatim,
/// so untouched regions round-trip byte-for-byte (modulo the upstream
/// pre-document-trivia limitation noted in the module docs).
pub fn save(doc: &Document, path: &Path) -> Result<()> {
    fs::write(path, doc.to_string()).with_context(|| format!("write {}", path.display()))?;
    Ok(())
}

/// Replace a top-level scalar value (T-265 PR-a).
///
/// Walks `source` line-by-line to find a top-level (indent = 0)
/// mapping entry whose key is `key`, then rewrites that single line
/// to `key: value` while leaving every other byte of the document
/// (comments, blank lines, key order, every nested block) untouched.
///
/// **This is a sibling helper to [`set_nested_mapping`], not a
/// generalization of it** — the per-pm scope-lock at the top of
/// this module covers the nested-mapping insert gap; the top-level
/// scalar gap is structurally distinct (no parent path, no
/// indent-arithmetic, no leaf-block detection) and warrants its
/// own focused helper rather than bolting onto the nested one.
/// Used by [`crate::compose::Compose::load`] to auto-rewrite the
/// legacy `version: 2` integer literal to the semver string
/// `"2.0.0"` per owner ratification (tg 2989 + 3440).
///
/// # Errors
/// Returns an error if no top-level mapping entry named `key` is
/// found in the document. Document re-parse failures (which
/// shouldn't happen for the bounded edit this performs) are
/// surfaced as errors.
pub fn set_top_level_scalar(source: &str, key: &str, value: &str) -> Result<String> {
    let trailing_newline = source.ends_with('\n');
    let mut out_lines: Vec<String> = Vec::new();
    let mut rewrote = false;
    for line in source.lines() {
        if !rewrote {
            // Top-level means indent = 0 (no leading whitespace).
            // Skip blank lines and comment lines (full-line `#`).
            let trimmed = line.trim_start();
            let indent = line.len() - trimmed.len();
            if indent == 0 && !trimmed.is_empty() && !trimmed.starts_with('#') {
                if let Some((found_key, _rest)) = trimmed.split_once(':') {
                    if found_key == key {
                        out_lines.push(format!("{key}: {value}"));
                        rewrote = true;
                        continue;
                    }
                }
            }
        }
        out_lines.push(line.to_string());
    }
    if !rewrote {
        return Err(anyhow!(
            "set_top_level_scalar: no top-level key `{key}` found in document"
        ));
    }
    let mut joined = out_lines.join("\n");
    if trailing_newline && !joined.ends_with('\n') {
        joined.push('\n');
    }
    // T-265 PR-a: deliberately return the spliced String directly
    // rather than round-tripping through `Document::parse` →
    // `Document::to_string` — the yaml_edit 0.2.x upstream has a
    // pre-document-trivia limitation that strips leading comments
    // on round-trip, and the splice already preserves them
    // line-perfectly. The caller writes this straight to disk.
    Ok(joined)
}

/// Insert or replace a nested mapping at the given parent path.
///
/// `parent_path` is a sequence of mapping keys descending from the root.
/// All but the last key must resolve to a mapping that the helper can
/// either find in the source or create alongside its existing siblings.
/// The last key (the leaf) is the mapping the caller wants to upsert.
/// `value_pairs` becomes the body of that leaf mapping.
///
/// Existing siblings of the leaf — and existing siblings of any
/// intermediate the helper has to create — are preserved with their
/// comments and ordering intact. If the leaf mapping already exists,
/// it is replaced wholesale by `value_pairs`. Other adapters under the
/// same parent (e.g. `discord:` next to `telegram:`) survive.
///
/// # Errors
/// Returns an error if `parent_path` is empty or if the first key is
/// not a top-level mapping in the document.
pub fn set_nested_mapping(
    doc: Document,
    parent_path: &[&str],
    value_pairs: &[(&str, &str)],
) -> Result<Document> {
    if parent_path.is_empty() {
        return Err(anyhow!("set_nested_mapping: parent_path must not be empty"));
    }
    let source = doc.to_string();
    let edited = splice_nested_mapping(&source, parent_path, value_pairs)?;
    edited
        .parse::<Document>()
        .with_context(|| "re-parse spliced YAML")
}

/// Line-anchored splice: walk `source` to find the deepest existing
/// ancestor of `path`, then insert (or replace) the missing tail and the
/// leaf body at the right indent.
fn splice_nested_mapping(
    source: &str,
    path: &[&str],
    value_pairs: &[(&str, &str)],
) -> Result<String> {
    let lines: Vec<&str> = source.lines().collect();
    let trailing_newline = source.ends_with('\n');

    // Walk the path top-down, tracking the (line, indent) of each existing
    // ancestor. Stop at the first missing component.
    let mut current_indent: usize = 0;
    let mut search_start: usize = 0;
    let mut search_end: usize = lines.len();
    let mut existing_depth: usize = 0;
    let mut leaf_replace_range: Option<(usize, usize, usize)> = None; // (start_line, end_line_exclusive, leaf_indent)

    for (depth, key) in path.iter().enumerate() {
        let parent_indent = current_indent;
        let child_indent_min = if depth == 0 { 0 } else { parent_indent + 1 };
        match find_key_in_block(&lines, search_start, search_end, key, child_indent_min) {
            Some((line_idx, key_indent)) => {
                existing_depth = depth + 1;
                current_indent = key_indent;
                let block_end = block_end_after(&lines, line_idx, key_indent);
                if depth == path.len() - 1 {
                    leaf_replace_range = Some((line_idx, block_end, key_indent));
                } else {
                    search_start = line_idx + 1;
                    search_end = block_end;
                }
            }
            None => break,
        }
    }

    if existing_depth == 0 {
        return Err(anyhow!(
            "set_nested_mapping: top-level key `{}` not found",
            path[0]
        ));
    }

    // Build the replacement / insertion block.
    let insert_indent = if existing_depth == path.len() {
        // Leaf already exists; reuse its indent.
        leaf_replace_range.expect("leaf existed").2
    } else {
        // First missing component lands one level deeper than its parent.
        current_indent + 2
    };

    let missing_tail = &path[existing_depth..];
    let mut block_lines: Vec<String> = Vec::new();
    let mut indent = insert_indent;
    for key in missing_tail {
        block_lines.push(format!("{:indent$}{key}:", "", indent = indent, key = key));
        indent += 2;
    }
    // Leaf-relative value indent. When the leaf was missing, the
    // `missing_tail` loop already emitted the leaf key and advanced
    // `indent` one level past it, so `indent` is the correct child
    // level. When the leaf already existed, `missing_tail` was empty —
    // `indent` still equals the leaf's own indent, so values must sit
    // one level deeper than the re-emitted leaf key. Without this they
    // serialize as siblings of the leaf, silently nulling it (#311:
    // `bot setup` re-running against an essentials team — whose
    // scaffold pre-wires `interfaces.telegram` — produced `telegram:`
    // with sibling `bot_token_env:`, so `agent.telegram()` parsed
    // `None` and the Telegram bridge never started).
    let value_indent = if existing_depth == path.len() {
        insert_indent + 2
    } else {
        indent
    };
    if existing_depth == path.len() {
        // We're replacing an existing leaf — emit the leaf key line too.
        block_lines.push(format!(
            "{:indent$}{key}:",
            "",
            indent = insert_indent,
            key = path[path.len() - 1]
        ));
    }
    for (k, v) in value_pairs {
        block_lines.push(format!(
            "{:indent$}{k}: {v}",
            "",
            indent = value_indent,
            k = k,
            v = v
        ));
    }

    // Splice: replace the leaf-block range or insert at the parent's
    // block end.
    let mut out_lines: Vec<String> = lines.iter().map(|s| s.to_string()).collect();
    if let Some((start, end, _)) = leaf_replace_range {
        out_lines.splice(start..end, block_lines);
    } else {
        // Insert at the end of the deepest-existing-ancestor's block.
        // search_end is that block's end (exclusive); insert there.
        out_lines.splice(search_end..search_end, block_lines);
    }

    let mut joined = out_lines.join("\n");
    if trailing_newline && !joined.ends_with('\n') {
        joined.push('\n');
    }
    Ok(joined)
}

/// Within `lines[start..end]`, find a mapping key whose indent is `>=
/// min_indent` AND is the direct-child indent of its parent (i.e. the
/// minimum indent appearing in this slice that is `>= min_indent`).
/// Returns `(line_idx, indent)`.
fn find_key_in_block(
    lines: &[&str],
    start: usize,
    end: usize,
    key: &str,
    min_indent: usize,
) -> Option<(usize, usize)> {
    // First pass: find the smallest indent in this slice that's >= min_indent
    // and belongs to a mapping key line (`<indent>foo:` with foo non-empty).
    // That defines "direct children" of the parent.
    let mut child_indent: Option<usize> = None;
    for line in lines.iter().take(end).skip(start) {
        if let Some((indent, _)) = parse_mapping_key_line(line) {
            if indent >= min_indent {
                child_indent = Some(child_indent.map_or(indent, |c| c.min(indent)));
            }
        }
    }
    let child_indent = child_indent?;

    // Second pass: find the named key at exactly child_indent.
    for (i, line) in lines.iter().enumerate().take(end).skip(start) {
        if let Some((indent, found_key)) = parse_mapping_key_line(line) {
            if indent == child_indent && found_key == key {
                return Some((i, indent));
            }
        }
    }
    None
}

/// Returns `(indent, key)` if `line` is a `<indent>key:` mapping entry —
/// i.e. starts with spaces, has a non-empty unquoted-non-list key, and
/// ends `:` (possibly followed by whitespace + an inline value).
///
/// Conservative: this helper does NOT recognise quoted keys, flow
/// mappings, or anchors. The verbs T-077-E targets stick to the canonical
/// block-style YAML in `examples/*/.team/`, which uses none of those.
/// If a future verb needs broader coverage, escalate per the pm-locked
/// scope rule in the module docs — do not silently extend here.
fn parse_mapping_key_line(line: &str) -> Option<(usize, &str)> {
    let indent = line.len() - line.trim_start().len();
    let trimmed = line.trim_start();
    if trimmed.is_empty() || trimmed.starts_with('#') || trimmed.starts_with('-') {
        return None;
    }
    let colon_idx = trimmed.find(':')?;
    let key = &trimmed[..colon_idx];
    if key.is_empty() {
        return None;
    }
    // Reject lines like "key: value: tail" — only recognise where the key
    // contains no colon. This keeps us out of inline-value territory.
    if key.contains(':') {
        return None;
    }
    // After ':' must be end-of-line OR whitespace (then either end-of-line
    // for a parent mapping, or value).
    let after = &trimmed[colon_idx + 1..];
    if !after.is_empty() && !after.starts_with(char::is_whitespace) {
        // e.g. `http://...` — colon is part of a value, not a key separator.
        return None;
    }
    Some((indent, key))
}

/// End (exclusive) of the block belonging to a key at line `key_line` with
/// indent `key_indent`. The block includes every following line whose
/// effective indent is `> key_indent` plus interleaved blank/comment
/// lines, stopping at the first line with indent `<= key_indent` that is
/// itself a mapping key.
///
/// At end of file (no dedented sibling bounds the block) the end is the
/// leaf's own last content line, *not* `lines.len()`: trailing comment
/// lines that follow the file-final block are trivia that belong to the
/// file, not the leaf, and must survive a leaf replace — the whole point of
/// the comment-preserving substrate (#319). Interior trivia between the
/// leaf's content lines stays inside the block as before.
///
/// Caveat (pre-existing, not introduced by this fix): a run of *pure*
/// trailing blank lines at EOF can still lose one line to the
/// `lines()`/`join("\n")` round-trip in `splice_nested_mapping`, which
/// cannot distinguish `\n\n\n` from `\n\n`. Comments survive because they
/// are non-empty, and blanks adjacent to a surviving comment survive too;
/// only a terminal pure-blank run is affected. This bound is still a strict
/// improvement — before it, *all* trailing trivia after a file-final leaf
/// (comments included) was deleted by the splice.
fn block_end_after(lines: &[&str], key_line: usize, key_indent: usize) -> usize {
    // The leaf key line itself is always block content; deeper-indented
    // lines extend it. Track the last such content line so trailing trivia
    // at EOF is excluded from the returned range.
    let mut last_content = key_line;
    for (i, line) in lines.iter().enumerate().skip(key_line + 1) {
        let trimmed = line.trim_start();
        if trimmed.is_empty() || trimmed.starts_with('#') {
            continue;
        }
        let indent = line.len() - trimmed.len();
        if indent <= key_indent {
            return i;
        }
        last_content = i;
    }
    last_content + 1
}

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

    const COMMENTED_FIXTURE: &str = "\
version: 2

# managers block: each manager is a long-running agent.
managers:
  pm:
    runtime: claude-code  # canonical runtime
    role_prompt: roles/pm.md
    # interfaces lands here once `teamctl bot setup` runs
  eng_lead:
    runtime: claude-code
    role_prompt: roles/eng_lead.md

# trailing footer
";

    #[test]
    fn round_trip_preserves_byte_for_byte() {
        let dir = tempfile::tempdir().unwrap();
        let path = dir.path().join("fixture.yaml");
        fs::write(&path, COMMENTED_FIXTURE).unwrap();

        let doc = load(&path).unwrap();
        save(&doc, &path).unwrap();

        let after = fs::read_to_string(&path).unwrap();
        assert_eq!(
            after, COMMENTED_FIXTURE,
            "load → save without mutation must be byte-perfect"
        );
    }

    #[test]
    fn mutation_preserves_comments() {
        let dir = tempfile::tempdir().unwrap();
        let path = dir.path().join("fixture.yaml");
        fs::write(&path, COMMENTED_FIXTURE).unwrap();

        let doc = load(&path).unwrap();
        let doc = set_nested_mapping(
            doc,
            &["managers", "pm", "interfaces", "telegram"],
            &[("bot_token_env", "PM_TOKEN"), ("chat_ids_env", "PM_CHATS")],
        )
        .unwrap();
        save(&doc, &path).unwrap();

        let after = fs::read_to_string(&path).unwrap();

        assert!(
            after.contains("# managers block: each manager is a long-running agent."),
            "block comment dropped:\n{after}"
        );
        assert!(
            after.contains("# canonical runtime"),
            "trailing line comment dropped:\n{after}"
        );
        assert!(
            after.contains("# trailing footer"),
            "footer comment dropped:\n{after}"
        );
        assert!(
            after.contains("    interfaces:"),
            "interfaces not properly indented under pm:\n{after}"
        );
        assert!(
            after.contains("      telegram:"),
            "telegram not properly indented under interfaces:\n{after}"
        );
        assert!(
            after.contains("        bot_token_env: PM_TOKEN"),
            "leaf not properly indented:\n{after}"
        );
        assert!(after.contains("        chat_ids_env: PM_CHATS"));

        // Key ordering preserved on unchanged sections.
        let pm_idx = after.find("pm:").expect("pm key");
        let eng_idx = after.find("eng_lead:").expect("eng_lead key");
        assert!(pm_idx < eng_idx, "manager key order swapped:\n{after}");

        // Blank line separator between pm and eng_lead survives.
        assert!(
            after.contains("\n  eng_lead:"),
            "eng_lead boundary broken:\n{after}"
        );
    }

    /// Regression test for the dogfood-yaml class that hit PR #54 + PR #55.
    /// Saving through this substrate doesn't strip the comments the user
    /// put in their project YAML.
    #[test]
    fn save_does_not_strip_existing_comments() {
        let dir = tempfile::tempdir().unwrap();
        let path = dir.path().join("oss-shape.yaml");
        let fixture = "\
version: 2

project:
  id: oss
  name: OSS Maintainer
  cwd: ./workspace

# Hub-and-spoke: maintainer is the only manager; workers fan out below.
managers:
  maintainer:
    runtime: claude-code
    role_prompt: roles/maintainer.md
    # `teamctl bot setup` writes the interfaces.telegram block here.

workers:
  bug_fix:
    runtime: claude-code  # workers default to sonnet
    reports_to: maintainer
";
        fs::write(&path, fixture).unwrap();

        let doc = load(&path).unwrap();
        let doc = set_nested_mapping(
            doc,
            &["managers", "maintainer", "interfaces", "telegram"],
            &[
                ("bot_token_env", "TEAMCTL_TG_MAINTAINER_TOKEN"),
                ("chat_ids_env", "TEAMCTL_TG_MAINTAINER_CHATS"),
            ],
        )
        .unwrap();
        save(&doc, &path).unwrap();

        let after = fs::read_to_string(&path).unwrap();
        assert!(
            after.contains(
                "# Hub-and-spoke: maintainer is the only manager; workers fan out below."
            ),
            "block comment dropped — regression class still open:\n{after}"
        );
        assert!(
            after.contains("# `teamctl bot setup` writes the interfaces.telegram block here."),
            "inline comment dropped:\n{after}"
        );
        assert!(
            after.contains("# workers default to sonnet"),
            "trailing line comment dropped:\n{after}"
        );
        assert!(after.contains("    interfaces:"));
        assert!(after.contains("      telegram:"));
        assert!(after.contains("        bot_token_env: TEAMCTL_TG_MAINTAINER_TOKEN"));
        assert!(after.contains("        chat_ids_env: TEAMCTL_TG_MAINTAINER_CHATS"));
    }

    /// #319: when the replaced leaf is the file-final content block,
    /// followed only by comment and/or blank trivia, `block_end_after`
    /// must not absorb that trailing trivia into the splice range — the
    /// trailing comment/blank lines have to survive a leaf replace just as
    /// they do mid-file. Latent in production because a dedented sibling
    /// (e.g. a `workers:` block after `managers:`) usually bounds the leaf;
    /// hand-authored files that end on the replaced leaf hit it.
    #[test]
    fn replace_file_final_leaf_preserves_trailing_trivia() {
        let dir = tempfile::tempdir().unwrap();
        let path = dir.path().join("file-final.yaml");
        let fixture = "\
version: 2
managers:
  pm:
    runtime: claude-code
    interfaces:
      telegram:
        bot_token_env: OLD_TOKEN
        chat_ids_env: OLD_CHATS

# operator note: keep this trailing footer
";
        fs::write(&path, fixture).unwrap();

        let doc = load(&path).unwrap();
        let doc = set_nested_mapping(
            doc,
            &["managers", "pm", "interfaces", "telegram"],
            &[
                ("bot_token_env", "NEW_TOKEN"),
                ("chat_ids_env", "NEW_CHATS"),
            ],
        )
        .unwrap();
        save(&doc, &path).unwrap();

        let after = fs::read_to_string(&path).unwrap();
        // The replace itself worked.
        assert!(
            after.contains("        bot_token_env: NEW_TOKEN"),
            "leaf not replaced:\n{after}"
        );
        // The file-final trailing comment survives the replace (#319).
        assert!(
            after.contains("# operator note: keep this trailing footer"),
            "file-final trailing comment eaten on leaf replace (#319):\n{after}"
        );
        // The blank line separating the leaf from the footer survives too.
        assert!(
            after.contains("\n\n# operator note: keep this trailing footer"),
            "file-final trailing blank line eaten on leaf replace (#319):\n{after}"
        );
    }

    /// #319 edge: more than one trailing comment, interleaved with blank
    /// lines, all following a file-final leaf. `block_end_after` skips every
    /// comment/blank line without advancing `last_content`, so the splice
    /// range stops at the leaf's last value line and the whole trivia block
    /// survives verbatim and in order.
    #[test]
    fn replace_file_final_leaf_preserves_multiple_trailing_comments() {
        let dir = tempfile::tempdir().unwrap();
        let path = dir.path().join("multi-footer.yaml");
        let fixture = "\
version: 2
managers:
  pm:
    runtime: claude-code
    interfaces:
      telegram:
        bot_token_env: OLD_TOKEN
        chat_ids_env: OLD_CHATS

# footer line one

# footer line two
# footer line three
";
        fs::write(&path, fixture).unwrap();

        let doc = load(&path).unwrap();
        let doc = set_nested_mapping(
            doc,
            &["managers", "pm", "interfaces", "telegram"],
            &[
                ("bot_token_env", "NEW_TOKEN"),
                ("chat_ids_env", "NEW_CHATS"),
            ],
        )
        .unwrap();
        save(&doc, &path).unwrap();

        let after = fs::read_to_string(&path).unwrap();
        assert!(
            after.contains("        bot_token_env: NEW_TOKEN"),
            "leaf not replaced:\n{after}"
        );
        // Every trailing comment survives.
        for footer in [
            "# footer line one",
            "# footer line two",
            "# footer line three",
        ] {
            assert!(
                after.contains(footer),
                "trailing comment `{footer}` eaten on leaf replace (#319):\n{after}"
            );
        }
        // The trailing trivia survives verbatim — same comments, same
        // interleaved blanks, same order, right after the replaced leaf.
        assert!(
            after.contains(
                "        chat_ids_env: NEW_CHATS\n\n# footer line one\n\n# footer line two\n# footer line three\n"
            ),
            "trailing comment/blank cluster not preserved verbatim and in order:\n{after}"
        );
    }

    /// #319 edge: a comment that immediately abuts the file-final leaf with
    /// no blank line between the leaf's last value and the comment. The
    /// comment is still trailing trivia (its indent doesn't matter — the
    /// `#` guard short-circuits before the indent check) and must survive.
    #[test]
    fn replace_file_final_leaf_preserves_abutting_comment() {
        let dir = tempfile::tempdir().unwrap();
        let path = dir.path().join("abutting.yaml");
        let fixture = "\
version: 2
managers:
  pm:
    runtime: claude-code
    interfaces:
      telegram:
        bot_token_env: OLD_TOKEN
        chat_ids_env: OLD_CHATS
# abutting footer, no blank above
";
        fs::write(&path, fixture).unwrap();

        let doc = load(&path).unwrap();
        let doc = set_nested_mapping(
            doc,
            &["managers", "pm", "interfaces", "telegram"],
            &[
                ("bot_token_env", "NEW_TOKEN"),
                ("chat_ids_env", "NEW_CHATS"),
            ],
        )
        .unwrap();
        save(&doc, &path).unwrap();

        let after = fs::read_to_string(&path).unwrap();
        assert!(
            after.contains("        bot_token_env: NEW_TOKEN"),
            "leaf not replaced:\n{after}"
        );
        // The comment sits directly after the leaf's last value, with no
        // blank line inserted or eaten.
        assert!(
            after.contains("        chat_ids_env: NEW_CHATS\n# abutting footer, no blank above"),
            "abutting trailing comment eaten or shifted on leaf replace (#319):\n{after}"
        );
    }

    /// #319 edge: a trailing comment indented *deeper* than the file-final
    /// leaf. `block_end_after`'s `trimmed.starts_with('#')` guard fires
    /// before the `indent <= key_indent` check, so a deeper-indented comment
    /// is treated as trivia (not block content) and `last_content` is not
    /// advanced past it — the comment must survive uncorrupted rather than
    /// be absorbed into the replaced range.
    #[test]
    fn replace_file_final_leaf_preserves_deeper_indented_comment() {
        let dir = tempfile::tempdir().unwrap();
        let path = dir.path().join("deep-comment.yaml");
        let fixture = "\
version: 2
managers:
  pm:
    runtime: claude-code
    interfaces:
      telegram:
        bot_token_env: OLD_TOKEN
        chat_ids_env: OLD_CHATS
            # deeply indented operator note
";
        fs::write(&path, fixture).unwrap();

        let doc = load(&path).unwrap();
        let doc = set_nested_mapping(
            doc,
            &["managers", "pm", "interfaces", "telegram"],
            &[
                ("bot_token_env", "NEW_TOKEN"),
                ("chat_ids_env", "NEW_CHATS"),
            ],
        )
        .unwrap();
        save(&doc, &path).unwrap();

        let after = fs::read_to_string(&path).unwrap();
        assert!(
            after.contains("        bot_token_env: NEW_TOKEN"),
            "leaf not replaced:\n{after}"
        );
        // The deeper-indented comment survives with its indentation intact.
        assert!(
            after.contains("            # deeply indented operator note"),
            "deeper-indented trailing comment corrupted on leaf replace (#319):\n{after}"
        );
    }

    /// #319 edge: file-final leaf followed by a trailing comment, with the
    /// file NOT ending in a newline. The comment survival is the #319
    /// behavior (the file-final trailing comment is excluded from the splice
    /// range by `block_end_after`); the no-trailing-newline preservation is
    /// guarded by the orthogonal, pre-existing `source.ends_with('\n')` join
    /// branch, not by the `block_end_after` change — both must hold here.
    #[test]
    fn replace_file_final_leaf_preserves_no_trailing_newline() {
        let dir = tempfile::tempdir().unwrap();
        let path = dir.path().join("no-newline.yaml");
        // Note: fixture deliberately has no trailing newline.
        let fixture = "\
version: 2
managers:
  pm:
    runtime: claude-code
    interfaces:
      telegram:
        bot_token_env: OLD_TOKEN
        chat_ids_env: OLD_CHATS

# footer with no final newline";
        assert!(
            !fixture.ends_with('\n'),
            "fixture precondition: must not end in a newline"
        );
        fs::write(&path, fixture).unwrap();

        let doc = load(&path).unwrap();
        let doc = set_nested_mapping(
            doc,
            &["managers", "pm", "interfaces", "telegram"],
            &[
                ("bot_token_env", "NEW_TOKEN"),
                ("chat_ids_env", "NEW_CHATS"),
            ],
        )
        .unwrap();
        save(&doc, &path).unwrap();

        let after = fs::read_to_string(&path).unwrap();
        assert!(
            after.contains("        bot_token_env: NEW_TOKEN"),
            "leaf not replaced:\n{after}"
        );
        assert!(
            after.contains("# footer with no final newline"),
            "trailing comment eaten on leaf replace (#319):\n{after}"
        );
        // The no-trailing-newline shape is preserved.
        assert!(
            !after.ends_with('\n'),
            "splice must not introduce a trailing newline the source lacked:\n{after:?}"
        );
    }

    /// Idempotency: re-running set_nested_mapping with the same path
    /// replaces the leaf in place rather than appending a duplicate.
    /// Sibling adapters under the same parent survive.
    #[test]
    fn idempotent_replace_preserves_siblings() {
        let dir = tempfile::tempdir().unwrap();
        let path = dir.path().join("siblings.yaml");
        let fixture = "\
version: 2
managers:
  pm:
    runtime: claude-code
    interfaces:
      discord:
        bot_token_env: PM_DISCORD_TOKEN
      telegram:
        bot_token_env: OLD_TOKEN
        chat_ids_env: OLD_CHATS
";
        fs::write(&path, fixture).unwrap();

        let doc = load(&path).unwrap();
        let doc = set_nested_mapping(
            doc,
            &["managers", "pm", "interfaces", "telegram"],
            &[
                ("bot_token_env", "NEW_TOKEN"),
                ("chat_ids_env", "NEW_CHATS"),
            ],
        )
        .unwrap();
        save(&doc, &path).unwrap();

        let after = fs::read_to_string(&path).unwrap();
        assert_eq!(
            after.matches("telegram:").count(),
            1,
            "duplicate telegram block:\n{after}"
        );
        assert_eq!(
            after.matches("discord:").count(),
            1,
            "discord sibling lost:\n{after}"
        );
        assert!(
            after.contains("PM_DISCORD_TOKEN"),
            "discord adapter contents lost:\n{after}"
        );
        assert!(after.contains("NEW_TOKEN"));
        assert!(after.contains("NEW_CHATS"));
        assert!(!after.contains("OLD_TOKEN"));
        assert!(!after.contains("OLD_CHATS"));
    }

    /// #311 root cause: replacing an **already-existing** leaf must nest
    /// the value pairs one level *under* the re-emitted leaf key, not at
    /// the leaf's own indent. The string-only `idempotent_replace_*`
    /// test missed this because `contains("NEW_TOKEN")` is true even
    /// when the pair serializes as a sibling of `telegram:` (nulling the
    /// leaf). This asserts the parsed structure, not substrings: the
    /// essentials scaffold pre-wires `interfaces.telegram`, so every
    /// `bot setup` on a fresh team hit this replace path and the bridge
    /// never saw a telegram block.
    #[test]
    fn replace_existing_leaf_nests_values_under_it() {
        let dir = tempfile::tempdir().unwrap();
        let path = dir.path().join("prewired.yaml");
        // Mirrors the shipped `essentials` ops.yaml: a pre-wired
        // telegram leaf the wizard re-writes verbatim.
        let fixture = "\
version: 2
managers:
  builder:
    runtime: claude-code
    interfaces:
      telegram:
        bot_token_env: TEAMCTL_TG_BUILDER_TOKEN
        chat_ids_env: TEAMCTL_TG_BUILDER_CHATS
";
        fs::write(&path, fixture).unwrap();

        let doc = load(&path).unwrap();
        let doc = set_nested_mapping(
            doc,
            &["managers", "builder", "interfaces", "telegram"],
            &[
                ("bot_token_env", "TEAMCTL_TG_BUILDER_TOKEN"),
                ("chat_ids_env", "TEAMCTL_TG_BUILDER_CHATS"),
            ],
        )
        .unwrap();
        save(&doc, &path).unwrap();

        let after = fs::read_to_string(&path).unwrap();
        let v: serde_yaml::Value = serde_yaml::from_str(&after)
            .unwrap_or_else(|e| panic!("re-spliced YAML must parse: {e}\n{after}"));
        let tg = &v["managers"]["builder"]["interfaces"]["telegram"];
        assert!(
            tg.is_mapping(),
            "telegram leaf must remain a mapping, not be nulled by sibling pairs:\n{after}"
        );
        assert_eq!(
            tg["bot_token_env"].as_str(),
            Some("TEAMCTL_TG_BUILDER_TOKEN"),
            "bot_token_env must be nested under telegram:\n{after}"
        );
        assert_eq!(
            tg["chat_ids_env"].as_str(),
            Some("TEAMCTL_TG_BUILDER_CHATS"),
            "chat_ids_env must be nested under telegram:\n{after}"
        );
    }

    // T-265 PR-a: set_top_level_scalar sibling helper.

    #[test]
    fn set_top_level_scalar_replaces_integer_with_quoted_string() {
        // The headline use case: legacy `version: 2` integer →
        // canonical `version: "2.0.0"` semver string.
        let src = "\
# leading comment
version: 2
broker:
  type: sqlite
";
        let edited = set_top_level_scalar(src, "version", "\"2.0.0\"").unwrap();
        let out = edited;
        assert!(
            out.contains("version: \"2.0.0\""),
            "rewrite missing:\n{out}"
        );
        assert!(
            !out.contains("\nversion: 2\n"),
            "old literal survived:\n{out}"
        );
        // Comment + other top-level keys preserved.
        assert!(out.contains("# leading comment"));
        assert!(out.contains("broker:"));
        assert!(out.contains("type: sqlite"));
    }

    #[test]
    fn set_top_level_scalar_is_idempotent() {
        let src = "version: \"2.0.0\"\nbroker:\n  type: sqlite\n";
        let edited = set_top_level_scalar(src, "version", "\"2.0.0\"").unwrap();
        let out = edited;
        assert_eq!(
            out.matches("version:").count(),
            1,
            "no duplicate version line:\n{out}"
        );
        assert!(out.contains("version: \"2.0.0\""));
    }

    #[test]
    fn set_top_level_scalar_errors_on_missing_key() {
        let src = "broker:\n  type: sqlite\n";
        let err = set_top_level_scalar(src, "version", "\"2.0.0\"").expect_err("missing key");
        assert!(
            err.to_string().contains("no top-level key `version` found"),
            "error must name the missing key: {err}"
        );
    }

    #[test]
    fn set_top_level_scalar_only_touches_top_level_key() {
        // A nested `version:` inside another mapping must NOT be
        // rewritten — only the top-level one. Crucial for
        // projects/*.yaml which is OUT of PR-a's scope (still has
        // `version: u32`) but might be referenced inside a future
        // nested block.
        let src = "\
version: 2
nested:
  version: 99
  other: ok
";
        let edited = set_top_level_scalar(src, "version", "\"2.0.0\"").unwrap();
        let out = edited;
        assert!(
            out.contains("version: \"2.0.0\""),
            "top-level rewritten:\n{out}"
        );
        assert!(
            out.contains("  version: 99"),
            "nested version: 99 must be left alone:\n{out}"
        );
    }
}
