//! End-to-end test: load example module, compile to binary, run it.
//!
//! This test guards the most important property of the entire pipeline:
//! AST → typecheck → LLVM IR → clang → binary → correct stdout.

use std::path::Path;
use std::process::Command;

fn ail_bin() -> &'static str {
    env!("CARGO_BIN_EXE_ail")
}

fn build_and_run(example: &str) -> String {
    // Workspace root is two levels above the crate manifest.
    let manifest_dir = env!("CARGO_MANIFEST_DIR");
    let workspace = Path::new(manifest_dir).parent().unwrap().parent().unwrap();
    let src = workspace.join("examples").join(example);
    let tmp = std::env::temp_dir().join(format!(
        "ailang_e2e_{}_{}",
        example.replace('.', "_"),
        std::process::id()
    ));
    std::fs::create_dir_all(&tmp).unwrap();
    let out = tmp.join("bin");
    let status = Command::new(ail_bin())
        .args(["build", src.to_str().unwrap(), "-o"])
        .arg(&out)
        .status()
        .expect("ail build failed to run");
    assert!(status.success(), "ail build failed for {example}");
    let output = Command::new(&out).output().expect("execute binary");
    assert!(
        output.status.success(),
        "binary {} exited non-zero",
        out.display()
    );
    String::from_utf8(output.stdout).expect("stdout utf8")
}

/// Iter 18b: build with an explicit `--alloc=<alloc>` and run.
/// Mirrors [`build_and_run`] but threads the allocator selector through
/// to `ail build`. Used by the alloc-equivalence tests that assert the
/// `gc` and `rc` paths produce byte-identical stdout.
fn build_and_run_with_alloc(example: &str, alloc: &str) -> String {
    let manifest_dir = env!("CARGO_MANIFEST_DIR");
    let workspace = Path::new(manifest_dir).parent().unwrap().parent().unwrap();
    let src = workspace.join("examples").join(example);
    let tmp = std::env::temp_dir().join(format!(
        "ailang_e2e_alloc_{}_{}_{}",
        alloc,
        example.replace('.', "_"),
        std::process::id()
    ));
    std::fs::create_dir_all(&tmp).unwrap();
    let out = tmp.join("bin");
    let status = Command::new(ail_bin())
        .args([
            "build",
            src.to_str().unwrap(),
            &format!("--alloc={alloc}"),
            "-o",
        ])
        .arg(&out)
        .status()
        .expect("ail build failed to run");
    assert!(
        status.success(),
        "ail build --alloc={alloc} failed for {example}"
    );
    let output = Command::new(&out).output().expect("execute binary");
    assert!(
        output.status.success(),
        "binary {} (--alloc={alloc}) exited non-zero",
        out.display()
    );
    String::from_utf8(output.stdout).expect("stdout utf8")
}

#[test]
fn sum_1_to_10_is_55() {
    let stdout = build_and_run("sum.ail.json");
    assert_eq!(stdout.trim(), "55");
}

/// Guards block tracking in codegen: max3 has nested `if`s, and wrong
/// phi block tracking would produce a wrong result here.
#[test]
fn max3_picks_largest() {
    let stdout = build_and_run("max3.ail.json");
    assert_eq!(stdout.trim(), "17");
}

#[test]
fn hello_world_str_lit() {
    let stdout = build_and_run("hello.ail.json");
    assert_eq!(stdout.trim(), "Hello, AILang.");
}

/// Guards ADT codegen + match: recursive list, sum_list via match on Cons/Nil.
#[test]
fn list_sum_via_match() {
    let stdout = build_and_run("list.ail.json");
    assert_eq!(stdout.trim(), "42");
}

/// Iter 7: first-class function references — `apply(inc, 41)` must
/// produce 42, exercising fn-typed parameters and indirect call.
#[test]
fn higher_order_apply_inc() {
    let stdout = build_and_run("hof.ail.json");
    assert_eq!(stdout.trim(), "42");
}

/// Iter 8b: lambdas with capture. `let n = 3 in apply(\x. x + n, 39)`
/// must print 42 — the lambda captures `n` from the enclosing `let`,
/// the env struct is malloc'd, the closure pair is passed to `apply`,
/// and apply's indirect call unpacks both halves.
#[test]
fn closure_captures_let_n() {
    let stdout = build_and_run("closure.ail.json");
    assert_eq!(stdout.trim(), "42");
}

/// Iter 9 dogfood: a non-trivial program that combines ADTs, recursion,
/// closure-without-capture, fn-typed parameters, IO effects, and `let`-
/// sequencing of effectful sub-expressions in a pattern-match arm.
/// `map (\x. x * 2)` over `[1, 2, 3]` then print each element.
#[test]
fn list_map_doubles_then_prints() {
    let stdout = build_and_run("list_map.ail.json");
    let lines: Vec<&str> = stdout.lines().collect();
    assert_eq!(lines, vec!["2", "4", "6"]);
}

/// Iter 14a: end-to-end exercise of parameterised ADTs through a
/// polymorphic higher-order fn. `data List a` plus
/// `map : forall a b. ((a) -> b, List<a>) -> List<b>` recursive,
/// instantiated at `(Int, Int)`. Guards: forall with two type vars +
/// recursive call inside its own body + ctor construction with
/// substituted field types + match-arm bindings flowing into an
/// effectful tail call.
#[test]
fn list_map_poly_inc_then_prints() {
    let stdout = build_and_run("list_map_poly.ail.json");
    let lines: Vec<&str> = stdout.lines().collect();
    assert_eq!(lines, vec!["2", "3", "4"]);
}

/// Iter 14f: stress the Boehm conservative GC integration end-to-end.
/// `build 50` performs 50 `Cons` allocations via `GC_malloc`; `sum_list`
/// walks the resulting `List Int` and prints the sum (1275 = 50*51/2).
/// If GC is wired wrongly — missing `-lgc`, missing
/// `declare ptr @GC_malloc(i64)`, libgc misbehaving on this build host —
/// the test fails at link or run time. The match-on-Bool against
/// `(== n 0)` shape is used because `(pat-lit 0)` against an `Int`
/// scrutinee with a wildcard fallback is not currently in the grammar.
#[test]
fn gc_handles_recursive_list_construction() {
    let stdout = build_and_run("gc_stress.ail.json");
    assert_eq!(
        stdout.trim(),
        "1275",
        "expected sum [50,49,..,1] = 1275; \
         GC_malloc / -lgc integration may be broken"
    );
}

/// Iter 17a: per-fn arena via stack `alloca` for non-escaping
/// allocations. The fixture's `peek` and `count` fns each build a
/// `Box(_)` whose payload is dropped via a wildcard pattern; the
/// allocation is fully consumed locally and never flows to a fn
/// arg, ctor field, or the fn return. Escape analysis must flag
/// these allocations as non-escaping; codegen must lower them with
/// `alloca` instead of `@GC_malloc`. Two assertions:
/// (1) stdout matches the documented expected outputs;
/// (2) the emitted IR contains at least one `alloca i8, i64 16`
/// for the Box and zero `@GC_malloc` calls inside `peek`/`count`.
#[test]
fn iter17a_local_box_alloca() {
    let stdout = build_and_run("escape_local_demo.ail.json");
    assert_eq!(
        stdout.lines().collect::<Vec<_>>(),
        vec!["42", "42", "5", "0"],
        "escape_local_demo stdout drift"
    );

    // IR check: peek and count should each contain `alloca` and no
    // `@GC_malloc`. Emit, then split into per-fn bodies and inspect.
    let manifest_dir = env!("CARGO_MANIFEST_DIR");
    let workspace = Path::new(manifest_dir).parent().unwrap().parent().unwrap();
    let src = workspace
        .join("examples")
        .join("escape_local_demo.ail.json");
    let tmp = std::env::temp_dir().join(format!(
        "ailang_iter17a_alloca_{}",
        std::process::id()
    ));
    std::fs::create_dir_all(&tmp).unwrap();
    let out_ll = tmp.join("escape_local_demo.ll");
    let status = Command::new(ail_bin())
        .args(["emit-ir", src.to_str().unwrap(), "-o"])
        .arg(&out_ll)
        .status()
        .expect("ail emit-ir failed to run");
    assert!(status.success(), "ail emit-ir failed");
    let ir = std::fs::read_to_string(&out_ll).expect("read emitted IR");

    for fn_name in ["peek", "count"] {
        let header = format!("@ail_escape_local_demo_{fn_name}(");
        let start = ir
            .find(&header)
            .unwrap_or_else(|| panic!("fn {fn_name} not found in IR"));
        let after_header = &ir[start..];
        let end = after_header
            .find("\n}\n")
            .expect("fn body terminator not found");
        let body = &after_header[..end];
        assert!(
            body.contains("alloca i8, i64 16"),
            "fn {fn_name} should contain `alloca i8, i64 16`; body:\n{body}"
        );
        assert!(
            !body.contains("@GC_malloc"),
            "fn {fn_name} should NOT contain `@GC_malloc` (allocation \
             must be alloca'd); body:\n{body}"
        );
    }
}

/// Iter 14e: `tail: true` annotation on `print_list`'s recursive
/// call must reach LLVM as a `musttail call`. Asserted by emitting IR
/// for list_map_poly and grepping for the exact instruction. This is
/// the only direct evidence that the type-system marker actually
/// influences codegen — the e2e test above only checks observed
/// stdout, which `musttail` does not change.
#[test]
fn iter14e_print_list_recursion_emits_musttail() {
    let manifest_dir = env!("CARGO_MANIFEST_DIR");
    let workspace = Path::new(manifest_dir).parent().unwrap().parent().unwrap();
    let src = workspace.join("examples").join("list_map_poly.ail.json");
    let tmp = std::env::temp_dir().join(format!(
        "ailang_iter14e_musttail_{}",
        std::process::id()
    ));
    std::fs::create_dir_all(&tmp).unwrap();
    let out_ll = tmp.join("list_map_poly.ll");
    let status = Command::new(ail_bin())
        .args(["emit-ir", src.to_str().unwrap(), "-o"])
        .arg(&out_ll)
        .status()
        .expect("ail emit-ir failed to run");
    assert!(status.success(), "ail emit-ir failed");
    let ir = std::fs::read_to_string(&out_ll).expect("read emitted IR");

    // Find the musttail call to print_list inside the print_list body.
    // The exact line shape:
    //   %vN = musttail call i8 @ail_list_map_poly_print_list(ptr %vM)
    let has_musttail = ir.lines().any(|l| {
        l.contains("musttail call")
            && l.contains("@ail_list_map_poly_print_list(")
    });
    assert!(
        has_musttail,
        "expected `musttail call ... @ail_list_map_poly_print_list(...)` \
         in emitted IR; not found.\nIR:\n{ir}"
    );
    // Defence-in-depth: the musttail call must be immediately followed
    // by a `ret i8 %v...`. We assert the next non-empty line is a ret.
    let mut lines = ir.lines();
    while let Some(line) = lines.next() {
        if line.contains("musttail call")
            && line.contains("@ail_list_map_poly_print_list(")
        {
            let next = lines.next().unwrap_or("").trim();
            assert!(
                next.starts_with("ret i8 "),
                "musttail call must be immediately followed by `ret i8 ...`; \
                 got: `{next}`"
            );
            return;
        }
    }
    panic!("musttail call line not found (sanity check)");
}

/// Iter 11 dogfood: insertion sort over an 11-element IntList.
/// Exercises `<=`, `if`, mutual-leaf recursion (`insert` and `sort`),
/// nested ctor construction, and the Iter 10 seq operator inside
/// print_list. Validates that the language handles deeper ADT
/// recursion + branching without surprises.
#[test]
fn insertion_sort_orders_list() {
    let stdout = build_and_run("sort.ail.json");
    let lines: Vec<&str> = stdout.lines().collect();
    assert_eq!(
        lines,
        vec!["1", "1", "2", "3", "3", "4", "5", "5", "5", "6", "9"],
    );
}

/// Iter 12b: polymorphism end-to-end. `id : forall a. (a) -> a`
/// gets called with `42` (Int) and `true` (Bool); each instantiation
/// triggers a separate specialised LLVM fn. Output: 42, then "true".
#[test]
fn polymorphic_id_at_int_and_bool() {
    let stdout = build_and_run("poly_id.ail.json");
    let lines: Vec<&str> = stdout.lines().collect();
    assert_eq!(lines, vec!["42", "true"]);
}

/// Iter 12b: polymorphism with a function-typed parameter.
/// `apply : forall a b. ((a) -> b, a) -> b` invoked as
/// `apply(succ, 41) == 42`. Exercises the closure-pair ABI inside a
/// monomorphised body (a fn-typed param survives substitution).
#[test]
fn polymorphic_apply_with_fn_param() {
    let stdout = build_and_run("poly_apply.ail.json");
    assert_eq!(stdout.trim(), "42");
}

/// Iter 13b: round-trip a primitive through a parameterised ADT and a
/// polymorphic-fn boundary. `type Box[a] = MkBox(a)` plus
/// `unbox : forall a. (Box<a>) -> a` plus `print_int(unbox(MkBox(42)))`.
/// Guards: ctor lower with substituted LLVM field types,
/// match-arm field bindings carrying the substituted AILang type
/// down into a polymorphic fn body.
#[test]
fn parameterised_box_round_trip() {
    let stdout = build_and_run("box.ail.json");
    assert_eq!(stdout.trim(), "42");
}

/// Iter 13b: pattern-match on `Maybe<Int>`. `or_else(Some(7), 99) == 7`
/// then `or_else(None, 99) == 99`. Guards: match-arm field
/// substitution at a parameterised ctor (the `Some(x)` arm must bind
/// `x : Int`, not `x : a`).
#[test]
fn parameterised_maybe_match() {
    let stdout = build_and_run("maybe_int.ail.json");
    let lines: Vec<&str> = stdout.lines().collect();
    assert_eq!(lines, vec!["7", "99"]);
}

