//! Per-type drop-fn emission and per-let-close drop dispatch.
//!
//! All RC-allocator drop work lives here:
//!   - `emit_drop_fn_for_type` / `emit_iterative_drop_fn_for_type`
//!     emit `@drop_<m>_<T>(ptr)` per `Def::Type` under `--alloc=rc`.
//!     The recursive variant cascades through `field_drop_call`; the
//!     iterative variant uses an explicit worklist (Iter 18e) so
//!     `(drop-iterative)` types can free chains of arbitrary length
//!     without consuming proportional C stack.
//!   - `field_is_same_type` / `field_drop_call` are the internal
//!     dispatch helpers consulted by the per-type drop bodies and by
//!     match-arm / reuse-as drop emission elsewhere.
//!   - `is_rc_heap_allocated` / `synth_callee_ret_mode` /
//!     `drop_symbol_for_binder` / `emit_inlined_partial_drop` are the
//!     per-let-close cluster: codegen calls them from the
//!     `Term::Let` lowering to decide whether to dec, what symbol to
//!     dispatch to, and (when pattern destructuring transferred
//!     fields out) how to emit a partial-drop sequence inline.
//!   - `build_env_drop_fn` / `build_pair_drop_fn` build the per-
//!     closure drop fns that `lower_lambda` defers into the IR
//!     stream (Iter 18c.4 closure cleanup).
//!
//! Methods called from `lib.rs` are `pub(crate)`; helpers used only
//! among the drop-cluster methods stay private. Field access from
//! this submodule into the parent's private `Emitter` fields works
//! through the standard descendant-module privacy lane.

use ailang_core::ast::{ParamMode, Type, TypeDef};
use ailang_mir::{Callee, MTerm};
use std::collections::{BTreeMap, BTreeSet};

use super::synth::llvm_type;
use super::{AllocStrategy, Emitter, FnSig, Result};

impl<'a> Emitter<'a> {
    /// emit a `void @drop_<module>_<TypeName>(ptr %p)`
    /// function that decrements the refcount of every pointer-typed
    /// field of every ctor, then frees the outer box.
    ///
    /// Shape:
    /// ```text
    ///   define void @drop_<m>_<T>(ptr %p) {
    ///     %tag = load i64, ptr %p
    ///     switch i64 %tag, label %dflt [
    ///       i64 0, label %arm0
    ///       ...
    ///     ]
    ///   arm_i:
    ///     for each pointer-typed field f_j:
    ///       %addr = gep ptr %p, i64 (8 + 8*j)
    ///       %v    = load ptr, ptr %addr
    ///       call void @drop_<owner>_<FieldT>(ptr %v)   ; or @ailang_rc_dec
    ///     br label %join
    ///   dflt:
    ///     unreachable
    ///   join:
    ///     call void @ailang_rc_dec(ptr %p)
    ///     ret void
    ///   }
    /// ```
    ///
    /// For ADTs with no boxed children every arm is empty and falls
    /// straight through to `join`, which is just the final dec — see
    /// the assignment's "always emit drop_X for every ADT" decision
    /// (`rc_box_drop`'s `MkBox(Int)` is the canonical example).
    ///
    /// Recursion: when a ctor field's type is the same ADT (or any
    /// ADT in the workspace), the emitted call to
    /// `@drop_<owner>_<T>(field)` is recursive at the IR level and
    /// will overflow the stack on long lists. The 18e
    /// `(drop-iterative)` annotation routes such types through
    /// [`Self::emit_iterative_drop_fn_for_type`] instead, which
    /// replaces the recursive call with a worklist push. ADTs WITHOUT
    /// the annotation continue to use this recursive form — the
    /// orchestrator's choice: opt-in iterative drop where the depth
    /// is known to grow, recursive cascade everywhere else (cheaper
    /// IR, no worklist allocation).
    pub(crate) fn emit_drop_fn_for_type(&mut self, td: &TypeDef) {
        // Monomorphic / intrinsic ADTs: one drop fn, empty subst, no
        // suffix — byte-identical to the pre-leg-C emission.
        // Polymorphic ADTs: one drop fn per concrete instantiation
        // collected workspace-wide (leg C). Each fn substitutes the
        // declared type-vars to the instantiation's concrete args so
        // the dec-vs-skip decision is made on the *monomorph* field
        // type (a value-type field is an inline scalar → skipped; a
        // heap field is dec'd via its own per-monomorph drop symbol).
        self.emit_drop_fn_wrapper(td, Self::emit_drop_fn_for_instantiation);
    }

    /// Shared wrapper for [`Self::emit_drop_fn_for_type`],
    /// [`Self::emit_iterative_drop_fn_for_type`], and
    /// [`Self::emit_partial_drop_fn_for_type`]: the three differ only in
    /// which `*_for_instantiation` emitter they dispatch to. For a
    /// monomorphic / intrinsic ADT (`!is_suffixed`) emit exactly one fn
    /// with an empty subst and empty suffix; for a leg-C polymorphic
    /// ADT emit one fn per concrete instantiation, in
    /// `drop_monos.instantiations` order. The set and order of emitted
    /// functions — hence the emitted IR — is identical to the
    /// pre-dedup form. The instantiation plan is materialised up front
    /// so all `self.drop_monos` reads finish before `emit` borrows
    /// `&mut self`.
    fn emit_drop_fn_wrapper(
        &mut self,
        td: &TypeDef,
        emit: fn(&mut Self, &TypeDef, &BTreeMap<String, Type>, &str),
    ) {
        let key = (self.module_name.to_string(), td.name.clone());
        if !self.drop_monos.is_suffixed(&key) {
            emit(self, td, &BTreeMap::new(), "");
            return;
        }
        let plan: Vec<(BTreeMap<String, Type>, String)> = self
            .drop_monos
            .instantiations(&key)
            .into_iter()
            .map(|args| {
                let subst: BTreeMap<String, Type> =
                    td.vars.iter().cloned().zip(args.iter().cloned()).collect();
                let suffix = self
                    .drop_monos
                    .suffix_for(&key, &args)
                    .unwrap_or_default();
                (subst, suffix)
            })
            .collect();
        for (subst, suffix) in &plan {
            emit(self, td, subst, suffix);
        }
    }

