//! 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::*;
use std::collections::BTreeSet;

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

impl<'a> Emitter<'a> {
    /// Iter 18c.4: 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) {
        let m = self.module_name;
        let tname = &td.name;
        let mut out = String::new();
        out.push_str(&format!("define void @drop_{m}_{tname}(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");
        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) in ctor.fields.iter().enumerate() {
                // Decide what to call for this field. If the field
                // lowers to `ptr` (boxed), we issue a `dec` call.
                // For known ADT field types we route through that
                // ADT's own drop fn so the recursion cascades; for
                // anything else that lowers to `ptr` (Str, fn-typed,
                // unresolved Var), fall back to plain `ailang_rc_dec`.
                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 %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);
    }

    /// Iter 18e: 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) {
        let m = self.module_name;
        let tname = &td.name;
        let mut out = String::new();
        out.push_str(&format!("define void @drop_{m}_{tname}(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) in ctor.fields.iter().enumerate() {
                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);
    }

    /// Iter 18e helper: 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,
        }
    }

    /// Iter 18c.4: 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, .. } => {
                // Built-in pointer-typed cons: Str. No drop fn —
                // shallow `ailang_rc_dec` is the right answer (Str
                // payloads are NUL-terminated bytes in static
                // memory; nothing to recurse into).
                if matches!(name.as_str(), "Str") {
                    return "ailang_rc_dec".to_string();
                }
                // Qualified `module.T` → drop fn lives in `module`.
                if name.matches('.').count() == 1 {
                    let (prefix, suffix) = name.split_once('.').expect("checked");
                    if let Some(target) = self.import_map.get(prefix) {
                        return format!("drop_{target}_{suffix}");
                    }
                    // Fallback: treat the prefix itself as the owner
                    // module (typechecker would have rejected an
                    // unimported prefix earlier).
                    return format!("drop_{prefix}_{suffix}");
                }
                // Bare name: declared in the current module.
                format!("drop_{m}_{name}", m = self.module_name)
            }
            // 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(),
        }
    }

    /// Iter 18c.3: 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).
    ///
    /// Iter 18g.2 widens this to include `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`/`Implicit`-returning are
    /// still not trackable — they don't carry that signal.
    ///
    /// 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: &Term) -> bool {
        if !matches!(self.alloc, AllocStrategy::Rc) {
            return false;
        }
        match value {
            Term::Ctor { .. } | Term::Lam { .. } => {
                let term_ptr = (value as *const Term) as usize;
                !self.non_escape.contains(&term_ptr)
            }
            Term::App { callee, .. } => {
                // Iter 18g.2: a call whose callee carries
                // `ret_mode == Own` hands a fresh heap allocation to
                // the caller's frame. Trackable. `Borrow` and
                // `Implicit` ret-modes do not carry that signal —
                // returning by Borrow is a view into the callee's
                // owned data (caller does not own it), and Implicit
                // is the back-compat lane that 18c.3's debt covers.
                self.synth_callee_ret_mode(callee)
                    .map(|m| matches!(m, ParamMode::Own))
                    .unwrap_or(false)
            }
            _ => false,
        }
    }

    /// Iter 18g.2: 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: &Term) -> Option<ParamMode> {
        let cty = self.synth_arg_type(callee).ok()?;
        match cty {
            Type::Fn { ret_mode, .. } => Some(ret_mode),
            _ => None,
        }
    }

    /// Iter 18c.4: 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: &Term, val_ssa: &str) -> String {
        match value {
            Term::Ctor { type_name, .. } => {
                if type_name.matches('.').count() == 1 {
                    let (prefix, suffix) =
                        type_name.split_once('.').expect("checked");
                    if let Some(target) = self.import_map.get(prefix) {
                        return format!("drop_{target}_{suffix}");
                    }
                    return format!("drop_{prefix}_{suffix}");
                }
                format!("drop_{m}_{type_name}", m = self.module_name)
            }
            Term::Lam { .. } => self
                .closure_drops
                .get(val_ssa)
                .cloned()
                .unwrap_or_else(|| "ailang_rc_dec".to_string()),
            // Iter 18g.2: 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).
            Term::App { .. } => {
                if let Ok(ret_ty) = self.synth_arg_type(value) {
                    if let Type::Con { name, .. } = ret_ty {
                        if name.matches('.').count() == 1 {
                            let (prefix, suffix) =
                                name.split_once('.').expect("checked");
                            if let Some(target) = self.import_map.get(prefix) {
                                return format!("drop_{target}_{suffix}");
                            }
                            return format!("drop_{prefix}_{suffix}");
                        }
                        return format!("drop_{m}_{name}", m = self.module_name);
                    }
                }
                "ailang_rc_dec".to_string()
            }
            _ => "ailang_rc_dec".to_string(),
        }
    }

    /// Iter 18g.tidy.fu2: 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) {
        let m = self.module_name;
        let tname = &td.name;
        let mut out = String::new();
        out.push_str(&format!(
            "define void @partial_drop_{m}_{tname}(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) in ctor.fields.iter().enumerate() {
                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);
    }

    /// Iter 18g.tidy.fu2: 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, .. } => {
                if name == "Str" {
                    return None;
                }
                if name.matches('.').count() == 1 {
                    let (prefix, suffix) = name.split_once('.').expect("checked");
                    if let Some(target) = self.import_map.get(prefix) {
                        return Some(format!("partial_drop_{target}_{suffix}"));
                    }
                    return Some(format!("partial_drop_{prefix}_{suffix}"));
                }
                Some(format!(
                    "partial_drop_{m}_{name}",
                    m = self.module_name
                ))
            }
            _ => None,
        }
    }

    /// Iter 18g.tidy.fu2: 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)
    }

    /// Iter 18d.3: 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.
    ///
    /// Iter 18g.2 widened the input set to `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.
    /// Iter 18g.tidy.fu2 closes that path: 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: &Term,
        val_ssa: &str,
        moved: &BTreeSet<usize>,
    ) -> Result<()> {
        let (type_name, ctor_name) = match value {
            Term::Ctor { type_name, ctor, .. } => (type_name.as_str(), ctor.as_str()),
            _ => {
                // Iter 18g.tidy.fu2: dynamic-tag partial-drop via the
                // per-type helper. `value` is `Term::App` (Own-
                // returning) — the binder's static type is the App's
                // ret type, recovered through `synth_arg_type` /
                // `partial_drop_symbol_for_type`. `Term::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
                    .synth_arg_type(value)
                    .ok()
                    .and_then(|ty| self.partial_drop_symbol_for_type(&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)?;
        // 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_ail) in cref.ail_fields.iter().enumerate() {
            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(())
    }

    /// Iter 18c.4: 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
    }

    /// Iter 18c.4: 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
    }
}