/// Iter 15a: cross-module reference to a parameterised ADT, including
/// its ctors and a polymorphic combinator instantiated at `(Int, Int)`.
/// `std_maybe_demo` imports `std_maybe`, qualifies the type-name slot
/// of every `term-ctor` (`std_maybe.Maybe`), and exercises
/// `from_maybe`, `is_some`, `is_none`, and `map_maybe` once each.
/// Property protected: the qualified-only convention (Decision 6's
/// architectural pin extended to types and ctors per the brief)
/// flows end-to-end through check, codegen, and runtime.
#[test]
fn cross_module_maybe_demo() {
    let stdout = build_and_run("std_maybe_demo.ail.json");
    let lines: Vec<&str> = stdout.lines().collect();
    assert_eq!(lines, vec!["7", "99", "true", "true", "42"]);
}

/// Iter 15b: drives the polymorphic `std_list` combinators end-to-end.
/// Exercises a recursive cross-module ADT (`std_list.List<a>`) consumed
/// from a separate module that also imports `std_maybe`. Guards the
/// `qualify_local_types` propagation in both `Term::Ctor` synth and
/// `Term::Match` field binding — without it, recursive ctor fields
/// (`Cons a (List a)`) stay unqualified at the cross-module use site
/// and fail to unify against the qualified scrutinee args.
#[test]
fn std_list_demo() {
    let stdout = build_and_run("std_list_demo.ail.json");
    let lines: Vec<&str> = stdout.lines().collect();
    assert_eq!(
        lines,
        vec![
            "5", "false", "true", "1", "4", "10", "5", "2", "2", "15", "15"
        ]
    );
}

/// Iter 15c: empirical stress test for `std_list`'s folds at N=1000.
///
/// Property protected: `fold_left`'s tail-call marker actually
/// survives monomorphisation and lowers to `musttail call` in the
/// monomorphised `fold_left__I_I` body, so a 1000-element fold runs
/// in constant stack and does not segfault. `fold_right` is
/// constructor-blocked (the recursive call sits as the second arg
/// to `f`, not in tail position) and runs at a 1000-deep stack —
/// well within the default 8MB Linux stack budget. `build` itself
/// is recursive-but-unmarked (Cons-blocked) and likewise relies on
/// the stack budget at this scale. Both folds over the same
/// commutative-associative `+` produce the same sum (500500 =
/// 1000 * 1001 / 2).
///
/// If `fold_left` crashed here the tail marker would have been
/// lost in monomorphisation; if `fold_right` or `build` crashed,
/// stack frames would be far larger than estimated. Neither
/// happens at N=1000.
#[test]
fn std_list_stress_1000_element_folds() {
    let stdout = build_and_run("std_list_stress.ail.json");
    let lines: Vec<&str> = stdout.lines().collect();
    assert_eq!(
        lines,
        vec!["500500", "500500"],
        "expected sum 1..1000 = 500500 from both fold_left and \
         fold_right; mismatch indicates a fold bug or stack \
         overflow corrupting output"
    );
}

/// Iter 15e: `ail render | ail parse` round-trips canonical bytes.
/// Property protected: the CLI's `render` is the exact inverse of
/// `parse`, not the older human-pretty form. Regression of this
/// would mean piping `render` into `parse` no longer yields a
/// re-loadable module — a contract the help text now claims.
///
/// Picks `std_either.ail.json` (richest fixture: 2-type-var data,
/// 3-type-var fn, recursive ctors irrelevant) but the round-trip
/// test in `ailang-surface` covers all 25 fixtures structurally.
/// This test specifically exercises the CLI shell pipeline.
#[test]
fn render_parse_round_trip_canonical() {
    let manifest_dir = env!("CARGO_MANIFEST_DIR");
    let workspace = Path::new(manifest_dir).parent().unwrap().parent().unwrap();
    let src = workspace.join("examples").join("std_either.ail.json");
    let original = std::fs::read(&src).expect("read fixture");

    let render = Command::new(ail_bin())
        .args(["render", src.to_str().unwrap()])
        .output()
        .expect("run ail render");
    assert!(render.status.success(), "ail render failed");

    let tmp = std::env::temp_dir().join(format!("ailang_render_e2e_{}.ailx", std::process::id()));
    std::fs::write(&tmp, &render.stdout).expect("write rendered ailx");

    let parsed = Command::new(ail_bin())
        .args(["parse", tmp.to_str().unwrap()])
        .output()
        .expect("run ail parse");
    assert!(parsed.status.success(), "ail parse failed on rendered output");

    let mut round = parsed.stdout;
    while round.last() == Some(&b'\n') {
        round.pop();
    }
    let mut orig = original;
    while orig.last() == Some(&b'\n') {
        orig.pop();
    }
    assert_eq!(orig, round, "render | parse must produce canonical bytes");
}

/// Iter 16a: nested constructor patterns. Property protected:
/// `ailang_core::desugar` flattens `Cons a (Cons b _)` to a chain of
/// single-level matches before the checker/codegen sees it. Without
/// the desugar pass the checker would emit
/// `nested-ctor-pattern-not-allowed`; without the pre-codegen call,
/// the inner binders would be silently dropped by the codegen.
#[test]
fn nested_ctor_pattern_first_two_sum() {
    let stdout = build_and_run("nested_pat.ail.json");
    let lines: Vec<&str> = stdout.lines().collect();
    assert_eq!(lines, vec!["30"]);
}

/// Iter 15f: `std_pair` end-to-end. Polymorphic product type with
/// two type vars and a single constructor; six combinators including
/// the 3-type-var `map_first` / `map_second` reshapers. Property
/// protected: `Pair<a, b> -> Pair<c, b>` and `Pair<a, b> -> Pair<a, c>`
/// monomorphise correctly when each is exercised at distinct
/// `(a, b, c)` triples — the codegen must emit the right field types
/// for the substituted MkPair output.
#[test]
fn std_pair_demo() {
    let stdout = build_and_run("std_pair_demo.ail.json");
    let lines: Vec<&str> = stdout.lines().collect();
    assert_eq!(lines, vec!["7", "9", "9", "7", "8", "18"]);
}

/// Iter 15d: `std_either` end-to-end. First stdlib ADT with two type
/// parameters (`Either<e, a>`); first combinator with three type vars
/// (`either : (e -> c) -> (a -> c) -> Either<e, a> -> c`).
///
/// Property protected: monomorphisation correctly distinguishes per-
/// call-site instantiations, including (a) two `from_right` instances
/// differing only in the unused `e` (Int vs $u) and (b) the
/// 3-type-var `either__I_I_I` instantiation. If wildcard substitution
/// regressed, one of those would either fail to emit or collide with
/// another instantiation.
#[test]
fn std_either_demo() {
    let stdout = build_and_run("std_either_demo.ail.json");
    let lines: Vec<&str> = stdout.lines().collect();
    assert_eq!(
        lines,
        vec!["42", "99", "true", "true", "42", "6", "101"]
    );
}

/// Iter 15g: `std_either_list` end-to-end. First stdlib fn set that
/// imports three other stdlib modules (`std_list`, `std_either`,
/// `std_pair`) and returns a compound polymorphic ADT tree
/// (`partition_eithers : List<Either<e,a>> -> Pair<List<e>, List<a>>`).
///
/// Property protected: monomorphisation across `List × Either × Pair`
/// in a single call chain — `std_either_list.partition_eithers` is
/// instantiated at `(e=Int, a=Int)` and the result feeds
/// `std_pair.fst` / `std_pair.snd` (each at `(a=List<Int>, b=List<Int>)`)
/// into `std_list.length` (at `a=Int`). If cross-module ctor resolution
/// or substitution-through-nested-Con regressed, one of the
/// instantiations would either fail to emit or produce a wrong length.
/// The four expected outputs (2, 3, 2, 3) also pin the order
/// preservation of `lefts` and `rights` against the input
/// `[Left 1, Right 10, Left 2, Right 20, Right 30]`.
#[test]
fn std_either_list_demo() {
    let stdout = build_and_run("std_either_list_demo.ail.json");
    let lines: Vec<&str> = stdout.lines().collect();
    assert_eq!(lines, vec!["2", "3", "2", "3"]);
}

/// Iter 15h: `std_list.take` and `std_list.drop` end-to-end. Both are
/// index-driven recursive ADT-pattern fns — the first `std_list`
/// combinators that pair `if (<= n 0)` Int-arithmetic guards with a
/// recursive Cons/Nil match. Property protected: the `if` base-case
/// guard wraps a `match` whose recursive arm bodies use `(- n 1)` and
/// re-enter through `app` — a shape no other `std_list` fn has so
/// far. Six checks pin all three boundary regimes (n=0, 0<n<length,
/// n>length) on each combinator. Expected stdout: 0, 3, 5, 5, 3, 0.
#[test]
fn std_list_more_demo() {
    let stdout = build_and_run("std_list_more_demo.ail.json");
    let lines: Vec<&str> = stdout.lines().collect();
    assert_eq!(lines, vec!["0", "3", "5", "5", "3", "0"]);
}

/// Iter 16b.1: local recursive `let`. Property protected: a
/// `(let-rec ...)` term in a fn body whose recursive body has no
/// captures from the enclosing scope is lifted by the 16a desugar
/// pass to a synthetic top-level fn (`<name>$lr_N`), and the
/// resulting module type-checks and runs identically to a
/// hand-written top-level recursive fn. The fixture exercises
/// `fact` at n=1 (base case), n=3, n=5 — outputs 1, 6, 120.
/// If the desugar lift regressed (e.g. failed to substitute call
/// sites in the in-term, or generated a colliding lifted name),
/// the build would fail at typecheck or the binary would print
/// wrong values.
#[test]
fn local_rec_factorial_demo() {
    let stdout = build_and_run("local_rec_demo.ail.json");
    let lines: Vec<&str> = stdout.lines().collect();
    assert_eq!(lines, vec!["1", "6", "120"]);
}

/// Iter 16b.2: LetRec capture of a fn-param (path-1 safe subset).
/// Property protected: the desugar pass lifts a LetRec whose body
/// captures one or more enclosing-scope names (here: `n` from
/// `sum_below`'s params) into a synthetic top-level fn whose
/// signature has the capture types appended, and rewrites every
/// call site of the LetRec name in body and in_term to pass the
/// captures positionally as extra args. Without 16b.2, the
/// 16b.1-era panic ("would capture") would fire and the build
/// would fail. Three call sites: sum_below(1)=0, sum_below(5)=10,
/// sum_below(10)=45.
#[test]
fn local_rec_capture_demo() {
    let stdout = build_and_run("local_rec_capture.ail.json");
    let lines: Vec<&str> = stdout.lines().collect();
    assert_eq!(lines, vec!["0", "10", "45"]);
}

/// Iter 16b.3: LetRec capture of a `Term::Let`-bound name. Property
/// protected: the desugar pass leaves a LetRec whose only outside-
/// scope captures are Let-bound (type unknown until typecheck) in
/// place; the post-typecheck `lift_letrecs` pass in `ailang-check`
/// resolves capture types from the typechecker's env, lifts to a
/// synthetic top-level fn (`loop$lr_0(i, n, threshold)`), and
/// rewrites every call site. Without 16b.3, the 16b.2-era panic
/// ("16b.3 — let-binding captures") would fire. The threshold here
/// is `(app + 5 5)` so the lift exercises the type-synthesis path
/// (not just a literal). count_below(0)=0, count_below(5)=5,
/// count_below(15)=9 (i in 1..9 are below threshold 10).
#[test]
fn local_rec_let_capture_demo() {
    let stdout = build_and_run("local_rec_let_capture.ail.json");
    let lines: Vec<&str> = stdout.lines().collect();
    assert_eq!(lines, vec!["0", "5", "9"]);
}

/// Iter 16b.4: LetRec capture of a Match-arm pattern binding.
/// Property protected: the desugar pass leaves a LetRec whose
/// captures are `Pattern::Ctor`-Var-bound (here `threshold` and
/// `n`, both fields of `MkPair` in `Pair Int Int`) in place; the
/// post-typecheck `lift_letrecs` pass walks the enclosing
/// `Term::Match` arm, calls `type_check_pattern_for_lift` to
/// substitute the matched ADT's type args (`[Int, Int]`) into
/// `MkPair`'s declared field types, and lifts to a synthetic
/// top-level fn `loop$lr_0(i: Int, threshold: Int, n: Int) ->
/// Int`, rewriting every call site. Without 16b.4, the 16b.3-era
/// panic ("16b.4 — match-arm captures") would fire.
/// count_below(MkPair 10 0)=0, count_below(MkPair 10 5)=5,
/// count_below(MkPair 10 15)=9.
#[test]
fn local_rec_match_capture_demo() {
    let stdout = build_and_run("local_rec_match_capture.ail.json");
    let lines: Vec<&str> = stdout.lines().collect();
    assert_eq!(lines, vec!["0", "5", "9"]);
}

/// Iter 16b.5: LetRec name as a value in the in-clause (no capture).
/// Property protected: the desugar pass detects a non-callee use of
/// the LetRec name in `in_term` and wraps the rewritten in-term in
/// `(let factorial (lam ...) ...)` whose lam eta-expands the lifted
/// fn. The LetRec name then resolves to a closure-pair value usable
/// anywhere a `Fn(Int) -> Int` is expected (here: passed to a HOF
/// `apply5`). Without 16b.5, the desugar would panic with the
/// "name-as-value not supported" message and the build would fail.
/// Expected stdout: 120 (= apply5 factorial = factorial 5).
#[test]
fn local_rec_as_value_demo() {
    let stdout = build_and_run("local_rec_as_value.ail.json");
    let lines: Vec<&str> = stdout.lines().collect();
    assert_eq!(lines, vec!["120"]);
}

