//! RED-pin for leg 3 of bug #63 — the per-arm-vs-per-fn consume-
//! accounting leak at a match fall-through (the last open leg of the
//! Series milestone #61 leak tail).
//!
//! Property protected: under `--alloc=rc`, when an `(own ...)` heap
//! param is consumed on ONE match arm but is LIVE (un-consumed) on a
//! SIBLING fall-through arm, it MUST be dropped on that sibling arm
//! before the fn returns. The runtime RC stats line at exit must
//! report `live == 0` (equivalently `allocs == frees`) even though
//! stdout is already correct.
//!
//! This is deliberately Series/RawBuf-FREE: a plain user ADT (`Box`)
//! leaks identically, so the pin guards the GENERAL memory-model
//! consume-accounting path, not a Series-specific surface. The shape
//! is the SMALLEST that reproduces — a plain (non-recursive) `match`
//! is enough; tail recursion is not required. `run` takes an
//! `(own Box) b` and an `(own Flag) f`, matches on `f`: the `On` arm
//! consumes `b` (forwards it into the consuming call `sink`), the
//! `Off` arm returns without touching `b`. `main` calls `run b Off`,
//! so the `Off` (fall-through) arm runs and `b` stays live.
//!
//! Root cause: the fn-return Own-param dec in
//! `crates/ailang-codegen/src/lib.rs` (~line 1484) and the
//! pre-tail-call Own-param dec in
//! `crates/ailang-codegen/src/match_lower.rs` (~line 823) both gate
//! the drop on `consume_count != 0 -> skip`, reading the PER-FN
//! AGGREGATE consume map (`self.consume`, keyed `(def, binder)`). For
//! `b` that aggregate is `1` — the `On` arm consumed it — so every
//! drop site skips `b` entirely, including the `Off` arm where `b` is
//! genuinely live. The aggregate is the worst-case-path `max` of the
//! arm consume counts, computed by `merge_states` in
//! `crates/ailang-check/src/uniqueness.rs` (~line 480); the per-arm /
//! per-path consume information is destroyed there before it ever
//! reaches MIR (`MArm` carries only `{ pat, body }`; `MirDef.consume`
//! is the per-fn aggregate). So codegen has no per-arm liveness to
//! gate a fall-through drop on.
//!
//! DOUBLE-FREE HAZARD (central): the aggregate gate is conservative
//! precisely to avoid double-dropping a param that WAS consumed. The
//! emitted IR for `run` drops only the scrutinee `f` at the fn-return
//! join `mjoin`, never `b`; the `On` arm already consumed `b` into
//! `sink`. Any fix MUST drop `b` ONLY on the arm where it stays live
//! (the `Off` arm block), never at the post-`match` join (a merge
//! point that cannot tell the consumed path from the live path) and
//! never on the `On` arm — or it introduces a double-free.
//!
//! As of HEAD this test fails: the binary reports
//! `ailang_rc_stats: allocs=2 frees=1 live=1` — the `Box` param `b`
//! leaks once because the `Off` arm never drops it.
//!
//! The leg-1 (`tail_recur_owned_param_no_leak_pin.rs`) and leg-2
//! (`tail_recur_let_wrapped_no_leak_pin.rs`) pins guard params whose
//! AGGREGATE `consume_count == 0` (consumed on no arm). This pin is
//! intentionally distinct: the param here is consumed on one arm and
//! live on another, so the aggregate is `>= 1` and the existing gates
//! cannot express it. Do not collapse the three.

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

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

#[test]
fn alloc_rc_owned_param_consumed_on_one_arm_live_on_sibling_does_not_leak() {
    let manifest_dir = env!("CARGO_MANIFEST_DIR");
    let workspace = Path::new(manifest_dir).parent().unwrap().parent().unwrap();
    let src = workspace
        .join("examples")
        .join("match_arm_consume_split_no_leak_pin.ail");
    let tmp = std::env::temp_dir().join(format!(
        "ailang_match_arm_consume_split_no_leak_pin_{}",
        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 match_arm_consume_split_no_leak_pin.ail"
    );
    let output = Command::new(&out)
        .env("AILANG_RC_STATS", "1")
        .output()
        .expect("execute binary");
    assert!(
        output.status.success(),
        "binary exited non-zero: status {:?}",
        output.status
    );
    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 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 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();
        }
    }
    let allocs = allocs.expect("missing allocs= field");
    let frees = frees.expect("missing frees= field");
    let live = live.expect("missing live= field");
    assert_eq!(
        live, 0,
        "an (own) heap param consumed on one match arm but live on a \
         sibling fall-through arm leaks {live} slab(s) \
         (allocs={allocs} frees={frees}): the fn-return Own-param dec \
         in crates/ailang-codegen/src/lib.rs (~line 1484) and the \
         pre-tail-call dec in match_lower.rs (~line 823) both gate on \
         the PER-FN AGGREGATE consume_count (>= 1 here, from the \
         consuming arm) instead of per-arm liveness, so the param is \
         never dropped on the arm where it stays live."
    );
    assert_eq!(
        allocs, frees,
        "alloc/free mismatch (allocs={allocs} frees={frees} live={live})"
    );
}
