//! Lowering of `Term::Ctor`, `Term::ReuseAs`, and `Term::Match`.
//!
//! These three lowering paths form a tightly coupled cluster around
//! the AILang ADT runtime layout (`{ i64 tag; ptr fields[] }`). The
//! ctor and match lowerings are mirror images: the ctor body emits
//! `alloc + store-tag + store-fields`, the match arm body emits
//! `load-tag + switch + load-fields`. `Term::ReuseAs` (Iter 18d.1)
//! is the in-place rewrite optimisation: a ctor whose source comes
//! from a recently-destructured binder of the same shape reuses
//! the source's box rather than allocating a fresh one.
//!
//! All three are `pub(crate)` so `lower_term` (in lib.rs) can
//! dispatch to them; the ctor-lookup helper they share
//! (`lookup_ctor_in_pattern`) stays in lib.rs since it sits next
//! to the lookup-table family.
//!
//! Cross-references back into the parent module:
//!   - `synth_arg_type`, `lookup_ctor_by_type`,
//!     `lookup_ctor_in_pattern`, `collect_owner_local_types`,
//!     `field_drop_call`: lookup helpers, all `pub(crate)`.
//!   - `lower_term`, `start_block`, `fresh_ssa`, `fresh_id`:
//!     lowering primitives, all `pub(crate)`.

use ailang_core::ast::*;
use std::collections::{BTreeMap, BTreeSet};

use super::synth::llvm_type;
use super::subst::{apply_subst_to_type, qualify_local_types_codegen, unify_for_subst};
use super::{AllocStrategy, CodegenError, CtorRef, Emitter, Result};

impl<'a> Emitter<'a> {
    /// Heap box layout: 8 bytes tag (i64) followed by 8 bytes per field.
    /// i1 and i8 fields also occupy a full 8-byte slot — the typed
    /// load/store instructions write/read only the required size.
    ///
    /// Iter 17a: `term_ptr` is the pointer-as-usize of the lowered
    /// `Term::Ctor` node. If escape analysis flagged this site as
    /// non-escaping (i.e., the value cannot live past the current fn
    /// frame), allocation lowers to LLVM `alloca` instead of
    /// `@GC_malloc`. The rest of the lowering (tag store, field
    /// stores, ptr return) is identical.
    pub(crate) fn lower_ctor(
        &mut self,
        type_name: &str,
        ctor_name: &str,
        args: &[Term],
        term_ptr: usize,
    ) -> Result<(String, String)> {
        let cref = self.lookup_ctor_by_type(type_name, ctor_name)?;
        if args.len() != cref.ail_fields.len() {
            return Err(CodegenError::Internal(format!(
                "ctor `{type_name}/{ctor_name}` arity"
            )));
        }
        // Iter 13b: for parameterised ADTs the precomputed `cref.fields`
        // is meaningless because field types reference rigid type vars.
        // Derive a per-use-site substitution from the arg types and
        // re-lower each field type. Monomorphic ADTs hit the fast path
        // (no var-set, substitution is empty, ail_fields lower exactly
        // like cref.fields).
        // Iter 15b: for cross-module ctors, qualify any local type-cons
        // in `cref.ail_fields` (symmetric to the term-ctor synth fix).
        let qualified_ail_fields: Vec<Type> = if type_name.matches('.').count() == 1 {
            let (prefix, _) = type_name.split_once('.').expect("checked");
            if let Some(target) = self.import_map.get(prefix) {
                let owner_local_types = self.collect_owner_local_types(target);
                cref.ail_fields
                    .iter()
                    .map(|f| qualify_local_types_codegen(f, target, &owner_local_types))
                    .collect()
            } else {
                cref.ail_fields.clone()
            }
        } else {
            cref.ail_fields.clone()
        };
        let expected_llvm_tys: Vec<String> = if cref.type_vars.is_empty() {
            cref.fields.clone()
        } else {
            let arg_ail_tys: Vec<Type> = args
                .iter()
                .map(|a| self.synth_arg_type(a))
                .collect::<Result<_>>()?;
            let var_set: BTreeSet<&str> =
                cref.type_vars.iter().map(|s| s.as_str()).collect();
            let mut subst: BTreeMap<String, Type> = BTreeMap::new();
            for (exp, actual) in qualified_ail_fields.iter().zip(arg_ail_tys.iter()) {
                unify_for_subst(exp, actual, &var_set, &mut subst)?;
            }
            qualified_ail_fields
                .iter()
                .map(|f| llvm_type(&apply_subst_to_type(f, &subst)))
                .collect::<Result<_>>()?
        };
        // Evaluate arguments up front so allocation and store stay close
        // together.
        let mut compiled = Vec::new();
        for (a, exp) in args.iter().zip(expected_llvm_tys.iter()) {
            let (v, vty) = self.lower_term(a)?;
            if &vty != exp {
                return Err(CodegenError::Internal(format!(
                    "ctor `{ctor_name}` field type {vty} != expected {exp}"
                )));
            }
            compiled.push((v, vty));
        }
        let size_bytes = 8 + (compiled.len() * 8) as i64;
        let p = self.fresh_ssa();
        // Iter 17a: pick allocator based on escape analysis. `alloca`
        // for non-escaping (stack-allocated, freed on fn return);
        // `@GC_malloc` for everything else.
        if self.non_escape.contains(&term_ptr) {
            self.body.push_str(&format!(
                "  {p} = alloca i8, i64 {size_bytes}, align 8\n"
            ));
        } else {
            self.body.push_str(&format!(
                "  {p} = call ptr @{}(i64 {size_bytes})\n",
                self.alloc.fn_name()
            ));
        }
        // Write tag.
        self.body.push_str(&format!(
            "  store i64 {tag}, ptr {p}, align 8\n",
            tag = cref.tag
        ));
        // Write fields.
        for (i, (v, ty)) in compiled.iter().enumerate() {
            let off = 8 + i as i64 * 8;
            let addr = self.fresh_ssa();
            self.body.push_str(&format!(
                "  {addr} = getelementptr inbounds i8, ptr {p}, i64 {off}\n"
            ));
            self.body
                .push_str(&format!("  store {ty} {v}, ptr {addr}, align 8\n"));
        }
        Ok((p, "ptr".into()))
    }