/// Iter 16b.5: LetRec name as a value in the in-clause WITH capture.
/// Property protected: the eta-Lam wrap composes correctly with the
/// 16b.2 capture-augmentation. The lifted fn has signature
/// `factorial_plus$lr_N(n: Int, base: Int) -> Int`; the eta-Lam
/// wrap binds `factorial_plus` to a `(lam (n) (app
/// factorial_plus$lr_N n base))` whose free var `base` becomes a
/// standard 8b closure-env capture. This is the dynamic-env path:
/// the lifted fn has extra params, the eta-Lam captures them.
/// Expected stdout: 1320 (= 5*4*3*2*(1+10)), 12120 (= 5*4*3*2*(1+100)).
#[test]
fn local_rec_as_value_capture_demo() {
    let stdout = build_and_run("local_rec_as_value_capture.ail.json");
    let lines: Vec<&str> = stdout.lines().collect();
    assert_eq!(lines, vec!["1320", "12120"]);
}

/// Iter 16b.6: LetRec inside a polymorphic enclosing fn.
/// Property protected: when the enclosing fn is `Forall(a). Fn(...)`,
/// the desugar/lift pipeline now (a) enters fn-params as `KnownType`
/// (not `LetBound`) so a LetRec inside CAN capture them, and (b)
/// builds the synthetic lifted fn's signature as
/// `Forall(a). Fn(t1..tk, T1..Tm) -> tr`, mirroring the enclosing
/// fn's type vars. Capture types `T1..Tm` may mention `a`; the
/// outer Forall binds them. Codegen's mono pipeline (Iter 12b/14a)
/// then specialises `loop$lr_0` at every call with the same type
/// args as the enclosing `apply_n_times`.
///
/// `main` drives `apply_n_times` at TWO distinct instantiations
/// (`Int` with `succ`, `Bool` with `flip`) so the mono path is
/// exercised twice, producing both `loop$lr_0__I` and
/// `loop$lr_0__B` in the IR. Without 16b.6 the desugar pass would
/// have rejected the LetRec capture as a `LetBound`-style failure
/// (the 16b.2-era `Type::Forall` fallback marked all fn-params as
/// `LetBound`).
///
/// Expected stdout: 5 (succ applied 5 times to 0), false (flip
/// applied 4 times to false — even count of flips ⇒ false).
#[test]
fn poly_rec_capture_demo() {
    let stdout = build_and_run("poly_rec_capture.ail.json");
    let lines: Vec<&str> = stdout.lines().collect();
    assert_eq!(lines, vec!["5", "false"]);
}

/// Iter 16b.7: nested LetRec where the inner one captures the OUTER
/// LetRec's PARAMS (not its name).
///
/// Property protected: when an inner LetRec lifts inside an outer
/// LetRec's body, the outer's params are `ScopeEntry::KnownType` in
/// the inner's outer-scope (set up by the desugar pass at the same
/// site that marks the outer's NAME as `EnclosingLetRec`). The inner
/// lift therefore takes the 16b.2 fast path with the outer's params
/// appended to the inner's signature; once the outer is then lifted,
/// the call sites of `inner$lr_M(args, outer_param)` continue to
/// resolve `outer_param` as outer's still-in-scope param (later
/// renamed by the outer's own lift to its lifted-fn param of the
/// same name). Inner capturing the OUTER's NAME remains rejected —
/// see the desugar/lift `EnclosingLetRec` panic with the
/// closure-of-self message.
///
/// `nested_sum(n)` = sum of 1..=n by counting i ones for each i in
/// 1..=n. Expected stdout: 1, 6, 15 for n in {1, 3, 5}.
#[test]
fn nested_let_rec_demo() {
    let stdout = build_and_run("nested_let_rec.ail.json");
    let lines: Vec<&str> = stdout.lines().collect();
    assert_eq!(lines, vec!["1", "6", "15"]);
}

/// Guards `ail diff`: a modified body changes the hash of `sum`, while
/// `main` stays unchanged. Expects exit code 1, `changed` contains exactly
/// `sum`, `unchanged` contains `main`, `added`/`removed` empty.
#[test]
fn diff_detects_changed_def() {
    let manifest_dir = env!("CARGO_MANIFEST_DIR");
    let workspace = Path::new(manifest_dir).parent().unwrap().parent().unwrap();
    let src_a = workspace.join("examples").join("sum.ail.json");

    // Variant: load sum.ail.json, mutate the `then` branch (1 instead of 0)
    // in the `sum` definition. `main` stays bit-identical.
    let raw = std::fs::read(&src_a).expect("read sum.ail.json");
    let mut module: serde_json::Value = serde_json::from_slice(&raw).expect("parse sum.ail.json");
    {
        let defs = module
            .get_mut("defs")
            .and_then(|d| d.as_array_mut())
            .expect("defs array");
        for def in defs.iter_mut() {
            if def.get("name").and_then(|n| n.as_str()) == Some("sum") {
                // Replace the then branch literal 0 → literal 1.
                let new_then = serde_json::json!({
                    "t": "lit",
                    "lit": { "kind": "int", "value": 1 }
                });
                def["body"]["then"] = new_then;
            }
        }
    }

    let tmp = std::env::temp_dir().join(format!(
        "ailang_diff_changed_{}",
        std::process::id()
    ));
    std::fs::create_dir_all(&tmp).unwrap();
    let path_b = tmp.join("sum_v2.ail.json");
    std::fs::write(&path_b, serde_json::to_vec_pretty(&module).unwrap())
        .expect("write sum_v2.ail.json");

    let output = Command::new(ail_bin())
        .args([
            "diff",
            src_a.to_str().unwrap(),
            path_b.to_str().unwrap(),
            "--json",
        ])
        .output()
        .expect("ail diff failed to run");

    let code = output.status.code().expect("process terminated by signal");
    assert_eq!(
        code,
        1,
        "expected exit code 1 for differing modules; stderr: {}",
        String::from_utf8_lossy(&output.stderr)
    );

    let stdout = String::from_utf8(output.stdout).expect("stdout utf8");
    let v: serde_json::Value =
        serde_json::from_str(stdout.trim()).expect("stdout must be valid JSON");

    let added = v["added"].as_array().expect("added array");
    let removed = v["removed"].as_array().expect("removed array");
    let changed = v["changed"].as_array().expect("changed array");
    let unchanged = v["unchanged"].as_array().expect("unchanged array");

    assert!(added.is_empty(), "expected added empty: {added:?}");
    assert!(removed.is_empty(), "expected removed empty: {removed:?}");
    assert_eq!(changed.len(), 1, "expected one changed entry: {changed:?}");
    assert_eq!(
        changed[0].get("name").and_then(|n| n.as_str()),
        Some("sum")
    );
    assert_ne!(
        changed[0].get("hash_a").and_then(|n| n.as_str()),
        changed[0].get("hash_b").and_then(|n| n.as_str()),
        "hash_a and hash_b must differ for a changed def"
    );

    assert!(
        unchanged.iter().any(|e| e.get("name").and_then(|n| n.as_str()) == Some("main")),
        "expected `main` in unchanged: {unchanged:?}"
    );
}

/// Diff of a module with itself: exit 0, all lists except `unchanged` empty.
#[test]
fn diff_no_changes_exit_zero() {
    let manifest_dir = env!("CARGO_MANIFEST_DIR");
    let workspace = Path::new(manifest_dir).parent().unwrap().parent().unwrap();
    let src = workspace.join("examples").join("sum.ail.json");

    let output = Command::new(ail_bin())
        .args([
            "diff",
            src.to_str().unwrap(),
            src.to_str().unwrap(),
            "--json",
        ])
        .output()
        .expect("ail diff failed to run");

    let code = output.status.code().expect("process terminated by signal");
    assert_eq!(
        code,
        0,
        "expected exit code 0 for identical modules; stderr: {}",
        String::from_utf8_lossy(&output.stderr)
    );

    let stdout = String::from_utf8(output.stdout).expect("stdout utf8");
    let v: serde_json::Value =
        serde_json::from_str(stdout.trim()).expect("stdout must be valid JSON");

    assert!(v["added"].as_array().unwrap().is_empty());
    assert!(v["removed"].as_array().unwrap().is_empty());
    assert!(v["changed"].as_array().unwrap().is_empty());
    assert!(
        !v["unchanged"].as_array().unwrap().is_empty(),
        "self-diff should report unchanged defs"
    );
}

/// Guards the workspace loader (Iter 5a): the entry module `ws_main`
/// imports `ws_lib`; both must be found by the loader, loaded, and listed
/// in the JSON output. Cross-module typecheck/codegen is explicitly not
/// part of this test — it only verifies the loader pipeline.
#[test]
fn workspace_lists_imported_modules() {
    let manifest_dir = env!("CARGO_MANIFEST_DIR");
    let workspace = Path::new(manifest_dir).parent().unwrap().parent().unwrap();
    let entry = workspace.join("examples").join("ws_main.ail.json");

    let output = Command::new(ail_bin())
        .args(["workspace", entry.to_str().unwrap(), "--json"])
        .output()
        .expect("ail workspace failed to run");

    assert!(
        output.status.success(),
        "ail workspace exited non-zero; stderr: {}",
        String::from_utf8_lossy(&output.stderr)
    );

    let stdout = String::from_utf8(output.stdout).expect("stdout utf8");
    let v: serde_json::Value =
        serde_json::from_str(stdout.trim()).expect("stdout must be valid JSON");

    assert_eq!(
        v.get("entry").and_then(|n| n.as_str()),
        Some("ws_main"),
        "entry must be ws_main: {stdout}"
    );

    let modules = v["modules"].as_array().expect("modules must be array");
    // Iter 23.1: the loader auto-injects the `prelude` module, so
    // the count is the user's two modules plus prelude.
    assert_eq!(modules.len(), 3, "expected 3 modules: {stdout}");

    let names: Vec<&str> = modules
        .iter()
        .filter_map(|m| m.get("name").and_then(|n| n.as_str()))
        .collect();
    assert!(names.contains(&"ws_main"), "ws_main missing: {names:?}");
    assert!(names.contains(&"ws_lib"), "ws_lib missing: {names:?}");
    assert!(names.contains(&"prelude"), "prelude missing: {names:?}");
}

/// Guards Iter 5b: `ail check examples/ws_main.ail.json --json` must
/// resolve the cross-module call `ws_lib.add`. Expected: exit 0,
/// stdout exactly `[]` (empty diagnostic array).
#[test]
fn check_workspace_resolves_import() {
    let manifest_dir = env!("CARGO_MANIFEST_DIR");
    let workspace = Path::new(manifest_dir).parent().unwrap().parent().unwrap();
    let entry = workspace.join("examples").join("ws_main.ail.json");

    let output = Command::new(ail_bin())
        .args(["check", entry.to_str().unwrap(), "--json"])
        .output()
        .expect("ail check --json failed to run");

    let code = output.status.code().expect("process terminated by signal");
    assert_eq!(
        code,
        0,
        "expected exit 0; stderr: {}; stdout: {}",
        String::from_utf8_lossy(&output.stderr),
        String::from_utf8_lossy(&output.stdout),
    );

    let stdout = String::from_utf8(output.stdout).expect("stdout utf8");
    assert_eq!(stdout.trim(), "[]", "expected empty diagnostic array");
}

/// Guards Iter 5c (cross-module codegen): `ail build` over
/// `examples/ws_main.ail.json` must lower the workspace incl. `ws_lib`;
/// `@ail_ws_main_main` must call `@ail_ws_lib_add(2,3)` and print `5`.
#[test]
fn workspace_build_runs_imported_fn() {
    let stdout = build_and_run("ws_main.ail.json");
    assert_eq!(stdout.trim(), "5", "ws_lib.add(2,3) should print 5");
}

/// Guards Iter 23.1: `examples/ordering_match.ail.json` pattern-matches
/// on the prelude `Ordering` ctor `LT` without an explicit prelude import.
/// End-to-end: typecheck → codegen → link → run → stdout exactly `"1\n"`.
/// Property: the implicit prelude import wired by `build_check_env` /
/// `check_in_workspace` (Iter 23.1 Task 3) propagates through codegen so
/// the bare ctor name `LT` resolves cross-module without an explicit
/// `imports: ["prelude"]` entry in the source module.
#[test]
fn ordering_match_via_prelude_prints_1() {
    let stdout = build_and_run("ordering_match.ail.json");
    assert_eq!(stdout, "1\n");
}

/// Iter 23.2: end-to-end coverage for the auto-loaded `Eq` class
/// + three primitive instances. The fixture calls `eq` on Int /
/// Bool / Str with both an equal and an unequal pair each; the
/// monomorphiser synthesises `eq__Int` / `eq__Bool` / `eq__Str`
/// in the prelude module; the binary prints six lines, one per
/// call. Equal-call lines are `1`, unequal-call lines are `0`.
#[test]
fn eq_primitives_smoke_compiles_and_runs() {
    let stdout = build_and_run("eq_primitives_smoke.ail.json");
    assert_eq!(stdout, "1\n0\n1\n0\n1\n0\n");
}

/// Guards Iter 5d: `ail manifest --workspace --json` lists defs from all
/// modules of the workspace — visible via a `module` field per entry.
#[test]
fn manifest_workspace_lists_all_defs() {
    let manifest_dir = env!("CARGO_MANIFEST_DIR");
    let workspace = Path::new(manifest_dir).parent().unwrap().parent().unwrap();
    let entry = workspace.join("examples").join("ws_main.ail.json");

    let output = Command::new(ail_bin())
        .args([
            "manifest",
            entry.to_str().unwrap(),
            "--workspace",
            "--json",
        ])
        .output()
        .expect("ail manifest --workspace failed to run");

    assert!(
        output.status.success(),
        "ail manifest --workspace exited non-zero; stderr: {}",
        String::from_utf8_lossy(&output.stderr)
    );

    let stdout = String::from_utf8(output.stdout).expect("stdout utf8");
    let v: serde_json::Value =
        serde_json::from_str(stdout.trim()).expect("stdout must be valid JSON");

    let symbols = v["symbols"].as_array().expect("symbols must be array");
    let modules: Vec<&str> = symbols
        .iter()
        .filter_map(|s| s.get("module").and_then(|m| m.as_str()))
        .collect();
    assert!(
        modules.contains(&"ws_main"),
        "expected at least one symbol with module=ws_main; got {modules:?}"
    );
    assert!(
        modules.contains(&"ws_lib"),
        "expected at least one symbol with module=ws_lib; got {modules:?}"
    );
}

