//! Workspace monomorphisation pass — class-method entry plus
//! free-fn entry.
//!
//! Slots into the build pipeline after [`crate::lift_letrecs`] and
//! before `ailang_codegen::lower_workspace_with_alloc`. It is only
//! on the `build` / `run` paths — `ail check` stops at typecheck and
//! never runs this pass.
//!
//! ## What the pass does
//!
//! [`monomorphise_workspace`] turns a polymorphic, typechecked
//! workspace into a fully monomorphic one that codegen can lower
//! without any instance dispatch or `Type::Forall` instantiation:
//!
//! - **Early-out.** Workspaces with no specialisable targets
//!   (`workspace_has_specialisable_targets` — no resolvable
//!   class-method call sites and no concretely-instantiated
//!   polymorphic free fns) are returned cloned unchanged, so their
//!   `module_hash` is preserved bit-for-bit.
//! - **Synthesis fixpoint.** Otherwise the pass repeatedly walks
//!   every fn / const body in every module, collecting
//!   [`MonoTarget`]s, and synthesises one top-level [`Def::Fn`] per
//!   unique target, appended to that target's `defining_module`.
//!   Two target kinds share one fixpoint:
//!     1. `MonoTarget::ClassMethod` — the resolved instance body is
//!        looked up via [`ailang_core::workspace::Registry`], the
//!        class parameter substituted to the concrete type, and a
//!        `<method>__<type>` fn synthesised.
//!     2. `MonoTarget::FreeFn` — a `Type::Forall` free fn called at
//!        a fully-concrete substitution; the source body is taken
//!        directly and rigid-var-substituted into a
//!        `<name>__<type>…` fn.
//!
//!   The loop re-walks bodies added by the previous round, which is
//!   what closes it over chained class-method calls (e.g.
//!   `Eq.ne x y = not (eq x y)`). Targets are deduplicated within a
//!   round and across rounds via [`mono_target_key`].
//! - **Call-site rewrite.** A final pass rewrites every
//!   class-method / poly-free-fn `Term::Var` to its synthesised
//!   mono-symbol name (qualified with the instance's
//!   `defining_module` when it differs from the calling module),
//!   using a cursor aligned position-by-position with
//!   `collect_residuals_ordered`.
//!
//! Pre-existing `Def::Class` and `Def::Instance` entries are
//! preserved verbatim — codegen ignores them, but downstream
//! tooling (e.g. `ail describe`) may still consult them.
//!
//! ## Symbol-hashing invariant
//!
//! Synthesised FnDefs are post-typecheck artefacts. They do NOT
//! enter `CheckedModule.symbols` (built from the original on-disk
//! module at typecheck time and used by `ail diff` / `ail manifest`),
//! same convention as [`crate::lift_letrecs`]. The early-out path
//! additionally guarantees byte-identical workspace output —
//! `module_hash` is unchanged end-to-end for any workspace with no
//! monomorphisation targets.

use ailang_core::ast::{Arm, ClassDef, ConstDef, Def, FnDef as AstFnDef, InstanceDef, NewArg, Term, Type};
use ailang_core::workspace::Workspace;
use crate::Result;
use indexmap::IndexMap;
use std::collections::{BTreeMap, BTreeSet};

/// workspace-wide monomorphisation pass entry. See the
/// module-level doc for the architecture and contract.
///
/// Pre-condition: `ws` has been typechecked (`check_workspace(ws)`
/// returned no errors) and lifted (`lift_letrecs` per module). The
/// pass does not perform new type checking — it queries types via
/// `synth` on already-typechecked bodies.
pub fn monomorphise_workspace(ws: &Workspace) -> Result<Workspace> {
    // prep.1 (kernel-extension-mechanics): mirror `check_workspace`'s
    // pre-pass — desugar + qualify bare cross-module `Type::Con`
    // references to qualified form. Both passes must operate on the
    // identically-shaped workspace; otherwise the mono's residual /
    // free-fn-call observations would key on the original bare-form
    // Type values and fail unification against the qualified forms
    // the typechecker emitted into the post-prep.1 `Forall` body.
    let ws_prepared = crate::prepare_workspace_for_check(ws);
    let ws = &ws_prepared;
    // Fast path — no class / instance defs anywhere → nothing to do.
    // The pass also has no targets when there are class defs but no
    // instance defs (no callable methods at concrete types), but
    // the body walks would still be a wasted traversal; the
    // class-free check is the cheap way to opt out.
    if !workspace_has_specialisable_targets(ws) {
        return Ok(ws.clone());
    }

    let mut ws_owned: Workspace = ws.clone();
    let env = build_workspace_env(&ws_owned);
    let class_index = build_class_index(&ws_owned);

    let mut synthesised: BTreeSet<(String, String, String)> = BTreeSet::new();

    // Fixpoint: keep collecting until a round adds nothing new.
    // Each round walks every fn body in every module — including
    // bodies appended by the previous round, which is what makes
    // the loop close on chained class-method calls (e.g.
    // `Eq.ne x y = not (eq x y)`).
    loop {
        let new_targets = collect_targets_workspace_wide(&ws_owned, &env)?;
        // Dedup within a round (multiple call sites of `show` at
        // the same type produce N copies of the same MonoTarget)
        // AND across rounds (already-synthesised keys filtered out).
        // Stable iteration order — preserve first-seen target so
        // any later debugging round-trips to a deterministic output.
        let mut seen_this_round: BTreeSet<(String, String, String)> = BTreeSet::new();
        let mut new: Vec<MonoTarget> = Vec::new();
        for t in new_targets {
            let k = mono_target_key(&t);
            if synthesised.contains(&k) || !seen_this_round.insert(k) {
                continue;
            }
            new.push(t);
        }
        if new.is_empty() {
            break;
        }

        // Synthesise each new target; append its FnDef to the
        // target's `defining_module`. Mark the key as synthesised
        // so the next round won't re-collect.
        //
        // The mono pass uses ws_owned.registry as the source of
        // truth for ClassDef + InstanceDef lookups. Both come
        // from the un-modified workspace registry — synthesis
        // never mutates the registry.
        for t in &new {
            let key = mono_target_key(t);
            let (f, defining_module) = match t {
                MonoTarget::ClassMethod { class, type_, defining_module, .. } => {
                    // normalize to match Registry::normalize_type_for_lookup contract
                    let t_ty_norm = ws_owned
                        .registry
                        .normalize_type_for_lookup(defining_module.as_str(), type_);
                    let registry_key =
                        (class.clone(), ailang_core::canonical::type_hash(&t_ty_norm));
                    let entry = ws_owned
                        .registry
                        .entries
                        .get(&registry_key)
                        .ok_or_else(|| {
                            crate::CheckError::Internal(format!(
                                "monomorphise_workspace: target `{} {}` has no registry entry",
                                class,
                                ailang_core::pretty::type_to_string(type_),
                            ))
                        })?;
                    let class_def = class_index.get(class).ok_or_else(|| {
                        crate::CheckError::Internal(format!(
                            "monomorphise_workspace: class `{}` not found",
                            class
                        ))
                    })?;
                    // own ADT names of the module the synthesised
                    // method is appended to — so its instance type, if
                    // that module's own ADT, is localised back to bare
                    // before substitution (see `synthesise_mono_fn`).
                    let own_type_names: BTreeSet<String> = ws_owned
                        .modules
                        .get(defining_module)
                        .map(|m| {
                            m.defs
                                .iter()
                                .filter_map(|d| match d {
                                    Def::Type(td) => Some(td.name.clone()),
                                    _ => None,
                                })
                                .collect()
                        })
                        .unwrap_or_default();
                    (
                        synthesise_mono_fn(t, class_def, &entry.instance, &own_type_names)?,
                        defining_module.clone(),
                    )
                }
                MonoTarget::FreeFn { defining_module, .. } => (
                    synthesise_mono_fn_for_free_fn(t, &ws_owned)?,
                    defining_module.clone(),
                ),
            };
            let target_module = ws_owned
                .modules
                .get_mut(&defining_module)
                .ok_or_else(|| {
                    crate::CheckError::Internal(format!(
                        "monomorphise_workspace: defining module `{}` missing",
                        defining_module
                    ))
                })?;
            target_module.defs.push(Def::Fn(f));
            synthesised.insert(key);
        }
    }

    // Phase 3: rewrite call sites in every fn / const body. Walk in
    // the same pre-order as collect_residuals_ordered so the cursor
    // and the per-callsite target list align position-by-position.
    let env = build_workspace_env(&ws_owned); // re-build: ws_owned has new defs.
    let module_names: Vec<String> = ws_owned.modules.keys().cloned().collect();
    for mname in &module_names {
        // Per-module env.types overlay — see
        // [`apply_per_module_types_overlay`] for rationale.
        // Mirrors the overlay applied in
        // `collect_targets_workspace_wide` so the rewrite-phase
        // residual replay produces the same residual list as the
        // collection-phase walk (cursor alignment depends on it).
        let mut env_mod = env.clone();
        apply_per_module_types_overlay(&mut env_mod, &ws_owned, mname);
        // precompute the per-module poly-free-fn name
        // set used as the rewrite walker's second predicate.
        let poly_free_fns = poly_free_fn_names_for_module(&ws_owned, &env_mod, mname);
        // 2026-05-14 bugfix: per-poly-free-fn-name constraint count,
        // used by both walkers to advance their cursors past the
        // synth-Var-arm's class-residual pushes (see
        // [`poly_free_fn_constraint_counts_for_module`]).
        let poly_free_fn_ccounts =
            poly_free_fn_constraint_counts_for_module(&ws_owned, &env_mod, mname);
        let n_defs = ws_owned.modules[mname].defs.len();
        for i in 0..n_defs {
            let ordered: Vec<Option<MonoTarget>> = {
                let m = &ws_owned.modules[mname];
                let d = &m.defs[i];
                match d {
                    Def::Fn(f) => collect_residuals_ordered(
                        f,
                        mname,
                        &env_mod,
                        &poly_free_fns,
                        &poly_free_fn_ccounts,
                    )?,
                    Def::Const(c) => {
                        let pseudo = const_as_pseudo_fn(c);
                        collect_residuals_ordered(
                            &pseudo,
                            mname,
                            &env_mod,
                            &poly_free_fns,
                            &poly_free_fn_ccounts,
                        )?
                    }
                    _ => continue,
                }
            };
            let m = ws_owned.modules.get_mut(mname).unwrap();
            let d = &mut m.defs[i];
            let mut cursor = 0usize;
            let mut locals: BTreeSet<String> = BTreeSet::new();
            match d {
                Def::Fn(f) => {
                    // Top-level fn params shadow class-method / poly-free-fn names too.
                    for p in &f.params {
                        locals.insert(p.clone());
                    }
                    rewrite_mono_calls(
                        &mut f.body,
                        &env.method_to_candidate_classes,
                        &poly_free_fns,
                        &poly_free_fn_ccounts,
                        mname,
                        &ordered,
                        &mut cursor,
                        &mut locals,
                    );
                }
                Def::Const(c) => {
                    rewrite_mono_calls(
                        &mut c.value,
                        &env.method_to_candidate_classes,
                        &poly_free_fns,
                        &poly_free_fn_ccounts,
                        mname,
                        &ordered,
                        &mut cursor,
                        &mut locals,
                    );
                }
                _ => {}
            }
        }
    }

    Ok(ws_owned)
}