    /// Iter 18d.2: lower `Term::ReuseAs { source, body = Term::Ctor }`
    /// under `--alloc=rc` as a runtime refcount-1 dispatch.
    ///
    /// IR shape (Lean 4 / Roc lineage):
    /// ```text
    ///   %src      = <source's SSA>
    ///   %hdr_ptr  = getelementptr inbounds i8, ptr %src, i64 -8
    ///   %refcnt   = load i64, ptr %hdr_ptr
    ///   %is_one   = icmp eq i64 %refcnt, 1
    ///   br i1 %is_one, label %reuse, label %fresh
    ///
    /// reuse:
    ///   ; for each pointer-typed slot j: load old, drop_<field-T>(old)
    ///   ; store new tag at offset 0
    ///   ; store new field values at offsets 8, 16, ...
    ///   br label %join
    ///
    /// fresh:
    ///   %newp     = call ptr @ailang_rc_alloc(i64 SIZE)
    ///   ; store new tag + new fields (mirrors lower_ctor)
    ///   ; dec the now-superseded source
    ///   call void @drop_<m>_<source-type>(ptr %src)
    ///   br label %join
    ///
    /// join:
    ///   %result = phi ptr [ %src, %reuse_end ], [ %newp, %fresh_end ]
    /// ```
    ///
    /// The shape-mismatch invariant (source's ctor has the same field
    /// count and per-field LLVM types as `body`'s ctor) is enforced
    /// upstream by `ailang_check::reuse_shape::check_module`. Codegen
    /// trusts the check; if a mismatched shape ever reaches us, the
    /// `reuse` arm's per-slot field iteration is bounded by
    /// `expected_llvm_tys.len()` and the source-cell layout
    /// guaranteed by `lower_ctor`/`@ailang_rc_alloc` — we won't read
    /// past the end of the source, but we will silently corrupt
    /// fields. The shape check ensures that path is unreachable.
    pub(crate) fn lower_reuse_as_rc(
        &mut self,
        source: &Term,
        body_type_name: &str,
        body_ctor_name: &str,
        body_args: &[Term],
    ) -> Result<(String, String)> {
        // 1. Lower the source. Linearity (18d.1) guarantees this is a
        //    bare Var of an in-scope binder; lower_term resolves it
        //    to the binder's SSA.
        let (src_ssa, src_ty) = self.lower_term(source)?;
        if src_ty != "ptr" {
            return Err(CodegenError::Internal(format!(
                "reuse-as source type must be ptr, got {src_ty}"
            )));
        }
        // Iter 18d.3: the source binder name keys into `moved_slots`
        // so the reuse arm can skip dec'ing fields that earlier
        // pattern-extracted out of the source. Linearity already
        // ensures `source` is `Term::Var` here; the var-resolution
        // is just defensive.
        let source_binder: Option<String> = match source {
            Term::Var { name } => {
                if self.locals.iter().any(|(n, _, _, _)| n == name) {
                    Some(name.clone())
                } else {
                    None
                }
            }
            _ => None,
        };
        // The source's drop symbol — needed on the fresh arm to
        // release this caller's share of the source (refcount > 1,
        // by the branch we are inside). We deliberately use the
        // shallow `ailang_rc_dec` here, NOT the per-type cascading
        // drop:
        //
        // The fresh arm only fires when the source's refcount is
        // > 1, i.e. another holder also has a reference. In that
        // case the source's children are still owned by the other
        // holder; cascading dec would dec children we don't have
        // exclusive ownership over, and on aliasing with the new
        // box's fields could leave a use-after-free in the new box.
        // A shallow dec on the source's refcount preserves the
        // 18c.4 baseline: source.refcount goes down by one; the
        // box only frees when the LAST holder dec's it; children
        // are dec'd by that final dec's cascade, not by ours.
        //
        // Identical to the unused-source-after-reuse-misfire
        // pattern in Lean 4's reset/reuse codegen: the
        // refcount-decrement is the contract; the drop fn cascade
        // is owned by the last release, not by intermediate ones.
        let _ = source; // synth_arg_type not needed for shallow dec
        let src_drop_call = "ailang_rc_dec".to_string();

        // 2. Resolve the body ctor and its expected per-field LLVM
        //    types. Mirrors the head of lower_ctor — same type-var
        //    substitution path so parameterised ADT bodies work the
        //    same way reuse-as lowers as plain ctor lowers.
        let cref = self.lookup_ctor_by_type(body_type_name, body_ctor_name)?;
        if body_args.len() != cref.ail_fields.len() {
            return Err(CodegenError::Internal(format!(
                "reuse-as body ctor `{body_type_name}/{body_ctor_name}` arity"
            )));
        }
        let qualified_ail_fields: Vec<Type> = if body_type_name.matches('.').count() == 1 {
            let (prefix, _) = body_type_name.split_once('.').expect("checked");
            if let Some(target) = self.import_map.get(prefix) {
                let owner_local_types = self.collect_owner_local_types(target);
                cref.ail_fields
                    .iter()
                    .map(|f| qualify_local_types_codegen(f, target, &owner_local_types))
                    .collect()
            } else {
                cref.ail_fields.clone()
            }
        } else {
            cref.ail_fields.clone()
        };
        let expected_llvm_tys: Vec<String> = if cref.type_vars.is_empty() {
            cref.fields.clone()
        } else {
            let arg_ail_tys: Vec<Type> = body_args
                .iter()
                .map(|a| self.synth_arg_type(a))
                .collect::<Result<_>>()?;
            let var_set: BTreeSet<&str> =
                cref.type_vars.iter().map(|s| s.as_str()).collect();
            let mut subst: BTreeMap<String, Type> = BTreeMap::new();
            for (exp, actual) in qualified_ail_fields.iter().zip(arg_ail_tys.iter()) {
                unify_for_subst(exp, actual, &var_set, &mut subst)?;
            }
            qualified_ail_fields
                .iter()
                .map(|f| llvm_type(&apply_subst_to_type(f, &subst)))
                .collect::<Result<_>>()?
        };
        // (18d.2 currently does not dec old fields in the reuse
        // arm — see the rationale at the reuse arm — so the post-
        // substitution per-field AILang types are not needed here.
        // 18d.3 / 18e will revisit when the move-aware pattern
        // story lands and the reuse arm can dec moved-out slots
        // safely.)

        // 3. Evaluate the body's args. They live in SSAs that both
        //    branches consume, so they are computed before the branch
        //    on refcount.
        let mut compiled = Vec::new();
        for (a, exp) in body_args.iter().zip(expected_llvm_tys.iter()) {
            let (v, vty) = self.lower_term(a)?;
            if &vty != exp {
                return Err(CodegenError::Internal(format!(
                    "reuse-as body ctor `{body_ctor_name}` field type {vty} != expected {exp}"
                )));
            }
            compiled.push((v, vty));
        }

        // 4. Refcount-1 dispatch. Header is at offset -8 (see
        //    runtime/rc.c::header_of). A static / data-segment ptr
        //    (e.g. a top-level fn's static closure pair) would
        //    dereference garbage here, but reuse-as on such a
        //    pointer is meaningless — the linearity check would
        //    have rejected `<source>` as not naming a heap binder.
        let id = self.fresh_id();
        let reuse_lbl = format!("reuse.{id}");
        let fresh_lbl = format!("fresh.{id}");
        let join_lbl = format!("rejoin.{id}");

        let hdr_ptr = self.fresh_ssa();
        self.body.push_str(&format!(
            "  {hdr_ptr} = getelementptr inbounds i8, ptr {src_ssa}, i64 -8\n"
        ));
        let refcnt = self.fresh_ssa();
        self.body.push_str(&format!(
            "  {refcnt} = load i64, ptr {hdr_ptr}, align 8\n"
        ));
        let is_one = self.fresh_ssa();
        self.body.push_str(&format!(
            "  {is_one} = icmp eq i64 {refcnt}, 1\n"
        ));
        self.body.push_str(&format!(
            "  br i1 {is_one}, label %{reuse_lbl}, label %{fresh_lbl}\n"
        ));

        // 5. Reuse arm: overwrite tag and field slots in place.
        //
        //    Iter 18d.3: per-field dec is now safe — moved-out slots
        //    are skipped via the codegen `moved_slots` side table;
        //    non-moved slots are dec'd via `field_drop_call`'s null-
        //    guarded drop fns BEFORE the new field values overwrite
        //    them. The shape check (18d.2) guarantees source's old
        //    ctor and body's new ctor have matching field counts and
        //    types, so `qualified_ail_fields` is correct for both.
        //
        //    Why this is sound under the canonical
        //    `(reuse-as xs (Cons (+ h 1) (map_inc t)))` pattern:
        //    the `Cons` arm of the enclosing match recorded both
        //    field 0 and field 1 of `xs` as moved; both are skipped
        //    here and no dec executes against the reused slots.
        //    `(map_inc t)`'s in-place-rewritten box is stored into
        //    field 1 unchanged — no use-after-free.
        //
        //    For a fixture like `(match xs (Cons _ _ → reuse-as xs
        //    (Cons 0 0)))` where neither slot is moved, both
        //    pointer-typed fields are dec'd via `field_drop_call`,
        //    closing the 18d.2 leak.
        self.start_block(&reuse_lbl);
        // Iter 18d.3: dec non-moved pointer-typed slots before the
        // overwrite. The dec fns are null-guarded, so an empty slot
        // is a no-op. Clone the move set out of the side table so
        // the loop below can mutably borrow `self` for SSA allocation.
        let moves_for_src: BTreeSet<usize> = source_binder
            .as_ref()
            .and_then(|sb| self.moved_slots.get(sb).cloned())
            .unwrap_or_default();
        for (idx, fty_ail) in qualified_ail_fields.iter().enumerate() {
            let lty = llvm_type(fty_ail).unwrap_or_else(|_| "ptr".into());
            if lty != "ptr" {
                continue;
            }
            if moves_for_src.contains(&idx) {
                continue;
            }
            let off = 8 + (idx as i64) * 8;
            let addr = self.fresh_ssa();
            self.body.push_str(&format!(
                "  {addr} = getelementptr inbounds i8, ptr {src_ssa}, i64 {off}\n"
            ));
            let v = self.fresh_ssa();
            self.body.push_str(&format!(
                "  {v} = load ptr, ptr {addr}, align 8\n"
            ));
            let drop_call = self.field_drop_call(fty_ail);
            self.body.push_str(&format!(
                "  call void @{drop_call}(ptr {v})\n"
            ));
        }
        // Store new tag at offset 0. Even when the new tag equals the
        // old tag (most reuse-as fixtures: Cons → Cons), we emit the
        // store unconditionally — codegen doesn't have to special-
        // case "same tag", the store is cheap, and the IR is simpler.
        self.body.push_str(&format!(
            "  store i64 {tag}, ptr {src_ssa}, align 8\n",
            tag = cref.tag
        ));
        // Store new field values into the slots.
        for (i, (v, ty)) in compiled.iter().enumerate() {
            let off = 8 + i as i64 * 8;
            let addr = self.fresh_ssa();
            self.body.push_str(&format!(
                "  {addr} = getelementptr inbounds i8, ptr {src_ssa}, i64 {off}\n"
            ));
            self.body
                .push_str(&format!("  store {ty} {v}, ptr {addr}, align 8\n"));
        }
        let reuse_end = self.current_block.clone();
        self.body.push_str(&format!("  br label %{join_lbl}\n"));

        // 6. Fresh arm: standard alloc + tag + field stores; then
        //    dec the source (whose refcount was > 1, so this is the
        //    callee's release of its share — may or may not free).
        self.start_block(&fresh_lbl);
        let size_bytes = 8 + (compiled.len() * 8) as i64;
        let newp = self.fresh_ssa();
        self.body.push_str(&format!(
            "  {newp} = call ptr @{}(i64 {size_bytes})\n",
            self.alloc.fn_name()
        ));
        self.body.push_str(&format!(
            "  store i64 {tag}, ptr {newp}, align 8\n",
            tag = cref.tag
        ));
        for (i, (v, ty)) in compiled.iter().enumerate() {
            let off = 8 + i as i64 * 8;
            let addr = self.fresh_ssa();
            self.body.push_str(&format!(
                "  {addr} = getelementptr inbounds i8, ptr {newp}, i64 {off}\n"
            ));
            self.body
                .push_str(&format!("  store {ty} {v}, ptr {addr}, align 8\n"));
        }
        // Dec the source — the user's reuse-hint failed because the
        // box was shared. The source's drop fn cascades through any
        // boxed children of the OLD ctor (the slot we couldn't reuse),
        // matching the cascade lower_ctor would do for any other
        // owned binder going out of scope.
        self.body
            .push_str(&format!("  call void @{src_drop_call}(ptr {src_ssa})\n"));
        let fresh_end = self.current_block.clone();
        self.body.push_str(&format!("  br label %{join_lbl}\n"));

        // 7. Join: phi over the two arms' result pointers.
        self.start_block(&join_lbl);
        let phi = self.fresh_ssa();
        self.body.push_str(&format!(
            "  {phi} = phi ptr [ {src_ssa}, %{reuse_end} ], [ {newp}, %{fresh_end} ]\n"
        ));
        Ok((phi, "ptr".into()))
    }