/// Guards Iter 5d: `ail describe --workspace ws_lib.add` resolves the
/// qualified dot notation to the actually imported module.
#[test]
fn describe_workspace_resolves_qualified_name() {
    let manifest_dir = env!("CARGO_MANIFEST_DIR");
    let workspace = Path::new(manifest_dir).parent().unwrap().parent().unwrap();
    let entry = workspace.join("examples").join("ws_main.ail.json");

    let output = Command::new(ail_bin())
        .args([
            "describe",
            entry.to_str().unwrap(),
            "ws_lib.add",
            "--workspace",
            "--json",
        ])
        .output()
        .expect("ail describe --workspace failed to run");

    assert!(
        output.status.success(),
        "ail describe --workspace exited non-zero; stderr: {}",
        String::from_utf8_lossy(&output.stderr)
    );

    let stdout = String::from_utf8(output.stdout).expect("stdout utf8");
    let v: serde_json::Value =
        serde_json::from_str(stdout.trim()).expect("stdout must be valid JSON");

    assert_eq!(
        v.get("module").and_then(|m| m.as_str()),
        Some("ws_lib"),
        "module field must be ws_lib; got {stdout}"
    );
    assert_eq!(
        v.get("name").and_then(|n| n.as_str()),
        Some("add"),
        "def name must be add; got {stdout}"
    );
}

/// Guards Iter 6: `ail deps` no longer surfaces built-ins, params, or
/// let-bindings as edges. The single-module `sum` def in `sum.ail.json`
/// uses `+`, `-`, `==`, the param `n`, and the recursive call `sum`.
/// Only the recursive call must remain.
#[test]
fn deps_filters_builtins_params_locals() {
    let manifest_dir = env!("CARGO_MANIFEST_DIR");
    let workspace = Path::new(manifest_dir).parent().unwrap().parent().unwrap();
    let src = workspace.join("examples").join("sum.ail.json");

    let output = Command::new(ail_bin())
        .args(["deps", src.to_str().unwrap(), "--of", "sum", "--json"])
        .output()
        .expect("ail deps failed to run");
    assert!(
        output.status.success(),
        "ail deps exited non-zero; stderr: {}",
        String::from_utf8_lossy(&output.stderr)
    );

    let stdout = String::from_utf8(output.stdout).expect("stdout utf8");
    let v: serde_json::Value = serde_json::from_str(stdout.trim()).expect("valid JSON");
    let arr = v.as_array().expect("array of {name, refs}");
    let entry = arr
        .iter()
        .find(|e| e["name"].as_str() == Some("sum"))
        .expect("sum entry present");
    let refs: Vec<&str> = entry["refs"]
        .as_array()
        .expect("refs array")
        .iter()
        .filter_map(|x| x.as_str())
        .collect();
    assert_eq!(
        refs,
        vec!["sum"],
        "expected only the recursive call `sum`; got {refs:?}"
    );
}

/// Guards Iter 6 in workspace mode: edges into built-ins (`ws_lib.+`)
/// and into the params (`ws_lib.a`, `ws_lib.b`) of `ws_lib.add` must be
/// gone after the deps refactor.
#[test]
fn deps_workspace_filters_builtins_and_params() {
    let manifest_dir = env!("CARGO_MANIFEST_DIR");
    let workspace = Path::new(manifest_dir).parent().unwrap().parent().unwrap();
    let entry = workspace.join("examples").join("ws_main.ail.json");

    let output = Command::new(ail_bin())
        .args(["deps", entry.to_str().unwrap(), "--workspace", "--json"])
        .output()
        .expect("ail deps --workspace failed to run");
    assert!(output.status.success());

    let stdout = String::from_utf8(output.stdout).expect("stdout utf8");
    let v: serde_json::Value = serde_json::from_str(stdout.trim()).expect("valid JSON");
    let edges = v["edges"].as_array().expect("edges array");

    for e in edges {
        let to_def = e.get("to_def").and_then(|x| x.as_str()).unwrap_or("");
        assert_ne!(to_def, "+", "built-in `+` must not appear: {e}");
        assert_ne!(to_def, "a", "param `a` must not appear: {e}");
        assert_ne!(to_def, "b", "param `b` must not appear: {e}");
    }
}

/// Guards Iter 5d: `ail deps --workspace` contains a cross-module edge
/// `ws_main.main -> ws_lib.add`, because `ws_main` calls `ws_lib.add(2,3)`.
#[test]
fn deps_workspace_includes_cross_module() {
    let manifest_dir = env!("CARGO_MANIFEST_DIR");
    let workspace = Path::new(manifest_dir).parent().unwrap().parent().unwrap();
    let entry = workspace.join("examples").join("ws_main.ail.json");

    let output = Command::new(ail_bin())
        .args(["deps", entry.to_str().unwrap(), "--workspace", "--json"])
        .output()
        .expect("ail deps --workspace failed to run");

    assert!(
        output.status.success(),
        "ail deps --workspace exited non-zero; stderr: {}",
        String::from_utf8_lossy(&output.stderr)
    );

    let stdout = String::from_utf8(output.stdout).expect("stdout utf8");
    let v: serde_json::Value =
        serde_json::from_str(stdout.trim()).expect("stdout must be valid JSON");
    let edges = v["edges"].as_array().expect("edges must be array");

    let has_cross_edge = edges.iter().any(|e| {
        e.get("from_module").and_then(|s| s.as_str()) == Some("ws_main")
            && e.get("from_def").and_then(|s| s.as_str()) == Some("main")
            && e.get("to_module").and_then(|s| s.as_str()) == Some("ws_lib")
            && e.get("to_def").and_then(|s| s.as_str()) == Some("add")
    });
    assert!(
        has_cross_edge,
        "expected cross-module edge ws_main.main -> ws_lib.add; got {stdout}"
    );
}

/// Guards Iter 5d: `ail diff --workspace` detects an additional module
/// in the B workspace as an `added_modules` entry and exits with code 1.
#[test]
fn diff_workspace_added_module() {
    use std::fs;

    fn write_module(dir: &Path, name: &str, body: serde_json::Value) {
        let p = dir.join(format!("{name}.ail.json"));
        fs::write(&p, serde_json::to_vec_pretty(&body).unwrap()).unwrap();
    }

    let dir_a = std::env::temp_dir().join(format!(
        "ailang_diff_ws_a_{}",
        std::process::id()
    ));
    let dir_b = std::env::temp_dir().join(format!(
        "ailang_diff_ws_b_{}",
        std::process::id()
    ));
    let _ = fs::remove_dir_all(&dir_a);
    let _ = fs::remove_dir_all(&dir_b);
    fs::create_dir_all(&dir_a).unwrap();
    fs::create_dir_all(&dir_b).unwrap();

    // Both workspaces have an empty module `root`. B additionally
    // imports `extra`. Loader convention: `<root_dir>/<name>.ail.json`,
    // module name must match the file name.
    let empty_root = serde_json::json!({
        "schema": "ailang/v0",
        "name": "root",
        "imports": [],
        "defs": [],
    });
    let root_with_import = serde_json::json!({
        "schema": "ailang/v0",
        "name": "root",
        "imports": [{ "module": "extra" }],
        "defs": [],
    });
    let extra = serde_json::json!({
        "schema": "ailang/v0",
        "name": "extra",
        "imports": [],
        "defs": [],
    });

    write_module(&dir_a, "root", empty_root);
    write_module(&dir_b, "root", root_with_import);
    write_module(&dir_b, "extra", extra);

    let entry_a = dir_a.join("root.ail.json");
    let entry_b = dir_b.join("root.ail.json");

    let output = Command::new(ail_bin())
        .args([
            "diff",
            entry_a.to_str().unwrap(),
            entry_b.to_str().unwrap(),
            "--workspace",
            "--json",
        ])
        .output()
        .expect("ail diff --workspace failed to run");

    let code = output.status.code().expect("process terminated by signal");
    assert_eq!(
        code,
        1,
        "expected exit 1 when workspaces differ; stderr: {}",
        String::from_utf8_lossy(&output.stderr)
    );

    let stdout = String::from_utf8(output.stdout).expect("stdout utf8");
    let v: serde_json::Value =
        serde_json::from_str(stdout.trim()).expect("stdout must be valid JSON");

    let added = v["added_modules"]
        .as_array()
        .expect("added_modules must be array");
    assert!(
        added.iter().any(|e| e.get("name").and_then(|n| n.as_str()) == Some("extra")),
        "expected `extra` in added_modules; got {stdout}"
    );
    let removed = v["removed_modules"]
        .as_array()
        .expect("removed_modules must be array");
    assert!(
        removed.is_empty(),
        "expected removed_modules empty; got {stdout}"
    );
}

/// Guards the `--json` diagnostic format for tooling consumers.
/// `broken_unbound.ail.json` references a non-existent variable;
/// exit code 1 is expected, plus at least one diagnostic with
/// `severity == "error"` and `code == "unbound-var"`.
#[test]
fn check_json_unbound_var() {
    let manifest_dir = env!("CARGO_MANIFEST_DIR");
    let workspace = Path::new(manifest_dir).parent().unwrap().parent().unwrap();
    let src = workspace.join("examples").join("broken_unbound.ail.json");

    let output = Command::new(ail_bin())
        .args(["check", src.to_str().unwrap(), "--json"])
        .output()
        .expect("ail check --json failed to run");

    let code = output.status.code().expect("process terminated by signal");
    assert_eq!(code, 1, "expected exit code 1, stderr: {}", String::from_utf8_lossy(&output.stderr));

    let stdout = String::from_utf8(output.stdout).expect("stdout utf8");
    let diags: serde_json::Value =
        serde_json::from_str(stdout.trim()).expect("stdout must be valid JSON");
    let arr = diags.as_array().expect("diagnostics must be a JSON array");
    assert!(
        arr.iter().any(|d| {
            d.get("severity").and_then(|v| v.as_str()) == Some("error")
                && d.get("code").and_then(|v| v.as_str()) == Some("unbound-var")
        }),
        "expected at least one error diagnostic with code unbound-var; got: {stdout}"
    );
}

/// Iter 16c: literal patterns at top level and inside Ctor
/// sub-patterns. Property protected: the 16a desugar pass rewrites
/// every `Pattern::Lit` to a `Term::If` against the scrutinee/field
/// before either typecheck or codegen sees it. Without 16c, the
/// codegen would emit a Match arm with a Lit pattern and produce
/// either a wrong result or an `unreachable!` panic depending on
/// the path. The fixture exercises both sites: `classify` (top-
/// level lit arms) and `categorize_first` (nested lit-in-Ctor).
///
/// Iter 16d update: the trailing `(case _ 0)` arm of
/// `categorize_first` was removed when the chain machinery's
/// terminator switched from a `Unit` literal to the polymorphic
/// `__unreachable__` builtin. Output is unchanged — this test
/// guards both 16c (lit-pattern desugar) and 16d (no-workaround
/// chain default).
#[test]
fn lit_pat_demo() {
    let stdout = build_and_run("lit_pat.ail.json");
    let lines: Vec<&str> = stdout.lines().collect();
    assert_eq!(lines, vec!["100", "200", "999", "-1", "0", "7"]);
}

/// Iter 16d: `__unreachable__` as an explicit user-callable
/// primitive. Property protected: a polymorphic `forall a. a` value
/// reference (a) typechecks against any expected type at the use
/// site (here `Int`), and (b) codegen lowers it to LLVM
/// `unreachable`, with the surrounding `if` correctly forwarding
/// the live branch's value past the terminated branch. The fixture
/// only ever hits the live branch, so the binary must succeed and
/// print the expected outputs.
#[test]
fn unreachable_demo() {
    let stdout = build_and_run("unreachable_demo.ail.json");
    let lines: Vec<&str> = stdout.lines().collect();
    assert_eq!(lines, vec!["4", "5"]);
}

/// Iter 18a: `(borrow T)` and `(own T)` mode annotations on
/// fn-type parameters. Properties protected:
/// (1) the parser accepts `(borrow ...)` and `(own ...)` only as
///     wrappers in fn-type param/ret slots and round-trips them
///     into [`ailang_core::ParamMode`] on `Type::Fn`;
/// (2) the canonical-JSON serialisation emits `param_modes` only
///     when at least one mode is non-Implicit (verified by reading
///     the file and confirming the substring is present), so
///     pre-18a fixtures continue to hash bit-identically;
/// (3) the typechecker treats modes as transparent — it accepts a
///     fn whose parameter is `(borrow (con List))` even when the
///     same value is later passed to a fn whose parameter is
///     `(own (con List))`, because Iter 18a does not enforce
///     mode compatibility (deferred to 18c);
/// (4) codegen ignores modes entirely: list_length and sum_list
///     compile to the same LLVM IR they would have without the
///     wrappers, and the binary prints `3` then `6`.
#[test]
fn borrow_own_demo_modes_are_metadata_only() {
    // Sanity-check (2): the on-disk JSON contains `param_modes`
    // tokens for both fns. If the schema regressed and the field
    // were dropped, this would fail before the binary even runs.
    let manifest_dir = env!("CARGO_MANIFEST_DIR");
    let workspace = Path::new(manifest_dir).parent().unwrap().parent().unwrap();
    let json_path = workspace.join("examples").join("borrow_own_demo.ail.json");
    let json = std::fs::read_to_string(&json_path).expect("read borrow_own_demo.ail.json");
    assert!(
        json.contains("\"param_modes\":[\"borrow\"]"),
        "expected `param_modes:[\"borrow\"]` in canonical JSON; the schema for `(borrow T)` regressed"
    );
    assert!(
        json.contains("\"param_modes\":[\"own\"]"),
        "expected `param_modes:[\"own\"]` in canonical JSON; the schema for `(own T)` regressed"
    );

    let stdout = build_and_run("borrow_own_demo.ail.json");
    let lines: Vec<&str> = stdout.lines().collect();
    assert_eq!(lines, vec!["3", "6"]);
}