/// walk every fn / const body in the workspace,
/// returning the union of [`collect_mono_targets`] outputs. Const
/// bodies are wrapped in a synthetic zero-arg `FnDef` for the
/// collection call — the residual gathering only depends on body
/// shape, so the wrapping is harmless.
fn collect_targets_workspace_wide(
    ws: &Workspace,
    env: &crate::Env,
) -> Result<Vec<MonoTarget>> {
    let mut out: Vec<MonoTarget> = Vec::new();
    for (mname, m) in &ws.modules {
        // Per-module env.types overlay — see
        // [`apply_per_module_types_overlay`] for rationale. The env
        // arriving here is workspace-flat (via `build_check_env`);
        // synth's `Term::Ctor` arm needs the per-module shape so a
        // bare canonical type-name resolves to the current module's
        // TypeDef rather than a same-named entry from another module.
        let mut env_mod = env.clone();
        apply_per_module_types_overlay(&mut env_mod, ws, mname);
        for d in &m.defs {
            match d {
                Def::Fn(f) => {
                    out.extend(collect_mono_targets(f, mname, &env_mod)?);
                }
                Def::Const(c) => {
                    let pseudo = const_as_pseudo_fn(c);
                    out.extend(collect_mono_targets(&pseudo, mname, &env_mod)?);
                }
                _ => {}
            }
        }
    }
    Ok(out)
}

/// wrap a [`ConstDef`] in a synthetic zero-arg
/// [`AstFnDef`] for residual-collection / rewrite purposes. Const
/// bodies have no parameter list, so the residual gathering only
/// depends on body shape — the wrapping is a structural adapter,
/// not a semantic conversion. Two callers (Phase 1 collection and
/// Phase 3 rewrite) need the same shape; sharing the constructor
/// keeps them in lockstep.
fn const_as_pseudo_fn(c: &ConstDef) -> AstFnDef {
    AstFnDef {
        name: c.name.clone(),
        ty: c.ty.clone(),
        params: Vec::new(),
        body: c.value.clone(),
        doc: None,
        suppress: Vec::new(),
    export: None,
    }
}

/// compute the set of names (bare + dot-qualified) that
/// `synth`'s Var arm would resolve to a polymorphic free fn when
/// called from module `mname`. The rewrite walker and the slot
/// collector both consult this set to decide whether a `Term::Var`
/// site contributes a slot (i.e. needs cursor advancement and a
/// potential mono-symbol rewrite).
///
/// The predicate must agree EXACTLY with `synth`'s Var arm's
/// `free_fn_owner` derivation. Three sources contribute:
///
/// 1. Same-module poly `Def::Fn`s (bare names).
/// 2. Implicitly-imported modules' poly `Def::Fn`s (bare names,
///    via the iter-23.4-prep fall-through).
/// 3. Dot-qualified `alias.name` forms for every import alias.
///
/// Builtins (`==`, `+`, etc.) are explicitly excluded — they live
/// only in `env.globals`, never in `env.module_globals[<owner>]`,
/// so they don't pass synth's "in module_globals" gate.
fn poly_free_fn_names_for_module(
    ws: &Workspace,
    env: &crate::Env,
    mname: &str,
) -> BTreeSet<String> {
    let mut out: BTreeSet<String> = BTreeSet::new();
    // 1. Same-module poly fns.
    if let Some(m) = ws.modules.get(mname) {
        for d in &m.defs {
            if let Def::Fn(f) = d {
                if matches!(&f.ty, Type::Forall { .. }) {
                    out.insert(f.name.clone());
                }
            }
        }
    }
    // 2 + 3. Imported modules' poly fns: bare (via implicit-import
    // fall-through) AND dot-qualified (alias.name).
    // prep.1: also enumerate type-scoped spellings
    // `<TypeName>.<fn>` for every TypeDef declared in the imported
    // module — `synth`'s Var-arm resolves these via the TypeDef-first
    // ladder and `rewrite_mono_calls` consumes the same spelling.
    //
    // raw-buf.3: the bare implicit-import name is suppressed when the
    // current module declares a same-name global. `synth`'s Var-arm
    // resolves a same-module global (lib.rs ~3317) BEFORE the
    // implicit-import fall-through (~3329), so a bare call there is the
    // local def, not the imported poly fn. Adding the bare name here
    // anyway would make `rewrite_mono_calls` treat the local call as a
    // poly-free-fn site and over-advance the slot cursor (surfaced when
    // a kernel-tier type-scoped op collided with a fixture-local
    // monomorphic fn of the same bare name). The dot-qualified /
    // type-scoped spellings are unambiguous and stay unconditional.
    let local_global_names: BTreeSet<String> = env
        .module_globals
        .get(mname)
        .map(|g| g.keys().cloned().collect())
        .unwrap_or_default();
    if let Some(imports) = env.module_imports.get(mname) {
        for (alias_or_name, target_mod) in imports {
            let target_types: Vec<String> = env
                .module_types
                .get(target_mod)
                .map(|tys| tys.keys().cloned().collect())
                .unwrap_or_default();
            if let Some(target_globals) = env.module_globals.get(target_mod) {
                for (n, t) in target_globals {
                    if matches!(t, Type::Forall { .. }) {
                        // Implicit-import bare name (today only the
                        // prelude is implicit; the loop is generic).
                        // Suppressed if shadowed by a same-module global.
                        if !local_global_names.contains(n) {
                            out.insert(n.clone());
                        }
                        // Dot-qualified form.
                        out.insert(format!("{alias_or_name}.{n}"));
                        // prep.1: type-scoped form per TypeDef.
                        for tn in &target_types {
                            out.insert(format!("{tn}.{n}"));
                        }
                    }
                }
            }
        }
    }
    out
}

/// Bugfix 2026-05-14 (`bugfix-mono-cursor-print-with-class-method-arg`):
/// per-module map from poly-free-fn name (in the exact spellings
/// produced by [`poly_free_fn_names_for_module`] — bare same-module,
/// implicit-import bare, and dot-qualified `alias.name`) to the number
/// of class constraints carried by that fn's `Type::Forall`.
///
/// Both mono walkers ([`interleave_slots`] and [`rewrite_mono_calls`])
/// must advance their cursors by `1 + N` at a poly-free-fn `Var` site
/// where `N` is the constraint count: the synth Var-arm (see
/// `crates/ailang-check/src/lib.rs` iter 24.3 block) pushes one
/// `ResidualConstraint` per declared constraint at the same site where
/// it pushes the `FreeFnCall` observation, so the slot-channel cursors
/// would otherwise drift by `N` on every poly-free-fn-with-constraints
/// `Var`.
fn poly_free_fn_constraint_counts_for_module(
    ws: &Workspace,
    env: &crate::Env,
    mname: &str,
) -> BTreeMap<String, usize> {
    let mut out: BTreeMap<String, usize> = BTreeMap::new();
    // 1. Same-module poly fns.
    if let Some(m) = ws.modules.get(mname) {
        for d in &m.defs {
            if let Def::Fn(f) = d {
                if let Type::Forall { constraints, .. } = &f.ty {
                    out.insert(f.name.clone(), constraints.len());
                }
            }
        }
    }
    // 2 + 3. Imported modules' poly fns: bare AND dot-qualified.
    // prep.1: also enumerate type-scoped spellings `<TypeName>.<fn>`
    // per TypeDef in the imported module — mirror of
    // `poly_free_fn_names_for_module`.
    //
    // raw-buf.3: same bare-name shadow suppression as
    // `poly_free_fn_names_for_module` — kept in lockstep so the two
    // maps agree on which spellings count as poly-free-fn sites.
    let local_global_names: BTreeSet<String> = env
        .module_globals
        .get(mname)
        .map(|g| g.keys().cloned().collect())
        .unwrap_or_default();
    if let Some(imports) = env.module_imports.get(mname) {
        for (alias_or_name, target_mod) in imports {
            let target_types: Vec<String> = env
                .module_types
                .get(target_mod)
                .map(|tys| tys.keys().cloned().collect())
                .unwrap_or_default();
            if let Some(target_globals) = env.module_globals.get(target_mod) {
                for (n, t) in target_globals {
                    if let Type::Forall { constraints, .. } = t {
                        if !local_global_names.contains(n) {
                            out.insert(n.clone(), constraints.len());
                        }
                        out.insert(format!("{alias_or_name}.{n}"), constraints.len());
                        for tn in &target_types {
                            out.insert(format!("{tn}.{n}"), constraints.len());
                        }
                    }
                }
            }
        }
    }
    out
}

/// workspace-wide `class-name -> ClassDef` index.
/// Used by the fixpoint to look up the matching class definition
/// when synthesising a fn.
///
/// keys carry the qualified class name
/// (`<defining_module>.<Class>`) to match the post-canonical-class-form residual /
/// `MonoTarget::ClassMethod.class` field and the registry's
/// qualified entries key.
fn build_class_index(ws: &Workspace) -> BTreeMap<String, ClassDef> {
    let mut idx = BTreeMap::new();
    for (mod_name, m) in &ws.modules {
        for d in &m.defs {
            if let Def::Class(c) = d {
                let qualified = format!("{mod_name}.{}", c.name);
                idx.insert(qualified, c.clone());
            }
        }
    }
    idx
}

/// returns `true` iff any module in `ws`
/// declares at least one specialisable target — a [`Def::Class`]
/// or [`Def::Instance`] (class-method residuals) OR a
/// `Type::Forall`-quantified [`Def::Fn`] (free-fn case). Cheap
/// workspace scan; the early-out keeps target-free workspaces
/// byte-identical through the pass.
///
/// Today the prelude is auto-injected into every workspace and
/// brings its Eq/Ord classes along, so the predicate is effectively
/// always true; the generalisation matters for principled
/// correctness should a future workspace skip the prelude.
fn workspace_has_specialisable_targets(ws: &Workspace) -> bool {
    ws.modules.values().any(|m| {
        m.defs.iter().any(|d| match d {
            Def::Class(_) | Def::Instance(_) => true,
            Def::Fn(f) => matches!(f.ty, Type::Forall { .. }),
            _ => false,
        })
    })
}