    /// emit one recursive `drop_<m>_<T><suffix>` body. `subst` maps the
    /// declared type-vars to the concrete instantiation args (empty for
    /// monomorphic ADTs); `sym_suffix` is the `__<...>` mono suffix
    /// (empty for monomorphic ADTs).
    fn emit_drop_fn_for_instantiation(
        &mut self,
        td: &TypeDef,
        subst: &BTreeMap<String, Type>,
        sym_suffix: &str,
    ) {
        let m = self.module_name;
        let tname = &td.name;
        let mut out = String::new();
        out.push_str(&format!("define void @drop_{m}_{tname}{sym_suffix}(ptr %p) {{\n"));
        out.push_str("entry:\n");
        // Null guard: a null payload is a no-op (matches
        // `runtime/rc.c::ailang_rc_dec`'s null guard).
        out.push_str("  %is_null = icmp eq ptr %p, null\n");
        out.push_str("  br i1 %is_null, label %ret, label %live\n");
        out.push_str("live:\n");
        // FROZEN ABI (embedding boundary) — see design/contracts/0003-embedding-abi.md.
        out.push_str("  %tag = load i64, ptr %p, align 8\n");

        let n_ctors = td.ctors.len();
        // Switch over the tag. Each ctor gets one arm.
        out.push_str("  switch i64 %tag, label %dflt [\n");
        for (i, _) in td.ctors.iter().enumerate() {
            out.push_str(&format!("    i64 {i}, label %arm_{i}\n"));
        }
        out.push_str("  ]\n");

        // Per-ctor arm: iterate fields, dec the boxed ones.
        let mut local = 0u64;
        for (i, ctor) in td.ctors.iter().enumerate() {
            out.push_str(&format!("arm_{i}:\n"));
            for (j, fty_decl) in ctor.fields.iter().enumerate() {
                // Substitute the declared field type to its monomorph.
                // A value-type field (`Int`/`Bool`/`Float`/`Unit`)
                // lowers to a non-`ptr` scalar → skip the dec (it is
                // stored inline, NOT a heap pointer — `rc_dec` on it
                // would SIGSEGV). A heap field lowers to `ptr` → dec via
                // `field_drop_call` on the *concrete* type, so the
                // cascade targets the field's own per-monomorph symbol.
                let fty = super::subst::apply_subst_to_type(fty_decl, subst);
                let lty = llvm_type(&fty).unwrap_or_else(|_| "ptr".into());
                if lty != "ptr" {
                    continue;
                }
                // FROZEN ABI (embedding boundary) — see design/contracts/0003-embedding-abi.md.
                let off = 8 + (j as i64) * 8;
                let addr_id = local;
                local += 1;
                let val_id = local;
                local += 1;
                out.push_str(&format!(
                    "  %a{addr_id} = getelementptr inbounds i8, ptr %p, i64 {off}\n"
                ));
                out.push_str(&format!(
                    "  %v{val_id} = load ptr, ptr %a{addr_id}, align 8\n"
                ));
                let drop_call = self.field_drop_call(&fty);
                // Recursive call into the field's drop fn. If the
                // field's type is itself `(drop-iterative)`, that drop
                // fn is the worklist variant — recursion stops at one
                // level. Otherwise this is the unbounded recursive
                // cascade; safe only on bounded-depth ADTs (the
                // `(drop-iterative)` annotation exists for the
                // unbounded ones).
                out.push_str(&format!(
                    "  call void @{drop_call}(ptr %v{val_id})\n"
                ));
            }
            out.push_str("  br label %join\n");
        }

        // Default arm: unreachable when the typechecker has accepted
        // the input — every legal box has one of the ctor tags.
        out.push_str("dflt:\n");
        if n_ctors == 0 {
            // No ctors at all: a Type with zero ctors cannot be
            // instantiated; the drop fn is dead. Still emit a
            // br-to-join for IR validity.
            out.push_str("  br label %join\n");
        } else {
            out.push_str("  unreachable\n");
        }

        // Join: free the outer box.
        out.push_str("join:\n");
        out.push_str("  call void @ailang_rc_dec(ptr %p)\n");
        out.push_str("  br label %ret\n");
        out.push_str("ret:\n");
        out.push_str("  ret void\n");
        out.push_str("}\n\n");
        self.body.push_str(&out);
    }

    /// emit `drop_<m>_<T>` for a `(drop-iterative)` type.
    /// Replaces the recursive cascade in [`Self::emit_drop_fn_for_type`]
    /// with an iterative-with-explicit-worklist body so cells of
    /// arbitrary chain depth can free without consuming proportional
    /// C stack.
    ///
    /// IR shape:
    /// ```text
    ///   define void @drop_<m>_<T>(ptr %p) {
    ///   entry:
    ///     %is_null = icmp eq ptr %p, null
    ///     br i1 %is_null, label %ret, label %init_wl
    ///   init_wl:
    ///     %wl = call ptr @ailang_drop_worklist_new()
    ///     call void @ailang_drop_worklist_push(ptr %wl, ptr %p)
    ///     br label %loop_head
    ///   loop_head:
    ///     %cur = call ptr @ailang_drop_worklist_pop(ptr %wl)
    ///     %done = icmp eq ptr %cur, null
    ///     br i1 %done, label %finish, label %dispatch
    ///   dispatch:
    ///     %tag = load i64, ptr %cur, align 8
    ///     switch i64 %tag, label %dflt [
    ///       i64 0, label %arm_0
    ///       ...
    ///     ]
    ///   arm_i:
    ///     for each pointer-typed field f_j of ctor i:
    ///       %addr = gep %cur, 8 + 8*j
    ///       %v    = load ptr, ptr %addr
    ///       if field type is T (same as the type being dropped):
    ///         call void @ailang_drop_worklist_push(ptr %wl, ptr %v)
    ///       else:
    ///         call void @drop_<owner>_<F>(ptr %v)   ; or @ailang_rc_dec
    ///     call void @ailang_rc_dec(ptr %cur)
    ///     br label %loop_head
    ///   dflt:
    ///     unreachable
    ///   finish:
    ///     call void @ailang_drop_worklist_free(ptr %wl)
    ///     br label %ret
    ///   ret:
    ///     ret void
    ///   }
    /// ```
    ///
    /// Mono-typed worklist. Every pointer pushed onto `%wl` is a `T`
    /// (the type being dropped). For a field whose type is `T` itself
    /// → push (continues the iterative cascade). For any other ADT
    /// field type `T'` → call `drop_<m'>_<T'>` directly: if `T'` is
    /// also `(drop-iterative)`, that fn allocates its own worklist
    /// instance (no nesting); if `T'` is non-iterative, it recurses
    /// stack-wise (depth bounded by the number of *distinct* nested
    /// ADTs reachable from `T`, which is small in practice).
    ///
    /// This interpretation of the assignment's "should also use the
    /// worklist" clause was chosen because a heterogeneously-typed
    /// worklist would require storing a (ptr, drop-handler) tuple per
    /// entry plus a vtable dispatch on pop — significant complexity
    /// for the case where two distinct ADTs are mutually recursive
    /// AND both are drop-iterative AND the chain is millions deep.
    /// That triple-conjunct is not on the 18-arc's critical path; if
    /// it surfaces in practice, a follow-up iter can extend the
    /// worklist entry shape. The mono-typed version captures the
    /// stack-overflow-on-long-self-chains problem fully.
    pub(crate) fn emit_iterative_drop_fn_for_type(&mut self, td: &TypeDef) {
        self.emit_drop_fn_wrapper(td, Self::emit_iterative_drop_fn_for_instantiation);
    }