/// Iter 16e: polymorphic `==`. Properties protected:
/// (1) `==` typechecks at Int / Bool / Str / Unit (the fixture
///     uses every supported case directly via `(app == ...)`);
/// (2) codegen dispatches each arg-type to the right LLVM shape
///     — `icmp eq i64`, `icmp eq i1`, `@strcmp + icmp eq i32 0`,
///     constant `i1 true` — and the binary's stdout matches the
///     boolean truth-table for those operators;
/// (3) 16c's `build_eq` desugar of `(pat-lit "hi")` over a `Str`
///     scrutinee now goes through, since `==` no longer rejects
///     non-Int args at typecheck.
#[test]
fn eq_demo() {
    let stdout = build_and_run("eq_demo.ail.json");
    let lines: Vec<&str> = stdout.lines().collect();
    assert_eq!(
        lines,
        vec![
            "true", "false", // == on Int
            "false", "true", // == on Bool
            "true", "false", // == on Str
            "true", // == on Unit
            "1", "2", "0", // classify_str: Str lit-pattern desugar
        ]
    );
}

/// Iter 18b: --alloc=rc routes allocation through ailang_rc_alloc
/// (8-byte refcount header, libc malloc backing). With no inc/dec
/// emission yet (18c work), programs leak under this mode but must
/// still produce correct stdout — that's the validation 18b ships.
#[test]
fn alloc_rc_produces_same_stdout_as_gc() {
    // Pick a fixture with non-trivial allocation: list-building + match.
    let example = "list.ail.json";
    let stdout_gc = build_and_run_with_alloc(example, "gc");
    let stdout_rc = build_and_run_with_alloc(example, "rc");
    assert_eq!(stdout_gc, stdout_rc, "alloc=rc must match alloc=gc");
    assert_eq!(stdout_rc.trim(), "42");
}

/// Iter 18c.1: `Term::Clone` is a pure schema addition. In 18c.1 the
/// wrapper is identity for both typechecker (same type as inner) and
/// codegen (same SSA reg, no extra IR) — programs that contain
/// `(clone X)` produce the same stdout they would have produced
/// without the wrapper. Iter 18c.3 will replace the codegen
/// passthrough with `call void @ailang_rc_inc` under `--alloc=rc`.
#[test]
fn clone_demo_is_identity_in_18c1() {
    let stdout = build_and_run("clone_demo.ail.json");
    assert_eq!(stdout.trim(), "42");
}

/// Iter 18d.2: under `--alloc=rc`, `Term::ReuseAs { source, body =
/// Term::Ctor }` lowers to a runtime refcount-1 dispatch — when the
/// source's box is unique we overwrite it in place (skipping the
/// alloc + cascade-dec round-trip); otherwise we fall back to a
/// fresh allocation and dec the source. Other allocators keep the
/// 18d.1 identity behaviour.
///
/// Properties guarded:
/// (1) The fixture's canonical JSON contains `"t":"reuse-as"` — the
///     schema for the new variant did not regress.
/// (2) `--alloc=gc` produces `9` (1+1 + 2+1 + 3+1) — the legacy
///     identity codegen still produces correct output.
/// (3) `--alloc=rc` produces the SAME `9` — the in-place rewrite is
///     observably equivalent to the gc path on this fixture.
/// (4) `--alloc=rc` exits cleanly (status 0; no segfault, no
///     refcount underflow). `build_and_run_with_alloc` panics on
///     non-zero exit, so this is implicit but worth naming.
/// (5) The emitted IR for `--alloc=rc` contains both:
///     - `icmp eq i64 %... 1` (the refcount-1 branch test)
///     - a path that does NOT call `@ailang_rc_alloc` for the inner
///       Cons ctor on the reuse arm — verified by the
///       `reuse.<id>:` block label and the absence of
///       `@ailang_rc_alloc` calls inside it.
#[test]
fn reuse_as_demo_under_rc_uses_inplace_rewrite() {
    let manifest_dir = env!("CARGO_MANIFEST_DIR");
    let workspace = Path::new(manifest_dir).parent().unwrap().parent().unwrap();
    let json_path = workspace.join("examples").join("reuse_as_demo.ail.json");
    let json = std::fs::read_to_string(&json_path).expect("read reuse_as_demo.ail.json");
    assert!(
        json.contains("\"t\":\"reuse-as\""),
        "expected `\"t\":\"reuse-as\"` in canonical JSON; the schema for `(reuse-as ...)` regressed"
    );

    // (2) gc baseline.
    let stdout_gc = build_and_run_with_alloc("reuse_as_demo.ail.json", "gc");
    assert_eq!(stdout_gc.trim(), "9");
    // (3) rc matches gc, (4) clean exit (build_and_run_with_alloc
    //     panics on non-zero status).
    let stdout_rc = build_and_run_with_alloc("reuse_as_demo.ail.json", "rc");
    assert_eq!(stdout_rc.trim(), "9");

    // (5) Inspect the rc IR text. We re-lower via the codegen API
    //     directly so the assertion runs without depending on the
    //     filesystem layout of `ail build`'s tmpdir.
    let ws = ailang_core::load_workspace(&json_path).expect("load workspace");
    let mut lifted_modules = std::collections::BTreeMap::new();
    for (mname, m) in &ws.modules {
        let desugared = ailang_core::desugar::desugar_module(m);
        let lifted = ailang_check::lift_letrecs(&desugared)
            .unwrap_or_else(|e| panic!("lift_letrecs in module `{mname}`: {e:?}"));
        lifted_modules.insert(mname.clone(), lifted);
    }
    let lifted_ws = ailang_core::Workspace {
        entry: ws.entry.clone(),
        modules: lifted_modules,
        root_dir: ws.root_dir.clone(),
        registry: ws.registry.clone(),
    };
    let ir = ailang_codegen::lower_workspace_with_alloc(
        &lifted_ws,
        ailang_codegen::AllocStrategy::Rc,
    )
    .expect("lower workspace under rc");
    // The refcount-1 branch test: lower_reuse_as_rc emits exactly
    // one such instruction per reuse-as site. Search lenient on the
    // SSA register name (we don't care which `%v...` was assigned).
    assert!(
        ir.contains("icmp eq i64") && ir.contains(", 1\n"),
        "rc IR missing `icmp eq i64 %... 1` (the refcount-1 branch test). IR was:\n{ir}"
    );
    assert!(
        ir.contains("br i1") && ir.contains("label %reuse"),
        "rc IR missing `br i1 ... label %reuse...` (the refcount-1 branch). IR was:\n{ir}"
    );
    // The reuse arm must NOT call `@ailang_rc_alloc` — that's the
    // whole point of the in-place rewrite. We check the substring
    // that begins with the reuse label and ends at the next label
    // (the label `rejoin.` per `lower_reuse_as_rc`'s scheme).
    let reuse_idx = ir.find("reuse.").expect("reuse label present");
    // Walk forward to the next block label after the reuse: arm.
    // `lower_reuse_as_rc` emits `reuse.<id>:` then code, then
    // `br label %rejoin.<id>`. The fresh arm immediately follows
    // as `fresh.<id>:` — that's the boundary.
    let after_reuse = &ir[reuse_idx..];
    let reuse_arm_end = after_reuse
        .find("\nfresh.")
        .expect("fresh label follows the reuse arm in lower_reuse_as_rc layout");
    let reuse_arm = &after_reuse[..reuse_arm_end];
    assert!(
        !reuse_arm.contains("@ailang_rc_alloc"),
        "reuse arm must not call @ailang_rc_alloc (the slot is reused in place). Reuse arm IR was:\n{reuse_arm}"
    );
    // Iter 18d.3: in this canonical fixture the pattern `(Cons h t)`
    // moves the only pointer-typed slot of the source's old ctor
    // (slot 1, the tail; slot 0 is Int and never a dec candidate).
    // The reuse arm therefore must emit NEITHER a per-field drop
    // call nor an outer `ailang_rc_dec` against the source — only
    // the unconditional tag store and field stores. The 18d.2 leak
    // (full slot leaking under the reuse arm regardless of moves)
    // is closed: the precise-skip behaviour matches what 18d.3's
    // moved_slots side table records for `xs` here.
    assert!(
        !reuse_arm.contains("@drop_"),
        "reuse arm must not call any `@drop_<m>_<T>` — both pointer slots are moved in this fixture. Reuse arm IR was:\n{reuse_arm}"
    );
    assert!(
        !reuse_arm.contains("@ailang_rc_dec"),
        "reuse arm must not call `@ailang_rc_dec` against the source (refcount-1 path keeps the slot alive in place). Reuse arm IR was:\n{reuse_arm}"
    );
}

/// Iter 18b: extends `alloc_rc_produces_same_stdout_as_gc` to a larger
/// fixture (`std_list_demo`) so more allocation sites — folds, maps,
/// cross-module ctors — are exercised under `--alloc=rc`. Same
/// invariant: stdout must be byte-identical to the `gc` build.
#[test]
fn alloc_rc_matches_gc_on_std_list_demo() {
    let example = "std_list_demo.ail.json";
    let stdout_gc = build_and_run_with_alloc(example, "gc");
    let stdout_rc = build_and_run_with_alloc(example, "rc");
    assert_eq!(
        stdout_gc, stdout_rc,
        "alloc=rc must match alloc=gc on std_list_demo"
    );
}

/// Iter 18c.3: codegen actually emits `ailang_rc_dec` at end-of-scope
/// for trackable RC binders under `--alloc=rc`. This is the first
/// fixture where the `dec` path runs at runtime — `b` is bound to a
/// heap-allocated `MkBox`, the box is read via `match` (a borrow,
/// `consume_count == 0`), and the let scope closes before the fn
/// returns Unit. Codegen emits `call void @ailang_rc_dec(ptr %v1)`
/// after the match's join block; the box's refcount drops to 0 and
/// the underlying `malloc`'d block is freed by `runtime/rc.c`.
///
/// Properties guarded:
/// (1) the binary still produces the expected stdout (`42`) — i.e.
///     dec does not free the box BEFORE `match` reads its `Int`
///     payload;
/// (2) the binary exits cleanly (exit code 0, no segfault) — i.e.
///     the dec call does not double-free or trip
///     `ailang_rc_dec`'s underflow guard;
/// (3) the GC and RC paths produce byte-identical stdout — i.e.
///     the dec emission did not perturb behaviour visible at the
///     output boundary.
///
/// Box has no boxed children, so the shallow-free contract of 18c.3's
/// `ailang_rc_dec` is sufficient. A recursive ADT (List, Tree) would
/// leak its boxed children silently — that's the 18c.4 scope, not
/// this iter.
#[test]
fn alloc_rc_emits_dec_for_unique_let_bound_box() {
    let example = "rc_box_drop.ail.json";
    let stdout_gc = build_and_run_with_alloc(example, "gc");
    let stdout_rc = build_and_run_with_alloc(example, "rc");
    assert_eq!(stdout_gc.trim(), "42");
    assert_eq!(stdout_rc.trim(), "42");
    assert_eq!(stdout_gc, stdout_rc, "alloc=rc must match alloc=gc on rc_box_drop");
}

/// Iter 18c.4: per-type drop fns + recursive `dec` cascade. Builds a
/// 5-element `IntList` and reduces it via `sum_list`. Under
/// `--alloc=rc` the codegen emits `define void @drop_<m>_IntList`
/// whose `Cons` arm recursively calls itself on the `tail` field —
/// the first iter where a recursive ADT under RC frees its tail
/// cells when their drop fn is invoked. Stdout must match `--alloc=gc`
/// (`15`) and the binary must exit cleanly.
///
/// Note: in this fixture the binder `xs` has `consume_count == 1`
/// (passed to `sum_list`), so codegen does NOT emit a drop call at
/// `main`'s let-close — the cells stay leaked at process exit (the
/// caller / fn-param dec story is 18d). The fixture's purpose is to
/// (a) lock in the recursive-ADT IR-shape (covered in the codegen
/// IR-shape test below) and (b) confirm the program still runs
/// correctly. The companion `rc_list_drop_borrow` fixture exercises
/// the dec path itself.
#[test]
fn alloc_rc_recursive_list_sum() {
    let example = "rc_list_drop.ail.json";
    let stdout_gc = build_and_run_with_alloc(example, "gc");
    let stdout_rc = build_and_run_with_alloc(example, "rc");
    assert_eq!(stdout_gc.trim(), "15");
    assert_eq!(stdout_rc.trim(), "15");
    assert_eq!(
        stdout_gc, stdout_rc,
        "alloc=rc must match alloc=gc on rc_list_drop"
    );
}