/// deterministic mono-symbol name for a
/// `(base-name, types...)` tuple. The N-ary form supports both
/// single-type-var class methods (today's six primitive Eq/Ord
/// symbols pass a one-element slice and produce identical output
/// to the pre-iter-23.4 implementation — verified by the
/// hash-stability pin in `crates/ail/tests/mono_hash_stability.rs`)
/// AND N-ary free-fn instantiations (e.g. `Type::Forall.vars = ["a", "b"]`
/// produces `<name>__<surface-a>__<surface-b>` in declaration order).
///
/// Per type, primitive `Type::Con` (`Int`, `Bool`, `Str`, `Unit`,
/// `Float`) produces the surface name for diagnostic and ABI
/// legibility. All other types — parameterised cons, user-defined
/// ADTs, function types — fall to the 8-hex-prefix of
/// `ailang_core::canonical::type_hash`. The hash route ensures
/// uniqueness without requiring a flattened surface form for
/// arbitrarily nested types.
///
/// Separator choice: `__` (double underscore) — `#` terminates LLVM
/// IR global identifiers and breaks C-ABI symbol names on most
/// targets, so the produced name has to survive codegen unaltered.
///
/// Determinism: `ailang_core::canonical::type_hash` is the same
/// function `workspace::build_registry` uses to key
/// [`ailang_core::workspace::Registry::entries`], so a registry-key match implies a
/// `mono_symbol` match.
pub fn mono_symbol(base: &str, ty: &Type) -> String {
    mono_symbol_n(base, std::slice::from_ref(ty))
}

/// N-ary variant of [`mono_symbol`]. The single-type
/// public-API stays as [`mono_symbol`] (used by class-method
/// emission) for surface compatibility; N-ary callers (free-fn
/// emission with multi-arg type instantiations) use this form
/// directly.
pub fn mono_symbol_n(base: &str, types: &[Type]) -> String {
    let mut parts: Vec<String> = Vec::with_capacity(1 + types.len());
    parts.push(base.to_string());
    for ty in types {
        parts.push(type_to_mono_suffix(ty));
    }
    parts.join("__")
}

/// raw-buf.3: the symbol base for a free-fn mono target. A
/// type-scoped op (`scope = Some("RawBuf")`, name `"get"`) bases on
/// `"RawBuf_get"`, so `mono_symbol_n` mints `RawBuf_get__Int`; a
/// bare free fn (`scope = None`) keeps its bare base.
pub fn scoped_base(scope: &Option<String>, name: &str) -> String {
    match scope {
        Some(t) => format!("{t}_{name}"),
        None => name.to_string(),
    }
}

/// Public view of the per-type-arg mono suffix mangler. Codegen's
/// per-monomorph drop-fn naming (`drop_<m>_Pair__Int_Int`) reuses this
/// exact mangling so a drop symbol's suffix matches the fn-symbol
/// scheme byte-for-byte. Delegates to the same private helper
/// `mono_symbol_n` uses, so its fn-symbol output is unperturbed.
pub fn type_mono_suffix(ty: &Type) -> String {
    type_to_mono_suffix(ty)
}

fn type_to_mono_suffix(ty: &Type) -> String {
    match primitive_surface_name(ty) {
        Some(p) => p.to_string(),
        None => {
            let h = ailang_core::canonical::type_hash(ty);
            h[..8].to_string()
        }
    }
}

/// Returns the surface name iff `ty` is a zero-arity primitive
/// `Type::Con`. Used by [`mono_symbol`] to gate the human-readable
/// form. The match is intentionally narrow: `Int<args>` (malformed
/// but parser-accepting) is treated as compound, so it falls to
/// the hash form. The primitive-set itself lives in
/// [`ailang_core::primitives::primitive_surface_name`].
fn primitive_surface_name(ty: &Type) -> Option<&'static str> {
    match ty {
        Type::Con { name, args } if args.is_empty() => {
            ailang_core::primitives::primitive_surface_name(name)
        }
        _ => None,
    }
}

/// a specialisation request. One synthesised
/// monomorphic `Def::Fn` will be produced per unique
/// [`mono_target_key`]. Two source-body kinds share one fixpoint:
///
/// - [`MonoTarget::ClassMethod`]: a class-constraint residual
///   resolved to a concrete `(class, method, type)` triple. The
///   body comes from `Registry::entries[(class, type-hash)]` (the
///   instance body, or the class default if the instance omits the
///   method).
/// - [`MonoTarget::FreeFn`]: a call site to a polymorphic free
///   `Def::Fn` with a fully-concrete substitution. The body comes
///   directly from the polymorphic `Def::Fn`'s `body` after
///   rigid-var substitution. Added in iter 23.4 to unify the
///   typecheck-time and codegen-time specialisers.
#[derive(Debug, Clone)]
pub enum MonoTarget {
    ClassMethod {
        class: String,
        method: String,
        type_: Type,
        defining_module: String,
    },
    FreeFn {
        name: String,
        /// Concrete type arguments in `Type::Forall.vars` declaration order.
        type_args: Vec<Type>,
        /// Module in which the polymorphic source `Def::Fn` is declared;
        /// the synthesised mono `Def::Fn` is appended there.
        defining_module: String,
        /// The TypeDef scope (`Some("RawBuf")`) when the call resolved
        /// type-scoped; `None` for a bare free fn. Part of the dedup
        /// key and the minted symbol base.
        scope: Option<String>,
    },
}

/// dedup key for a [`MonoTarget`]. Class-method
/// targets key on `("class", "<class>.<method>", <type-hash>)`; free-fn
/// targets key on `("free", "<name>", <joined-type-hashes>)`. The two
/// kinds are guaranteed-disjoint by the first component, so a
/// class-method and a free-fn with the same base name never collide.
/// Consumed by the workspace fixpoint and the rewrite walker.
pub fn mono_target_key(t: &MonoTarget) -> (String, String, String) {
    match t {
        MonoTarget::ClassMethod { class, method, type_, .. } => (
            "class".into(),
            format!("{class}.{method}"),
            ailang_core::canonical::type_hash(type_),
        ),
        MonoTarget::FreeFn { name, type_args, scope, .. } => {
            let joined = type_args
                .iter()
                .map(ailang_core::canonical::type_hash)
                .collect::<Vec<_>>()
                .join("|");
            ("free".into(), scoped_base(scope, name), joined)
        }
    }
}

/// Thin wrapper over [`crate::build_check_env`]. The mono pass needs
/// the same workspace-flat `Env` shape as `check_in_workspace`, so both
/// share one source of truth. Per-fn entry points
/// (`collect_mono_targets`, `collect_residuals_ordered`) clone the env
/// and apply per-fn overlay (current_module, globals from
/// module_globals, imports from module_imports, rigid_vars) plus the
/// per-module env.types overlay
/// (see `apply_per_module_types_overlay`).
pub fn build_workspace_env(ws: &Workspace) -> crate::Env {
    crate::build_check_env(ws)
}

/// Rebuild `env.types` to contain only the TypeDefs declared in
/// `module_name`'s `Def::Type`s, mirroring `check_in_workspace`'s
/// per-module overlay at `crates/ailang-check/src/lib.rs:1234`.
///
/// The mono pass re-runs `crate::synth` on every fn body to
/// recover residual class constraints. `synth`'s `Term::Ctor` arm
/// looks up bare canonical `type_name`s via `env.types.get(name)`
/// (lib.rs:1967); without this overlay, `env.types` would be the
/// workspace-flat map built by `build_workspace_env`, and a bare
/// `type_name` in module A could resolve to a same-named TypeDef
/// from module B. The overlay restores per-module bare-name
/// scoping.
///
/// Caller-contract assumption: the workspace has already
/// typechecked, so duplicate type names within a single module
/// cannot occur.
fn apply_per_module_types_overlay(env: &mut crate::Env, ws: &Workspace, module_name: &str) {
    env.types.clear();
    if let Some(m) = ws.modules.get(module_name) {
        for d in &m.defs {
            if let Def::Type(td) = d {
                env.types.insert(td.name.clone(), td.clone());
            }
        }
    }
}

/// re-run [`crate::synth`] on `f`'s body to recover
/// the per-fn residual class constraints. Filter to fully-
/// concrete residuals (the only ones eligible for monomorphisation),
/// look up each `(class, type-hash)` in the registry to recover
/// the `defining_module`, and return the list. Var-shaped or
/// metavar-shaped residuals are silently skipped — the
/// 22b.2 typecheck pass has already fired
/// `MissingConstraint`/`NoInstance` for any that should not exist
/// at this point.
/// Apply the current substitution to a meta and, if the result is
/// fully concrete, normalise it to canonical-form for registry lookup.
///
/// Returns `Some(normalised)` if the meta resolves to a concrete type;
/// `None` if it does not (rigid var or unbound meta — the caller
/// decides the policy: break with `has_rigid = true` at the FreeFn
/// target-collection site, or `Type::unit()`-default at the residual-
/// ordering site).
///
/// extracted from two byte-identical call sites at
/// `collect_mono_targets` and `collect_residuals_ordered` per
/// audit-24's [medium-2] drift item. The byte-identity invariant
/// (Phase 2 synthesis name must match Phase 3 rewrite cursor's
/// lookup name) is now enforced by construction.
fn apply_subst_and_normalize(
    env: &crate::Env,
    module_name: &str,
    m: &Type,
    subst: &crate::Subst,
) -> Option<Type> {
    let resolved = subst.apply(m);
    if crate::is_fully_concrete(&resolved) {
        Some(
            env.workspace_registry
                .normalize_type_for_lookup(module_name, &resolved),
        )
    } else {
        None
    }
}

/// Unify a freshly-synthesised function body type against the function's
/// declared return type, threading the solution into `subst`. Mirrors
/// `check_fn`'s closing `unify(&ret_ty, &body_ty, …)` (lib.rs:2498) and
/// is the single site both mono synth re-entries
/// ([`collect_mono_targets`] and [`collect_residuals_ordered`]) call, so
/// they cannot drift on how a result-only callee forall var (whose only
/// pin is the declared return type — e.g. `RawBuf.new`'s element type)
/// gets resolved before the free-fn arm reads it. A non-`Type::Fn`
/// `inner_ty` is a no-op (both callers early-return on it before reaching
/// the synth, so this is unreachable in practice; kept total for safety).
fn unify_body_against_declared_ret(
    inner_ty: &Type,
    body_ty: &Type,
    subst: &mut crate::Subst,
) -> Result<()> {
    if let Type::Fn { ret, .. } = inner_ty {
        crate::unify(ret, body_ty, subst)?;
    }
    Ok(())
}