    /// emit one iterative `drop_<m>_<T><suffix>` body (worklist variant).
    /// `subst` / `sym_suffix` carry the leg-C per-monomorph
    /// instantiation, identical in role to
    /// [`Self::emit_drop_fn_for_instantiation`].
    fn emit_iterative_drop_fn_for_instantiation(
        &mut self,
        td: &TypeDef,
        subst: &BTreeMap<String, Type>,
        sym_suffix: &str,
    ) {
        let m = self.module_name;
        let tname = &td.name;
        let mut out = String::new();
        out.push_str(&format!("define void @drop_{m}_{tname}{sym_suffix}(ptr %p) {{\n"));
        out.push_str("entry:\n");
        // Null guard — symmetric with the recursive variant. A null
        // payload skips worklist allocation entirely.
        out.push_str("  %is_null = icmp eq ptr %p, null\n");
        out.push_str("  br i1 %is_null, label %ret, label %init_wl\n");
        out.push_str("init_wl:\n");
        out.push_str("  %wl = call ptr @ailang_drop_worklist_new()\n");
        out.push_str(
            "  call void @ailang_drop_worklist_push(ptr %wl, ptr %p)\n",
        );
        out.push_str("  br label %loop_head\n");

        out.push_str("loop_head:\n");
        out.push_str(
            "  %cur = call ptr @ailang_drop_worklist_pop(ptr %wl)\n",
        );
        out.push_str("  %done = icmp eq ptr %cur, null\n");
        out.push_str("  br i1 %done, label %finish, label %dispatch\n");

        out.push_str("dispatch:\n");
        out.push_str("  %tag = load i64, ptr %cur, align 8\n");
        let n_ctors = td.ctors.len();
        out.push_str("  switch i64 %tag, label %dflt [\n");
        for (i, _) in td.ctors.iter().enumerate() {
            out.push_str(&format!("    i64 {i}, label %arm_{i}\n"));
        }
        out.push_str("  ]\n");

        // Per-ctor arms. For each pointer-typed field decide push vs
        // direct call based on whether the field's type is the same
        // as the type being dropped.
        let mut local = 0u64;
        for (i, ctor) in td.ctors.iter().enumerate() {
            out.push_str(&format!("arm_{i}:\n"));
            for (j, fty_decl) in ctor.fields.iter().enumerate() {
                // Substitute to the monomorph (same reasoning as the
                // recursive variant): a value-type field is inline and
                // must not be dec'd/pushed.
                let fty = super::subst::apply_subst_to_type(fty_decl, subst);
                let lty = llvm_type(&fty).unwrap_or_else(|_| "ptr".into());
                if lty != "ptr" {
                    continue;
                }
                let off = 8 + (j as i64) * 8;
                let addr_id = local;
                local += 1;
                let val_id = local;
                local += 1;
                out.push_str(&format!(
                    "  %a{addr_id} = getelementptr inbounds i8, ptr %cur, i64 {off}\n"
                ));
                out.push_str(&format!(
                    "  %v{val_id} = load ptr, ptr %a{addr_id}, align 8\n"
                ));
                if self.field_is_same_type(&fty, &td.name) {
                    // Same-type field: push onto the worklist —
                    // continues the iterative cascade. Null-guarding
                    // is handled inside `ailang_drop_worklist_push`
                    // itself (skips null payloads).
                    out.push_str(&format!(
                        "  call void @ailang_drop_worklist_push(ptr %wl, ptr %v{val_id})\n"
                    ));
                } else {
                    // Different-type field: dispatch to that type's
                    // own drop fn (which itself decides recursive vs.
                    // iterative). `field_drop_call` resolves the
                    // symbol; its null-guard semantics are the same
                    // as the recursive variant.
                    let drop_call = self.field_drop_call(&fty);
                    out.push_str(&format!(
                        "  call void @{drop_call}(ptr %v{val_id})\n"
                    ));
                }
            }
            // Dec the outer cell. Worklist holds no other reference
            // to this pointer (push happened exactly once on the
            // parent's cascade, and pop just removed that entry), so
            // the cell's refcount drops by exactly one here. Children
            // pushed above keep their own refcounts pending until
            // their loop iteration.
            out.push_str("  call void @ailang_rc_dec(ptr %cur)\n");
            out.push_str("  br label %loop_head\n");
        }

        // Default arm: unreachable when the typechecker has accepted
        // the input. Same shape as the recursive variant.
        out.push_str("dflt:\n");
        if n_ctors == 0 {
            out.push_str("  br label %finish\n");
        } else {
            out.push_str("  unreachable\n");
        }

        out.push_str("finish:\n");
        out.push_str("  call void @ailang_drop_worklist_free(ptr %wl)\n");
        out.push_str("  br label %ret\n");
        out.push_str("ret:\n");
        out.push_str("  ret void\n");
        out.push_str("}\n\n");
        self.body.push_str(&out);
    }