/// Iter 18c.4 / 18d.4: exercise the recursive drop cascade at
/// runtime AND the arm-close pattern-binder dec.
///
/// 18c.4 originally used this fixture to lock in the recursive
/// `drop_<m>_IntList` cascade at `xs`'s let-close. 18d.3 introduced
/// move-tracking and shipped a documented memory-hygiene regression:
/// the `t` pattern-binder of `(Cons h t)` was unused (consume_count
/// == 0) and the `xs` partial-drop now skipped slot 1 (moved into
/// `t`), so the 4-cell tail leaked.
///
/// 18d.4 closes that regression by emitting an arm-close drop on
/// `t` itself (symmetric to the let-close drop emission seam). At
/// runtime the Cons arm now:
///   1. Loads slot 0 (h: Int → primitive, no drop).
///   2. Loads slot 1 (t: IntList → moved out of `xs`).
///   3. Prints h.
///   4. **NEW (18d.4):** calls
///      `@drop_rc_list_drop_borrow_IntList(ptr %t)` — recursively
///      cascades through the 4-cell tail, freeing each cell.
///   5. Branches to the join.
/// And at `xs`'s let-close, the inlined partial drop skips slot 1
/// (already freed by the arm) and dec's the outer Cons cell.
///
/// Properties guarded:
///   1. The binary runs to completion (no segfault from a
///      mishandled recursive cascade or refcount underflow).
///   2. Stdout matches `--alloc=gc` byte-for-byte (`11` — the head
///      of the 5-element list).
///   3. The Cons arm's body emits the per-type drop on `t` between
///      the `print h` call and the arm's branch back to the match
///      join — the IR-shape signature of 18d.4's arm-close
///      pattern-binder dec.
#[test]
fn alloc_rc_borrow_only_recursive_list_drop() {
    let example = "rc_list_drop_borrow.ail.json";
    let stdout_gc = build_and_run_with_alloc(example, "gc");
    let stdout_rc = build_and_run_with_alloc(example, "rc");
    assert_eq!(stdout_gc.trim(), "11");
    assert_eq!(stdout_rc.trim(), "11");
    assert_eq!(
        stdout_gc, stdout_rc,
        "alloc=rc must match alloc=gc on rc_list_drop_borrow"
    );

    // Iter 18d.4 IR-shape assertion: the Cons arm of main's match
    // emits a drop on `t` after lowering the body. We re-lower under
    // rc and inspect main's IR — the arm sequence must contain
    //   call void @ai/print_int(...)            (the `print h`)
    //   call void @drop_rc_list_drop_borrow_IntList(ptr %v...)  (NEW: 18d.4 t-drop)
    //   br label %mjoin.<id>                    (arm-close branch)
    // in that order, in the Cons arm's basic block.
    let manifest_dir = env!("CARGO_MANIFEST_DIR");
    let workspace = Path::new(manifest_dir).parent().unwrap().parent().unwrap();
    let json_path = workspace.join("examples").join(example);
    let ws = ailang_core::load_workspace(&json_path).expect("load workspace");
    let mut lifted_modules = std::collections::BTreeMap::new();
    for (mname, m) in &ws.modules {
        let desugared = ailang_core::desugar::desugar_module(m);
        let lifted = ailang_check::lift_letrecs(&desugared)
            .unwrap_or_else(|e| panic!("lift_letrecs in module `{mname}`: {e:?}"));
        lifted_modules.insert(mname.clone(), lifted);
    }
    let lifted_ws = ailang_core::Workspace {
        entry: ws.entry.clone(),
        modules: lifted_modules,
        root_dir: ws.root_dir.clone(),
        registry: ws.registry.clone(),
    };
    let ir = ailang_codegen::lower_workspace_with_alloc(
        &lifted_ws,
        ailang_codegen::AllocStrategy::Rc,
    )
    .expect("lower workspace under rc");
    let main_start = ir
        .find("define i8 @ail_rc_list_drop_borrow_main(")
        .expect("main definition present");
    let main_end_offset = ir[main_start..]
        .find("\n}\n")
        .expect("main definition ends with `}`");
    let main_body = &ir[main_start..main_start + main_end_offset];
    // The arm-close drop must hit the per-type IntList drop fn (NOT
    // the shallow `ailang_rc_dec` fallback for dynamic-tag binders
    // with non-empty moved_slots — `t` has empty moved_slots in this
    // fixture because the arm body doesn't re-scrutinise it).
    assert!(
        main_body.contains("call void @drop_rc_list_drop_borrow_IntList(ptr "),
        "main's body must call `@drop_rc_list_drop_borrow_IntList(ptr ...)` for the unused Cons-arm `t` binder. main body was:\n{main_body}"
    );
}

/// Iter 18d.3: move-aware pattern bindings — let-close inlines a
/// per-field dec sequence that skips moved slots and dec's non-moved
/// (e.g. wildcarded) pointer-typed slots.
///
/// The fixture defines a `Pair (IntList, IntList)` and pattern-matches
/// it as `(Pair a _)` — the first slot is moved into `a` (consumed by
/// `sum_list a`), the second slot is wildcarded and remains owned by
/// the Pair box. At p's let-close, codegen replaces the uniform
/// `drop_<m>_Pair` call with an inline sequence:
///
///   1. NO load+drop for slot 0 (offset 8) — it is in
///      `moved_slots["p"]` → skipped.
///   2. YES load+drop for slot 1 (offset 16) via
///      `@drop_<m>_IntList(<slot1_v>)` — it is non-moved.
///   3. YES `@ailang_rc_dec(<p>)` for the outer Pair box.
///
/// Properties guarded:
///   1. The binary runs to completion under both `--alloc=gc` and
///      `--alloc=rc`.
///   2. Stdout matches `--alloc=gc` byte-for-byte (`6` — the sum of
///      the first list, [1,2,3]).
///   3. The IR at p's let-close contains an inlined per-field drop
///      for slot 1 (the wildcarded IntList) but NOT the uniform
///      `drop_<m>_Pair` call against `p`.
#[test]
fn alloc_rc_partial_drop_skips_moved_keeps_wildcarded() {
    let example = "pat_extract_partial_drop.ail.json";
    let stdout_gc = build_and_run_with_alloc(example, "gc");
    let stdout_rc = build_and_run_with_alloc(example, "rc");
    assert_eq!(stdout_gc.trim(), "6");
    assert_eq!(stdout_rc.trim(), "6");
    assert_eq!(
        stdout_gc, stdout_rc,
        "alloc=rc must match alloc=gc on pat_extract_partial_drop"
    );

    // Re-lower under rc to inspect the IR shape directly.
    let manifest_dir = env!("CARGO_MANIFEST_DIR");
    let workspace = Path::new(manifest_dir).parent().unwrap().parent().unwrap();
    let json_path = workspace.join("examples").join(example);
    let ws = ailang_core::load_workspace(&json_path).expect("load workspace");
    let mut lifted_modules = std::collections::BTreeMap::new();
    for (mname, m) in &ws.modules {
        let desugared = ailang_core::desugar::desugar_module(m);
        let lifted = ailang_check::lift_letrecs(&desugared)
            .unwrap_or_else(|e| panic!("lift_letrecs in module `{mname}`: {e:?}"));
        lifted_modules.insert(mname.clone(), lifted);
    }
    let lifted_ws = ailang_core::Workspace {
        entry: ws.entry.clone(),
        modules: lifted_modules,
        root_dir: ws.root_dir.clone(),
        registry: ws.registry.clone(),
    };
    let ir = ailang_codegen::lower_workspace_with_alloc(
        &lifted_ws,
        ailang_codegen::AllocStrategy::Rc,
    )
    .expect("lower workspace under rc");

    // The inline partial drop must dispatch through the IntList drop
    // fn for the wildcarded slot 1. The presence of THIS specific
    // call — `@drop_pat_extract_partial_drop_IntList(ptr ...)` — in
    // main's body proves a non-moved pointer slot was dec'd inline.
    assert!(
        ir.contains("call void @drop_pat_extract_partial_drop_IntList(ptr "),
        "expected an inline `@drop_pat_extract_partial_drop_IntList(ptr ...)` for the wildcarded slot 1 of `p`. IR was:\n{ir}"
    );
    // Conversely, the uniform Pair drop fn (which would cascade
    // through BOTH slots — the wrong shape because slot 0 was
    // moved) must NOT be called at p's let-close. The Pair drop
    // fn is still EMITTED as a per-type definition (used by any
    // potential ADT-recursive cascade), but the call site at
    // let-close must be the inlined sequence.
    let main_start = ir
        .find("define i8 @ail_pat_extract_partial_drop_main(")
        .expect("main definition present");
    let main_end_offset = ir[main_start..]
        .find("\n}\n")
        .expect("main definition ends with `}`");
    let main_body = &ir[main_start..main_start + main_end_offset];
    assert!(
        !main_body.contains("@drop_pat_extract_partial_drop_Pair("),
        "main's body must not call the uniform `@drop_pat_extract_partial_drop_Pair(...)` — at let-close, the moved-slot-aware inline sequence replaces it. main body was:\n{main_body}"
    );
    assert!(
        main_body.contains("call void @ailang_rc_dec(ptr "),
        "main's body must call `@ailang_rc_dec(ptr ...)` for the outer Pair box at let-close. main body was:\n{main_body}"
    );
}

/// Iter 18d.4: Own-param dec at fn return. Symmetric to 18c.3/18c.4's
/// `Term::Let`-scope-close drop emission and 18d.4's arm-close
/// pattern-binder dec, fired at the lexical close of a fn body. The
/// `head_or_zero` fn declares its only parameter `xs` as
/// `(own (con IntList))` — the static "caller handed off ownership"
/// signal. Under `--alloc=rc`, codegen now emits a drop call against
/// the param SSA (`%arg_xs`) before the fn's `ret`.
///
/// Properties guarded:
///   1. The binary runs to completion under both `--alloc=gc` and
///      `--alloc=rc` (no segfault from a mishandled cascade or
///      refcount underflow when the cascade hits the moved tail).
///   2. Stdout matches `--alloc=gc` byte-for-byte (`11` — the head
///      of the 5-element list).
///   3. `head_or_zero`'s body contains a drop call against
///      `%arg_xs` BEFORE the `ret i64`. The drop may be the
///      per-type `@drop_<m>_IntList(ptr %arg_xs)` (when no slots
///      were moved out of `xs` — not the canonical case here) OR
///      the inlined-partial fallback `@ailang_rc_dec(ptr %arg_xs)`
///      (the canonical case here, since the Cons arm pattern-binds
///      slot 1 into `t`, recording `moved_slots[xs] = {1}`). Either
///      shape is acceptable per the iter brief; what matters is
///      that ANY drop fires.
#[test]
fn alloc_rc_own_param_dec_at_fn_return() {
    let example = "rc_own_param_drop.ail.json";
    let stdout_gc = build_and_run_with_alloc(example, "gc");
    let stdout_rc = build_and_run_with_alloc(example, "rc");
    assert_eq!(stdout_gc.trim(), "11");
    assert_eq!(stdout_rc.trim(), "11");
    assert_eq!(
        stdout_gc, stdout_rc,
        "alloc=rc must match alloc=gc on rc_own_param_drop"
    );

    // Re-lower under rc to inspect head_or_zero's IR shape.
    let manifest_dir = env!("CARGO_MANIFEST_DIR");
    let workspace = Path::new(manifest_dir).parent().unwrap().parent().unwrap();
    let json_path = workspace.join("examples").join(example);
    let ws = ailang_core::load_workspace(&json_path).expect("load workspace");
    let mut lifted_modules = std::collections::BTreeMap::new();
    for (mname, m) in &ws.modules {
        let desugared = ailang_core::desugar::desugar_module(m);
        let lifted = ailang_check::lift_letrecs(&desugared)
            .unwrap_or_else(|e| panic!("lift_letrecs in module `{mname}`: {e:?}"));
        lifted_modules.insert(mname.clone(), lifted);
    }
    let lifted_ws = ailang_core::Workspace {
        entry: ws.entry.clone(),
        modules: lifted_modules,
        root_dir: ws.root_dir.clone(),
        registry: ws.registry.clone(),
    };
    let ir = ailang_codegen::lower_workspace_with_alloc(
        &lifted_ws,
        ailang_codegen::AllocStrategy::Rc,
    )
    .expect("lower workspace under rc");

    // Locate head_or_zero's body in the IR.
    let fn_start = ir
        .find("define i64 @ail_rc_own_param_drop_head_or_zero(")
        .expect("head_or_zero definition present");
    let fn_end_offset = ir[fn_start..]
        .find("\n}\n")
        .expect("head_or_zero ends with `}`");
    let fn_body = &ir[fn_start..fn_start + fn_end_offset];

    // The Own-param drop must fire on `%arg_xs` somewhere in
    // head_or_zero's body. Three acceptable shapes:
    //   - per-type drop `@drop_<m>_IntList(ptr %arg_xs)` (empty
    //     moved_slots case),
    //   - tag-conditional partial-drop
    //     `@partial_drop_<m>_IntList(ptr %arg_xs, i64 <mask>)`
    //     (Iter 18g.tidy.fu2: non-empty moved_slots, dynamic-tag),
    //   - shallow `@ailang_rc_dec(ptr %arg_xs)` (legacy fallback;
    //     the typechecker accepts non-ADT param types but moved_slots
    //     cannot populate for those, so this branch is dead in
    //     practice).
    // The canonical fixture here hits the partial-drop shape: the
    // Cons arm moves slot 1 into the arm-bound `t`, so
    // moved_slots[xs] = {1} at fn return → mask=2.
    let has_per_type_drop =
        fn_body.contains("call void @drop_rc_own_param_drop_IntList(ptr %arg_xs)");
    let has_partial_drop = fn_body
        .contains("call void @partial_drop_rc_own_param_drop_IntList(ptr %arg_xs,");
    let has_shallow_dec = fn_body.contains("call void @ailang_rc_dec(ptr %arg_xs)");
    assert!(
        has_per_type_drop || has_partial_drop || has_shallow_dec,
        "head_or_zero's body must drop the Own-mode param `xs` (per-type, partial-drop, or shallow dec) before the fn's `ret`. Body was:\n{fn_body}"
    );

    // The drop must precede the fn's `ret`. Scope: any `ret i64 ...`
    // in the body comes AFTER the drop call site. We check by
    // splitting on the `ret` instruction and ensuring at least one
    // drop call occurred before each ret.
    //
    // Implementation note: the body has a single `ret` (the join
    // block's value) since `head_or_zero` returns Int unconditionally
    // at the body root. The drop is emitted right before that ret
    // by the Iter 18d.4 emit_fn pre-ret seam.
    let ret_idx = fn_body.find("\n  ret i64").expect("head_or_zero has a ret i64");
    let pre_ret = &fn_body[..ret_idx];
    let drop_in_pre_ret = pre_ret.contains("call void @drop_rc_own_param_drop_IntList(ptr %arg_xs)")
        || pre_ret
            .contains("call void @partial_drop_rc_own_param_drop_IntList(ptr %arg_xs,")
        || pre_ret.contains("call void @ailang_rc_dec(ptr %arg_xs)");
    assert!(
        drop_in_pre_ret,
        "head_or_zero's `%arg_xs` drop must appear BEFORE the `ret i64` instruction. Pre-ret slice was:\n{pre_ret}"
    );
}