pub fn collect_mono_targets(
    f: &AstFnDef,
    module_name: &str,
    env: &crate::Env,
) -> Result<Vec<MonoTarget>> {
    // An `(intrinsic)` body is signature-only — codegen supplies it via
    // the intercept registry, so it carries no mono targets. Skip the
    // synth re-entry (which would hit synth's `Term::Intrinsic`
    // unreachable guard), matching `check_fn`'s body-check skip.
    if crate::is_intrinsic_body(f) {
        return Ok(Vec::new());
    }
    // Build the per-def env exactly as `check_fn` does — install
    // rigid vars, set the current module, etc. This mirrors
    // `crate::check_fn` minus the diagnostic emission.
    let (rigids, inner_ty): (Vec<String>, Type) = match &f.ty {
        Type::Forall { vars, constraints: _, body } => (vars.clone(), (**body).clone()),
        other => (vec![], other.clone()),
    };
    // Non-fn signature carries no mono slots — keep the early-return
    // guard; the param-type extraction itself goes through the shared
    // `crate::fn_param_types` helper so this site and
    // `lower_to_mir::lower_module` cannot drift. (`ret` / `effects`
    // were extracted here only to be discarded.)
    if !matches!(&inner_ty, Type::Fn { .. }) {
        return Ok(Vec::new());
    }
    let param_tys: Vec<Type> = crate::fn_param_types(&f.ty);

    let mut env = env.clone();
    for v in &rigids {
        env.rigid_vars.insert(v.clone());
    }
    env.current_module = module_name.to_string();
    // Seed `env.globals` from the current module's fns so `synth`'s
    // `Term::Var` lookup (lib.rs:1678) resolves bare same-module
    // references — most importantly self-recursive top-level fns.
    // Mirrors `check_in_workspace` (lib.rs:1135-1139). Top-level fn
    // names are only per-module-unique (workspace-wide collisions
    // are legal), so seeding must be scoped to the current module —
    // unlike the workspace-wide flat `env.types` / `env.ctor_index`
    // tables that `build_workspace_env` populates.
    if let Some(g) = env.module_globals.get(module_name).cloned() {
        for (n, t) in g {
            env.globals.insert(n, t);
        }
    }
    // Seed `env.imports` from the current module's import list so
    // `synth`'s qualified-var path (lib.rs:1697) resolves `Mod.fn`
    // references. Mirrors `check_in_workspace` (lib.rs:1147-1152).
    // Per-module because aliases collide across modules.
    if let Some(im) = env.module_imports.get(module_name).cloned() {
        env.imports = im;
    }

    let mut locals: IndexMap<String, Type> = IndexMap::new();
    for (n, t) in f.params.iter().zip(param_tys.iter()) {
        locals.insert(n.clone(), t.clone());
    }
    let mut effects: BTreeSet<String> = BTreeSet::new();
    let mut subst = crate::Subst::default();
    let mut counter: u32 = 0;
    let mut residuals: Vec<crate::ResidualConstraint> = Vec::new();
    let mut free_fn_calls: Vec<crate::FreeFnCall> = Vec::new();
    // mono's residual-collection synth re-entry collects-and-
    // discards warnings — typecheck has already run and surfaced any
    // shadow warnings; mono is a post-typecheck pass.
    let mut warnings_discarded: Vec<crate::diagnostic::Diagnostic> = Vec::new();
    // loop-recur iter 2: mono re-synth begins from top-of-body —
    // empty loop-stack (any `Term::Loop` pushes its own frame as the
    // walk descends).
    let mut loop_stack: Vec<Vec<(String, crate::Type)>> = Vec::new();
    let body_ty = crate::synth(
        &f.body,
        &env,
        &mut locals,
        &mut loop_stack,
        &mut effects,
        &f.name,
        &mut subst,
        &mut counter,
        &mut residuals,
        &mut free_fn_calls,
        &mut warnings_discarded,
        &mut crate::linearity::LetBinderTypes::new(),
    )?;
    // Unify the synthesised body type against the declared return type,
    // mirroring `check_fn`'s final `unify(&ret_ty, &body_ty, …)`
    // (lib.rs:2498). synth is bottom-up and only equates a result-only
    // forall var of a callee with the function's declared return type at
    // this boundary: e.g. `mk : (Int) -> Box Float` whose body allocates
    // `(new RawBuf n)` (desugared to `(app RawBuf.new n)`) where
    // `RawBuf.new : forall a. (Int) -> RawBuf a` — `a` is never pinned
    // by an argument, so the free-fn-call meta for `RawBuf.new` stays
    // unbound through the body walk and the free-fn arm below would
    // Unit-default it (minting the unregistered `RawBuf_new__Unit`).
    // Threading the return type into the same `subst` the FreeFnCall
    // metas are read from pins `a := Float` from the ctor-field context
    // `(con RawBuf a)` of the result `Box Float`. The pre-mono typecheck
    // already proved this unifies; here we only need its solution to flow
    // into the mono substitution. `collect_residuals_ordered` applies the
    // identical unification so both passes resolve the same `type_args`
    // and the minted symbol matches the Phase-3 rewrite target.
    unify_body_against_declared_ret(&inner_ty, &body_ty, &mut subst)?;

    // Filter residuals to fully-concrete ones; look up
    // defining_module via the registry.
    let mut out: Vec<MonoTarget> = Vec::new();
    for r in residuals {
        let r_ty = subst.apply(&r.type_);
        if !crate::is_fully_concrete(&r_ty) {
            continue;
        }
        // normalize to match Registry::normalize_type_for_lookup contract
        let r_ty_norm = env
            .workspace_registry
            .normalize_type_for_lookup(module_name, &r_ty);
        let key = (r.class.clone(), ailang_core::canonical::type_hash(&r_ty_norm));
        let entry = match env.workspace_registry.entries.get(&key) {
            Some(e) => e,
            None => continue, // no-instance — Task 10 of 22b.2 fires; skip silently here.
        };
        out.push(MonoTarget::ClassMethod {
            class: r.class.clone(),
            method: r.method.clone(),
            type_: r_ty,
            defining_module: entry.defining_module.clone(),
        });
    }
    // Free-fn arm: each `FreeFnCall` observation recorded by
    // `synth`'s Var arm carries the bare name, the
    // owning module (resolved through synth's lookup ladder), the
    // forall vars (declaration order), and the per-var fresh
    // metavars. Post-`synth`, `subst.apply` each meta to recover
    // the concrete type-arg at this call site. Unbound (metavar)
    // vars default to `Type::unit()` — mirrors the pre-iter-23.4
    // codegen-side `derive_substitution` behaviour at
    // `crates/ailang-codegen/src/subst.rs:57-61`, where a forall
    // var the args couldn't pin (e.g. `is_empty Nil` for
    // `is_empty : forall a. (List<a>) -> Bool`) collapses to a
    // single Unit-defaulted specialisation. The specialised body
    // must not actually consume an `a`-typed value, or typecheck
    // would have rejected; the Unit default is sound and
    // deterministic.
    for fc in free_fn_calls {
        // if any resolved type-arg is a rigid Type::Var
        // (i.e. a forall var of the *enclosing* polymorphic fn,
        // not a free metavar), skip this target. The enclosing fn
        // will be monomorphised in its own right, and that mono
        // synthesis re-walks the body with rigid → concrete
        // substitution, at which point this same call site is
        // re-observed with concrete type-args. Without the skip, a
        // body like `at_most x y = not (gt x y)` (a polymorphic
        // free fn calling another polymorphic free fn) would
        // produce a spurious `gt__Unit` target whose body
        // references `compare` at Unit — which has no Ord
        // instance, so the synthesised body fails to typecheck.
        //
        // Unit-default for unbound metavars (e.g. `is_empty(Nil)`
        // where the elem type is unobservable from the args alone)
        // is preserved: a metavar resolves to a `$m`-prefixed
        // Var via `subst.apply`, distinct from a rigid Var.
        let mut type_args: Vec<Type> = Vec::with_capacity(fc.metas.len());
        let mut has_rigid = false;
        for m in &fc.metas {
            match apply_subst_and_normalize(&env, module_name, m, &subst) {
                Some(normalised) => type_args.push(normalised),
                None => {
                    // Helper returned None: either rigid var or unbound
                    // metavar. Site-1 policy diverges: rigid → break with
                    // `has_rigid = true` (the enclosing poly fn will be
                    // monomorphised in its own right and this site will
                    // be re-observed with concrete substitution); unbound
                    // metavar → default to Unit (iter 23.4 behaviour,
                    // matching `derive_substitution`'s unobservable-var
                    // policy in codegen).
                    let resolved = subst.apply(m);
                    if contains_rigid_var(&resolved) {
                        has_rigid = true;
                        break;
                    } else {
                        type_args.push(Type::unit());
                    }
                }
            }
        }
        if has_rigid {
            continue;
        }
        out.push(MonoTarget::FreeFn {
            name: fc.name.clone(),
            type_args,
            defining_module: fc.owner_module.clone(),
            scope: fc.scope.clone(),
        });
    }
    Ok(out)
}

/// true iff `t` contains a non-metavar `Type::Var` anywhere.
/// Used by free-fn target collection to distinguish rigid forall vars
/// (skip — wait for the enclosing fn's mono pass) from unbound metavars
/// (Unit-default — no later round will pin them). The naming
/// convention is: metavars are `Type::Var { name: "$m<id>" }`; rigid
/// forall vars use their source name (`a`, `b`, …) which cannot start
/// with `$` per the identifier rules.
fn contains_rigid_var(t: &Type) -> bool {
    match t {
        Type::Var { name } => !name.starts_with("$m"),
        Type::Con { args, .. } => args.iter().any(contains_rigid_var),
        Type::Fn { params, ret, .. } => {
            params.iter().any(contains_rigid_var) || contains_rigid_var(ret)
        }
        Type::Forall { body, .. } => contains_rigid_var(body),
    }
}