    /// is `fty` the same ADT as `td_name` in the
    /// current module? Used by the iterative-drop body to decide
    /// "push to worklist" (same type) vs. "call its drop fn directly"
    /// (different type).
    ///
    /// Returns `true` only when the field type is a `Type::Con`
    /// referencing `td_name` AND the reference resolves to the
    /// current module (bare or qualified-but-self). Qualified names
    /// pointing at *other* modules are different types — even when
    /// they spell the same suffix. Type-vars and fn-types are never
    /// the same as the ADT being dropped (parametric self-recursion
    /// could bind a var to T, but the bound is invisible at codegen
    /// since we don't monomorphise drop fns).
    fn field_is_same_type(&self, fty: &Type, td_name: &str) -> bool {
        match fty {
            Type::Con { name, .. } => {
                if name.matches('.').count() == 1 {
                    let (prefix, suffix) = name.split_once('.').expect("checked");
                    if suffix != td_name {
                        return false;
                    }
                    // Resolve the prefix; same-module iff the resolved
                    // target equals self.module_name.
                    let target = self
                        .import_map
                        .get(prefix)
                        .map(|s| s.as_str())
                        .unwrap_or(prefix);
                    target == self.module_name
                } else {
                    name == td_name
                }
            }
            _ => false,
        }
    }

    /// pick the drop-fn symbol to call for a single
    /// pointer-typed field. Routes ADT fields to their own
    /// `drop_<owner>_<T>` symbol so the recursion cascades through
    /// recursive types (List, Tree). Falls back to `ailang_rc_dec`
    /// for non-ADT pointer types (Str, fn-typed, unresolved Var) —
    /// those are not user-defined ADTs and have no per-type drop fn.
    pub(crate) fn field_drop_call(&self, fty: &Type) -> String {
        match fty {
            Type::Con { name, args } => {
                // Built-in pointer-typed cons: Str. No drop fn —
                // shallow `ailang_rc_dec` is the right answer.
                // Str has two realisations sharing the consumer
                // ABI (len at offset 0, bytes at offset 8):
                //   - heap-Str: malloc'd slab with real rc_header
                //     at `payload - 8`; rc_dec is the correct
                //     refcount-and-free path.
                //   - static-Str: packed-struct LLVM global
                //     <{ i64, [N x i8] }> in .rodata, no rc_header
                //     slot; rc_dec would read undefined bytes at
                //     `payload - 8`. Codegen-level elision
                //     (`emit_inlined_partial_drop` move-tracking
                //     from iter 18d.3 + non-escape lowering from
                //     iter 18b) keeps static-Str pointers out of
                //     this call along every shipping execution
                //     path. The codegen-level invariant is the
                //     protection; no runtime guard backs it up.
                if matches!(name.as_str(), "Str") {
                    return "ailang_rc_dec".to_string();
                }
                // Resolve owner + per-monomorph suffix in one place
                // (leg C). A polymorphic non-intrinsic ADT instantiation
                // (`Box Int`, `Pair Int Int`) gets the same `__<suffix>`
                // the emission loop minted for that instantiation;
                // monomorphic / intrinsic ADTs keep the un-suffixed
                // symbol byte-for-byte.
                self.adt_drop_symbol(name, args)
            }
            // Type::Fn (closure-typed field) → no per-type drop fn
            // exists for closures (each closure has its own per-pair
            // drop fn keyed by the lam-id, not by type). Iter 18c.4
            // shallow-frees the closure-pair; the env and any
            // captured ADT fields leak. A future iter that types
            // closure-typed fields with a runtime descriptor pointer
            // would close this. For 18c.4's recursive-ADT story this
            // is acceptable — the shipping fixtures don't store
            // closures inside ADT fields.
            Type::Fn { .. } => "ailang_rc_dec".to_string(),
            // Type::Var (parameterised ADT field whose type-arg was
            // not pinned at declaration): we don't know the field's
            // concrete shape from the static decl alone, and the
            // generated drop fn is a single symbol per ADT (no
            // monomorphisation). Shallow free is the conservative
            // choice — boxed children of polymorphic-typed fields
            // leak. Iter 18d/18e will revisit this when reuse hints
            // and the worklist allocator land.
            Type::Var { .. } | Type::Forall { .. } => "ailang_rc_dec".to_string(),
        }
    }

    /// resolve the `drop_<owner>_<T>` symbol for an ADT `Type::Con`,
    /// applying the leg-C per-monomorph suffix when the resolved ADT is
    /// polymorphic + non-intrinsic. The caller has already excluded
    /// `Str` and non-`Con` shapes. `name` is the (possibly qualified)
    /// type name; `args` are its concrete instantiation arguments.
    ///
    /// The suffix decision lives in [`dropmono::DropAdtMeta`], shared
    /// with the emission loop, so a call symbol matches its definition
    /// byte-for-byte (a mismatch is an IR link error).
    pub(crate) fn adt_drop_symbol(&self, name: &str, args: &[Type]) -> String {
        self.adt_symbol("drop_", name, args)
    }

    /// resolve the `partial_drop_<owner>_<T>` symbol for an ADT
    /// `Type::Con`, applying the leg-C per-monomorph suffix. Parallel
    /// to [`Self::adt_drop_symbol`]. The caller has already excluded
    /// `Str` / non-`Con` shapes.
    pub(crate) fn adt_partial_drop_symbol(&self, name: &str, args: &[Type]) -> String {
        self.adt_symbol("partial_drop_", name, args)
    }

    /// shared core of [`Self::adt_drop_symbol`] and
    /// [`Self::adt_partial_drop_symbol`]: build the `<prefix><owner>_<bare>`
    /// base from the resolved ADT key and append the leg-C per-monomorph
    /// suffix when one applies. The two public symbols differ only in the
    /// `prefix` literal (`drop_` vs `partial_drop_`); the emitted string is
    /// byte-identical to the pre-dedup form, which matters because these are
    /// IR symbol names (a mismatch is a link error).
    fn adt_symbol(&self, prefix: &str, name: &str, args: &[Type]) -> String {
        let key = super::dropmono::resolve_adt_key(
            name,
            self.module_name,
            &self.import_map,
        );
        let base = format!("{prefix}{owner}_{bare}", owner = key.0, bare = key.1);
        match self.drop_monos.suffix_for(&key, args) {
            Some(suffix) => format!("{base}{suffix}"),
            None => base,
        }
    }