/// Iter 18e: `(drop-iterative)` opt-in annotation. The fixture
/// declares `IntList` with `(drop-iterative)` and runs an N=1,000,000
/// cell list through `head_or_zero`. The fn signature is
/// `(own (con IntList))` — 18d.4 emits a drop on the param at fn
/// return; under 18e that drop dispatches into the worklist body of
/// `drop_<m>_<IntList>` (or, in the canonical case here, into the
/// arm-close drop on `t` since the Cons arm pattern-binds the tail).
/// Either way, freeing the 1M-cell chain runs the iterative loop in
/// `runtime/rc.c` instead of recursing 1M frames deep on the C
/// stack.
///
/// Why 1M and not 100K: at 100K the recursive cascade still fits
/// in Linux's 8MB default stack (~64B/frame). 1M overflows clean
/// without the annotation (SIGSEGV) and runs cleanly with it. The
/// test would be a false-negative — passing without the iterative
/// body — at 100K.
///
/// Properties guarded:
///   1. `--alloc=rc` produces stdout `1\n` (the head of the
///      tail-recursively-built `[1, 2, ..., 1_000_000]`).
///   2. The binary exits cleanly (status 0; no SIGSEGV from a
///      recursive cascade overflowing the C stack — which DOES
///      happen on the same fixture with the annotation removed,
///      hand-verified during 18e implementation).
///   3. `--alloc=gc` produces the same stdout (allocator-equivalence
///      backstop; 1M cells at 24B each ≈ 24MB, well within Boehm's
///      capacity).
#[test]
fn alloc_rc_drop_iterative_handles_million_cell_list() {
    let example = "rc_drop_iterative_long_list.ail.json";
    let stdout_gc = build_and_run_with_alloc(example, "gc");
    let stdout_rc = build_and_run_with_alloc(example, "rc");
    assert_eq!(stdout_rc.trim(), "1");
    assert_eq!(stdout_gc.trim(), "1");
    assert_eq!(
        stdout_gc, stdout_rc,
        "alloc=rc must match alloc=gc on rc_drop_iterative_long_list"
    );
}

/// Iter 18e: IR-shape signature of `(drop-iterative)`. The drop fn
/// for the annotated type contains a worklist loop (`br label
/// %loop_head`, the runtime-helper calls, a backedge from each ctor
/// arm) and does NOT contain a recursive `call void @drop_<m>_<T>(...)`
/// against itself — the recursion has been replaced by worklist
/// dispatch.
#[test]
fn iter18e_drop_iterative_emits_worklist_body_no_self_recursion() {
    let example = "rc_drop_iterative_long_list.ail.json";
    let manifest_dir = env!("CARGO_MANIFEST_DIR");
    let workspace = Path::new(manifest_dir).parent().unwrap().parent().unwrap();
    let json_path = workspace.join("examples").join(example);
    let ws = ailang_core::load_workspace(&json_path).expect("load workspace");
    let mut lifted_modules = std::collections::BTreeMap::new();
    for (mname, m) in &ws.modules {
        let desugared = ailang_core::desugar::desugar_module(m);
        let lifted = ailang_check::lift_letrecs(&desugared)
            .unwrap_or_else(|e| panic!("lift_letrecs in module `{mname}`: {e:?}"));
        lifted_modules.insert(mname.clone(), lifted);
    }
    let lifted_ws = ailang_core::Workspace {
        entry: ws.entry.clone(),
        modules: lifted_modules,
        root_dir: ws.root_dir.clone(),
        registry: ws.registry.clone(),
    };
    let ir = ailang_codegen::lower_workspace_with_alloc(
        &lifted_ws,
        ailang_codegen::AllocStrategy::Rc,
    )
    .expect("lower workspace under rc");

    // Locate the drop fn body. There is exactly one
    // `define void @drop_rc_drop_iterative_long_list_IntList(`.
    let drop_start = ir
        .find("define void @drop_rc_drop_iterative_long_list_IntList(")
        .expect("drop fn definition present");
    let drop_end_offset = ir[drop_start..]
        .find("\n}\n")
        .expect("drop fn ends with `}`");
    let drop_body = &ir[drop_start..drop_start + drop_end_offset];

    // Worklist signatures: backedge label, push, pop, free, all
    // present in the body.
    assert!(
        drop_body.contains("br label %loop_head"),
        "iterative drop body must contain a `br label %loop_head` backedge. Body was:\n{drop_body}"
    );
    assert!(
        drop_body.contains("call ptr @ailang_drop_worklist_new()"),
        "iterative drop body must call ailang_drop_worklist_new. Body was:\n{drop_body}"
    );
    assert!(
        drop_body.contains("call ptr @ailang_drop_worklist_pop(ptr %wl)"),
        "iterative drop body must call ailang_drop_worklist_pop. Body was:\n{drop_body}"
    );
    assert!(
        drop_body.contains("call void @ailang_drop_worklist_push(ptr %wl,"),
        "iterative drop body must call ailang_drop_worklist_push for same-type fields. Body was:\n{drop_body}"
    );
    assert!(
        drop_body.contains("call void @ailang_drop_worklist_free(ptr %wl)"),
        "iterative drop body must call ailang_drop_worklist_free at finish. Body was:\n{drop_body}"
    );

    // Critical negative: NO recursive self-call in the drop body —
    // the whole point of 18e is that the recursion was replaced by
    // worklist dispatch. (Calls to other drop fns are still allowed,
    // since cross-type ADT fields go through the regular
    // `field_drop_call` path.)
    assert!(
        !drop_body.contains(
            "call void @drop_rc_drop_iterative_long_list_IntList("
        ),
        "iterative drop body must NOT recursively call itself — the recursion was replaced by worklist dispatch. Body was:\n{drop_body}"
    );
}

/// Iter 18e: control — the same fixture WITHOUT the
/// `(drop-iterative)` annotation must still emit the recursive
/// 18c.4 body. We synthesise the unannotated module on the fly
/// (mutating the workspace's parsed AST) so this test is independent
/// of any on-disk fixture changes.
#[test]
fn iter18e_no_annotation_keeps_recursive_drop_body() {
    let example = "rc_drop_iterative_long_list.ail.json";
    let manifest_dir = env!("CARGO_MANIFEST_DIR");
    let workspace = Path::new(manifest_dir).parent().unwrap().parent().unwrap();
    let json_path = workspace.join("examples").join(example);
    let ws = ailang_core::load_workspace(&json_path).expect("load workspace");
    // Mutate every TypeDef in every module to clear the annotation.
    let mut modules = std::collections::BTreeMap::new();
    for (mname, m) in &ws.modules {
        let mut mc = m.clone();
        for d in &mut mc.defs {
            if let ailang_core::ast::Def::Type(td) = d {
                td.drop_iterative = false;
            }
        }
        let desugared = ailang_core::desugar::desugar_module(&mc);
        let lifted = ailang_check::lift_letrecs(&desugared)
            .unwrap_or_else(|e| panic!("lift_letrecs in module `{mname}`: {e:?}"));
        modules.insert(mname.clone(), lifted);
    }
    let lifted_ws = ailang_core::Workspace {
        entry: ws.entry.clone(),
        modules,
        root_dir: ws.root_dir.clone(),
        registry: ws.registry.clone(),
    };
    let ir = ailang_codegen::lower_workspace_with_alloc(
        &lifted_ws,
        ailang_codegen::AllocStrategy::Rc,
    )
    .expect("lower workspace under rc");

    let drop_start = ir
        .find("define void @drop_rc_drop_iterative_long_list_IntList(")
        .expect("drop fn definition present");
    let drop_end_offset = ir[drop_start..]
        .find("\n}\n")
        .expect("drop fn ends with `}`");
    let drop_body = &ir[drop_start..drop_start + drop_end_offset];

    // The 18c.4 recursive shape signature: a self-call AND no
    // worklist plumbing.
    assert!(
        drop_body.contains(
            "call void @drop_rc_drop_iterative_long_list_IntList(ptr %v"
        ),
        "without (drop-iterative), the body must still recursively cascade through the tail. Body was:\n{drop_body}"
    );
    assert!(
        !drop_body.contains("ailang_drop_worklist_new"),
        "without (drop-iterative), the body must not call worklist runtime helpers. Body was:\n{drop_body}"
    );
}

/// Iter 18d.4 regression: pattern-binder dec at arm close (Iter A in
/// the 18d.4 emission seam) was firing on pattern-bound pointer
/// fields whose enclosing fn is Implicit-mode. The dec freed memory
/// the caller still referenced, producing refcount underflow or
/// SIGSEGV.
///
/// The fixture's shape — a heap-allocated `t` shared between a
/// pattern-destructuring callee (`pin`) and a recursive call that
/// re-passes `t` (`loop`) — is the canonical case where the bug
/// fires. `pin` does not consume the pattern-bound children (`l`,
/// `r`); pre-fix codegen emitted `drop_<m>_<Tree>(l)` and
/// `drop_<m>_<Tree>(r)` at arm close, dec'ing pointers that lived
/// inside `t`'s box, which the outer `loop`'s next iteration still
/// expects to be valid.
///
/// Properties guarded:
/// (1) The binary exits cleanly under `--alloc=rc` (no SIGSEGV
///     from a use-after-free, no abort from
///     `ailang_rc_dec: refcount underflow` in `runtime/rc.c`).
/// (2) Stdout matches `--alloc=gc` (`0`).
///
/// Pre-fix this test fails with exit code 139 (SIGSEGV) under rc.
/// Post-fix both arms produce the same clean output. Kept as
/// regression coverage — see CLAUDE.md "Bug fixes — TDD, always".
#[test]
fn alloc_rc_pattern_bind_in_implicit_fn_does_not_dec_borrowed_children() {
    let example = "rc_pin_recurse_implicit.ail.json";
    let stdout_gc = build_and_run_with_alloc(example, "gc");
    let stdout_rc = build_and_run_with_alloc(example, "rc");
    assert_eq!(stdout_gc.trim(), "0");
    assert_eq!(stdout_rc.trim(), "0");
    assert_eq!(
        stdout_gc, stdout_rc,
        "alloc=rc must match alloc=gc on rc_pin_recurse_implicit"
    );
}

/// Iter 18g.0: build the example under `--alloc=rc`, run with
/// `AILANG_RC_STATS=1`, and return the parsed `(allocs, frees, live)`
/// triple from the runtime's atexit summary on stderr. The summary
/// line shape is fixed by `runtime/rc.c`'s `ailang_rc_stats_atexit`:
///
///     ailang_rc_stats: allocs=N frees=M live=K
///
/// Tests that need to assert allocation accounting (leaks, prompt-
/// drop coverage, refcount-based correctness) consume this triple
/// directly. `live == 0` at exit is the leak-free invariant for
/// fixtures whose `main` body returns Int/Unit; fixtures that
/// intentionally leak (e.g. Implicit-mode coverage) are gated to a
/// non-zero expected value naming the leak.
fn build_and_run_with_rc_stats(example: &str) -> (String, u64, u64, i64) {
    let manifest_dir = env!("CARGO_MANIFEST_DIR");
    let workspace = Path::new(manifest_dir).parent().unwrap().parent().unwrap();
    let src = workspace.join("examples").join(example);
    let tmp = std::env::temp_dir().join(format!(
        "ailang_e2e_rcstats_{}_{}",
        example.replace('.', "_"),
        std::process::id()
    ));
    std::fs::create_dir_all(&tmp).unwrap();
    let out = tmp.join("bin");
    let status = Command::new(ail_bin())
        .args([
            "build",
            src.to_str().unwrap(),
            "--alloc=rc",
            "-o",
        ])
        .arg(&out)
        .status()
        .expect("ail build failed to run");
    assert!(status.success(), "ail build --alloc=rc failed for {example}");
    let output = Command::new(&out)
        .env("AILANG_RC_STATS", "1")
        .output()
        .expect("execute binary");
    assert!(
        output.status.success(),
        "binary {} (--alloc=rc, AILANG_RC_STATS=1) exited non-zero",
        out.display()
    );
    let stdout = String::from_utf8(output.stdout).expect("stdout utf8");
    let stderr = String::from_utf8(output.stderr).expect("stderr utf8");
    let line = stderr
        .lines()
        .find(|l| l.starts_with("ailang_rc_stats:"))
        .unwrap_or_else(|| {
            panic!("missing ailang_rc_stats line in stderr; stderr was:\n{stderr}")
        });
    let mut allocs: Option<u64> = None;
    let mut frees: Option<u64> = None;
    let mut live: Option<i64> = None;
    for tok in line.split_whitespace() {
        if let Some(v) = tok.strip_prefix("allocs=") {
            allocs = v.parse().ok();
        } else if let Some(v) = tok.strip_prefix("frees=") {
            frees = v.parse().ok();
        } else if let Some(v) = tok.strip_prefix("live=") {
            live = v.parse().ok();
        }
    }
    (
        stdout,
        allocs.expect("missing allocs= field"),
        frees.expect("missing frees= field"),
        live.expect("missing live= field"),
    )
}

/// Iter 18g.1 regression: explicit-mode tail-recursive list-sum must
/// not leak the LCons outer cells.
///
/// Background: 18f.2's tail-latency bench found that
/// `bench_latency_explicit.ailx` peaks at the same RSS as the
/// implicit-mode variant despite carrying mode annotations
/// throughout the hot path. Diagnosis: in
///
///     (case (LCons h t) (tail-app sum_acc t (...)))
///
/// the pattern-binder `t` is consumed into the tail-call
/// (`consume_count > 0`), so 18d.4 Iter A skips the arm-close drop;
/// the outer LCons cell is now a husk (its only ptr field `t` was
/// moved out) but no drop site emits the shallow `ailang_rc_dec`
/// for it. One outer cell leaks per consumed list element.
///
/// Pre-fix on this fixture: `live = 100` (one LCons leak per
/// element of the 100-cell list).
/// Post-fix: `live = 0` (LNil + LCons cells all reach refcount
/// zero before exit).
///
/// Kept as regression coverage. The `(drop-iterative)` annotation
/// on IntList means the freeing happens via the worklist allocator,
/// not direct recursion — both paths must accumulate frees.
#[test]
fn alloc_rc_explicit_mode_tail_sum_does_not_leak_outer_cells() {
    let (stdout, allocs, frees, live) =
        build_and_run_with_rc_stats("rc_tail_sum_explicit_leak.ail.json");
    assert_eq!(
        stdout.trim(),
        "4950",
        "sum 0..99 must equal 4950 regardless of leak status"
    );
    assert_eq!(
        live, 0,
        "explicit-mode tail-sum leaks {live} cells (allocs={allocs} frees={frees}); \
         18d.4 Iter A skips the arm-close drop because t is consumed into the tail-call, \
         and no drop site emits the shallow rc_dec for the moved-from outer LCons cell"
    );
}