/// produce a `FnDef` for a single (target, class,
/// instance) triple. The synthesised fn:
///
/// 1. has name [`mono_symbol`]`(target.method, target.type_)`,
/// 2. has type = the class's method type with the class param
///    substituted to `target.type_` (rigid-var substitution via
///    `crate::substitute_rigids`); the result is a plain
///    `Type::Fn` with no `Forall`,
/// 3. has params + body taken from the instance's matching
///    `InstanceMethod.body` if present and Lam-shaped; from the
///    class's `default` body if the instance omits the method;
///    or directly from the body if the method has no params
///    (zero-arg method types).
///
/// Errors (all [`crate::CheckError::Internal`] — caller-contract
/// violations that cannot occur after typecheck has succeeded):
///
/// - The instance omits the method AND the class has no `default`
///   — unreachable in practice because
///   `workspace::build_registry`'s `MissingMethod` check fires at
///   load.
/// - The class itself has no method by that name — also caught at
///   load.
/// - The method type is `(args) -> ret` with `args` non-empty but
///   the resolved body is not [`Term::Lam`] — schema-shape
///   mismatch enforced by 22b.2's instance-method check.
pub fn synthesise_mono_fn(
    target: &MonoTarget,
    class_def: &ClassDef,
    instance: &InstanceDef,
    own_type_names: &BTreeSet<String>,
) -> Result<AstFnDef> {
    let (class_name, method, type_, defining_module) = match target {
        MonoTarget::ClassMethod { class, method, type_, defining_module } => {
            (class, method, type_, defining_module)
        }
        MonoTarget::FreeFn { .. } => {
            return Err(crate::CheckError::Internal(
                "synthesise_mono_fn: called with FreeFn variant; use \
                 synthesise_mono_fn_for_free_fn instead"
                    .into(),
            ));
        }
    };
    // Locate the class method declaration.
    let class_method = class_def
        .methods
        .iter()
        .find(|m| &m.name == method)
        .ok_or_else(|| {
            crate::CheckError::Internal(format!(
                "synthesise_mono_fn: class `{}` has no method `{}`",
                class_def.name, method
            ))
        })?;

    // Build the substitution `param := type_` and apply it to the
    // method type. The result is a concrete `Type::Fn` (no
    // `Forall`).
    //
    // `type_` is registry-canonical: an instance whose type is the
    // *defining module's own* ADT carries the qualified
    // `<defining_module>.<T>` spelling (mono normalises every observed
    // instance type through `Registry::normalize_type_for_lookup` for
    // dedup + symbol naming). The synthesised method is appended to that
    // same `defining_module`, whose own type-cons stay bare under the
    // own-module-types-stay-bare invariant — so substituting the
    // qualified `type_` into the method signature mints a param typed
    // `<defining_module>.<T>` against a body that pattern-matches the
    // bare ctor, and the post-mono `synth` re-entry in `lower_to_mir`
    // rejects the clash (`expected show_user_adt.IntBox, got IntBox`).
    // Localise `type_` back to the defining module's bare convention
    // before substitution, symmetric to `synthesise_mono_fn_for_free_fn`.
    // (Primitives and cross-module instance types are untouched —
    // `localize_own_types` only strips `<defining_module>.<T>` for `T` in
    // that module's own ADTs.)
    let local_type = localize_own_types(type_, defining_module, own_type_names);
    let mut mapping: BTreeMap<String, Type> = BTreeMap::new();
    mapping.insert(class_def.param.clone(), local_type);
    let concrete_method_ty = crate::substitute_rigids(&class_method.ty, &mapping);

    // Resolve the body — instance override first, then class default.
    let body_term: Term = match instance.methods.iter().find(|im| &im.name == method) {
        Some(im) => im.body.clone(),
        None => class_method.default.clone().ok_or_else(|| {
            crate::CheckError::Internal(format!(
                "synthesise_mono_fn: instance `{} {}` omits method `{}` and class has no \
                 default (registry-build should have rejected this)",
                class_name,
                ailang_core::pretty::type_to_string(type_),
                method,
            ))
        })?,
    };

    // Decide the (params, body) shape based on whether the method
    // type takes positional args.
    let method_has_params = matches!(
        &class_method.ty,
        Type::Fn { params, .. } if !params.is_empty()
    );
    let (params, body): (Vec<String>, Term) = if method_has_params {
        match body_term {
            Term::Lam { params, body, .. } => (params, *body),
            other => {
                return Err(crate::CheckError::Internal(format!(
                    "synthesise_mono_fn: method `{}` has positional params but body is not \
                     a Lam: {:?}",
                    method, other
                )));
            }
        }
    } else {
        (Vec::new(), body_term)
    };

    Ok(AstFnDef {
        name: mono_symbol(method, type_),
        ty: concrete_method_ty,
        params,
        body,
        doc: None,
        suppress: Vec::new(),
    export: None,
    })
}

/// Rewrite every `<defining_module>.<T>` `Type::Con` whose `<T>` is one
/// of `defining_module`'s own ADT names back to the bare `<T>` form,
/// recursing structurally. This is the inverse of
/// [`Registry::normalize_type_for_lookup`](ailang_core::workspace::Registry::normalize_type_for_lookup)
/// restricted to the owning module: cross-module qualified names,
/// primitives, and type variables are left intact. Used to localise a
/// registry-canonical monomorphisation type-arg into the defining
/// module's own bare convention before it is substituted into a
/// polymorphic free-fn signature/body (which carry own-module type-cons
/// bare). See the call site in [`synthesise_mono_fn_for_free_fn`].
fn localize_own_types(t: &Type, defining_module: &str, own_type_names: &BTreeSet<String>) -> Type {
    match t {
        Type::Con { name, args } => {
            let local = name
                .strip_prefix(defining_module)
                .and_then(|rest| rest.strip_prefix('.'))
                .filter(|bare| own_type_names.contains(*bare))
                .map(|bare| bare.to_string())
                .unwrap_or_else(|| name.clone());
            Type::Con {
                name: local,
                args: args
                    .iter()
                    .map(|a| localize_own_types(a, defining_module, own_type_names))
                    .collect(),
            }
        }
        Type::Fn { params, ret, effects, param_modes, ret_mode } => Type::Fn {
            params: params
                .iter()
                .map(|p| localize_own_types(p, defining_module, own_type_names))
                .collect(),
            ret: Box::new(localize_own_types(ret, defining_module, own_type_names)),
            effects: effects.clone(),
            param_modes: param_modes.clone(),
            ret_mode: *ret_mode,
        },
        Type::Forall { vars, constraints, body } => Type::Forall {
            vars: vars.clone(),
            constraints: constraints.clone(),
            body: Box::new(localize_own_types(body, defining_module, own_type_names)),
        },
        Type::Var { .. } => t.clone(),
    }
}

/// synthesise a monomorphic `FnDef` for a polymorphic
/// free-fn target. The source body comes directly from the
/// polymorphic `Def::Fn`'s `body` (NOT from `Registry::entries`);
/// rigid-var substitution applies the `target.type_args` to the
/// source `Type::Forall { body }`.
///
/// The body is passed through unchanged — the rewrite walker
/// (Task 6) handles inner class-method / poly-call rewrites in a
/// subsequent fixpoint round, mirroring the class-method arm's
/// body-passthrough pattern.
///
/// Errors are all [`crate::CheckError::Internal`] — caller-contract
/// violations that cannot occur after typecheck has succeeded.
pub fn synthesise_mono_fn_for_free_fn(
    target: &MonoTarget,
    ws: &Workspace,
) -> Result<AstFnDef> {
    let (name, type_args, defining_module, scope) = match target {
        MonoTarget::FreeFn { name, type_args, defining_module, scope } => {
            (name, type_args, defining_module, scope)
        }
        MonoTarget::ClassMethod { .. } => {
            return Err(crate::CheckError::Internal(
                "synthesise_mono_fn_for_free_fn: called with ClassMethod variant; use \
                 synthesise_mono_fn instead"
                    .into(),
            ));
        }
    };
    let source_module = ws.modules.get(defining_module).ok_or_else(|| {
        crate::CheckError::Internal(format!(
            "synthesise_mono_fn_for_free_fn: defining module `{}` not in workspace",
            defining_module
        ))
    })?;
    let source_fn = source_module
        .defs
        .iter()
        .find_map(|d| match d {
            Def::Fn(f) if &f.name == name => Some(f),
            _ => None,
        })
        .ok_or_else(|| {
            crate::CheckError::Internal(format!(
                "synthesise_mono_fn_for_free_fn: no source Def::Fn `{}` in module `{}`",
                name, defining_module
            ))
        })?;

    let (forall_vars, inner_ty) = match &source_fn.ty {
        Type::Forall { vars, body, .. } => (vars.clone(), (**body).clone()),
        other => {
            return Err(crate::CheckError::Internal(format!(
                "synthesise_mono_fn_for_free_fn: source fn `{}` is not Type::Forall; got {:?}",
                name, other
            )));
        }
    };
    if forall_vars.len() != type_args.len() {
        return Err(crate::CheckError::Internal(format!(
            "synthesise_mono_fn_for_free_fn: arity mismatch for `{}` — {} forall vars vs {} \
             type args",
            name,
            forall_vars.len(),
            type_args.len()
        )));
    }
    // The observed `type_args` are registry-canonical: a forall var
    // bound to the *defining module's own* ADT carries the qualified
    // `<defining_module>.<T>` spelling, because mono normalises every
    // observed type-arg through `Registry::normalize_type_for_lookup`
    // (see `apply_subst_and_normalize`) so that cross-module call sites
    // dedup to one specialisation and mint a stable symbol name. The
    // polymorphic source signature and body, by contrast, are in the
    // module's own *bare* convention — the qualify pre-pass deliberately
    // leaves own-module type-cons bare (lib.rs:4358). Substituting a
    // qualified type-arg into that bare signature mints a
    // self-inconsistent specialisation: a qualified `std_list.List`
    // accumulator parameter against a bare `List` list argument and Lam
    // body. The post-mono `synth` re-entry (`lower_to_mir`) re-derives
    // own-module type-cons in bare form and rejects the clash
    // (`expected std_list.List<Int>, got List<Int>`). Localise each
    // type-arg back to the defining module's bare convention before
    // substitution so the synthesised signature + body stay uniformly
    // bare-own. The stored `type_args` are left normalised — the symbol
    // name (line below) and the Phase-3 rewrite cursor both key on the
    // canonical form, preserving their byte-identity invariant. (The
    // ClassMethod arm needs no analogue: it already substitutes the
    // un-normalised `target.type_`.)
    let own_type_names: BTreeSet<String> = source_module
        .defs
        .iter()
        .filter_map(|d| match d {
            Def::Type(td) => Some(td.name.clone()),
            _ => None,
        })
        .collect();
    let mapping: BTreeMap<String, Type> = forall_vars
        .iter()
        .cloned()
        .zip(
            type_args
                .iter()
                .map(|t| localize_own_types(t, defining_module, &own_type_names)),
        )
        .collect();

    // Apply rigid-var substitution on the function type AND on the
    // body (the latter required because a free-fn body may contain
    // inner `Term::Lam` nodes whose `param_tys` / `ret_ty` reference
    // the outer Forall vars; those references must be substituted
    // so the post-mono body's re-synth in Phase 3 sees concrete
    // types throughout).
    //
    // The class-method arm in `synthesise_mono_fn` doesn't need
    // this because instance bodies are Lam-unwrapped during synth
    // (the outer Lam's `param_tys` are dropped); a free-fn body
    // can carry arbitrary nested Lams with `Type::Var`-bearing
    // `param_tys`, which is the case for e.g. `List.length`
    // (type-scoped, an inner accumulating lambda over `(b, a)`).
    let new_type = crate::substitute_rigids(&inner_ty, &mapping);
    let new_body = crate::substitute_rigids_in_term(&source_fn.body, &mapping);

    Ok(AstFnDef {
        name: mono_symbol_n(&scoped_base(scope, name), type_args.as_slice()),
        ty: new_type,
        params: source_fn.params.clone(),
        body: new_body,
        doc: source_fn.doc.clone(),
        suppress: Vec::new(),
    export: None,
    })
}