    pub(crate) fn lower_match(
        &mut self,
        scrutinee: &Term,
        arms: &[Arm],
    ) -> Result<(String, String)> {
        let s_ail = self.synth_arg_type(scrutinee)?;
        let (s_val, s_ty) = self.lower_term(scrutinee)?;
        if s_ty != "ptr" {
            return Err(CodegenError::Internal(format!(
                "match on non-ADT scrutinee (got {s_ty}); MVP supports only ADTs"
            )));
        }
        // Iter 18d.3: move tracking key — the bare-Var binder name of
        // the scrutinee, if it has one. A complex expression scrutinee
        // (e.g. the result of `(map_inc xs)` matched directly) has no
        // binder we can key off; moves through such matches are
        // untracked (see the assignment's "untracked scrutinee"
        // fallback). Only record when the var actually resolves to a
        // local binder.
        let scrutinee_binder: Option<String> = match scrutinee {
            Term::Var { name } => {
                if self.locals.iter().any(|(n, _, _, _)| n == name) {
                    Some(name.clone())
                } else {
                    None
                }
            }
            _ => None,
        };

        // Load tag.
        let tag = self.fresh_ssa();
        self.body
            .push_str(&format!("  {tag} = load i64, ptr {s_val}, align 8\n"));

        // Separate arms.
        let mut ctor_arms: Vec<(CtorRef, &Arm, Vec<Option<String>>)> = Vec::new();
        let mut open_arm: Option<&Arm> = None;
        let mut open_var: Option<String> = None;
        for arm in arms {
            match &arm.pat {
                Pattern::Wild => {
                    open_arm = Some(arm);
                }
                Pattern::Var { name } => {
                    open_arm = Some(arm);
                    open_var = Some(name.clone());
                }
                Pattern::Ctor { ctor, fields } => {
                    // Iter 15a: lookup falls back to imported modules when
                    // a bare ctor name doesn't resolve locally.
                    let cref = self.lookup_ctor_in_pattern(ctor)?;
                    let bindings: Vec<Option<String>> = fields
                        .iter()
                        .map(|p| match p {
                            Pattern::Var { name } => Some(name.clone()),
                            Pattern::Wild => None,
                            _ => None, // MVP: nested ctor/lit patterns not supported
                        })
                        .collect();
                    ctor_arms.push((cref, arm, bindings));
                }
                Pattern::Lit { .. } => {
                    return Err(CodegenError::Internal(
                        "MVP: lit patterns in match not supported".into(),
                    ));
                }
            }
        }

        let id = self.fresh_id();
        let join_lbl = format!("mjoin.{id}");
        let default_lbl = format!("mdefault.{id}");

        // switch
        let mut sw = format!(
            "  switch i64 {tag}, label %{default_lbl} [\n",
            tag = tag
        );
        let mut arm_labels: Vec<String> = Vec::new();
        for (i, (cref, _, _)) in ctor_arms.iter().enumerate() {
            let lbl = format!("marm.{id}.{i}");
            sw.push_str(&format!("    i64 {}, label %{}\n", cref.tag, lbl));
            arm_labels.push(lbl);
        }
        sw.push_str("  ]\n");
        self.body.push_str(&sw);

        let mut phi_inputs: Vec<(String, String)> = Vec::new(); // (value, block)
        let mut result_ty: Option<String> = None;

        for (i, (cref, arm, bindings)) in ctor_arms.iter().enumerate() {
            self.start_block(&arm_labels[i]);
            // Iter 13b: derive substitution for parameterised ADTs from
            // the scrutinee's concrete type-args. Map `cref.type_vars[i]`
            // → `s_ail.args[i]`. For monomorphic ADTs the mapping is
            // empty and substitution is a no-op. The substituted AILang
            // types are used both for the LLVM `load` instruction and as
            // the AILang-type slot of the local — without the latter, a
            // downstream `unbox(b)` would see `b: a` (rigid var) instead
            // of `b: Int`.
            let arm_subst: BTreeMap<String, Type> = if cref.type_vars.is_empty() {
                BTreeMap::new()
            } else {
                match &s_ail {
                    Type::Con { args, .. } if args.len() == cref.type_vars.len() => cref
                        .type_vars
                        .iter()
                        .cloned()
                        .zip(args.iter().cloned())
                        .collect(),
                    _ => {
                        return Err(CodegenError::Internal(format!(
                            "match: scrutinee type `{}` not aligned with ctor `{}`'s {} type vars",
                            ailang_core::pretty::type_to_string(&s_ail),
                            cref.type_name,
                            cref.type_vars.len(),
                        )));
                    }
                }
            };
            // Load fields and bind as locals.
            // Iter 15b: when the scrutinee's ADT lives in another module,
            // `cref.ail_fields[idx]` carries the field type written in
            // the owner's local namespace. Qualify it before substituting
            // — symmetric to the term-ctor and pat-ctor fixes in
            // ailang-check.
            let owning_module: Option<String> = match &s_ail {
                Type::Con { name, .. } if name.matches('.').count() == 1 => name
                    .split_once('.')
                    .map(|(p, _)| p.to_string()),
                _ => None,
            };
            let mut pushed = 0usize;
            // Iter 18d.3: collect the binder names this arm pushes so
            // we can drop their `moved_slots` entries when the arm
            // body closes (these binders are themselves freshly named
            // here — their move tracking starts empty and ends at arm
            // body close).
            //
            // Iter 18d.4: the metadata is widened to (name, SSA,
            // llvm-type, AILang-type) so that the arm-close drop
            // emission can route the call through the binder's
            // per-type drop symbol without re-walking `self.locals`.
            // The same tuple shape `self.locals` carries.
            let mut arm_pushed_binders: Vec<(String, String, String, Type)> = Vec::new();
            for (idx, binding) in bindings.iter().enumerate() {
                if let Some(bname) = binding {
                    let raw_ail = cref.ail_fields.get(idx).cloned().unwrap_or(Type::unit());
                    let qualified_ail = match &owning_module {
                        Some(m) => {
                            let owner_local_types = self.collect_owner_local_types(m);
                            qualify_local_types_codegen(&raw_ail, m, &owner_local_types)
                        }
                        None => raw_ail,
                    };
                    let bind_ail = if arm_subst.is_empty() {
                        qualified_ail
                    } else {
                        apply_subst_to_type(&qualified_ail, &arm_subst)
                    };
                    let fty = llvm_type(&bind_ail)?;
                    let off = 8 + idx as i64 * 8;
                    let addr = self.fresh_ssa();
                    self.body.push_str(&format!(
                        "  {addr} = getelementptr inbounds i8, ptr {s_val}, i64 {off}\n"
                    ));
                    let v = self.fresh_ssa();
                    self.body.push_str(&format!(
                        "  {v} = load {fty}, ptr {addr}, align 8\n"
                    ));
                    // Iter 18d.3: a non-wildcard, pointer-typed slot
                    // bound here is treated as moved out of the
                    // scrutinee binder. The source slot is NOT
                    // mutated; codegen records the move statically so
                    // a later top-level dec sequence (let-close,
                    // reuse-arm) skips this slot.
                    if fty == "ptr" {
                        if let Some(sb) = &scrutinee_binder {
                            self.moved_slots
                                .entry(sb.clone())
                                .or_default()
                                .insert(idx);
                        }
                    }
                    self.locals.push((
                        bname.clone(),
                        v.clone(),
                        fty.clone(),
                        bind_ail.clone(),
                    ));
                    arm_pushed_binders.push((bname.clone(), v, fty, bind_ail));
                    pushed += 1;
                }
            }
            // Iter 18d.4 fix + 18g.1: scrutinee ownership signal,
            // hoisted here so both the pre-tail-call shallow-dec
            // (Iter 18g.1, below) and the arm-close pattern-binder
            // dec (Iter A, further below) consult the same gate.
            //
            // If the scrutinee resolves to a fn-param whose mode is
            // `Borrow` or `Implicit`, the caller still holds a
            // reference to the heap value the pattern-binders were
            // loaded from. Dec'ing those binders (Iter A) — or the
            // scrutinee's outer cell (18g.1) — fragments the
            // caller's structure. Only `Own` carries the static
            // "caller handed off ownership" signal that makes the
            // children-dec safe.
            //
            // Non-fn-param scrutinees (let-binders, temp expressions)
            // are treated as owned: the let-binder owns its RC ref,
            // and a temp scrutinee was just produced by the local
            // frame.
            let scrutinee_is_owned: bool = match &scrutinee_binder {
                Some(sb) => match self.current_param_modes.get(sb) {
                    Some(mode) => matches!(mode, ParamMode::Own),
                    None => true,
                },
                None => true,
            };
            // Iter 18g.1: pre-tail-call shallow-dec for moved-from
            // scrutinee outer cell. Iter A (arm-close pattern-binder
            // dec, below) cannot fire when `arm.body` is a tail call:
            // `lower_term` emits `musttail call ... ret` and sets
            // `block_terminated = true`, so the post-arm-body Iter A
            // block is skipped. Iter B (Own-param dec at fn return) is
            // also skipped for the same reason — the fn body's tail
            // path is the tail call, not a fall-through `ret`.
            //
            // Concretely, in `(case (LCons h t) (tail-app sum_acc t (...)))`,
            // the LCons outer cell (`xs`) has its only ptr field `t`
            // moved into the tail call's args list (via
            // `arm_pushed_binders` -> `moved_slots[xs]`). After the
            // tail call, the outer cell is a husk: its tag and Int
            // fields are stale, its ptr field points to memory now
            // owned downstream. Nothing dec's the husk → one outer
            // cell leak per consumed list element. 18f.2's tail-
            // latency bench surfaced this: explicit-mode RC RSS peaks
            // at the same level as implicit-mode RC despite full mode
            // annotations.
            //
            // The fix is a shallow `ailang_rc_dec(scrutinee_ssa)`
            // emitted *before* `lower_term(arm.body)`, which lands
            // ahead of the `musttail call`. Shallow is safe iff every
            // ptr-typed slot in this ctor was bound by a non-wildcard
            // pattern (so its content is moved into a binder that
            // owns it downstream); a Wild-bound ptr slot would still
            // hold a live reference that the shallow free would
            // strand. The gate enforces both: arm.body must be a
            // tail call, scrutinee must be statically owned (the
            // 18d.4-Iter-A param-mode gate), and every ptr field of
            // the active ctor must be in `moved_slots[scrutinee]`.
            //
            // The dec lowers to `ailang_rc_dec` directly (not
            // `field_drop_call` / `drop_<m>_<T>`) because the per-
            // type drop fn would re-walk the ptr fields, dec'ing
            // values that are now owned by downstream frames —
            // that's exactly the bug 18d.3's `moved_slots` was set
            // up to prevent.
            let arm_body_is_tail_call = matches!(
                &arm.body,
                Term::App { tail: true, .. } | Term::Do { tail: true, .. }
            );
            if matches!(self.alloc, AllocStrategy::Rc)
                && arm_body_is_tail_call
                && scrutinee_is_owned
            {
                if let Some(sb) = &scrutinee_binder {
                    let moved = self.moved_slots.get(sb).cloned().unwrap_or_default();
                    let mut all_ptr_moved = true;
                    let mut any_ptr_field = false;
                    for idx in 0..bindings.len() {
                        let raw_ail = cref.ail_fields.get(idx).cloned().unwrap_or(Type::unit());
                        let qualified_ail = match &owning_module {
                            Some(m) => {
                                let owner_local_types = self.collect_owner_local_types(m);
                                qualify_local_types_codegen(&raw_ail, m, &owner_local_types)
                            }
                            None => raw_ail,
                        };
                        let bind_ail = if arm_subst.is_empty() {
                            qualified_ail
                        } else {
                            apply_subst_to_type(&qualified_ail, &arm_subst)
                        };
                        let fty = llvm_type(&bind_ail).unwrap_or_else(|_| "ptr".into());
                        if fty == "ptr" {
                            any_ptr_field = true;
                            if !moved.contains(&idx) {
                                all_ptr_moved = false;
                                break;
                            }
                        }
                    }
                    if any_ptr_field && all_ptr_moved {
                        self.body.push_str(&format!(
                            "  call void @ailang_rc_dec(ptr {s_val})\n"
                        ));
                    }
                }
            }
            let (val, vty) = self.lower_term(&arm.body)?;
            // Iter 18d.4: arm-close pattern-binder dec. Symmetric to
            // 18c.3/18c.4's `Term::Let`-scope-close drop emission, but
            // fired at the lexical close of a match arm. For each
            // pattern-bound binder this arm pushed, emit a drop call
            // iff:
            //   - alloc strategy is `Rc`,
            //   - the binder's lowered type is `ptr` (heap-allocated),
            //   - uniqueness inference recorded `consume_count == 0`
            //     for the binder in this fn's body (no downstream use
            //     consumed it; the binder's slot owns the only ref),
            //   - the arm's tail SSA value is not the binder itself
            //     (returning the binder transfers ownership to the
            //     phi-join consumer; that consumer dec's, not us),
            //   - the current block is still open (a tail-call /
            //     `unreachable` already exited; nothing to emit).
            //
            // Closes the 18d.3-shipped regression on
            // `alloc_rc_borrow_only_recursive_list_drop`: the `t`
            // pattern-binder of `(Cons h t)` is unused, has
            // `consume_count == 0`, and previously leaked because
            // 18d.3's `moved_slots` interrupts the `xs`-cascade
            // through the tail slot. Now the arm itself dec's `t`,
            // freeing the 4-cell tail.
            //
            // Iter 18d.4 fix (regression
            // `alloc_rc_pattern_bind_in_implicit_fn_does_not_dec_borrowed_children`):
            // Iter A is symmetric to Iter B (Own-param dec at fn
            // return) and must share Iter B's mode gate. If the
            // scrutinee resolves to a fn-param whose mode is
            // `Borrow` or `Implicit`, the caller still holds a
            // reference to the heap value the pattern-binders were
            // loaded from. Dec'ing those binders fragments the
            // caller's structure (refcount underflow on subsequent
            // accesses; segfault under deeper recursion).
            //
            // Only `Own` carries the static "caller handed off
            // ownership" signal that makes the children-dec safe.
            // Non-fn-param scrutinees (let-binders, temp
            // expressions) are treated as owned — the let-binder
            // owns its RC ref, and a temp scrutinee was just
            // produced by the local frame.
            //
            // The `scrutinee_is_owned` binding is hoisted above so
            // the 18g.1 pre-tail-call shallow-dec sees the same
            // gate; do not re-declare here.
            if matches!(self.alloc, AllocStrategy::Rc)
                && !self.block_terminated
                && scrutinee_is_owned
            {
                for (bname, b_ssa, b_lty, b_ail) in &arm_pushed_binders {
                    if b_lty != "ptr" {
                        continue;
                    }
                    let consume_count = self
                        .uniqueness
                        .get(&(self.current_def.clone(), bname.clone()))
                        .map(|info| info.consume_count)
                        .unwrap_or(u32::MAX);
                    if consume_count != 0 {
                        continue;
                    }
                    if &val == b_ssa {
                        // The binder IS the arm's tail value —
                        // ownership transfers to the join consumer.
                        continue;
                    }
                    let moves = self
                        .moved_slots
                        .get(bname)
                        .cloned()
                        .unwrap_or_default();
                    if moves.is_empty() {
                        // Common case (canonical regression `t`):
                        // route through the per-type drop fn for the
                        // binder's static type. `field_drop_call`
                        // resolves `Type::Con` to `drop_<owner>_<T>`
                        // and falls back to `ailang_rc_dec` for
                        // closure / Var fields (same fallback the
                        // recursive cascade uses).
                        let drop_call = self.field_drop_call(b_ail);
                        self.body.push_str(&format!(
                            "  call void @{drop_call}(ptr {b_ssa})\n"
                        ));
                    } else {
                        // Iter 18g.tidy.fu2: pattern-binder with
                        // statically-recorded moved slots routes
                        // through the tag-conditional helper
                        // `partial_drop_<owner>_<T>(b, mask)`. The
                        // binder's runtime tag is dynamic but its
                        // static type (`b_ail`) selects the per-type
                        // helper, which dispatches on the runtime tag
                        // and dec's only the unmoved fields. Closes
                        // the 18d.4 carve-out: previously the shallow
                        // `ailang_rc_dec` leaked the unmoved fields of
                        // whichever ctor `b` happened to be at runtime.
                        let sym = self.partial_drop_symbol_for_type(b_ail);
                        let mask = Self::build_moved_mask(&moves);
                        if let (Some(sym), Some(mask)) = (sym, mask) {
                            self.body.push_str(&format!(
                                "  call void @{sym}(ptr {b_ssa}, i64 {mask})\n"
                            ));
                        } else {
                            self.body.push_str(&format!(
                                "  call void @ailang_rc_dec(ptr {b_ssa})\n"
                            ));
                        }
                    }
                }
            }
            // pop bindings
            for _ in 0..pushed {
                self.locals.pop();
            }
            // Iter 18d.3: arm-bound binders go out of scope at arm
            // body close. Drop their move-tracking entries (any moves
            // recorded against `h`/`t` belonged to this arm only).
            for (bname, _, _, _) in &arm_pushed_binders {
                self.moved_slots.remove(bname);
            }
            // Iter 14e: if the arm body lowered to a `musttail call` +
            // `ret`, the block is already terminated. Skip the
            // fall-through `br` and exclude this arm from the join phi.
            if !self.block_terminated {
                phi_inputs.push((val, self.current_block.clone()));
                self.body
                    .push_str(&format!("  br label %{join_lbl}\n"));
                if let Some(rt) = &result_ty {
                    if rt != &vty {
                        return Err(CodegenError::Internal(format!(
                            "match arm result type {vty} != {rt}"
                        )));
                    }
                } else {
                    result_ty = Some(vty);
                }
            }
        }

        // default block
        self.start_block(&default_lbl);
        if let Some(arm) = open_arm {
            // set up var binding if needed
            let pushed = if let Some(name) = open_var.take() {
                self.locals
                    .push((name, s_val.clone(), "ptr".into(), s_ail.clone()));
                1
            } else {
                0
            };
            let (val, vty) = self.lower_term(&arm.body)?;
            for _ in 0..pushed {
                self.locals.pop();
            }
            if !self.block_terminated {
                phi_inputs.push((val, self.current_block.clone()));
                self.body
                    .push_str(&format!("  br label %{join_lbl}\n"));
                if let Some(rt) = &result_ty {
                    if rt != &vty {
                        return Err(CodegenError::Internal(format!(
                            "match default arm result type {vty} != {rt}"
                        )));
                    }
                } else {
                    result_ty = Some(vty);
                }
            }
        } else {
            // Typechecker guarantees exhaustiveness, so unreachable.
            self.body.push_str("  unreachable\n");
        }

        // join
        // Iter 14e: if every arm tail-called and terminated its own
        // block, no predecessor branches into the join. Mark the whole
        // match as block-terminated and emit no join body — the
        // surrounding context (top-level fn body, seq rhs, etc.) checks
        // `block_terminated` before any fall-through emission.
        if phi_inputs.is_empty() {
            self.block_terminated = true;
            // Return a dummy SSA + type that won't be consumed. Use the
            // result_ty if any arm produced one, else fall back to i8.
            let rt = result_ty.unwrap_or_else(|| "i8".into());
            return Ok(("0".into(), rt));
        }
        self.start_block(&join_lbl);
        let phi = self.fresh_ssa();
        let rt = result_ty.unwrap_or_else(|| "i64".into());
        let phi_args = phi_inputs
            .iter()
            .map(|(v, b)| format!("[ {v}, %{b} ]"))
            .collect::<Vec<_>>()
            .join(", ");
        self.body.push_str(&format!(
            "  {phi} = phi {rt} {phi_args}\n"
        ));
        Ok((phi, rt))
    }

}