/// Iter 18g.2 regression: a `let`-binder whose value is the result of
/// an Own-returning function call must be dropped at let-scope close.
///
/// Background: 18c.3's `is_rc_heap_allocated` returns `false` for any
/// `Term::App` shape (the doc-comment explicitly defers the
/// owned-returning-call case to "later iters tied to `(own)` ret-mode
/// contracts"). With Iter 18a's mode contracts in place, that
/// limitation is no longer load-bearing — the call's `ret_mode == Own`
/// is the static signal that a fresh heap allocation has flowed into
/// the binder, and the let-scope close is the right place to dec it.
///
/// 18f.2's bench surfaced the user-visible cost: in
/// `(let t (app build_tree 19) ...)`, `t` was not trackable and the
/// depth-19 Tree (~1M cells) leaked at every program exit.
///
/// Pre-fix on this minimal fixture: `live = 3` (one TNode + two TLeaf
/// children).
/// Post-fix: `live = 0`.
#[test]
fn alloc_rc_let_binder_for_owned_returning_app_drops_at_scope_close() {
    let (stdout, allocs, frees, live) =
        build_and_run_with_rc_stats("rc_let_owned_app_leak.ail.json");
    assert_eq!(
        stdout.trim(),
        "1",
        "pin(TNode) returns 1 regardless of leak status"
    );
    assert_eq!(
        live, 0,
        "let-binder for an Own-returning app leaked {live} cells \
         (allocs={allocs} frees={frees}); is_rc_heap_allocated must \
         recognise Term::App with ret_mode=Own as trackable, and the \
         drop-symbol resolution must derive drop_<m>_<T> from the \
         call's return type"
    );
}

/// Iter 18g tidy negative-coverage: a let-binder whose value is the
/// result of an `Implicit`-ret-mode (default, unannotated) call must
/// NOT be trackable. Implicit is the back-compat lane that 18c.3
/// documented as "params don't get any dec — leak rather than mis-
/// dec"; the symmetric carve-out for ret-modes is "Implicit-returning
/// calls do not flow ownership to the caller, the caller must NOT
/// dec".
///
/// If `is_rc_heap_allocated` were mistakenly to fire on this shape,
/// the let-scope close would emit an unjustified dec — refcount
/// underflow at runtime (the cell's RC reaches zero before any other
/// owner had a chance to dec). The fixture under `--alloc=rc` must:
///
/// (1) Build cleanly (no consume-while-borrowed false positive,
///     parse error, or unimplemented).
/// (2) Exit cleanly with stdout `7` — the unboxed Int from the cell.
/// (3) Report `live = 1` — the one MkT cell leaks. The leak is the
///     intentional Implicit-mode behaviour; if codegen ever started
///     dec'ing this shape it would crash with refcount underflow
///     before the leak number could even be observed.
///
/// This test is the negative-side companion to
/// `alloc_rc_let_binder_for_owned_returning_app_drops_at_scope_close`:
/// the asymmetry between (own)-ret-mode (live=0) and Implicit-ret-mode
/// (live=1) is the documented contract.
#[test]
fn alloc_rc_let_binder_for_implicit_returning_app_does_not_drop() {
    let (stdout, allocs, frees, live) =
        build_and_run_with_rc_stats("rc_let_implicit_returning_app.ail.json");
    assert_eq!(stdout.trim(), "7", "alloc(7) -> unbox should print 7");
    assert_eq!(
        live, 1,
        "Implicit-ret-mode App must leak (not crash with refcount \
         underflow); allocs={allocs} frees={frees} live={live}. \
         A live count of 0 means is_rc_heap_allocated mistakenly \
         marked Implicit-ret-mode App as trackable; a non-zero exit \
         means the unjustified dec triggered the underflow guard."
    );
}

/// Iter 18g tidy follow-up: let-alias-aware mode propagation.
///
/// 18d.4 Iter A's fix gated arm-close pattern-binder dec on the
/// scrutinee's `current_param_modes` lookup, but only fn-params
/// register there. A let-binder whose value is `Term::Var` aliasing
/// a non-Own fn-param defeats the gate: the lookup misses, the
/// default treats it as owned, and the arm-close drop fires on
/// pattern-binders whose underlying memory is still owned by the
/// caller. The fixture's shape:
///
///     (fn pin_aliased (params t)        ; t: Implicit-mode (default)
///       (let a t                         ; a aliases t
///         (match a                       ; matched-on alias
///           (case (TLeaf) 0)
///           (case (TNode v l r) 1))))
///
/// Pre-fix on a recursive caller passing the same heap value: the
/// arm-close drop fragments the caller's tree, the recursive call
/// re-loads now-freed cells, and the binary aborts (SIGSEGV / RC
/// underflow).
///
/// Post-fix: `current_param_modes` propagates through `Term::Let
/// { value: Term::Var(p), ... }` for the duration of the let body,
/// so a let-aliased Implicit / Borrow scrutinee correctly skips
/// the arm-close drop. Output matches `--alloc=gc` (`0`).
#[test]
fn alloc_rc_let_alias_of_implicit_param_does_not_dec_borrowed_children() {
    let example = "rc_let_alias_implicit_param.ail.json";
    let stdout_gc = build_and_run_with_alloc(example, "gc");
    let stdout_rc = build_and_run_with_alloc(example, "rc");
    assert_eq!(stdout_gc.trim(), "0");
    assert_eq!(stdout_rc.trim(), "0");
    assert_eq!(
        stdout_gc, stdout_rc,
        "alloc=rc must match alloc=gc on rc_let_alias_implicit_param"
    );
}

/// Iter 18g.tidy.fu2 regression — RED-then-GREEN for the
/// dynamic-tag partial-drop carve-out at fn-return.
///
/// Three carve-out sites previously fell back to a shallow
/// `ailang_rc_dec` when a binder's `moved_slots` was non-empty
/// and the runtime ctor tag was dynamic (not statically known
/// from a `Term::Ctor` value). The shallow dec frees the outer
/// cell but does not walk the unmoved ptr fields — those leak
/// silently.
///
/// **Site 1 (this fixture): Iter B Own-param dec at fn return
/// (lib.rs).**
///
///     (fn use_first
///       (params (own (con Pair)))
///       (body
///         (match p
///           (case (pat-ctor MkPair a _) 1))))
///
/// `p` is consumed by the match. Its `consume_count == 0`,
/// `mode == Own`, and `moved_slots[p] = {0}` (slot 0 was bound,
/// slot 1 wildcarded). At fn-return, Iter B's dynamic-tag fallback
/// fired shallow `ailang_rc_dec(p)`, freeing p's outer Pair but
/// leaking the unbound second `MkCell` and its two `MkWrap`
/// children — three cells.
///
/// fu2 routes the carve-out through `partial_drop_<m>_Pair(p,
/// mask)`, which dispatches on the runtime tag and dec's slot 1
/// (the unmoved field) via `drop_<m>_Cell`, cascading correctly.
///
/// Pre-fix: `live = 3`. Post-fix: `live = 0`.
#[test]
fn alloc_rc_partial_drop_at_fn_return_does_not_leak_unmoved_fields() {
    let (stdout, allocs, frees, live) =
        build_and_run_with_rc_stats("rc_own_param_partial_drop_leak.ail.json");
    assert_eq!(
        stdout.trim(),
        "1",
        "use_first returns 1 regardless of leak status"
    );
    assert_eq!(
        live, 0,
        "Own-param fn-return partial-drop carve-out leaked {live} cells \
         (allocs={allocs} frees={frees}); fu2 must route the dynamic-tag \
         fallback through `partial_drop_<m>_<T>(p, mask)` so the unmoved \
         slots dec via the per-type cascade rather than being shallow-freed"
    );
}

/// Iter 18g.tidy.fu2 regression — site 2: Iter A arm-close
/// pattern-binder dec (match_lower.rs).
///
/// An outer match's arm-bound pattern-binder may itself be the
/// scrutinee of an inner match that moves out some of its fields.
/// At outer-arm close, `moved_slots[bname]` is non-empty and
/// `consume_count[bname] == 0` — the carve-out previously emitted
/// a shallow `ailang_rc_dec`, leaking the inner ctor's unbound
/// (wildcarded) ptr fields.
///
///     (match p
///       (case (pat-ctor MkPair a b)
///         (match a
///           (case (pat-ctor MkCell w1 _) 1))))
///
/// `a` has `moves={0}` (w1 bound, slot 1 of MkCell wildcarded).
/// Pre-fix: shallow dec on `a` left slot 1's `MkWrap(2)` cell
/// allocated.
/// Post-fix: `partial_drop_<m>_Cell(a, mask=001)` dec's slot 1.
///
/// Pre-fix: `live = 1`. Post-fix: `live = 0`.
#[test]
fn alloc_rc_partial_drop_at_arm_close_does_not_leak_unmoved_fields() {
    let (stdout, allocs, frees, live) =
        build_and_run_with_rc_stats("rc_match_arm_partial_drop_leak.ail.json");
    assert_eq!(
        stdout.trim(),
        "1",
        "use_first returns 1 regardless of leak status"
    );
    assert_eq!(
        live, 0,
        "match-arm pattern-binder partial-drop carve-out leaked {live} cells \
         (allocs={allocs} frees={frees}); fu2 must route arm-bound binders \
         with non-empty moved_slots through `partial_drop_<m>_<T>(b, mask)`"
    );
}

/// Iter 18g.tidy.fu2 regression — site 3: let-close for an
/// App-bound binder (drop.rs `emit_inlined_partial_drop`
/// non-Ctor branch).
///
/// `emit_inlined_partial_drop` was keyed against `Term::Ctor`
/// values; for `Term::App` (Own-returning) values its fallback
/// shallow-dec'd the binder. With pattern-matching of the
/// App-bound binder (`(let p (app build_pair) (match p ...))`),
/// the body's `moved_slots[p]` is non-empty but the binder's
/// runtime tag is dynamic (unknown from the App's surface).
///
/// fu2 derives the type from `synth_arg_type(value)` and routes
/// through `partial_drop_<m>_<T>(p, mask)`.
///
/// Pre-fix: `live = 3` (slot 1's whole MkCell + two MkWraps).
/// Post-fix: `live = 0`.
#[test]
fn alloc_rc_partial_drop_at_app_let_close_does_not_leak_unmoved_fields() {
    let (stdout, allocs, frees, live) =
        build_and_run_with_rc_stats("rc_app_let_partial_drop_leak.ail.json");
    assert_eq!(
        stdout.trim(),
        "1",
        "use_cell returns 1 regardless of leak status"
    );
    assert_eq!(
        live, 0,
        "App-bound let-close partial-drop carve-out leaked {live} cells \
         (allocs={allocs} frees={frees}); fu2 must derive the binder's \
         static type from synth_arg_type(value) and route through \
         `partial_drop_<m>_<T>(p, mask)`"
    );
}

/// Iter 20d: `ail merge-prose` reads two files and prints a prompt
/// that frames the prose-round-trip task for an external LLM. Smoke
/// test: writes two temp inputs, invokes the binary, asserts exit 0
/// and that the captured stdout carries the role-statement landmark
/// plus both inputs verbatim.
#[test]
fn merge_prose_prints_framed_prompt() {
    let tmp = std::env::temp_dir().join(format!(
        "ailang_e2e_merge_prose_{}",
        std::process::id()
    ));
    std::fs::create_dir_all(&tmp).unwrap();
    let orig_path = tmp.join("orig.ail.json");
    let prose_path = tmp.join("edited.prose.txt");
    // Iter 20f: merge-prose now loads the module via ailang_core,
    // renders to Form-A, and embeds *that* in the prompt — so the
    // input must be a well-formed ailang/v0 JSON-AST that
    // load_module accepts. The schema, name, and an empty defs list
    // are the minimum.
    let orig_bytes =
        b"{\"schema\":\"ailang/v0\",\"name\":\"foo\",\"imports\":[],\"defs\":[]}";
    let prose_bytes = b"// module foo\n\nfn bar() -> Int { 42 }\n";
    std::fs::write(&orig_path, orig_bytes).unwrap();
    std::fs::write(&prose_path, prose_bytes).unwrap();

    let output = Command::new(ail_bin())
        .args(["merge-prose"])
        .arg(&orig_path)
        .arg(&prose_path)
        .output()
        .expect("ail merge-prose failed to run");
    assert!(
        output.status.success(),
        "ail merge-prose exited non-zero: stderr={}",
        String::from_utf8_lossy(&output.stderr)
    );
    let stdout = String::from_utf8(output.stdout).expect("stdout utf8");
    assert!(!stdout.is_empty(), "empty stdout from merge-prose");
    assert!(
        stdout.contains("integrating prose edits"),
        "prompt missing role-statement landmark; got:\n{stdout}"
    );
    // Original module is now embedded as Form-A, not JSON.
    assert!(
        stdout.contains("(module foo"),
        "prompt missing original Form-A content; got:\n{stdout}"
    );
    assert!(
        stdout.contains("fn bar() -> Int { 42 }"),
        "prompt missing edited prose content"
    );
    // The embedded spec must be present.
    assert!(
        stdout.contains("FORM-A SPECIFICATION"),
        "prompt missing FORM-A SPECIFICATION section"
    );
}