/// rewrite every polymorphic call site in
/// `body` to the corresponding mono symbol, using `ordered_targets`
/// — the per-call-site resolved targets collected by a parallel
/// synth-replay run on the same body via [`collect_residuals_ordered`].
///
/// Two kinds of call site trigger cursor advancement:
///
/// - **Class-method** sites: `Term::Var { name }` where `name` is in
///   `class_methods`. Synth pushes a residual at such sites.
/// - **Polymorphic free-fn** sites (iter 23.4): `Term::Var { name }`
///   where `name` is in `poly_free_fns` (the set of bare + qualified
///   names that resolve to a `Type::Forall`-quantified `Def::Fn` in
///   `caller_module`'s scope per `synth`'s Var arm). Synth pushes a
///   `FreeFnCall` observation at such sites.
///
/// Local-shadowed names are skipped in both kinds — synth's
/// lookup-precedence rule resolves them to the local binding and
/// pushes nothing, so the walker must not advance either.
///
/// At a matching cursor position, the slot's `MonoTarget` variant
/// drives the rewrite:
///
/// - `ClassMethod`: `<method>__<typesurfacename>`, possibly
///   `<defining_module>.<...>` if cross-module.
/// - `FreeFn`: `<name>__<typesurfacename1>__<typesurfacename2>__…`,
///   possibly `<defining_module>.<...>` if cross-module.
/// - `None` (slot is None): residual was non-concrete or instance
///   not registered; cursor still advances (to keep alignment) but
///   the name is left unchanged.
#[allow(clippy::too_many_arguments)]
fn rewrite_mono_calls(
    body: &mut Term,
    method_to_candidate_classes: &BTreeMap<String, BTreeSet<String>>,
    poly_free_fns: &BTreeSet<String>,
    poly_free_fn_ccounts: &BTreeMap<String, usize>,
    caller_module: &str,
    ordered_targets: &[Option<MonoTarget>],
    cursor: &mut usize,
    locals: &mut BTreeSet<String>,
) {
    match body {
        Term::Var { name } => {
            // Two-way predicate: cursor advances at any Term::Var
            // whose name synth would resolve to either a class-method
            // residual OR a poly-free-fn observation. Locally
            // shadowed names match neither — synth pushes nothing.
            //
            // the class-method side is method-keyed natively via
            // `method_to_candidate_classes`. Class disambiguation
            // (which class declared the resolved method) lives in the
            // residual slot the cursor consumes, not here.
            //
            // 2026-05-14 bugfix: a poly-free-fn `Var` whose source
            // `Type::Forall` carries `N` class constraints consumes
            // `1 + N` slots, not 1: the synth Var-arm pushes one
            // `ResidualConstraint` per constraint (driving an entry
            // into the `class_slots` channel) AT THE SAME SITE as the
            // single `FreeFnCall` observation. The slot at `*cursor`
            // is the FreeFn slot used for the actual rename; the
            // following `N` slots are filler class slots — they exist
            // only to keep the post-Var cursor positions aligned for
            // later class-method `Var`s in the body. Class-method
            // `Var`s themselves stay at advance-by-1 (synth pushes
            // exactly one residual and no FreeFnCall).
            let is_class_method = method_to_candidate_classes.contains_key(name);
            let is_poly_free_fn = !is_class_method && poly_free_fns.contains(name);
            let should_advance = (is_class_method || is_poly_free_fn) && !locals.contains(name);
            if should_advance {
                // Capture the constraint count BEFORE the rename
                // below mutates `name`; lookup keys are the synth-
                // visible spelling.
                let n_filler = if is_poly_free_fn {
                    poly_free_fn_ccounts.get(name).copied().unwrap_or(0)
                } else {
                    0
                };
                // The outer `Some` means the cursor slot exists; the inner
                // `Some` means the observation at that slot is concrete.
                // A `Some(None)` slot is a residual / non-concrete
                // observation and leaves the name unchanged.
                if let Some(Some(t)) = ordered_targets.get(*cursor) {
                    let (sym, defining_module) = match t {
                        MonoTarget::ClassMethod { method, type_, defining_module, .. } => {
                            (mono_symbol(method, type_), defining_module.as_str())
                        }
                        MonoTarget::FreeFn { name: fn_name, type_args, defining_module, scope } => {
                            (mono_symbol_n(&scoped_base(scope, fn_name), type_args.as_slice()), defining_module.as_str())
                        }
                    };
                    let new_name = if defining_module == caller_module {
                        sym
                    } else {
                        format!("{}.{}", defining_module, sym)
                    };
                    *name = new_name;
                }
                *cursor += 1 + n_filler;
            }
        }
        Term::App { callee, args, .. } => {
            rewrite_mono_calls(callee, method_to_candidate_classes, poly_free_fns, poly_free_fn_ccounts, caller_module, ordered_targets, cursor, locals);
            for a in args {
                rewrite_mono_calls(a, method_to_candidate_classes, poly_free_fns, poly_free_fn_ccounts, caller_module, ordered_targets, cursor, locals);
            }
        }
        Term::Let { name, value, body } => {
            rewrite_mono_calls(value, method_to_candidate_classes, poly_free_fns, poly_free_fn_ccounts, caller_module, ordered_targets, cursor, locals);
            let inserted = locals.insert(name.clone());
            rewrite_mono_calls(body, method_to_candidate_classes, poly_free_fns, poly_free_fn_ccounts, caller_module, ordered_targets, cursor, locals);
            if inserted {
                locals.remove(name);
            }
        }
        Term::LetRec { name, params, body, in_term, .. } => {
            let name_inserted = locals.insert(name.clone());
            let mut params_inserted: Vec<String> = Vec::new();
            for p in params {
                if locals.insert(p.clone()) {
                    params_inserted.push(p.clone());
                }
            }
            rewrite_mono_calls(body, method_to_candidate_classes, poly_free_fns, poly_free_fn_ccounts, caller_module, ordered_targets, cursor, locals);
            for p in &params_inserted {
                locals.remove(p);
            }
            rewrite_mono_calls(in_term, method_to_candidate_classes, poly_free_fns, poly_free_fn_ccounts, caller_module, ordered_targets, cursor, locals);
            if name_inserted {
                locals.remove(name);
            }
        }
        Term::If { cond, then, else_ } => {
            rewrite_mono_calls(cond, method_to_candidate_classes, poly_free_fns, poly_free_fn_ccounts, caller_module, ordered_targets, cursor, locals);
            rewrite_mono_calls(then, method_to_candidate_classes, poly_free_fns, poly_free_fn_ccounts, caller_module, ordered_targets, cursor, locals);
            rewrite_mono_calls(else_, method_to_candidate_classes, poly_free_fns, poly_free_fn_ccounts, caller_module, ordered_targets, cursor, locals);
        }
        Term::Do { args, .. } => {
            for a in args {
                rewrite_mono_calls(a, method_to_candidate_classes, poly_free_fns, poly_free_fn_ccounts, caller_module, ordered_targets, cursor, locals);
            }
        }
        Term::Ctor { args, .. } => {
            for a in args {
                rewrite_mono_calls(a, method_to_candidate_classes, poly_free_fns, poly_free_fn_ccounts, caller_module, ordered_targets, cursor, locals);
            }
        }
        Term::Match { scrutinee, arms } => {
            rewrite_mono_calls(scrutinee, method_to_candidate_classes, poly_free_fns, poly_free_fn_ccounts, caller_module, ordered_targets, cursor, locals);
            for Arm { pat, body } in arms {
                let binders = crate::collect_pattern_binders(pat);
                let mut inserted: Vec<String> = Vec::new();
                for b in &binders {
                    if locals.insert(b.clone()) {
                        inserted.push(b.clone());
                    }
                }
                rewrite_mono_calls(body, method_to_candidate_classes, poly_free_fns, poly_free_fn_ccounts, caller_module, ordered_targets, cursor, locals);
                for b in &inserted {
                    locals.remove(b);
                }
            }
        }
        Term::Lam { params, body, .. } => {
            let mut inserted: Vec<String> = Vec::new();
            for p in params {
                if locals.insert(p.clone()) {
                    inserted.push(p.clone());
                }
            }
            rewrite_mono_calls(body, method_to_candidate_classes, poly_free_fns, poly_free_fn_ccounts, caller_module, ordered_targets, cursor, locals);
            for p in &inserted {
                locals.remove(p);
            }
        }
        Term::Seq { lhs, rhs } => {
            rewrite_mono_calls(lhs, method_to_candidate_classes, poly_free_fns, poly_free_fn_ccounts, caller_module, ordered_targets, cursor, locals);
            rewrite_mono_calls(rhs, method_to_candidate_classes, poly_free_fns, poly_free_fn_ccounts, caller_module, ordered_targets, cursor, locals);
        }
        Term::Clone { value } => {
            rewrite_mono_calls(value, method_to_candidate_classes, poly_free_fns, poly_free_fn_ccounts, caller_module, ordered_targets, cursor, locals);
        }
        Term::ReuseAs { source, body } => {
            rewrite_mono_calls(source, method_to_candidate_classes, poly_free_fns, poly_free_fn_ccounts, caller_module, ordered_targets, cursor, locals);
            rewrite_mono_calls(body, method_to_candidate_classes, poly_free_fns, poly_free_fn_ccounts, caller_module, ordered_targets, cursor, locals);
        }
        Term::Loop { binders, body } => {
            for b in binders.iter_mut() {
                rewrite_mono_calls(&mut b.init, method_to_candidate_classes, poly_free_fns, poly_free_fn_ccounts, caller_module, ordered_targets, cursor, locals);
            }
            rewrite_mono_calls(body, method_to_candidate_classes, poly_free_fns, poly_free_fn_ccounts, caller_module, ordered_targets, cursor, locals);
        }
        Term::Recur { args } => {
            for a in args.iter_mut() {
                rewrite_mono_calls(a, method_to_candidate_classes, poly_free_fns, poly_free_fn_ccounts, caller_module, ordered_targets, cursor, locals);
            }
        }
        // #51: a `Term::New` carrying a written `NewArg::Type` survives
        // desugar (polymorphic `new`, e.g. `(new RawBuf (con Int) 3)`)
        // and is lowered to a monomorphised global-fn call HERE. synth
        // pushed exactly one `FreeFnCall` observation for this site
        // (BEFORE its value-args), so the cursor slot at `*cursor` is the
        // mono target for `RawBuf_new__<elem>`. We rename the call to the
        // mangled symbol, then descend into the value-args (which become
        // the App args) so their own cursor advances stay aligned.
        //
        // A type-arg-free `Term::New` (monomorphic `new`, e.g.
        // `(new Counter 42)`) was already lowered to `(app T.new …)` at
        // desugar and never reaches this arm — synth pushed no
        // observation, so no slot is consumed.
        Term::New { type_name, args, .. } => {
            let has_type_arg = args.iter().any(|a| matches!(a, NewArg::Type(_)));
            if has_type_arg {
                let callee_name = if let Some(Some(MonoTarget::FreeFn {
                    name: fn_name,
                    type_args,
                    defining_module,
                    scope,
                })) = ordered_targets.get(*cursor)
                {
                    let sym = mono_symbol_n(&scoped_base(scope, fn_name), type_args.as_slice());
                    if defining_module == caller_module {
                        sym
                    } else {
                        format!("{}.{}", defining_module, sym)
                    }
                } else {
                    // Slot is None / non-concrete: leave an unmangled
                    // type-scoped spelling. The written type-arg is
                    // concrete (it is the author's annotation), so this
                    // branch is not expected; fall back to the
                    // type-scoped name (`RawBuf` from `raw_buf.RawBuf`)
                    // rather than panic.
                    let bare_type =
                        type_name.rsplit('.').next().unwrap_or(type_name.as_str());
                    format!("{bare_type}.new")
                };
                *cursor += 1;
                // Rewrite value-args in place (advances the cursor for
                // any nested mono calls), then replace the node with the
                // equivalent app to the monomorphised `new`.
                let mut value_args: Vec<Term> = Vec::new();
                for arg in args.iter_mut() {
                    if let NewArg::Value(v) = arg {
                        rewrite_mono_calls(v, method_to_candidate_classes, poly_free_fns, poly_free_fn_ccounts, caller_module, ordered_targets, cursor, locals);
                        value_args.push(v.clone());
                    }
                }
                *body = Term::App {
                    callee: Box::new(Term::Var { name: callee_name }),
                    args: value_args,
                    tail: false,
                };
            } else {
                for arg in args.iter_mut() {
                    if let NewArg::Value(v) = arg {
                        rewrite_mono_calls(v, method_to_candidate_classes, poly_free_fns, poly_free_fn_ccounts, caller_module, ordered_targets, cursor, locals);
                    }
                }
            }
        }
        Term::Lit { .. } => {}
        Term::Intrinsic => {}
    }
}