    /// predicate the `Term::Let` lowering uses to decide
    /// whether a let-binder owns a fresh RC-heap allocation that
    /// codegen should `dec` at scope close.
    ///
    /// Returns `true` exactly when:
    ///  - the active allocator is `Rc`,
    ///  - `value` is a `Term::Ctor` or `Term::Lam` (the two AST shapes
    ///    that lower through the heap-allocation path), AND
    ///  - the term is *not* in the current fn's `non_escape` set —
    ///    escaping ctors/lambdas go through `ailang_rc_alloc`,
    ///    non-escaping ones become stack `alloca`s and must NOT be
    ///    `dec`'d (they are freed by LLVM at fn return).
    ///
    /// The widened input set includes `Term::App` whose callee's
    /// fn-type carries `ret_mode == Own`. The mode contract states that
    /// the callee hands the returned cell's ownership to the caller's
    /// frame; the let-scope close is the right place for the caller's
    /// dec. Calls whose callee is `Borrow`-returning are still not
    /// trackable — a borrow-return is a view, not an owned ref.
    ///
    /// Other value shapes (vars, literals, matches, …) return `false`
    /// here. A `Term::Var` returning an RC-allocated box would already
    /// be tracked by an earlier let-binder; tracking it again here
    /// would double-dec.
    pub(crate) fn is_rc_heap_allocated(&self, value: &MTerm) -> bool {
        if !matches!(self.alloc, AllocStrategy::Rc) {
            return false;
        }
        match value {
            MTerm::Ctor { .. } | MTerm::Lam { .. } => {
                let term_ptr = (value as *const MTerm) as usize;
                !self.non_escape.contains(&term_ptr)
            }
            MTerm::App { callee, .. } => {
                // a call whose callee carries
                // `ret_mode == Own` hands a fresh heap allocation to
                // the caller's frame. Trackable. A `Borrow` ret-mode
                // does not carry that signal — returning by Borrow is a
                // view into the callee's owned data (caller does not
                // own it).
                self.synth_callee_ret_mode(callee)
                    .map(|m| matches!(m, ParamMode::Own))
                    .unwrap_or(false)
            }
            MTerm::Loop { .. } => {
                // Loop result is owned-and-untracked (seeds are moved in).
                // Track iff its static type is boxed/heap (llvm `ptr`).
                // mir.4 removed the former `Str` carve-out here: a loop
                // that returns `Str` now always returns an owned heap
                // slab, because `lower_to_mir` promotes every `Str`
                // literal in a loop-carried position — seed inits, recur
                // args, AND tail (exit-arm) result literals — to
                // `StrRep::Heap` (codegen `str_clone`s each into a fresh
                // `rc_header` slab). So `ailang_rc_dec` on a loop-`Str`
                // result is sound; the static-literal UB that motivated
                // the carve-out cannot reach a loop result. Unboxed
                // primitives (Int/Bool/Float/Unit) lower to non-`ptr` and
                // are correctly excluded by the `ptr` gate.
                let t = value.ty();
                let is_ptr = matches!(
                    crate::synth::llvm_type(&t).as_deref(),
                    Ok("ptr")
                );
                is_ptr
            }
            _ => false,
        }
    }

    /// lookup helper for a callee's `ret_mode`. Returns
    /// `Some(mode)` when the callee resolves to a fn-typed term;
    /// `None` for shapes whose type is not a `Type::Fn` (typechecker
    /// would already have rejected an App on a non-fn, but the helper
    /// is defensive). Used by [`Self::is_rc_heap_allocated`] and the
    /// [`Self::drop_symbol_for_binder`] App-arm to decide both
    /// trackability and the drop-fn symbol.
    fn synth_callee_ret_mode(&self, callee: &Callee) -> Option<ParamMode> {
        // A resolved callee carries its fn-type as `sig`; an indirect
        // one carries it on the boxed sub-term. Both yield the callee
        // `ret_mode` identically — there is no behaviour change from
        // mir.1b, only a different place the same fn-type is read from.
        let cty = match callee {
            Callee::Indirect(inner) => inner.ty(),
            Callee::Static { sig, .. } | Callee::Builtin { sig, .. } => sig.clone(),
        };
        match cty {
            Type::Fn { ret_mode, .. } => Some(ret_mode),
            _ => None,
        }
    }

    /// pick the drop-fn symbol to call at the close of a
    /// trackable `Term::Let` scope. For a `Term::Ctor` binder the
    /// symbol is `drop_<owner>_<TypeName>` — derived from the ctor's
    /// `type_name` (which already encodes the owning module via the
    /// `module.T` form when cross-module). For a `Term::Lam` binder
    /// the symbol comes from `closure_drops`, populated by
    /// [`Self::lower_lambda`] when it emitted the per-pair drop fn.
    ///
    /// Falls back to `ailang_rc_dec` for any other shape — should be
    /// unreachable since `is_rc_heap_allocated` only returns `true`
    /// for `Term::Ctor` / `Term::Lam`, but a defensive fallback
    /// keeps the IR well-formed even if the predicate ever widens.
    pub(crate) fn drop_symbol_for_binder(&self, value: &MTerm, val_ssa: &str) -> String {
        match value {
            MTerm::Ctor { type_name, .. } => {
                // The binder's instantiated result type carries the
                // concrete args (`Box Int` → args `[Int]`); read them off
                // `value.ty()` so the leg-C per-monomorph suffix matches
                // the emitted `drop_<m>_Box__Int`. `type_name` alone
                // would lose the instantiation.
                let args = match value.ty() {
                    Type::Con { args, .. } => args,
                    _ => Vec::new(),
                };
                self.adt_drop_symbol(type_name, &args)
            }
            MTerm::Lam { .. } => self
                .closure_drops
                .get(val_ssa)
                .cloned()
                .unwrap_or_else(|| "ailang_rc_dec".to_string()),
            // an Own-returning call hands a freshly heap-
            // allocated cell whose static type is the callee's
            // `ret`. Resolve the per-type drop fn from that ret-type
            // so the cascade walks the cell's pointer-typed children
            // (e.g. an Own-returned `Tree` fans out via
            // `drop_<m>_<Tree>`). Falls back to `ailang_rc_dec` if
            // the ret-type is not a `Type::Con` (e.g. a bare type
            // var on an as-yet-unmonomorphised polymorphic call —
            // the monomorphised copies will resolve correctly).
            MTerm::App { .. } | MTerm::Loop { .. } => {
                if let Type::Con { name, args } = value.ty() {
                    // Symmetric to `field_drop_call`'s Str arm: Str is a
                    // built-in pointer type with no per-type drop fn. Both
                    // heap-Str (rc_header at payload-8) and static-Str
                    // (codegen-elision keeps static pointers out of this
                    // path) consume via `ailang_rc_dec`.
                    if name == "Str" {
                        return "ailang_rc_dec".to_string();
                    }
                    return self.adt_drop_symbol(&name, &args);
                }
                "ailang_rc_dec".to_string()
            }
            _ => "ailang_rc_dec".to_string(),
        }
    }

