//! RED-pin for the per-iteration heap-**Str** loop-binder leak across
//! `recur` (bug #49, Str leg).
//!
//! Property protected: under `--alloc=rc`, when a `(loop ...)` carries a
//! `Str` loop binder seeded with a literal and a `recur` REPLACES that
//! binder with a FRESH heap value (a `str_concat` result), every
//! superseded prior heap slab MUST be RC-decremented at the `recur`
//! store. Even though the loop's FINAL result is correctly consumed
//! (here by `print`), the intermediate per-iteration `str_concat` slabs
//! leak. The runtime RC stats line at exit must report `live == 0`
//! (equivalently `allocs == frees`).
//!
//! Root cause (data-flow traced): codegen's `Term::Recur` arm in
//! `crates/ailang-codegen/src/lib.rs` (the per-binder store loop, the
//! arm added in commit f488d31) emits the superseded-value dec only
//! under the gate `matches!(self.alloc, Rc) && is_ptr && !is_str`. The
//! `!is_str` clause excludes a `Str`-typed binder entirely, so the
//! `recur` falls straight to the plain `store` and the prior
//! `str_concat` heap slab it overwrites is never dec'd. This is the SAME
//! leak class as the boxed-ADT leg fixed in f488d31 — the ONLY
//! difference is the `!is_str` clause in the gate.
//!
//! Why the Str exclusion existed: the `Str` loop *seed* (`"x"`) may be a
//! constexpr-GEP into a static global with no `rc_header`, so an
//! UNCONDITIONAL dec on iteration 1 would be UB. The fix must dec the
//! superseded value WITHOUT dec'ing a static-literal seed (the same
//! static-vs-heap-`Str` representation question behind the deliberate
//! `Str` exclusions in commits 8bcdae1 and f488d31).
//!
//! As of HEAD this test fails: stderr reports
//! `ailang_rc_stats: allocs=4 frees=1 live=3` — the three superseded Str
//! slabs (seed "x" plus two intermediate concat results) leak while
//! stdout is already correct (`xyyy`).

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

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

/// Build `examples/<fixture>.ail` under `--alloc=rc`, run it with
/// `AILANG_RC_STATS=1`, and return `(allocs, frees, live, stdout)`.
fn build_run_stats(fixture: &str) -> (u64, u64, i64, String) {
    let manifest_dir = env!("CARGO_MANIFEST_DIR");
    let workspace = Path::new(manifest_dir).parent().unwrap().parent().unwrap();
    let src = workspace.join("examples").join(fixture);
    let tmp = std::env::temp_dir().join(format!(
        "ailang_{}_{}",
        fixture.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 {fixture}");
    let output = Command::new(&out)
        .env("AILANG_RC_STATS", "1")
        .output()
        .expect("execute binary");
    assert!(
        output.status.success(),
        "binary exited non-zero for {fixture}: status {:?}",
        output.status
    );
    let stdout = String::from_utf8(output.stdout).expect("stdout utf8");
    let stderr = String::from_utf8(output.stderr).expect("stderr utf8");
    let stats_line = stderr
        .lines()
        .find(|l| l.starts_with("ailang_rc_stats:"))
        .unwrap_or_else(|| {
            panic!("missing ailang_rc_stats line for {fixture}; 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 stats_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();
        }
    }
    (
        allocs.expect("missing allocs= field"),
        frees.expect("missing frees= field"),
        live.expect("missing live= field"),
        stdout,
    )
}

/// The #49 fix (mir.4): a heap-Str loop binder replaced by `recur` no
/// longer leaks. `lower_to_mir` promotes every loop-carried Str literal
/// (seed, recur args, and exit-arm result literals) to `StrRep::Heap`,
/// so codegen `str_clone`s each into an owned heap slab; the recur dec
/// gate AND the loop-result scope-close drop both dropped their `!is_str`
/// carve-out, so every superseded slab is freed at the recur store and
/// the final result is freed at the caller's scope-close. Was RED at
/// `live=3` before mir.4 (the seed plus two intermediate `str_concat`
/// slabs); now `live=0`.
#[test]
fn alloc_rc_str_loop_binder_replaced_by_recur_does_not_leak() {
    let (allocs, frees, live, stdout) =
        build_run_stats("loop_recur_str_binder_no_leak_pin.ail");
    assert_eq!(
        stdout.trim_end(),
        "xyyy",
        "loop result mis-built (allocs={allocs} frees={frees} live={live})"
    );
    assert_eq!(
        live, 0,
        "a heap-Str loop binder replaced by `recur` leaks {live} slab(s) \
         (allocs={allocs} frees={frees}); mir.4 must promote the seed and \
         every recur arg to StrRep::Heap and drop the `!is_str` carve-out \
         on both the recur dec gate (lib.rs) and the loop-result \
         scope-close drop (drop.rs)."
    );
    assert_eq!(
        allocs, frees,
        "alloc/free mismatch (allocs={allocs} frees={frees} live={live})"
    );
}

/// mir.4 soundness, recur-arg leg: a Str loop binder whose `recur`
/// rebinds it to a fresh static LITERAL each iteration. The seed-only
/// promotion would miss this — the recur literal "reset" must also be
/// Heap-promoted so its supersede dec frees a real slab instead of
/// UB-dec'ing a header-less static. stdout `reset`, `live=0`.
#[test]
fn alloc_rc_str_loop_binder_recur_literal_does_not_leak() {
    let (allocs, frees, live, stdout) =
        build_run_stats("loop_str_recur_literal_no_leak_pin.ail");
    assert_eq!(
        stdout.trim_end(),
        "reset",
        "recur-literal loop result mis-built (allocs={allocs} frees={frees} live={live})"
    );
    assert_eq!(
        live, 0,
        "a Str loop binder recur'd with a static literal leaks {live} \
         slab(s) (allocs={allocs} frees={frees}); the recur literal must \
         be Heap-promoted (StrRep::Heap) like the seed."
    );
    assert_eq!(
        allocs, frees,
        "recur-literal leg alloc/free mismatch (allocs={allocs} frees={frees} live={live})"
    );
}

/// mir.4 soundness, loop-result leg: a loop that RETURNS a static Str
/// LITERAL from its exit arm. The result is freed at the caller's
/// scope-close, which (after mir.4 deletes the `!is_str` carve-out on
/// the loop-result drop path) `ailang_rc_dec`s it — so the exit-arm
/// literal "result" must be Heap-promoted, or the program SIGSEGVs on a
/// header-less static. This pin would crash (non-zero exit) if the
/// exit-arm tail promotion regressed. stdout `result`, `live=0`.
#[test]
fn alloc_rc_str_loop_static_exit_literal_does_not_leak() {
    let (allocs, frees, live, stdout) =
        build_run_stats("loop_str_static_exit_no_leak_pin.ail");
    assert_eq!(
        stdout.trim_end(),
        "result",
        "static-exit loop result mis-built (allocs={allocs} frees={frees} live={live})"
    );
    assert_eq!(
        live, 0,
        "a loop returning a static Str literal leaks {live} slab(s) \
         (allocs={allocs} frees={frees}); the exit-arm literal must be \
         Heap-promoted (StrRep::Heap) so the scope-close dec frees a real \
         slab rather than UB-dec'ing a header-less static."
    );
    assert_eq!(
        allocs, frees,
        "static-exit leg alloc/free mismatch (allocs={allocs} frees={frees} live={live})"
    );
}