/// resolve a residual's class for mono target construction.
/// Returns `Some(class)` for a discharge-ready residual (single-class
/// or refined-multi-candidate); `None` if the multi-candidate residual
/// cannot be refined — in which case typecheck-side already raised a
/// `CheckError`, so reaching this branch means the residual slipped
/// past discharge and the mono cursor should emit a None-slot
/// defensively.
pub fn resolve_residual_class_for_mono(
    r: &crate::ResidualConstraint,
    registry_unit: &BTreeMap<(String, String), ()>,
) -> Option<String> {
    if r.candidates.is_some() {
        match crate::refine_multi_candidate_residual(r, &[], registry_unit) {
            crate::RefineOutcome::Resolved(c) => Some(c),
            _ => None,
        }
    } else {
        Some(r.class.clone())
    }
}

/// traversal-ordered residual collection — used by
/// the rewrite walker to align cursor positions. Unlike
/// [`collect_mono_targets`], this includes non-concrete residuals
/// as `None`, preserving one-entry-per-callsite alignment.
pub(crate) fn collect_residuals_ordered(
    f: &AstFnDef,
    module_name: &str,
    env: &crate::Env,
    poly_free_fns: &BTreeSet<String>,
    poly_free_fn_ccounts: &BTreeMap<String, usize>,
) -> Result<Vec<Option<MonoTarget>>> {
    // Intrinsic bodies carry no mono slots — see `collect_mono_targets`.
    if crate::is_intrinsic_body(f) {
        return Ok(Vec::new());
    }
    let (rigids, inner_ty): (Vec<String>, Type) = match &f.ty {
        Type::Forall { vars, constraints: _, body } => (vars.clone(), (**body).clone()),
        other => (vec![], other.clone()),
    };
    // Non-fn signature carries no mono slots — keep the early-return
    // guard; the param-type extraction itself goes through the shared
    // `crate::fn_param_types` helper so this site and
    // `lower_to_mir::lower_module` cannot drift.
    if !matches!(&inner_ty, Type::Fn { .. }) {
        return Ok(Vec::new());
    }
    let param_tys: Vec<Type> = crate::fn_param_types(&f.ty);

    let mut env = env.clone();
    for v in &rigids {
        env.rigid_vars.insert(v.clone());
    }
    env.current_module = module_name.to_string();
    // same `env.globals` seeding as `collect_mono_targets`
    // — see the comment there for rationale. Both fns re-run `synth`
    // and must agree with the main check path's per-module env shape.
    if let Some(g) = env.module_globals.get(module_name).cloned() {
        for (n, t) in g {
            env.globals.insert(n, t);
        }
    }
    // same `env.imports` seeding as `collect_mono_targets`
    // — see the comment there for rationale.
    if let Some(im) = env.module_imports.get(module_name).cloned() {
        env.imports = im;
    }

    let mut locals: IndexMap<String, Type> = IndexMap::new();
    for (n, t) in f.params.iter().zip(param_tys.iter()) {
        locals.insert(n.clone(), t.clone());
    }
    let mut effects: BTreeSet<String> = BTreeSet::new();
    let mut subst = crate::Subst::default();
    let mut counter: u32 = 0;
    let mut residuals: Vec<crate::ResidualConstraint> = Vec::new();
    let mut free_fn_calls: Vec<crate::FreeFnCall> = Vec::new();
    // mono's residual-collection synth re-entry collects-and-
    // discards warnings — typecheck has already run and surfaced any
    // shadow warnings; mono is a post-typecheck pass.
    let mut warnings_discarded: Vec<crate::diagnostic::Diagnostic> = Vec::new();
    // loop-recur iter 2: mono re-synth begins from top-of-body —
    // empty loop-stack (any `Term::Loop` pushes its own frame as the
    // walk descends).
    let mut loop_stack: Vec<Vec<(String, crate::Type)>> = Vec::new();
    let body_ty = crate::synth(
        &f.body,
        &env,
        &mut locals,
        &mut loop_stack,
        &mut effects,
        &f.name,
        &mut subst,
        &mut counter,
        &mut residuals,
        &mut free_fn_calls,
        &mut warnings_discarded,
        &mut crate::linearity::LetBinderTypes::new(),
    )?;
    // Mirror `collect_mono_targets`'s body-against-declared-ret
    // unification (see the comment there). Both passes must thread the
    // return type into `subst` identically so a result-only callee
    // forall var (e.g. `RawBuf.new`'s element) resolves to the same
    // concrete type in both — otherwise this pass's rewrite target would
    // disagree with the symbol `collect_mono_targets` minted.
    unify_body_against_declared_ret(&inner_ty, &body_ty, &mut subst)?;

    // Build two per-channel slot lists — one for class-method
    // residuals, one for poly-free-fn observations —
    // each in synth's push order. Then walk the AST in synth's
    // traversal order, classifying each `Term::Var` site as
    // class-method, poly-free-fn, or neither (and consuming from
    // the appropriate channel). The output slot list interleaves
    // the two channels in source-AST order; the rewrite walker
    // consumes it with a single cursor.
    let class_slots: Vec<Option<MonoTarget>> = residuals
        .into_iter()
        .map(|r| {
            let r_ty = subst.apply(&r.type_);
            if !crate::is_fully_concrete(&r_ty) {
                return None;
            }
            let r_ty_norm = env
                .workspace_registry
                .normalize_type_for_lookup(module_name, &r_ty);
            // resolve the residual's class via the multi-
            // candidate refinement helper. Single-class residuals
            // (`candidates: None`) flow through `r.class.clone()`
            // unchanged; multi-candidate residuals filter against
            // the registry, with the discharge path's CheckError
            // already raised at typecheck time (so reaching here
            // with a non-Resolved outcome means the residual was
            // somehow not gated upstream — emit `None` defensively).
            let registry_unit: BTreeMap<(String, String), ()> = env
                .workspace_registry
                .entries
                .keys()
                .map(|k| (k.clone(), ()))
                .collect();
            let normalized_residual = crate::ResidualConstraint {
                class: r.class.clone(),
                type_: r_ty_norm.clone(),
                method: r.method.clone(),
                candidates: r.candidates.clone(),
            };
            let resolved_class =
                resolve_residual_class_for_mono(&normalized_residual, &registry_unit)?;

            let key = (resolved_class.clone(), ailang_core::canonical::type_hash(&r_ty_norm));
            let entry = env.workspace_registry.entries.get(&key)?;
            Some(MonoTarget::ClassMethod {
                class: resolved_class,
                method: r.method.clone(),
                type_: r_ty,
                defining_module: entry.defining_module.clone(),
            })
        })
        .collect();
    let free_fn_slots: Vec<Option<MonoTarget>> = free_fn_calls
        .iter()
        .map(|fc| {
            // Mirrors `collect_mono_targets`'s Unit-default for unpinned
            // forall vars. Per-meta resolution: pinned → keep; unpinned
            // → `Type::unit()`. This ensures the cursor-walker emits a
            // Some(target) at every poly-free-fn call site, so the
            // rewrite walker's cursor advancement maps to a real
            // mono-symbol rewrite (no `None`-slots from this channel).
            let type_args: Vec<Type> = fc
                .metas
                .iter()
                .map(|m| {
                    apply_subst_and_normalize(&env, module_name, m, &subst)
                        .unwrap_or_else(Type::unit)
                })
                .collect();
            Some(MonoTarget::FreeFn {
                name: fc.name.clone(),
                type_args,
                defining_module: fc.owner_module.clone(),
                scope: fc.scope.clone(),
            })
        })
        .collect();

    // Walk the AST in the same pre-order the rewrite walker uses
    // (which mirrors synth's traversal order). At each Term::Var
    // matching `class_methods` or `poly_free_fns` (and not locally
    // shadowed), emit one slot consuming from the appropriate
    // channel. The resulting Vec is in interleaved walker-order,
    // ready for the rewrite walker's single-cursor consumption.
    let mut out: Vec<Option<MonoTarget>> = Vec::new();
    let mut class_cur = 0usize;
    let mut free_cur = 0usize;
    let mut walker_locals: BTreeSet<String> = f.params.iter().cloned().collect();
    interleave_slots(
        &f.body,
        &env.method_to_candidate_classes,
        poly_free_fns,
        poly_free_fn_ccounts,
        &class_slots,
        &free_fn_slots,
        &mut class_cur,
        &mut free_cur,
        &mut walker_locals,
        &mut out,
    );
    Ok(out)
}