    /// tag-conditional partial-drop helper.
    /// Emits `void @partial_drop_<m>_<T>(ptr %p, i64 %mask)` —
    /// structurally parallel to [`Self::emit_drop_fn_for_type`] but
    /// gates each ptr-field dec on a bit of `%mask`. If bit j of the
    /// mask is set, slot j was moved out of the cell (its content
    /// belongs to a downstream owner) and the dec is skipped; if the
    /// bit is clear, the field is loaded and routed through
    /// `field_drop_call` (same dispatch the recursive cascade uses).
    /// The outer box is always dec'd at `join`.
    ///
    /// Closes the three dynamic-tag carve-outs that previously
    /// fell back to shallow `ailang_rc_dec`:
    ///   1. `lib.rs` Iter B Own-param dec at fn-return when
    ///      `moved_slots[param]` is non-empty;
    ///   2. `match_lower.rs` Iter A arm-close pattern-binder dec
    ///      when the binder was itself the scrutinee of an inner
    ///      match that moved out some fields;
    ///   3. [`Self::emit_inlined_partial_drop`]'s non-Ctor branch
    ///      (let-binder whose value is `Term::App`).
    ///
    /// All three share the same shape: a binder whose runtime ctor
    /// tag is dynamic (not statically known from a `Term::Ctor`)
    /// and whose `moved_slots` is a strict subset of its ptr fields.
    /// Pre-fu2 the unmoved fields leaked silently; fu2 routes them
    /// through this helper.
    ///
    /// IR shape (analogous to `emit_drop_fn_for_type`):
    /// ```text
    ///   define void @partial_drop_<m>_<T>(ptr %p, i64 %mask) {
    ///     %is_null = icmp eq ptr %p, null
    ///     br i1 %is_null, label %ret, label %live
    ///   live:
    ///     %tag = load i64, ptr %p
    ///     switch i64 %tag, label %dflt [...]
    ///   arm_i:
    ///     ; for each ptr field f_j of ctor i:
    ///     %b_j = and i64 %mask, (1 << j)
    ///     %s_j = icmp ne i64 %b_j, 0
    ///     br i1 %s_j, label %after_i_j, label %do_i_j
    ///   do_i_j:
    ///     %a = gep i8, ptr %p, (8 + 8*j)
    ///     %v = load ptr, ptr %a
    ///     call void @<field_drop_call>(ptr %v)
    ///     br label %after_i_j
    ///   after_i_j:
    ///     ; next field, or br label %join
    ///   dflt: unreachable
    ///   join:
    ///     call void @ailang_rc_dec(ptr %p)
    ///     br label %ret
    ///   ret:
    ///     ret void
    ///   }
    /// ```
    ///
    /// One helper per ADT, emitted alongside `drop_<m>_<T>`. We do
    /// NOT emit a `(drop-iterative)` partial-drop variant: the
    /// helper runs once on the binder (carve-out sites are not
    /// cascade points — the unmoved fields go through their own
    /// `drop_<m>_<F>` which itself decides recursive vs iterative).
    pub(crate) fn emit_partial_drop_fn_for_type(&mut self, td: &TypeDef) {
        self.emit_drop_fn_wrapper(td, Self::emit_partial_drop_fn_for_instantiation);
    }

    /// emit one `partial_drop_<m>_<T><suffix>` body. `subst` /
    /// `sym_suffix` carry the leg-C per-monomorph instantiation,
    /// identical in role to [`Self::emit_drop_fn_for_instantiation`].
    fn emit_partial_drop_fn_for_instantiation(
        &mut self,
        td: &TypeDef,
        subst: &BTreeMap<String, Type>,
        sym_suffix: &str,
    ) {
        let m = self.module_name;
        let tname = &td.name;
        let mut out = String::new();
        out.push_str(&format!(
            "define void @partial_drop_{m}_{tname}{sym_suffix}(ptr %p, i64 %mask) {{\n"
        ));
        out.push_str("entry:\n");
        out.push_str("  %is_null = icmp eq ptr %p, null\n");
        out.push_str("  br i1 %is_null, label %ret, label %live\n");
        out.push_str("live:\n");
        out.push_str("  %tag = load i64, ptr %p, align 8\n");

        let n_ctors = td.ctors.len();
        out.push_str("  switch i64 %tag, label %dflt [\n");
        for (i, _) in td.ctors.iter().enumerate() {
            out.push_str(&format!("    i64 {i}, label %arm_{i}\n"));
        }
        out.push_str("  ]\n");

        let mut local = 0u64;
        for (i, ctor) in td.ctors.iter().enumerate() {
            out.push_str(&format!("arm_{i}:\n"));
            for (j, fty_decl) in ctor.fields.iter().enumerate() {
                let fty = super::subst::apply_subst_to_type(fty_decl, subst);
                let lty = llvm_type(&fty).unwrap_or_else(|_| "ptr".into());
                if lty != "ptr" {
                    continue;
                }
                let bit_id = local;
                local += 1;
                let set_id = local;
                local += 1;
                let bitmask: u64 = 1u64 << j;
                out.push_str(&format!(
                    "  %b{bit_id} = and i64 %mask, {bitmask}\n"
                ));
                out.push_str(&format!(
                    "  %s{set_id} = icmp ne i64 %b{bit_id}, 0\n"
                ));
                out.push_str(&format!(
                    "  br i1 %s{set_id}, label %after_{i}_{j}, label %do_{i}_{j}\n"
                ));
                out.push_str(&format!("do_{i}_{j}:\n"));
                let off = 8 + (j as i64) * 8;
                let addr_id = local;
                local += 1;
                let val_id = local;
                local += 1;
                out.push_str(&format!(
                    "  %a{addr_id} = getelementptr inbounds i8, ptr %p, i64 {off}\n"
                ));
                out.push_str(&format!(
                    "  %v{val_id} = load ptr, ptr %a{addr_id}, align 8\n"
                ));
                let drop_call = self.field_drop_call(&fty);
                out.push_str(&format!(
                    "  call void @{drop_call}(ptr %v{val_id})\n"
                ));
                out.push_str(&format!("  br label %after_{i}_{j}\n"));
                out.push_str(&format!("after_{i}_{j}:\n"));
            }
            out.push_str("  br label %join\n");
        }

        out.push_str("dflt:\n");
        if n_ctors == 0 {
            out.push_str("  br label %join\n");
        } else {
            out.push_str("  unreachable\n");
        }

        out.push_str("join:\n");
        out.push_str("  call void @ailang_rc_dec(ptr %p)\n");
        out.push_str("  br label %ret\n");
        out.push_str("ret:\n");
        out.push_str("  ret void\n");
        out.push_str("}\n\n");
        self.body.push_str(&out);
    }

    /// raw-buf.4: emit the flat `drop_<m>_<T>` for an
    /// intrinsic-storage TypeDef (e.g. RawBuf). The slab is
    /// `[size:i64][primitive elements]` with no tag at offset 0, so
    /// the generic tag-switch drop is wrong; the elements are
    /// primitives carrying no recursive drops. The flat drop is just
    /// the null-guarded outer rc-dec — same `entry/live/ret` shape
    /// and `@ailang_rc_dec` call form as the generic drop's `join`
    /// tail, minus the tag-switch.
    pub(crate) fn emit_flat_intrinsic_drop_fn(&mut self, td: &TypeDef) {
        self.emit_flat_intrinsic_drop_fn_inner(td, "drop", "ptr %p");
    }

    /// raw-buf.4: emit the flat `partial_drop_<m>_<T>` for an
    /// intrinsic-storage TypeDef. Emitted for symbol-resolution
    /// parity with the generic path (every TypeDef gets both a drop
    /// and a partial-drop). No carve-out site can actually call it —
    /// the sole ctor field is a primitive, never a moved-out ptr
    /// field — so the `%mask` arg is ignored and the body is the same
    /// flat outer rc-dec as the drop fn.
    pub(crate) fn emit_flat_intrinsic_partial_drop_fn(&mut self, td: &TypeDef) {
        self.emit_flat_intrinsic_drop_fn_inner(td, "partial_drop", "ptr %p, i64 %mask");
    }

    /// Shared body of [`Self::emit_flat_intrinsic_drop_fn`] and
    /// [`Self::emit_flat_intrinsic_partial_drop_fn`]. The two differ
    /// only in the symbol `prefix` (`drop` vs `partial_drop`) and the
    /// `params` list (`ptr %p` vs `ptr %p, i64 %mask`); everything from
    /// `entry:` down — the null guard, the single `@ailang_rc_dec`, and
    /// the `ret` tail — is identical, since the `%mask` is ignored.
    /// Interpolating `prefix`/`params` reproduces each function's old
    /// IR byte-for-byte.
    fn emit_flat_intrinsic_drop_fn_inner(
        &mut self,
        td: &TypeDef,
        prefix: &str,
        params: &str,
    ) {
        let m = self.module_name;
        let tname = &td.name;
        let mut out = String::new();
        out.push_str(&format!(
            "define void @{prefix}_{m}_{tname}({params}) {{\n"
        ));
        out.push_str("entry:\n");
        out.push_str("  %is_null = icmp eq ptr %p, null\n");
        out.push_str("  br i1 %is_null, label %ret, label %live\n");
        out.push_str("live:\n");
        out.push_str("  call void @ailang_rc_dec(ptr %p)\n");
        out.push_str("  br label %ret\n");
        out.push_str("ret:\n");
        out.push_str("  ret void\n");
        out.push_str("}\n\n");
        self.body.push_str(&out);
    }

    /// resolve the `partial_drop_<owner>_<T>`
    /// symbol for a type, parallel to the existing per-type drop
    /// dispatch in `field_drop_call`. Returns `None` for non-ADT
    /// types (Str, fn-typed, type-vars) — callers fall back to
    /// shallow `ailang_rc_dec` in those cases (no per-type drop
    /// fn exists either, and `moved_slots` would not have been
    /// populated for those shapes anyway).
    pub(crate) fn partial_drop_symbol_for_type(&self, ty: &Type) -> Option<String> {
        match ty {
            Type::Con { name, args } => {
                if name == "Str" {
                    return None;
                }
                Some(self.adt_partial_drop_symbol(name, args))
            }
            _ => None,
        }
    }

    /// build the i64 moved-slots bitmask for a
    /// `partial_drop_<m>_<T>` call. Bit j is set iff slot j is in
    /// `moved`. We reject slot indices ≥ 64 with `None` — no
    /// language-level ADT has that many fields, but the cap is
    /// load-bearing because the helper's mask is a single i64.
    /// On overflow, callers fall back to shallow dec (correctness-
    /// safe; carve-out leak path remains as before, but the cap is
    /// effectively unreachable).
    pub(crate) fn build_moved_mask(moved: &BTreeSet<usize>) -> Option<u64> {
        let mut mask: u64 = 0;
        for &idx in moved {
            if idx >= 64 {
                return None;
            }
            mask |= 1u64 << idx;
        }
        Some(mask)
    }