/// walk a body
/// in synth's traversal order, interleaving class-method and
/// poly-free-fn slots in source-AST order. The walker MUST stay
/// in lockstep with `rewrite_mono_calls` — same predicates, same
/// shadowing handling.
///
/// class-method presence is method-keyed natively via
/// `method_to_candidate_classes` (post-`MethodNameCollision`-retirement,
/// `class_methods` is tuple-keyed by `(class, method)` and not
/// directly probable by method name alone).
#[allow(clippy::too_many_arguments)]
fn interleave_slots(
    term: &Term,
    method_to_candidate_classes: &BTreeMap<String, BTreeSet<String>>,
    poly_free_fns: &BTreeSet<String>,
    poly_free_fn_ccounts: &BTreeMap<String, usize>,
    class_slots: &[Option<MonoTarget>],
    free_fn_slots: &[Option<MonoTarget>],
    class_cur: &mut usize,
    free_cur: &mut usize,
    locals: &mut BTreeSet<String>,
    out: &mut Vec<Option<MonoTarget>>,
) {
    match term {
        Term::Var { name } => {
            let is_class_method = method_to_candidate_classes.contains_key(name);
            let is_poly_free_fn = !is_class_method && poly_free_fns.contains(name);
            let advances = (is_class_method || is_poly_free_fn) && !locals.contains(name);
            if advances {
                if is_class_method {
                    let slot = class_slots.get(*class_cur).cloned().unwrap_or(None);
                    out.push(slot);
                    *class_cur += 1;
                } else {
                    // 2026-05-14 bugfix: a poly-free-fn Var consumes
                    // `1 + N` slots — the FreeFn slot (drives the
                    // rewrite walker's rename) followed by `N` filler
                    // class slots, where `N` is the constraint count
                    // of this fn's source `Type::Forall`. Synth's
                    // Var-arm pushes one `ResidualConstraint` per
                    // declared constraint AT THIS SITE before pushing
                    // the `FreeFnCall` observation; without consuming
                    // the corresponding class slots here, the class-
                    // method cursor drifts by `N` for every following
                    // class-method Var in the body.
                    let slot = free_fn_slots.get(*free_cur).cloned().unwrap_or(None);
                    out.push(slot);
                    *free_cur += 1;
                    let n_filler = poly_free_fn_ccounts.get(name).copied().unwrap_or(0);
                    for _ in 0..n_filler {
                        let filler = class_slots.get(*class_cur).cloned().unwrap_or(None);
                        out.push(filler);
                        *class_cur += 1;
                    }
                }
            }
        }
        Term::App { callee, args, .. } => {
            interleave_slots(callee, method_to_candidate_classes, poly_free_fns, poly_free_fn_ccounts, class_slots, free_fn_slots, class_cur, free_cur, locals, out);
            for a in args {
                interleave_slots(a, method_to_candidate_classes, poly_free_fns, poly_free_fn_ccounts, class_slots, free_fn_slots, class_cur, free_cur, locals, out);
            }
        }
        Term::Let { name, value, body } => {
            interleave_slots(value, method_to_candidate_classes, poly_free_fns, poly_free_fn_ccounts, class_slots, free_fn_slots, class_cur, free_cur, locals, out);
            let inserted = locals.insert(name.clone());
            interleave_slots(body, method_to_candidate_classes, poly_free_fns, poly_free_fn_ccounts, class_slots, free_fn_slots, class_cur, free_cur, locals, out);
            if inserted {
                locals.remove(name);
            }
        }
        Term::LetRec { name, params, body, in_term, .. } => {
            let name_inserted = locals.insert(name.clone());
            let mut params_inserted: Vec<String> = Vec::new();
            for p in params {
                if locals.insert(p.clone()) {
                    params_inserted.push(p.clone());
                }
            }
            interleave_slots(body, method_to_candidate_classes, poly_free_fns, poly_free_fn_ccounts, class_slots, free_fn_slots, class_cur, free_cur, locals, out);
            for p in &params_inserted {
                locals.remove(p);
            }
            interleave_slots(in_term, method_to_candidate_classes, poly_free_fns, poly_free_fn_ccounts, class_slots, free_fn_slots, class_cur, free_cur, locals, out);
            if name_inserted {
                locals.remove(name);
            }
        }
        Term::If { cond, then, else_ } => {
            interleave_slots(cond, method_to_candidate_classes, poly_free_fns, poly_free_fn_ccounts, class_slots, free_fn_slots, class_cur, free_cur, locals, out);
            interleave_slots(then, method_to_candidate_classes, poly_free_fns, poly_free_fn_ccounts, class_slots, free_fn_slots, class_cur, free_cur, locals, out);
            interleave_slots(else_, method_to_candidate_classes, poly_free_fns, poly_free_fn_ccounts, class_slots, free_fn_slots, class_cur, free_cur, locals, out);
        }
        Term::Do { args, .. } => {
            for a in args {
                interleave_slots(a, method_to_candidate_classes, poly_free_fns, poly_free_fn_ccounts, class_slots, free_fn_slots, class_cur, free_cur, locals, out);
            }
        }
        Term::Ctor { args, .. } => {
            for a in args {
                interleave_slots(a, method_to_candidate_classes, poly_free_fns, poly_free_fn_ccounts, class_slots, free_fn_slots, class_cur, free_cur, locals, out);
            }
        }
        Term::Match { scrutinee, arms } => {
            interleave_slots(scrutinee, method_to_candidate_classes, poly_free_fns, poly_free_fn_ccounts, class_slots, free_fn_slots, class_cur, free_cur, locals, out);
            for Arm { pat, body } in arms {
                let binders = crate::collect_pattern_binders(pat);
                let mut inserted: Vec<String> = Vec::new();
                for b in &binders {
                    if locals.insert(b.clone()) {
                        inserted.push(b.clone());
                    }
                }
                interleave_slots(body, method_to_candidate_classes, poly_free_fns, poly_free_fn_ccounts, class_slots, free_fn_slots, class_cur, free_cur, locals, out);
                for b in &inserted {
                    locals.remove(b);
                }
            }
        }
        Term::Lam { params, body, .. } => {
            let mut inserted: Vec<String> = Vec::new();
            for p in params {
                if locals.insert(p.clone()) {
                    inserted.push(p.clone());
                }
            }
            interleave_slots(body, method_to_candidate_classes, poly_free_fns, poly_free_fn_ccounts, class_slots, free_fn_slots, class_cur, free_cur, locals, out);
            for p in &inserted {
                locals.remove(p);
            }
        }
        Term::Seq { lhs, rhs } => {
            interleave_slots(lhs, method_to_candidate_classes, poly_free_fns, poly_free_fn_ccounts, class_slots, free_fn_slots, class_cur, free_cur, locals, out);
            interleave_slots(rhs, method_to_candidate_classes, poly_free_fns, poly_free_fn_ccounts, class_slots, free_fn_slots, class_cur, free_cur, locals, out);
        }
        Term::Clone { value } => {
            interleave_slots(value, method_to_candidate_classes, poly_free_fns, poly_free_fn_ccounts, class_slots, free_fn_slots, class_cur, free_cur, locals, out);
        }
        Term::ReuseAs { source, body } => {
            interleave_slots(source, method_to_candidate_classes, poly_free_fns, poly_free_fn_ccounts, class_slots, free_fn_slots, class_cur, free_cur, locals, out);
            interleave_slots(body, method_to_candidate_classes, poly_free_fns, poly_free_fn_ccounts, class_slots, free_fn_slots, class_cur, free_cur, locals, out);
        }
        Term::Loop { binders, body } => {
            for b in binders {
                interleave_slots(&b.init, method_to_candidate_classes, poly_free_fns, poly_free_fn_ccounts, class_slots, free_fn_slots, class_cur, free_cur, locals, out);
            }
            interleave_slots(body, method_to_candidate_classes, poly_free_fns, poly_free_fn_ccounts, class_slots, free_fn_slots, class_cur, free_cur, locals, out);
        }
        Term::Recur { args } => {
            for a in args {
                interleave_slots(a, method_to_candidate_classes, poly_free_fns, poly_free_fn_ccounts, class_slots, free_fn_slots, class_cur, free_cur, locals, out);
            }
        }
        // #51: a `Term::New` carrying a written `NewArg::Type` survives
        // desugar and synth pushed exactly one `FreeFnCall` observation
        // for it (BEFORE its value-args), so it consumes one free-fn
        // slot HERE — mirroring `rewrite_mono_calls`'s `Term::New` arm
        // so the two walkers stay in lockstep. A type-arg-free `Term::New`
        // was lowered to `(app T.new …)` at desugar and never reaches
        // this arm, pushing no slot.
        Term::New { args, .. } => {
            let has_type_arg = args.iter().any(|a| matches!(a, NewArg::Type(_)));
            if has_type_arg {
                let slot = free_fn_slots.get(*free_cur).cloned().unwrap_or(None);
                out.push(slot);
                *free_cur += 1;
            }
            for arg in args {
                if let NewArg::Value(v) = arg {
                    interleave_slots(v, method_to_candidate_classes, poly_free_fns, poly_free_fn_ccounts, class_slots, free_fn_slots, class_cur, free_cur, locals, out);
                }
            }
        }
        Term::Lit { .. } => {}
        Term::Intrinsic => {}
    }
}

#[cfg(test)]
mod tests {
    use super::*;
    use ailang_core::ast::Type;

    /// mono's residual-class resolver refines multi-candidate
    /// residuals via the same logic as discharge. A multi-candidate
    /// residual with a concrete `type_` and a single registry-survivor
    /// resolves to that class.
    #[test]
    fn mq2_mono_multi_candidate_resolves_to_single_class() {
        let mut candidates = std::collections::BTreeSet::new();
        candidates.insert("prelude.Show".to_string());
        candidates.insert("userlib.Show".to_string());
        let residual = crate::ResidualConstraint {
            class: "prelude.Show".to_string(), // tentative
            type_: Type::Con { name: "Int".to_string(), args: vec![] },
            method: "show".to_string(),
            candidates: Some(candidates),
        };
        let mut registry_unit: BTreeMap<(String, String), ()> = Default::default();
        let int_h = ailang_core::canonical::type_hash(
            &Type::Con { name: "Int".to_string(), args: vec![] }
        );
        registry_unit.insert(("prelude.Show".to_string(), int_h), ());

        let resolved = resolve_residual_class_for_mono(&residual, &registry_unit);
        assert_eq!(resolved, Some("prelude.Show".to_string()));
    }

    /// single-class residual (candidates: None) flows through
    /// unchanged.
    #[test]
    fn mq2_mono_single_class_residual_unchanged() {
        let residual = crate::ResidualConstraint {
            class: "prelude.Eq".to_string(),
            type_: Type::Con { name: "Int".to_string(), args: vec![] },
            method: "eq".to_string(),
            candidates: None,
        };
        let registry_unit: BTreeMap<(String, String), ()> = Default::default();
        let resolved = resolve_residual_class_for_mono(&residual, &registry_unit);
        assert_eq!(resolved, Some("prelude.Eq".to_string()));
    }

    /// multi-candidate residual that cannot refine (zero
    /// registry survivors) returns None.
    #[test]
    fn mq2_mono_multi_candidate_no_survivors_returns_none() {
        let mut candidates = std::collections::BTreeSet::new();
        candidates.insert("prelude.Show".to_string());
        candidates.insert("userlib.Show".to_string());
        let residual = crate::ResidualConstraint {
            class: "prelude.Show".to_string(),
            type_: Type::Con { name: "MyType".to_string(), args: vec![] },
            method: "show".to_string(),
            candidates: Some(candidates),
        };
        let registry_unit: BTreeMap<(String, String), ()> = Default::default();
        let resolved = resolve_residual_class_for_mono(&residual, &registry_unit);
        assert_eq!(resolved, None);
    }
}