    /// emit a partial-drop sequence inline at a let-close
    /// site whose binder has moved-out pattern slots. Replaces the
    /// uniform `drop_<m>_<T>(ptr)` call: load each pointer-typed
    /// field whose slot index is NOT in `moved`, dispatch through
    /// `field_drop_call` (the same per-type or shallow drop the
    /// recursive cascade picks), then `ailang_rc_dec` the outer
    /// box. Skips slots in `moved` entirely — those values were
    /// transferred to a pattern-bound binder that owns the dec
    /// for them.
    ///
    /// The widened input set includes `Term::App` (Own-returning
    /// call). The static per-field emission below is keyed
    /// against a `Term::Ctor`'s known ctor; a `Term::App` binder
    /// whose body pattern-matches it has a *dynamic* runtime tag.
    /// instead of falling back
    /// to shallow `ailang_rc_dec` (which leaked the unmoved fields),
    /// we route through the tag-conditional helper
    /// [`Self::emit_partial_drop_fn_for_type`], which dispatches on
    /// the runtime tag and dec's only the unmoved fields.
    pub(crate) fn emit_inlined_partial_drop(
        &mut self,
        value: &MTerm,
        val_ssa: &str,
        moved: &BTreeSet<usize>,
    ) -> Result<()> {
        let (type_name, ctor_name) = match value {
            MTerm::Ctor { type_name, ctor, .. } => (type_name.as_str(), ctor.as_str()),
            _ => {
                // dynamic-tag partial-drop via the
                // per-type helper. `value` is `MTerm::App` (Own-
                // returning) — the binder's static type is the App's
                // ret type, read off `value.ty()` and mapped through
                // `partial_drop_symbol_for_type`. `MTerm::Lam` shapes
                // never reach here with a non-empty `moved` (you can't
                // pattern-match a closure-pair); the fallback below
                // handles them defensively.
                let sym = self.partial_drop_symbol_for_type(&value.ty());
                if let (Some(sym), Some(mask)) =
                    (sym, Self::build_moved_mask(moved))
                {
                    self.body.push_str(&format!(
                        "  call void @{sym}(ptr {val_ssa}, i64 {mask})\n"
                    ));
                } else {
                    self.body.push_str(&format!(
                        "  call void @ailang_rc_dec(ptr {val_ssa})\n"
                    ));
                }
                return Ok(());
            }
        };
        let cref = self.lookup_ctor_by_type(type_name, ctor_name)?;
        // leg C: substitute the declared field types to the binder's
        // concrete instantiation (read off `value.ty()`'s args against
        // the ADT's declared type-vars), so a value-type field is
        // recognised as inline (non-`ptr`, skipped) and a heap field
        // routes through its own per-monomorph drop symbol. For a
        // monomorphic ADT the subst is empty and this is a no-op.
        let inst_subst: BTreeMap<String, Type> =
            match (&cref.type_vars, value.ty()) {
                (vars, Type::Con { args, .. })
                    if !vars.is_empty() && vars.len() == args.len() =>
                {
                    vars.iter().cloned().zip(args).collect()
                }
                _ => BTreeMap::new(),
            };
        // Per-field dec for non-moved pointer-typed slots. ail_fields
        // are the AILang-level field types; field_drop_call resolves
        // them to either `drop_<owner>_<T>` (ADTs cascade) or
        // `ailang_rc_dec` (Str / closures / vars).
        for (idx, fty_decl) in cref.ail_fields.iter().enumerate() {
            let fty_ail = super::subst::apply_subst_to_type(fty_decl, &inst_subst);
            let lty = llvm_type(&fty_ail).unwrap_or_else(|_| "ptr".into());
            if lty != "ptr" {
                continue;
            }
            if moved.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 {val_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"
            ));
        }
        // Finally dec the outer box. The per-type drop fn would have
        // done this in its `join` block; we replicate it here.
        self.body.push_str(&format!(
            "  call void @ailang_rc_dec(ptr {val_ssa})\n"
        ));
        Ok(())
    }

    /// build the IR text for a closure env's drop fn.
    /// Layout: 8 bytes per capture, in declaration order.
    /// For each pointer-typed capture, emit a load + drop call;
    /// finally `ailang_rc_dec` the env block.
    pub(crate) fn build_env_drop_fn(
        &self,
        sym: &str,
        cap_meta: &[(String, String, String, Type, Option<FnSig>)],
    ) -> String {
        let mut out = String::new();
        out.push_str(&format!("define void @{sym}(ptr %env) {{\nentry:\n"));
        out.push_str("  %is_null = icmp eq ptr %env, null\n");
        out.push_str("  br i1 %is_null, label %ret, label %live\n");
        out.push_str("live:\n");
        let mut local = 0u64;
        for (i, (_cname, _outer_ssa, lty, ail_ty, _sig)) in cap_meta.iter().enumerate() {
            if lty != "ptr" {
                continue;
            }
            let off = (i as i64) * 8;
            let addr_id = local;
            local += 1;
            let val_id = local;
            local += 1;
            out.push_str(&format!(
                "  %a{addr_id} = getelementptr inbounds i8, ptr %env, i64 {off}\n"
            ));
            out.push_str(&format!(
                "  %v{val_id} = load ptr, ptr %a{addr_id}, align 8\n"
            ));
            let drop_call = self.field_drop_call(ail_ty);
            out.push_str(&format!(
                "  call void @{drop_call}(ptr %v{val_id})\n"
            ));
        }
        out.push_str("  call void @ailang_rc_dec(ptr %env)\n");
        out.push_str("  br label %ret\n");
        out.push_str("ret:\n");
        out.push_str("  ret void\n}\n\n");
        out
    }

    /// build the IR text for a closure pair's drop fn.
    /// Layout: { ptr thunk, ptr env } — env at offset 8.
    /// Loads env, calls the env drop, then decs the pair box.
    pub(crate) fn build_pair_drop_fn(
        &self,
        sym: &str,
        env_drop: &str,
        has_env: bool,
    ) -> String {
        let mut out = String::new();
        out.push_str(&format!("define void @{sym}(ptr %p) {{\nentry:\n"));
        out.push_str("  %is_null = icmp eq ptr %p, null\n");
        out.push_str("  br i1 %is_null, label %ret, label %live\n");
        out.push_str("live:\n");
        if has_env {
            // The pair layout is `{ ptr thunk, ptr env }`; env is the
            // second field. Note the single braces — this is a plain
            // `push_str`, not a `format!` call, so brace-escaping does
            // not apply. (Iter 18c.4 originally shipped doubled braces
            // here; surfaced when the rc backend became the corpus
            // default and a closure-with-captures escaped.)
            out.push_str(
                "  %ea = getelementptr inbounds { ptr, ptr }, ptr %p, i64 0, i32 1\n",
            );
            out.push_str("  %env = load ptr, ptr %ea, align 8\n");
            out.push_str(&format!("  call void @{env_drop}(ptr %env)\n"));
        } else {
            // No env — the env-drop is still emitted (uniform shape)
            // but is a no-op on null. Skip the load and call dec
            // directly on the pair.
            let _ = env_drop;
        }
        out.push_str("  call void @ailang_rc_dec(ptr %p)\n");
        out.push_str("  br label %ret\n");
        out.push_str("ret:\n");
        out.push_str("  ret void\n}\n\n");
        out
    }
}
