//! `ail` — CLI for AILang.
//!
//! The user-facing toolchain binary. Wraps the `ailang-core`
//! (loader/AST/hashing), `ailang-check` (typechecker), and
//! `ailang-codegen` (LLVM IR emitter) crates into a set of
//! deliberately small subcommands. The slicing follows the project's
//! LLM-author audience: each individual tool gives the LLM the
//! minimum context needed for one task — `manifest` for an overview,
//! `describe` for a single def, `emit-ir` for the exact machine view,
//! `build` for full-pipeline validation. No "do everything" command.
//!
//! # Subcommands
//!
//! - `manifest` — compact symbol table of a module (or workspace);
//!   one line per def with type, effects, hash.
//! - `render` — print a `.ail.json` module as form-(A) text. Exact
//!   inverse of `parse`; piping `render | parse` round-trips to the
//!   same canonical bytes.
//! - `describe` — full detail of a single definition, JSON or text.
//! - `deps` — static call edges per def (or for one named def);
//!   workspace mode emits cross-module edges.
//! - `check` — load + schema-validate + typecheck. Emits structured
//!   diagnostics with `--json`; exit code 1 on any error.
//! - `emit-ir` — write LLVM IR (`.ll`) for the module. Useful for
//!   inspecting the generated code without invoking `clang`.
//! - `build` — full pipeline (`check` → `emit-ir` → `clang`) producing
//!   a native binary at `--out`.
//! - `run` — `build` into a tempdir and execute the binary; passes
//!   the binary's exit code through.
//! - `builtins` — list the built-in effect ops (`io/print_int`,
//!   `io/print_bool`, `io/print_str`, ...) with their signatures.
//! - `diff` — semantic, hash-based module/workspace diff. Works even
//!   when a module doesn't currently typecheck.
//! - `workspace` — load an entry module's transitive imports and list
//!   the reachable modules with hash and def count.
//!
//! # External tooling
//!
//! `build` and `run` shell out to `clang` to link the emitted IR into
//! a native binary. `clang` must therefore be on `PATH` for those
//! subcommands; the other subcommands have no external dependencies.
//!
//! # rustdoc surface
//!
//! This is a binary crate with no `pub` items. The user-facing CLI
//! help is generated by `clap` from the `#[command(...)]` doc
//! comments on `Cmd` variants and surfaces as `ail --help`, not as
//! rustdoc. Private helpers are intentionally undocumented at the
//! rustdoc level — read the source.

use anyhow::{Context, Result};
use clap::{Parser, Subcommand};
use std::path::{Path, PathBuf};

#[derive(Parser)]
#[command(name = "ail", version, about = "AILang toolchain")]
struct Cli {
    #[command(subcommand)]
    cmd: Cmd,
}

#[derive(Subcommand)]
enum Cmd {
    /// Loads a module and prints a compact symbol table.
    Manifest {
        path: PathBuf,
        #[arg(long)]
        json: bool,
        /// Recursively loads all modules of the workspace and lists their
        /// defs together. Default mode stays single-module.
        #[arg(long)]
        workspace: bool,
    },
    /// Prints the module as form (A) text — the canonical authoring
    /// surface and the exact inverse of `parse`. Round-tripping
    /// `render | parse` reproduces the input's canonical bytes
    /// (gated by `crates/ailang-surface/tests/round_trip.rs`).
    Render { path: PathBuf },
    /// Prints a single definition as JSON or pretty text.
    Describe {
        path: PathBuf,
        name: String,
        #[arg(long)]
        json: bool,
        /// Loads the workspace and searches in all modules.
        /// `name` may be given in dot notation (`<module>.<def>`);
        /// without a dot: entry module first, then fallback to all modules
        /// (error `ambiguous-name` if ambiguous).
        #[arg(long)]
        workspace: bool,
    },
    /// Lists which symbols each definition calls (statically).
    Deps {
        path: PathBuf,
        /// Only for one symbol; without argument: for all. In workspace
        /// mode the name may be in dot notation (`<module>.<def>`).
        #[arg(long)]
        of: Option<String>,
        #[arg(long)]
        json: bool,
        /// Workspace mode: edges are cross-module aware
        /// (`<from-mod>.<from-def> -> <to-mod>.<to-def>`).
        #[arg(long)]
        workspace: bool,
    },
    /// Typechecks a module.
    Check {
        path: PathBuf,
        /// Structured diagnostics as a JSON array on stdout.
        /// Exit code 1 when at least one error is reported.
        #[arg(long)]
        json: bool,
    },
    /// Writes LLVM IR (.ll) for the module.
    EmitIr {
        path: PathBuf,
        #[arg(short, long)]
        out: Option<PathBuf>,
    },
    /// Full pipeline: check + emit-ir + clang -> binary.
    Build {
        path: PathBuf,
        #[arg(short, long)]
        out: Option<PathBuf>,
        /// Optimization (e.g. `-O2`); default `-O0` for debuggability.
        #[arg(long, default_value = "-O0")]
        opt: String,
        /// Heap allocator. `rc` (default, canonical) routes through
        /// `runtime/rc.c`'s `@ailang_rc_alloc` (libc-malloc backing +
        /// 8-byte refcount header) with `ailang_rc_inc`/`_dec`
        /// instrumentation emitted by codegen; this is the runtime
        /// AILang's memory model (RC + uniqueness, Decision 10) is
        /// designed for. `gc` retains the Boehm conservative GC path
        /// (`@GC_malloc`, libgc); it is kept as a differential parity
        /// oracle for codegen diagnosis (Decision 9). `bump` is a
        /// bench-only no-free stub from `runtime/bump.c` — it leaks
        /// every allocation by design.
        #[arg(long, default_value = "rc", value_parser = ["gc", "bump", "rc"])]
        alloc: String,
    },
    /// Build into a tempdir and execute. Exits with the binary's exit code.
    /// Convenience wrapper around `build` + invocation of the resulting
    /// binary; useful in iteration loops where we don't care about the
    /// output artefact's location.
    Run {
        path: PathBuf,
        /// Optimization (e.g. `-O2`); default `-O0` for debuggability.
        #[arg(long, default_value = "-O0")]
        opt: String,
        /// Heap allocator. See `build --alloc` for details. Default `rc`.
        #[arg(long, default_value = "rc", value_parser = ["gc", "bump", "rc"])]
        alloc: String,
        /// Args passed through to the compiled program.
        #[arg(last = true)]
        args: Vec<String>,
    },
    /// Lists built-in operations with their signatures.
    Builtins {
        #[arg(long)]
        json: bool,
    },
    /// Semantic module diff via def hash.
    ///
    /// Compares two modules purely structurally on top-level defs:
    /// per name, the hashes of canonical bytes are compared. The
    /// diff works even when a module doesn't currently typecheck —
    /// only the schema and the JSON form must be loadable.
    ///
    /// Exit code: 0 if no changes (other than `unchanged`), otherwise 1.
    Diff {
        a: PathBuf,
        b: PathBuf,
        #[arg(long)]
        json: bool,
        /// Compares two workspaces (entry modules + transitive imports)
        /// module by module. `added_modules`/`removed_modules` for fully
        /// added or removed modules, `changed_modules` for modules with
        /// a different hash; per changed module, the usual
        /// single-module sub-diff structure.
        #[arg(long)]
        workspace: bool,
    },
    /// Loads a workspace (entry module + transitive imports) and lists
    /// all reachable modules with hash and def count.
    ///
    /// Iter 5a: listing only. Cross-module typecheck/codegen follow in
    /// 5b/5c; existing subcommands continue to work per single module.
    Workspace {
        entry: PathBuf,
        #[arg(long)]
        json: bool,
    },
    /// Parses a `.ailx` source file (form (A)) into canonical
    /// `.ail.json`. Iter 14c addition; symmetric to `render`.
    ///
    /// The form-(A) projection is one of potentially many producers of
    /// `Module` values. The JSON-AST remains the source of truth and
    /// is what every other subcommand consumes.
    Parse {
        path: PathBuf,
        #[arg(short, long)]
        output: Option<PathBuf>,
    },
    /// Iter 20a: prints the module as human-readable prose (form B).
    ///
    /// One-way projection: the renderer is deterministic, but no
    /// parser exists for the prose surface. The canonical authoring
    /// surface remains form (A) (`render` / `parse`) and the canonical
    /// hashable artefact remains the JSON-AST.
    Prose { path: PathBuf },
    /// Iter 20d: composes a prompt for the prose-edit round-trip.
    ///
    /// Given the original `.ail.json` and the edited `.prose.txt`,
    /// prints a prompt that asks an external LLM to emit an updated
    /// `.ail.json` integrating the prose edits. AILang ships no LLM
    /// client of its own; the user pipes the prompt to their tool of
    /// choice (Claude Code, Anthropic API, OpenAI CLI, etc.) and runs
    /// `ail check` on the result.
    ///
    /// See `docs/PROSE_ROUNDTRIP.md` for the full cycle and the
    /// rationale for keeping the API client out of scope.
    MergeProse {
        /// Original `.ail.json` (carries load-bearing detail the prose elides).
        original: PathBuf,
        /// Edited `.prose.txt` (carries the human's intent).
        edited: PathBuf,
    },
    /// ct.1 (dev-only): rewrite every `*.ail.json` under `<dir>` so
    /// that bare cross-module `Type::Con` and `Term::Ctor.type_name`
    /// references gain their owning module's qualifier. Idempotent.
    ///
    /// Disambiguation: the first import (in declaration order) that
    /// owns a matching type wins. Ties are NOT diagnosed — the
    /// migration silently picks the first match. Prelude is consulted
    /// as an implicit last-resort fallback when no listed import owns
    /// the name.
    MigrateCanonicalTypes {
        /// Directory containing `*.ail.json` to migrate (typically
        /// `examples/`).
        dir: PathBuf,
    },
}

/// Composes the prose-round-trip prompt described in
/// `docs/PROSE_ROUNDTRIP.md`.
///
/// The three payloads — the AILang Form-A specification, the original
/// module rendered as Form-A, and the edited prose — are inserted
/// verbatim between heredoc-style markers. The rest of the returned
/// string is the role-statement, contract, and output spec that frame
/// the LLM's task.
///
/// Iter 20f rewrote this function: the prompt now embeds
/// [`ailang_core::FORM_A_SPEC`] (the language reference an LLM needs
/// to generate AILang from scratch) and the original module as Form-A
/// (the canonical authoring surface, Decision 6) instead of JSON-AST.
/// JSON-AST stays the canonical hashable artefact, but no human or
/// LLM should write it directly — `ail parse` converts the LLM's
/// Form-A output to JSON.
///
/// Pure: same inputs always yield the same bytes. The CLI wrapper
/// (`Cmd::MergeProse`) is just file-reading + Form-A render + `print!`.
fn compose_merge_prose_prompt(original_form_a: &str, edited_prose: &str) -> String {
    // Built with String::push_str rather than format!() because
    // `FORM_A_SPEC` contains literal `{` and `}` from embedded JSON
    // examples, which would break format!'s placeholder escaping.
    let mut out = String::new();
    out.push_str(
        "You are integrating prose edits back into an AILang module.

ROLE
Your job is to produce an updated AILang module in Form-A (an .ailx
file) that reflects the human's prose edits while preserving the
load-bearing semantic detail from the original module.

CONTRACT
The prose is the source of intent: the human edited it to express
what the program should now do. The original Form-A carries
load-bearing detail that the prose surface elides — preserve those
details unless the prose explicitly contradicts them. Specifically:

  - Mode annotations on fn parameters (`(own T)`, `(borrow T)`).
    These are hard contracts (memory model). The prose shows them
    as `own T` / `borrow T`; the Form-A wraps them. If the prose is
    ambiguous, default to the original.
  - Effect annotations on return types (`(effects IO)`,
    `(effects Diverge)`). Prose shows these as `with IO` etc.; if
    uncertain, keep what the original had.
  - `tail` flags on calls. The prose prints `tail f(x)`; the Form-A
    keyword is `(tail-app f x)`. If the edit moved a call, decide
    whether the new position is still in tail position.
  - Doc strings (`(doc \"...\")` clauses; prose shows them as `///`).
  - `(suppress (code ...) (because ...))` clauses (prose shows them
    as `// @suppress code: reason`). The `because` MUST be non-empty.
  - Constructor names and arities (the prose `Cons(h, t)` must
    round-trip to `(term-ctor TYPE Cons h t)` with the right TYPE).

OUTPUT
Output ONLY the new Form-A bytes. No commentary, no markdown fences,
no preamble or postscript. The output must be parseable by
`ail parse` (it will be piped to that command immediately). The first
non-whitespace byte should be `(` (the opening of `(module ...)`)
and the last non-whitespace byte should be `)`.

",
    );
    out.push_str("FORM-A SPECIFICATION\n");
    out.push_str("<<<FORM_A_SPEC\n");
    out.push_str(ailang_core::FORM_A_SPEC);
    if !ailang_core::FORM_A_SPEC.ends_with('\n') {
        out.push('\n');
    }
    out.push_str("FORM_A_SPEC\n\n");
    out.push_str("ORIGINAL MODULE (Form-A)\n");
    out.push_str("<<<ORIGINAL_FORM_A\n");
    out.push_str(original_form_a);
    if !original_form_a.ends_with('\n') {
        out.push('\n');
    }
    out.push_str("ORIGINAL_FORM_A\n\n");
    out.push_str("EDITED PROSE\n");
    out.push_str("<<<EDITED_PROSE\n");
    out.push_str(edited_prose);
    if !edited_prose.ends_with('\n') {
        out.push('\n');
    }
    out.push_str("EDITED_PROSE\n\n");
    out.push_str("Emit the updated Form-A module now.\n");
    out
}

fn main() -> Result<()> {
    let cli = Cli::parse();
    match cli.cmd {
        Cmd::Manifest { path, json, workspace } => {
            if workspace {
                // Workspace mode: load all modules and emit their defs
                // together, alphabetically by (module, name).
                let ws = ailang_core::load_workspace(&path)?;
                let mut entries: Vec<(String, &ailang_core::Def)> = Vec::new();
                for (mod_name, m) in &ws.modules {
                    for d in &m.defs {
                        entries.push((mod_name.clone(), d));
                    }
                }
                entries.sort_by(|a, b| {
                    a.0.cmp(&b.0).then_with(|| a.1.name().cmp(b.1.name()))
                });

                if json {
                    let symbols: Vec<_> = entries
                        .iter()
                        .map(|(mod_name, d)| {
                            let (kind, ty, effects) = def_summary(d);
                            serde_json::json!({
                                "module": mod_name,
                                "name": d.name(),
                                "kind": kind,
                                "type": ty,
                                "effects": effects,
                                "hash": ailang_core::def_hash(d),
                            })
                        })
                        .collect();
                    let out = serde_json::json!({
                        "workspace": ws.entry,
                        "schema": ailang_core::SCHEMA,
                        "symbols": symbols,
                    });
                    println!("{}", serde_json::to_string_pretty(&out)?);
                } else {
                    // Text form: per entry `<module>.<def> :: <type> ![effs]  <hash>`.
                    let label_width = entries
                        .iter()
                        .map(|(m, d)| m.len() + 1 + d.name().len())
                        .max()
                        .unwrap_or(0);
                    for (mod_name, d) in &entries {
                        let (_, ty, effects) = def_summary(d);
                        let label = format!("{mod_name}.{}", d.name());
                        let eff = if effects.is_empty() {
                            String::new()
                        } else {
                            format!(" ![{}]", effects.join(","))
                        };
                        println!(
                            "{:<width$}  ::  {}{}  [{}]",
                            label,
                            ty,
                            eff,
                            ailang_core::def_hash(d),
                            width = label_width,
                        );
                    }
                }
            } else {
                let m = ailang_core::load_module(&path)?;
                if json {
                    let entries: Vec<_> = m
                        .defs
                        .iter()
                        .map(|d| {
                            let (kind, ty, effects) = def_summary(d);
                            serde_json::json!({
                                "name": d.name(),
                                "kind": kind,
                                "type": ty,
                                "effects": effects,
                                "hash": ailang_core::def_hash(d),
                            })
                        })
                        .collect();
                    let out = serde_json::json!({
                        "module": m.name,
                        "schema": m.schema,
                        "symbols": entries,
                    });
                    println!("{}", serde_json::to_string_pretty(&out)?);
                } else {
                    print!("{}", ailang_core::pretty::manifest(&m));
                }
            }
        }
        Cmd::Render { path } => {
            let m = ailang_core::load_module(&path)?;
            print!("{}", ailang_surface::print(&m));
        }
        Cmd::Prose { path } => {
            // Iter 20a: load via `load_module` (single-module mode,
            // matching how `render` / `parse` work). Workspace-wide
            // prose is not in scope for 20a.
            let m = ailang_core::load_module(&path)?;
            print!("{}", ailang_prose::module_to_prose(&m));
        }
        Cmd::MergeProse { original, edited } => {
            // Iter 20f: load the original .ail.json, render it as
            // Form-A (the canonical authoring surface, Decision 6),
            // and embed *that* in the prompt — not the raw JSON-AST.
            // Form-A is the form an LLM should produce; JSON is the
            // hashable artefact, not the writing surface. The prose
            // file is embedded byte-for-byte.
            let module = ailang_core::load_module(&original)
                .with_context(|| format!("loading {}", original.display()))?;
            let original_form_a = ailang_surface::print(&module);
            let prose = std::fs::read_to_string(&edited)
                .with_context(|| format!("reading {}", edited.display()))?;
            print!("{}", compose_merge_prose_prompt(&original_form_a, &prose));
        }
        Cmd::MigrateCanonicalTypes { dir } => {
            use ailang_core::ast::{Def, Module};
            use std::collections::{BTreeMap, BTreeSet};
            use std::fs;

            // Pass 1: load every *.ail.json in `dir` into a map by
            // module name (NOT via load_workspace — these fixtures
            // may currently fail validation).
            let mut modules: BTreeMap<String, (PathBuf, Module)> = BTreeMap::new();
            for entry in fs::read_dir(&dir)? {
                let entry = entry?;
                let path = entry.path();
                if path.extension().and_then(|s| s.to_str()) != Some("json") {
                    continue;
                }
                let bytes = fs::read(&path)?;
                let m: Module = match serde_json::from_slice(&bytes) {
                    Ok(m) => m,
                    Err(_) => continue, // not a Module (e.g. snapshot files)
                };
                modules.insert(m.name.clone(), (path, m));
            }

            // Include the embedded prelude (so the implicit fallback
            // matches the loader's behaviour).
            let prelude_json: &str = include_str!(
                "../../../examples/prelude.ail.json"
            );
            let prelude: Module = serde_json::from_str(prelude_json)
                .expect("embedded prelude must parse");
            if !modules.contains_key("prelude") {
                modules.insert(
                    "prelude".to_string(),
                    (PathBuf::new(), prelude),
                );
            }

            // Pre-pass: per-module local-types index.
            let mut local_types: BTreeMap<String, BTreeSet<String>> =
                BTreeMap::new();
            for (name, (_, m)) in &modules {
                let mut s = BTreeSet::new();
                for d in &m.defs {
                    if let Def::Type(t) = d {
                        s.insert(t.name.clone());
                    }
                }
                local_types.insert(name.clone(), s);
            }

            // Pass 2: rewrite each module's bare cross-module refs.
            // Skip the synthetic prelude entry (no path to write to).
            let mut rewritten = 0usize;
            for (mod_name, (path, m)) in modules.iter_mut() {
                if mod_name == "prelude" && path.as_os_str().is_empty() {
                    continue;
                }
                let import_names: Vec<String> =
                    m.imports.iter().map(|i| i.module.clone()).collect();
                let owning = mod_name.clone();
                let mut changed = false;
                for def in &mut m.defs {
                    rewrite_def(
                        def,
                        &owning,
                        &local_types,
                        &import_names,
                        &mut changed,
                    );
                }
                if changed {
                    let bytes = serde_json::to_vec_pretty(m)?;
                    fs::write(&*path, bytes)?;
                    println!("migrated: {}", path.display());
                    rewritten += 1;
                }
            }
            println!("done. {} file(s) rewritten.", rewritten);
        }
        Cmd::Describe { path, name, json, workspace } => {
            if workspace {
                let ws = ailang_core::load_workspace(&path)?;
                let (mod_name, def) = resolve_describe_name(&ws, &name)?;
                if json {
                    // We pass the def itself through and add the
                    // module so consumers know the context.
                    let mut v = serde_json::to_value(def)?;
                    if let Some(obj) = v.as_object_mut() {
                        obj.insert(
                            "module".to_string(),
                            serde_json::Value::String(mod_name.clone()),
                        );
                        obj.insert(
                            "hash".to_string(),
                            serde_json::Value::String(ailang_core::def_hash(def)),
                        );
                    }
                    println!("{}", serde_json::to_string_pretty(&v)?);
                } else {
                    let m = ws.modules.get(&mod_name).unwrap();
                    let one = ailang_core::Module {
                        schema: m.schema.clone(),
                        name: m.name.clone(),
                        imports: vec![],
                        defs: vec![def.clone()],
                    };
                    let h = ailang_core::def_hash(def);
                    println!("module: {}", mod_name);
                    println!("hash: {h}");
                    print!("{}", ailang_surface::print(&one));
                }
            } else {
                let m = ailang_core::load_module(&path)?;
                let def = m
                    .defs
                    .iter()
                    .find(|d| d.name() == name)
                    .with_context(|| format!("no def `{name}` in module `{}`", m.name))?;
                if json {
                    let s = serde_json::to_string_pretty(def)?;
                    println!("{s}");
                } else {
                    // Form-(A) projection of a one-def module.
                    let one = ailang_core::Module {
                        schema: m.schema.clone(),
                        name: m.name.clone(),
                        imports: vec![],
                        defs: vec![def.clone()],
                    };
                    let h = ailang_core::def_hash(def);
                    println!("hash: {h}");
                    print!("{}", ailang_surface::print(&one));
                }
            }
        }
        Cmd::Check { path, json } => {
            // Iter 5b: `ail check` now **always** loads via
            // `load_workspace` and checks cross-module. For modules
            // without imports, the workspace loader behaves equivalently
            // to `load_module` plus a hash consistency check of the
            // entry file — keeping the path uniform.
            if json {
                // JSON mode: stdout contains only the diagnostics
                // array. Workspace load errors are emitted as structured
                // diagnostics (codes `module-not-found`,
                // `module-cycle`, `module-name-mismatch`, `schema-mismatch`).
                // Real I/O errors on the entry file remain fatal.
                let diags = match ailang_core::load_workspace(&path) {
                    Ok(ws) => ailang_check::check_workspace(&ws),
                    Err(e) => match workspace_error_to_diagnostic(&e) {
                        Some(d) => vec![d],
                        None => return Err(anyhow::anyhow!(e)),
                    },
                };
                println!("{}", serde_json::to_string(&diags)?);
                if diags
                    .iter()
                    .any(|d| matches!(d.severity, ailang_check::Severity::Error))
                {
                    std::process::exit(1);
                }
            } else {
                let ws = ailang_core::load_workspace(&path)?;
                let diags = ailang_check::check_workspace(&ws);
                if !diags.is_empty() {
                    for d in &diags {
                        eprintln!(
                            "{}: [{}] {}{}",
                            match d.severity {
                                ailang_check::Severity::Error => "error",
                                ailang_check::Severity::Warning => "warning",
                            },
                            d.code,
                            d.def
                                .as_ref()
                                .map(|n| format!("{n}: "))
                                .unwrap_or_default(),
                            d.message,
                        );
                    }
                    // Iter 19a: only Error-severity diagnostics fail the
                    // command. Warning-severity diagnostics (e.g. the
                    // `over-strict-mode` lint) print to stderr but the
                    // module is still considered to have typechecked.
                    if diags
                        .iter()
                        .any(|d| matches!(d.severity, ailang_check::Severity::Error))
                    {
                        std::process::exit(1);
                    }
                }
                let total: usize = ws.modules.values().map(|m| m.defs.len()).sum();
                println!(
                    "ok ({} symbols across {} modules)",
                    total,
                    ws.modules.len()
                );
            }
        }
        Cmd::EmitIr { path, out } => {
            // Iter 5c: workspace lowering. For single-module programs the
            // workspace is effectively a trivial workspace with one module.
            let ws = ailang_core::load_workspace(&path)?;
            let diags = ailang_check::check_workspace(&ws);
            if !diags.is_empty() {
                for d in &diags {
                    eprintln!(
                        "{}: [{}] {}{}",
                        match d.severity {
                            ailang_check::Severity::Error => "error",
                            ailang_check::Severity::Warning => "warning",
                        },
                        d.code,
                        d.def
                            .as_ref()
                            .map(|n| format!("{n}: "))
                            .unwrap_or_default(),
                        d.message,
                    );
                }
                // Iter 19a: only Error-severity blocks codegen.
                if diags
                    .iter()
                    .any(|d| matches!(d.severity, ailang_check::Severity::Error))
                {
                    std::process::exit(1);
                }
            }
            let ir = ailang_codegen::lower_workspace(&ws)?;
            match out {
                Some(p) => {
                    std::fs::write(&p, ir)?;
                    eprintln!("wrote {}", p.display());
                }
                None => print!("{ir}"),
            }
        }
        Cmd::Build { path, out, opt, alloc } => {
            let strategy = parse_alloc_strategy(&alloc)?;
            let bin = build_to(&path, out, &opt, strategy)?;
            eprintln!("built {}", bin.display());
        }
        Cmd::Run { path, opt, alloc, args } => {
            // Iter 9b: build into a fresh tempdir per run, exec, propagate
            // exit code. The artefact dir is left around (no cleanup) so
            // it can be inspected in case of a crash; OS temp policy
            // collects them.
            let strategy = parse_alloc_strategy(&alloc)?;
            let tmpdir = std::env::temp_dir().join(format!(
                "ailang-run-{}",
                std::process::id()
            ));
            std::fs::create_dir_all(&tmpdir)?;
            let bin = build_to(&path, Some(tmpdir.join("bin")), &opt, strategy)?;
            let status = std::process::Command::new(&bin)
                .args(&args)
                .status()
                .with_context(|| format!("executing {}", bin.display()))?;
            std::process::exit(status.code().unwrap_or(127));
        }
        Cmd::Builtins { json } => {
            let list = ailang_check::builtins::list();
            if json {
                let arr: Vec<_> = list
                    .iter()
                    .map(|(n, s)| serde_json::json!({ "name": n, "sig": s }))
                    .collect();
                println!("{}", serde_json::to_string_pretty(&arr)?);
            } else {
                for (n, sig) in list {
                    println!("{n:<16}  {sig}");
                }
            }
        }
        Cmd::Diff { a, b, json, workspace } => {
            if workspace {
                let ws_a = ailang_core::load_workspace(&a)?;
                let ws_b = ailang_core::load_workspace(&b)?;
                let report = build_workspace_diff(&ws_a, &ws_b);
                if json {
                    let v = workspace_diff_report_to_json(&report);
                    println!("{}", serde_json::to_string_pretty(&v)?);
                } else {
                    print!("{}", render_workspace_diff_text(&report));
                }
                if !report.is_identical() {
                    std::process::exit(1);
                }
            } else {
                let ma = ailang_core::load_module(&a)?;
                let mb = ailang_core::load_module(&b)?;
                let report = build_diff(&ma, &mb);
                if json {
                    let v = diff_report_to_json(&report);
                    println!("{}", serde_json::to_string_pretty(&v)?);
                } else {
                    print!("{}", render_diff_text(&report));
                }
                if !report.is_identical() {
                    std::process::exit(1);
                }
            }
        }
        Cmd::Workspace { entry, json } => {
            let ws = ailang_core::load_workspace(&entry)?;
            // Iterate alphabetically over module names (BTreeMap order
            // is already sorted; assert it explicitly).
            let mut entries: Vec<(String, String, usize)> = ws
                .modules
                .iter()
                .map(|(name, m)| {
                    (
                        name.clone(),
                        ailang_core::module_hash(m),
                        m.defs.len(),
                    )
                })
                .collect();
            entries.sort_by(|a, b| a.0.cmp(&b.0));

            if json {
                let arr: Vec<_> = entries
                    .iter()
                    .map(|(name, hash, defs)| {
                        serde_json::json!({
                            "name": name,
                            "hash": hash,
                            "defs": defs,
                        })
                    })
                    .collect();
                let out = serde_json::json!({
                    "entry": ws.entry,
                    "modules": arr,
                });
                println!("{}", serde_json::to_string_pretty(&out)?);
            } else {
                // Align column width to the longest module name. The
                // first column marks the entry module with `*`.
                let name_width = entries
                    .iter()
                    .map(|(n, _, _)| n.len())
                    .max()
                    .unwrap_or(0)
                    .max(6);
                println!("entry: {}", ws.entry);
                for (name, hash, defs) in &entries {
                    let marker = if *name == ws.entry { "*" } else { " " };
                    println!(
                        "{marker} {:<width$}  {}  {:>3} defs",
                        name,
                        hash,
                        defs,
                        width = name_width,
                    );
                }
            }
        }
        Cmd::Parse { path, output } => {
            // Read .ailx, parse via the surface crate, emit canonical
            // JSON. Symmetric to `render` (which goes the other way).
            let src = std::fs::read_to_string(&path)
                .with_context(|| format!("reading {}", path.display()))?;
            let module = match ailang_surface::parse(&src) {
                Ok(m) => m,
                Err(e) => {
                    eprintln!("parse error: {e}");
                    std::process::exit(1);
                }
            };
            let bytes = ailang_core::canonical::to_bytes(&module);
            match output {
                Some(p) => {
                    std::fs::write(&p, &bytes)?;
                    eprintln!("wrote {}", p.display());
                }
                None => {
                    use std::io::Write;
                    std::io::stdout().write_all(&bytes)?;
                    println!();
                }
            }
        }
        Cmd::Deps { path, of, json, workspace } => {
            if workspace {
                let ws = ailang_core::load_workspace(&path)?;

                // `--of NAME`: optional, accepts dot notation
                // (`<module>.<def>`) or a bare name (matches in all
                // modules where the def exists).
                let of_filter: Option<(Option<String>, String)> = of.as_ref().map(|s| {
                    if let Some(idx) = s.find('.') {
                        let m = s[..idx].to_string();
                        let d = s[idx + 1..].to_string();
                        (Some(m), d)
                    } else {
                        (None, s.clone())
                    }
                });

                // Collect edges: (from_module, from_def, target).
                // `target` is either `Edge::Def { to_module, to_def }`
                // or `Edge::Effect(eff/op)`.
                let mut def_edges: Vec<(String, String, String, String)> = Vec::new();
                let mut effect_edges: Vec<(String, String, String)> = Vec::new();

                for (mod_name, m) in &ws.modules {
                    // Import map of the module — needed for cross-module resolution.
                    let import_map = build_import_map(m);

                    for d in &m.defs {
                        if let Some((mf, df)) = &of_filter {
                            if d.name() != df {
                                continue;
                            }
                            if let Some(mf) = mf {
                                if mod_name != mf {
                                    continue;
                                }
                            }
                        }

                        let refs = collect_refs(d);
                        for r in &refs {
                            // Effect refs are encoded as `effect:<eff>/<op>`
                            // (see `walk_term`).
                            if let Some(rest) = r.strip_prefix("effect:") {
                                effect_edges.push((
                                    mod_name.clone(),
                                    d.name().to_string(),
                                    rest.to_string(),
                                ));
                                continue;
                            }
                            // ctor:* / type:*  — no cross-module defs at the
                            // MVP language stage; pass through as an opaque
                            // marker with empty to_module.
                            if r.starts_with("ctor:") || r.starts_with("type:") {
                                def_edges.push((
                                    mod_name.clone(),
                                    d.name().to_string(),
                                    String::new(),
                                    r.clone(),
                                ));
                                continue;
                            }
                            // Var ref: can be local or qualified (`pre.def`).
                            if let Some(idx) = r.find('.') {
                                let pre = &r[..idx];
                                let to_def = &r[idx + 1..];
                                let to_module = import_map
                                    .get(pre)
                                    .cloned()
                                    .unwrap_or_else(|| pre.to_string());
                                def_edges.push((
                                    mod_name.clone(),
                                    d.name().to_string(),
                                    to_module,
                                    to_def.to_string(),
                                ));
                            } else {
                                // Local reference — stays in the same module.
                                def_edges.push((
                                    mod_name.clone(),
                                    d.name().to_string(),
                                    mod_name.clone(),
                                    r.clone(),
                                ));
                            }
                        }
                    }
                }

                def_edges.sort();
                effect_edges.sort();

                if json {
                    let mut edges_json: Vec<serde_json::Value> = Vec::new();
                    for (fm, fd, tm, td) in &def_edges {
                        edges_json.push(serde_json::json!({
                            "from_module": fm,
                            "from_def": fd,
                            "to_module": tm,
                            "to_def": td,
                        }));
                    }
                    for (fm, fd, eff) in &effect_edges {
                        edges_json.push(serde_json::json!({
                            "from_module": fm,
                            "from_def": fd,
                            "effect": eff,
                        }));
                    }
                    let out = serde_json::json!({
                        "workspace": ws.entry,
                        "edges": edges_json,
                    });
                    println!("{}", serde_json::to_string_pretty(&out)?);
                } else {
                    for (fm, fd, tm, td) in &def_edges {
                        if tm.is_empty() {
                            println!("{fm}.{fd} -> {td}");
                        } else {
                            println!("{fm}.{fd} -> {tm}.{td}");
                        }
                    }
                    for (fm, fd, eff) in &effect_edges {
                        println!("{fm}.{fd} -> effect:{eff}");
                    }
                }
            } else {
                let m = ailang_core::load_module(&path)?;
                let mut entries = Vec::new();
                for d in &m.defs {
                    if let Some(filter) = &of {
                        if d.name() != filter {
                            continue;
                        }
                    }
                    let mut refs: Vec<String> = collect_refs(d).into_iter().collect();
                    refs.sort();
                    entries.push((d.name().to_string(), refs));
                }
                if json {
                    let arr: Vec<_> = entries
                        .iter()
                        .map(|(n, r)| serde_json::json!({ "name": n, "refs": r }))
                        .collect();
                    println!("{}", serde_json::to_string_pretty(&arr)?);
                } else {
                    for (n, refs) in entries {
                        if refs.is_empty() {
                            println!("{n:>20}  -");
                        } else {
                            println!("{n:>20}  -> {}", refs.join(", "));
                        }
                    }
                }
            }
        }
    }
    Ok(())
}

/// Converts a `WorkspaceLoadError` into a suitable diagnostic for the
/// JSON mode of `ail check`. Pure I/O errors have no module diagnostic
/// equivalent (they aren't the pipeline's concern for a consumer); for
/// those we return `None` and let the caller fail fatally.
fn workspace_error_to_diagnostic(
    e: &ailang_core::WorkspaceLoadError,
) -> Option<ailang_check::Diagnostic> {
    use ailang_core::WorkspaceLoadError as W;
    match e {
        W::Io { .. } => None,
        W::Schema { source, .. } => match source {
            ailang_core::Error::SchemaMismatch { expected, got } => Some(
                ailang_check::Diagnostic::error(
                    "schema-mismatch",
                    format!(
                        "schema mismatch: expected {expected:?}, got {got:?}"
                    ),
                )
                .with_ctx(serde_json::json!({
                    "expected": expected,
                    "actual": got,
                })),
            ),
            _ => None,
        },
        W::ModuleNotFound { name, expected_path } => Some(
            ailang_check::Diagnostic::error(
                "module-not-found",
                format!(
                    "module `{name}` not found (expected at {})",
                    expected_path.display()
                ),
            )
            .with_ctx(serde_json::json!({
                "module": name,
                "expected_path": expected_path.display().to_string(),
            })),
        ),
        W::ModuleNameMismatch {
            name_in_file,
            name_from_path,
        } => Some(
            ailang_check::Diagnostic::error(
                "module-name-mismatch",
                format!(
                    "module name mismatch: file says {name_in_file:?}, path implies {name_from_path:?}"
                ),
            )
            .with_ctx(serde_json::json!({
                "name_in_file": name_in_file,
                "name_from_path": name_from_path,
            })),
        ),
        W::Cycle { path } => Some(
            ailang_check::Diagnostic::error(
                "module-cycle",
                format!("import cycle: {}", path.join(" -> ")),
            )
            .with_ctx(serde_json::json!({
                "path": path,
            })),
        ),
        W::ModuleHashMismatch { name } => Some(
            ailang_check::Diagnostic::error(
                "module-hash-mismatch",
                format!("module `{name}` loaded twice with differing content"),
            )
            .with_ctx(serde_json::json!({
                "module": name,
            })),
        ),
        // Iter 22b.1: typeclass-coherence diagnostics emitted from
        // `workspace::build_registry`. The codes follow the JOURNAL
        // queue's wording and Decision 11 §"Diagnostic categories".
        W::OrphanInstance {
            class,
            type_repr,
            defining_module,
            class_module,
            type_module,
        } => Some(
            ailang_check::Diagnostic::error(
                "orphan-instance",
                format!(
                    "orphan instance: `instance {class} {type_repr}` declared in `{defining_module}`, \
                    but neither `{class}` (in `{class_module}`) nor `{type_repr}` (in `{type_module}`) lives there"
                ),
            )
            .with_ctx(serde_json::json!({
                "class": class,
                "type": type_repr,
                "defining_module": defining_module,
                "class_module": class_module,
                "type_module": type_module,
            })),
        ),
        W::DuplicateInstance {
            class,
            type_repr,
            first_module,
            second_module,
        } => Some(
            ailang_check::Diagnostic::error(
                "duplicate-instance",
                format!(
                    "duplicate instance: `instance {class} {type_repr}` declared in both `{first_module}` and `{second_module}`"
                ),
            )
            .with_ctx(serde_json::json!({
                "class": class,
                "type": type_repr,
                "first_module": first_module,
                "second_module": second_module,
            })),
        ),
        W::MissingMethod {
            class,
            type_repr,
            method,
        } => Some(
            ailang_check::Diagnostic::error(
                "missing-method",
                format!(
                    "instance `{class} {type_repr}` is missing a body for method `{method}` (no default)"
                ),
            )
            .with_ctx(serde_json::json!({
                "class": class,
                "type": type_repr,
                "method": method,
            })),
        ),
        W::OverridingNonExistentMethod {
            class,
            type_repr,
            method,
        } => Some(
            ailang_check::Diagnostic::error(
                "overriding-non-existent-method",
                format!(
                    "instance `{class} {type_repr}` provides body for method `{method}`, but class `{class}` does not declare it"
                ),
            )
            .with_ctx(serde_json::json!({
                "class": class,
                "type": type_repr,
                "method": method,
            })),
        ),
        W::KindMismatch {
            class,
            param,
            method,
            defining_module,
        } => Some(
            ailang_check::Diagnostic::error(
                "kind-mismatch",
                format!(
                    "kind mismatch in class `{class}`: parameter `{param}` is used in applied position \
                    inside method `{method}` (Decision 11 axis 5: class params are kind `*` only)"
                ),
            )
            .with_ctx(serde_json::json!({
                "class": class,
                "param": param,
                "method": method,
                "defining_module": defining_module,
            })),
        ),
        W::InvalidSuperclassParam {
            class,
            superclass,
            expected_param,
            got_type,
        } => Some(
            ailang_check::Diagnostic::error(
                "invalid-superclass-param",
                format!(
                    "class `{class}` declares superclass `{superclass} {got_type}`, but its own parameter is `{expected_param}` — superclass `type` must equal class `param`"
                ),
            )
            .with_ctx(serde_json::json!({
                "class": class,
                "superclass": superclass,
                "expected_param": expected_param,
                "got_type": got_type,
            })),
        ),
        W::UnboundConstraintTypeVar {
            class,
            method,
            constraint_class,
            var,
        } => Some(
            ailang_check::Diagnostic::error(
                "constraint-references-unbound-type-var",
                format!(
                    "in class `{class}` method `{method}`: constraint `{constraint_class} {var}` references unbound type variable `{var}`"
                ),
            )
            .with_ctx(serde_json::json!({
                "class": class,
                "method": method,
                "constraint_class": constraint_class,
                "var": var,
            })),
        ),
        W::MethodNameCollision {
            method,
            kind,
            first_origin,
            second_origin,
        } => Some(
            ailang_check::Diagnostic::error(
                "method-name-collision",
                format!(
                    "method name `{method}` collides ({kind}): defined in `{first_origin}` and `{second_origin}`"
                ),
            )
            .with_ctx(serde_json::json!({
                "method": method,
                "kind": kind,
                "first_origin": first_origin,
                "second_origin": second_origin,
            })),
        ),
        W::MissingSuperclassInstance {
            class,
            superclass,
            type_repr,
        } => Some(
            ailang_check::Diagnostic::error(
                "missing-superclass-instance",
                format!(
                    "instance `{class} {type_repr}` requires superclass instance `{superclass} {type_repr}`, but none was found"
                ),
            )
            .with_ctx(serde_json::json!({
                "class": class,
                "superclass": superclass,
                "type": type_repr,
            })),
        ),
        // Iter 23.1: the user named a module `prelude`, which the
        // loader reserves for the auto-injected prelude module.
        W::ReservedModuleName { name } => Some(
            ailang_check::Diagnostic::error(
                "reserved-module-name",
                format!(
                    "module name {name:?} is reserved (auto-injected by the loader)"
                ),
            )
            .with_ctx(serde_json::json!({
                "module": name,
            })),
        ),
        // ct.1 (canonical-type-names): bare `Type::Con` that does not
        // resolve to a primitive or a local TypeDef of the owning
        // module. `candidates` lists qualified suggestions scanned
        // from imports.
        W::BareCrossModuleTypeRef { module, name, candidates } => Some(
            ailang_check::Diagnostic::error(
                "bare-cross-module-type-ref",
                format!(
                    "module `{module}` contains bare type name `{name}` that does not resolve to a local type; \
                    candidates from imports: {candidates:?}"
                ),
            )
            .with_ctx(serde_json::json!({
                "module": module,
                "name": name,
                "candidates": candidates,
            })),
        ),
        // ct.1: qualified `<owner>.<type>` where `<owner>` is not a
        // known module or `<owner>` has no `TypeDef <type>`.
        W::BadCrossModuleTypeRef { module, name } => Some(
            ailang_check::Diagnostic::error(
                "bad-cross-module-type-ref",
                format!(
                    "module `{module}` references qualified type `{name}` but the owner module is not known \
                    or does not declare a type by that name"
                ),
            )
            .with_ctx(serde_json::json!({
                "module": module,
                "name": name,
            })),
        ),
        // ct.1: a class-reference field contains a `.` — class names
        // are not module-qualified in this milestone.
        W::QualifiedClassName { module, name, field } => Some(
            ailang_check::Diagnostic::error(
                "qualified-class-name",
                format!(
                    "module `{module}` contains qualified class name `{name}` in field `{field}`; \
                    class names are not module-qualified in this milestone"
                ),
            )
            .with_ctx(serde_json::json!({
                "module": module,
                "name": name,
                "field": field,
            })),
        ),
    }
}

/// Collects the *external* references of a definition: everything its body
/// depends on that lives outside the def itself. Filtered out:
///
/// - **Built-ins** (`+`, `-`, `==`, `not`, …): operators provided by the
///   runtime, not the user. The list is owned by `ailang_check::builtins`.
/// - **Parameters** of the surrounding `fn`.
/// - **Local bindings** introduced by `let` and by pattern variables in
///   `match` arms.
///
/// Cross-module references (`<prefix>.<def>`) are always kept — a local
/// binding can never shadow a dotted name (the typechecker rejects defs
/// with a dot, see DESIGN.md "qualified cross-module references").
fn collect_refs(def: &ailang_core::Def) -> std::collections::BTreeSet<String> {
    let mut out = std::collections::BTreeSet::new();
    let builtins: std::collections::HashSet<&'static str> =
        ailang_check::builtins::value_names().into_iter().collect();
    match def {
        ailang_core::Def::Fn(f) => {
            let mut scope: std::collections::HashSet<String> =
                f.params.iter().cloned().collect();
            walk_term(&f.body, &mut out, &builtins, &mut scope);
        }
        ailang_core::Def::Const(c) => {
            let mut scope = std::collections::HashSet::new();
            walk_term(&c.value, &mut out, &builtins, &mut scope);
        }
        ailang_core::Def::Type(td) => {
            // A type def references the types of its fields.
            for c in &td.ctors {
                for ft in &c.fields {
                    if let ailang_core::Type::Con { name, .. } = ft {
                        out.insert(format!("type:{name}"));
                    }
                }
            }
        }
        // Iter 22b.1: `ail deps` does not yet trace references inside
        // class/instance defs. Walking method signatures and bodies
        // (default bodies, instance method bodies) lands in 22b.2 along
        // with the typecheck arms — until then a class/instance def
        // contributes no entries to the dependency graph. Tools that
        // call `collect_refs` (`ail deps`, the cross-module-fan-out
        // diagnostics) therefore treat them as leaves.
        ailang_core::Def::Class(_) | ailang_core::Def::Instance(_) => {}
    }
    out
}

fn walk_term(
    t: &ailang_core::Term,
    out: &mut std::collections::BTreeSet<String>,
    builtins: &std::collections::HashSet<&'static str>,
    scope: &mut std::collections::HashSet<String>,
) {
    use ailang_core::Term;
    match t {
        Term::Lit { .. } => {}
        Term::Var { name } => {
            // Qualified (`prefix.def`) — always external; the typechecker
            // forbids dots in def names, so no shadowing risk.
            if name.contains('.') {
                out.insert(name.clone());
                return;
            }
            if scope.contains(name) || builtins.contains(name.as_str()) {
                return;
            }
            out.insert(name.clone());
        }
        Term::App { callee, args, .. } => {
            walk_term(callee, out, builtins, scope);
            for a in args {
                walk_term(a, out, builtins, scope);
            }
        }
        Term::Let { name, value, body } => {
            // `value` is in the outer scope; only `body` sees the binding.
            walk_term(value, out, builtins, scope);
            let inserted = scope.insert(name.clone());
            walk_term(body, out, builtins, scope);
            if inserted {
                scope.remove(name);
            }
        }
        Term::If { cond, then, else_ } => {
            walk_term(cond, out, builtins, scope);
            walk_term(then, out, builtins, scope);
            walk_term(else_, out, builtins, scope);
        }
        Term::Do { op, args, .. } => {
            // Mark effect ops as `effect:io/print_int` so they can be
            // separated from normal function calls.
            out.insert(format!("effect:{op}"));
            for a in args {
                walk_term(a, out, builtins, scope);
            }
        }
        Term::Ctor { type_name, ctor, args } => {
            out.insert(format!("ctor:{type_name}/{ctor}"));
            for a in args {
                walk_term(a, out, builtins, scope);
            }
        }
        Term::Match { scrutinee, arms } => {
            walk_term(scrutinee, out, builtins, scope);
            for arm in arms {
                if let ailang_core::ast::Pattern::Ctor { ctor, .. } = &arm.pat {
                    out.insert(format!("ctor:{ctor}"));
                }
                let bound = bind_pattern(&arm.pat, scope);
                walk_term(&arm.body, out, builtins, scope);
                for n in bound {
                    scope.remove(&n);
                }
            }
        }
        Term::Lam { params, body, .. } => {
            // Lambda params shadow outer scope inside the body.
            let mut newly = Vec::new();
            for p in params {
                if scope.insert(p.clone()) {
                    newly.push(p.clone());
                }
            }
            walk_term(body, out, builtins, scope);
            for p in newly {
                scope.remove(&p);
            }
        }
        Term::Seq { lhs, rhs } => {
            walk_term(lhs, out, builtins, scope);
            walk_term(rhs, out, builtins, scope);
        }
        Term::LetRec { name, params, body, in_term, .. } => {
            // Iter 16b.1: `deps` walks the on-disk module before the
            // desugar pass, so a `Term::LetRec` is reachable here.
            // Treat it like a fn def for dependency purposes:
            // `name` shadows for both body and in_term; params shadow
            // inside the body.
            let inserted_name = scope.insert(name.clone());
            let mut newly = Vec::new();
            for p in params {
                if scope.insert(p.clone()) {
                    newly.push(p.clone());
                }
            }
            walk_term(body, out, builtins, scope);
            for p in newly {
                scope.remove(&p);
            }
            walk_term(in_term, out, builtins, scope);
            if inserted_name {
                scope.remove(name);
            }
        }
        Term::Clone { value } => {
            // Iter 18c.1: clone is identity for dependency-walk
            // purposes. The wrapper introduces no new symbols.
            walk_term(value, out, builtins, scope);
        }
        Term::ReuseAs { source, body } => {
            // Iter 18d.1: walk both children; the wrapper introduces no
            // new bindings of its own.
            walk_term(source, out, builtins, scope);
            walk_term(body, out, builtins, scope);
        }
    }
}

/// Adds the variable bindings introduced by a pattern to `scope` and
/// returns the names that were freshly inserted (so the caller can roll
/// them back). MVP-restricted: nested ctor patterns only contain
/// `Var`/`Wild` — see DESIGN.md.
fn bind_pattern(
    p: &ailang_core::ast::Pattern,
    scope: &mut std::collections::HashSet<String>,
) -> Vec<String> {
    use ailang_core::ast::Pattern;
    let mut added = Vec::new();
    match p {
        Pattern::Wild | Pattern::Lit { .. } => {}
        Pattern::Var { name } => {
            if scope.insert(name.clone()) {
                added.push(name.clone());
            }
        }
        Pattern::Ctor { fields, .. } => {
            for sub in fields {
                added.extend(bind_pattern(sub, scope));
            }
        }
    }
    added
}

// --- ail diff -------------------------------------------------------------

/// Purely structural module diff. Top-level defs are identified by
/// `name` and compared via the BLAKE3-16-hex of the canonical bytes.
struct DiffReport {
    module_a: String,
    module_b: String,
    added: Vec<DiffEntry>,
    removed: Vec<DiffEntry>,
    changed: Vec<ChangedEntry>,
    unchanged: Vec<DiffEntry>,
}

struct DiffEntry {
    name: String,
    hash: String,
    kind: &'static str,
}

struct ChangedEntry {
    name: String,
    hash_a: String,
    hash_b: String,
    kind_a: &'static str,
    kind_b: &'static str,
}

impl DiffReport {
    fn is_identical(&self) -> bool {
        self.added.is_empty() && self.removed.is_empty() && self.changed.is_empty()
    }
}

fn build_diff(a: &ailang_core::Module, b: &ailang_core::Module) -> DiffReport {
    let (added, removed, changed, unchanged) = diff_def_lists(&a.defs, &b.defs);
    DiffReport {
        module_a: a.name.clone(),
        module_b: b.name.clone(),
        added,
        removed,
        changed,
        unchanged,
    }
}

/// Pure list-diff computation for two def slices. Used both by the
/// single-module diff and — per `changed_module` — by the workspace diff,
/// so the 4-category logic lives in only one place.
fn diff_def_lists(
    a_defs: &[ailang_core::Def],
    b_defs: &[ailang_core::Def],
) -> (Vec<DiffEntry>, Vec<DiffEntry>, Vec<ChangedEntry>, Vec<DiffEntry>) {
    use std::collections::BTreeMap;

    let map_a: BTreeMap<&str, &ailang_core::Def> = a_defs
        .iter()
        .map(|d| (ailang_core::def_name(d), d))
        .collect();
    let map_b: BTreeMap<&str, &ailang_core::Def> = b_defs
        .iter()
        .map(|d| (ailang_core::def_name(d), d))
        .collect();

    let mut added = Vec::new();
    let mut removed = Vec::new();
    let mut changed = Vec::new();
    let mut unchanged = Vec::new();

    for (name, def_a) in &map_a {
        let hash_a = ailang_core::def_hash(def_a);
        let kind_a = ailang_core::def_kind(def_a);
        match map_b.get(*name) {
            None => removed.push(DiffEntry {
                name: (*name).to_string(),
                hash: hash_a,
                kind: kind_a,
            }),
            Some(def_b) => {
                let hash_b = ailang_core::def_hash(def_b);
                let kind_b = ailang_core::def_kind(def_b);
                if hash_a == hash_b {
                    unchanged.push(DiffEntry {
                        name: (*name).to_string(),
                        hash: hash_a,
                        kind: kind_a,
                    });
                } else {
                    changed.push(ChangedEntry {
                        name: (*name).to_string(),
                        hash_a,
                        hash_b,
                        kind_a,
                        kind_b,
                    });
                }
            }
        }
    }

    for (name, def_b) in &map_b {
        if !map_a.contains_key(*name) {
            added.push(DiffEntry {
                name: (*name).to_string(),
                hash: ailang_core::def_hash(def_b),
                kind: ailang_core::def_kind(def_b),
            });
        }
    }

    added.sort_by(|x, y| x.name.cmp(&y.name));
    removed.sort_by(|x, y| x.name.cmp(&y.name));
    changed.sort_by(|x, y| x.name.cmp(&y.name));
    unchanged.sort_by(|x, y| x.name.cmp(&y.name));

    (added, removed, changed, unchanged)
}

fn diff_report_to_json(r: &DiffReport) -> serde_json::Value {
    let entry = |e: &DiffEntry| {
        serde_json::json!({
            "name": e.name,
            "hash": e.hash,
            "kind": e.kind,
        })
    };
    let changed = |c: &ChangedEntry| {
        serde_json::json!({
            "name": c.name,
            "hash_a": c.hash_a,
            "hash_b": c.hash_b,
            "kind_a": c.kind_a,
            "kind_b": c.kind_b,
        })
    };
    serde_json::json!({
        "module_a": r.module_a,
        "module_b": r.module_b,
        "added":     r.added.iter().map(entry).collect::<Vec<_>>(),
        "removed":   r.removed.iter().map(entry).collect::<Vec<_>>(),
        "changed":   r.changed.iter().map(changed).collect::<Vec<_>>(),
        "unchanged": r.unchanged.iter().map(entry).collect::<Vec<_>>(),
    })
}

fn render_diff_text(r: &DiffReport) -> String {
    use std::fmt::Write;

    let mut out = String::new();
    let _ = writeln!(out, "diff: {} -> {}", r.module_a, r.module_b);

    if r.is_identical() && r.unchanged.is_empty() {
        let _ = writeln!(out, "no changes");
        return out;
    }

    // Uniform column width for the name/kind column so hashes line up
    // visually. The longest name sets the width.
    let name_width = r
        .added
        .iter()
        .map(|e| e.name.len())
        .chain(r.removed.iter().map(|e| e.name.len()))
        .chain(r.changed.iter().map(|e| e.name.len()))
        .chain(r.unchanged.iter().map(|e| e.name.len()))
        .max()
        .unwrap_or(0);

    for e in &r.added {
        let _ = writeln!(
            out,
            "+ {:<width$} ({})  {}",
            e.name,
            e.kind,
            e.hash,
            width = name_width
        );
    }
    for e in &r.removed {
        let _ = writeln!(
            out,
            "- {:<width$} ({})  {}",
            e.name,
            e.kind,
            e.hash,
            width = name_width
        );
    }
    for c in &r.changed {
        // If the kind changed (e.g. const → fn), show both.
        let kind = if c.kind_a == c.kind_b {
            c.kind_a.to_string()
        } else {
            format!("{} -> {}", c.kind_a, c.kind_b)
        };
        let _ = writeln!(
            out,
            "~ {:<width$} ({})  {} -> {}",
            c.name,
            kind,
            c.hash_a,
            c.hash_b,
            width = name_width
        );
    }
    for e in &r.unchanged {
        let _ = writeln!(
            out,
            "  {:<width$} ({})  {} (unchanged)",
            e.name,
            e.kind,
            e.hash,
            width = name_width
        );
    }
    out
}

// --- Workspace-aware helpers (Iter 5d) ------------------------------------

/// Compact summary of a def for manifest output (single and workspace
/// mode share this helper). Returns (kind, type-string, effects).
fn def_summary(d: &ailang_core::Def) -> (&'static str, String, Vec<String>) {
    match d {
        ailang_core::Def::Fn(f) => {
            let effects = match &f.ty {
                ailang_core::Type::Fn { effects, .. } => effects.clone(),
                _ => vec![],
            };
            ("fn", ailang_core::pretty::type_to_string(&f.ty), effects)
        }
        ailang_core::Def::Const(c) => (
            "const",
            ailang_core::pretty::type_to_string(&c.ty),
            vec![],
        ),
        ailang_core::Def::Type(t) => {
            let s = t
                .ctors
                .iter()
                .map(|c| {
                    if c.fields.is_empty() {
                        c.name.clone()
                    } else {
                        format!(
                            "{}({})",
                            c.name,
                            c.fields
                                .iter()
                                .map(ailang_core::pretty::type_to_string)
                                .collect::<Vec<_>>()
                                .join(", ")
                        )
                    }
                })
                .collect::<Vec<_>>()
                .join(" | ");
            ("type", s, vec![])
        }
        // Iter 22b.1: a one-line `class C a` / `instance C T` summary
        // for `ail manifest`. The `effects` slot is empty for both —
        // class methods carry their own effect annotations on each
        // method signature, but the class itself does not (it is a
        // collection of method signatures, not a single function).
        ailang_core::Def::Class(c) => (
            "class",
            format!("{} {}", c.name, c.param),
            vec![],
        ),
        ailang_core::Def::Instance(i) => (
            "instance",
            format!(
                "{} {}",
                i.class,
                ailang_core::pretty::type_to_string(&i.type_)
            ),
            vec![],
        ),
    }
}

/// Resolution for `ail describe --workspace <name>`.
///
/// 1. `name` contains exactly one dot → resolve `<module>.<def>` strictly.
/// 2. Otherwise search the entry module first; fall back to other modules
///    only if nothing there. Multiple hits produce `ambiguous-name`.
fn resolve_describe_name<'ws>(
    ws: &'ws ailang_core::Workspace,
    name: &str,
) -> Result<(String, &'ws ailang_core::Def)> {
    if let Some(idx) = name.find('.') {
        let mod_name = &name[..idx];
        let def_name = &name[idx + 1..];
        let m = ws.modules.get(mod_name).with_context(|| {
            format!("no module `{mod_name}` in workspace `{}`", ws.entry)
        })?;
        let def = m
            .defs
            .iter()
            .find(|d| d.name() == def_name)
            .with_context(|| {
                format!("no def `{def_name}` in module `{mod_name}`")
            })?;
        return Ok((mod_name.to_string(), def));
    }

    // Bare name: entry module first.
    if let Some(entry_mod) = ws.modules.get(&ws.entry) {
        if let Some(def) = entry_mod.defs.iter().find(|d| d.name() == name) {
            return Ok((ws.entry.clone(), def));
        }
    }

    // Fallback: collect all modules; error on ambiguity.
    let mut hits: Vec<(String, &ailang_core::Def)> = Vec::new();
    for (mod_name, m) in &ws.modules {
        if mod_name == &ws.entry {
            continue;
        }
        for d in &m.defs {
            if d.name() == name {
                hits.push((mod_name.clone(), d));
            }
        }
    }
    match hits.len() {
        0 => Err(anyhow::anyhow!(
            "no def `{name}` in workspace `{}`",
            ws.entry
        )),
        1 => Ok(hits.into_iter().next().unwrap()),
        _ => {
            let modules: Vec<String> =
                hits.iter().map(|(m, _)| m.clone()).collect();
            Err(anyhow::anyhow!(
                "[ambiguous-name] def `{name}` exists in multiple modules: {}",
                modules.join(", ")
            ))
        }
    }
}

/// Map `<prefix> -> <module-name>` for a single module. `<prefix>` is
/// the import alias if set, otherwise the module name itself. Module name
/// without a dot.
fn build_import_map(m: &ailang_core::Module) -> std::collections::BTreeMap<String, String> {
    let mut map = std::collections::BTreeMap::new();
    for imp in &m.imports {
        let prefix = imp.alias.clone().unwrap_or_else(|| imp.module.clone());
        map.insert(prefix, imp.module.clone());
    }
    map
}

// --- Workspace diff -------------------------------------------------------

struct WorkspaceDiffReport {
    workspace_a: String,
    workspace_b: String,
    added_modules: Vec<ModuleDiffEntry>,
    removed_modules: Vec<ModuleDiffEntry>,
    unchanged_modules: Vec<ModuleDiffEntry>,
    changed_modules: Vec<ChangedModuleEntry>,
}

struct ModuleDiffEntry {
    name: String,
    hash: String,
}

struct ChangedModuleEntry {
    name: String,
    hash_a: String,
    hash_b: String,
    added: Vec<DiffEntry>,
    removed: Vec<DiffEntry>,
    changed: Vec<ChangedEntry>,
    unchanged: Vec<DiffEntry>,
}

impl WorkspaceDiffReport {
    fn is_identical(&self) -> bool {
        self.added_modules.is_empty()
            && self.removed_modules.is_empty()
            && self.changed_modules.is_empty()
    }
}

fn build_workspace_diff(
    a: &ailang_core::Workspace,
    b: &ailang_core::Workspace,
) -> WorkspaceDiffReport {
    let mut added = Vec::new();
    let mut removed = Vec::new();
    let mut unchanged = Vec::new();
    let mut changed = Vec::new();

    for (name, ma) in &a.modules {
        let hash_a = ailang_core::module_hash(ma);
        match b.modules.get(name) {
            None => removed.push(ModuleDiffEntry {
                name: name.clone(),
                hash: hash_a,
            }),
            Some(mb) => {
                let hash_b = ailang_core::module_hash(mb);
                if hash_a == hash_b {
                    unchanged.push(ModuleDiffEntry {
                        name: name.clone(),
                        hash: hash_a,
                    });
                } else {
                    let (sub_added, sub_removed, sub_changed, sub_unchanged) =
                        diff_def_lists(&ma.defs, &mb.defs);
                    changed.push(ChangedModuleEntry {
                        name: name.clone(),
                        hash_a,
                        hash_b,
                        added: sub_added,
                        removed: sub_removed,
                        changed: sub_changed,
                        unchanged: sub_unchanged,
                    });
                }
            }
        }
    }
    for (name, mb) in &b.modules {
        if !a.modules.contains_key(name) {
            added.push(ModuleDiffEntry {
                name: name.clone(),
                hash: ailang_core::module_hash(mb),
            });
        }
    }

    added.sort_by(|x, y| x.name.cmp(&y.name));
    removed.sort_by(|x, y| x.name.cmp(&y.name));
    unchanged.sort_by(|x, y| x.name.cmp(&y.name));
    changed.sort_by(|x, y| x.name.cmp(&y.name));

    WorkspaceDiffReport {
        workspace_a: a.entry.clone(),
        workspace_b: b.entry.clone(),
        added_modules: added,
        removed_modules: removed,
        unchanged_modules: unchanged,
        changed_modules: changed,
    }
}

fn workspace_diff_report_to_json(r: &WorkspaceDiffReport) -> serde_json::Value {
    let mod_entry = |e: &ModuleDiffEntry| {
        serde_json::json!({ "name": e.name, "hash": e.hash })
    };
    let entry = |e: &DiffEntry| {
        serde_json::json!({
            "name": e.name,
            "hash": e.hash,
            "kind": e.kind,
        })
    };
    let changed_entry = |c: &ChangedEntry| {
        serde_json::json!({
            "name": c.name,
            "hash_a": c.hash_a,
            "hash_b": c.hash_b,
            "kind_a": c.kind_a,
            "kind_b": c.kind_b,
        })
    };
    let changed_mod = |c: &ChangedModuleEntry| {
        serde_json::json!({
            "name": c.name,
            "hash_a": c.hash_a,
            "hash_b": c.hash_b,
            "added":     c.added.iter().map(entry).collect::<Vec<_>>(),
            "removed":   c.removed.iter().map(entry).collect::<Vec<_>>(),
            "changed":   c.changed.iter().map(changed_entry).collect::<Vec<_>>(),
            "unchanged": c.unchanged.iter().map(entry).collect::<Vec<_>>(),
        })
    };
    serde_json::json!({
        "workspace_a": r.workspace_a,
        "workspace_b": r.workspace_b,
        "added_modules":     r.added_modules.iter().map(mod_entry).collect::<Vec<_>>(),
        "removed_modules":   r.removed_modules.iter().map(mod_entry).collect::<Vec<_>>(),
        "unchanged_modules": r.unchanged_modules.iter().map(mod_entry).collect::<Vec<_>>(),
        "changed_modules":   r.changed_modules.iter().map(changed_mod).collect::<Vec<_>>(),
    })
}

fn render_workspace_diff_text(r: &WorkspaceDiffReport) -> String {
    use std::fmt::Write;

    let mut out = String::new();
    let _ = writeln!(out, "workspace diff: {} -> {}", r.workspace_a, r.workspace_b);

    if r.is_identical() && r.unchanged_modules.is_empty() {
        let _ = writeln!(out, "no changes");
        return out;
    }

    for m in &r.added_modules {
        let _ = writeln!(out, "+ module {}  {}", m.name, m.hash);
    }
    for m in &r.removed_modules {
        let _ = writeln!(out, "- module {}  {}", m.name, m.hash);
    }
    for c in &r.changed_modules {
        let _ = writeln!(
            out,
            "~ module {}  {} -> {}",
            c.name, c.hash_a, c.hash_b
        );
        for e in &c.added {
            let _ = writeln!(out, "    + {} ({})  {}", e.name, e.kind, e.hash);
        }
        for e in &c.removed {
            let _ = writeln!(out, "    - {} ({})  {}", e.name, e.kind, e.hash);
        }
        for ce in &c.changed {
            let kind = if ce.kind_a == ce.kind_b {
                ce.kind_a.to_string()
            } else {
                format!("{} -> {}", ce.kind_a, ce.kind_b)
            };
            let _ = writeln!(
                out,
                "    ~ {} ({})  {} -> {}",
                ce.name, kind, ce.hash_a, ce.hash_b
            );
        }
        for e in &c.unchanged {
            let _ =
                writeln!(out, "      {} ({})  {} (unchanged)", e.name, e.kind, e.hash);
        }
    }
    for m in &r.unchanged_modules {
        let _ = writeln!(out, "  module {}  {} (unchanged)", m.name, m.hash);
    }

    out
}

fn parse_alloc_strategy(s: &str) -> Result<ailang_codegen::AllocStrategy> {
    match s {
        "gc" => Ok(ailang_codegen::AllocStrategy::Gc),
        "bump" => Ok(ailang_codegen::AllocStrategy::Bump),
        "rc" => Ok(ailang_codegen::AllocStrategy::Rc),
        other => anyhow::bail!(
            "unknown --alloc value `{other}` (expected `gc`, `bump`, or `rc`)"
        ),
    }
}

/// Locate the workspace-root `runtime/bump.c` file relative to the
/// `ail` binary. We search upwards for a directory that contains
/// `runtime/bump.c`; that's the bench-only allocator stub. If we
/// can't find it, the build aborts with a clear message — the
/// `--alloc=bump` path is opt-in, so this only fires when the user
/// asked for it.
fn locate_bump_runtime() -> Result<PathBuf> {
    // Two anchors we try in order:
    //   1. the directory containing the running `ail` binary, walked
    //      up to find `runtime/bump.c` (handles `target/release/ail`
    //      and `target/debug/ail` cleanly).
    //   2. the current working directory, walked up.
    let candidates = [
        std::env::current_exe().ok(),
        std::env::current_dir().ok(),
    ];
    for start in candidates.iter().flatten() {
        let mut cur: &Path = start.as_path();
        loop {
            let candidate = cur.join("runtime").join("bump.c");
            if candidate.exists() {
                return Ok(candidate);
            }
            match cur.parent() {
                Some(p) => cur = p,
                None => break,
            }
        }
    }
    anyhow::bail!(
        "could not locate `runtime/bump.c` (required for --alloc=bump). \
         Run `ail` from inside the AILang workspace."
    )
}

/// Locate the workspace-root `runtime/rc.c` file relative to the
/// `ail` binary, mirroring [`locate_bump_runtime`]. The `--alloc=rc`
/// path (Iter 18b) compiles this stub and links it instead of `-lgc`;
/// it supplies `ailang_rc_alloc` / `ailang_rc_inc` / `ailang_rc_dec`
/// against libc malloc/free.
fn locate_rc_runtime() -> Result<PathBuf> {
    let candidates = [
        std::env::current_exe().ok(),
        std::env::current_dir().ok(),
    ];
    for start in candidates.iter().flatten() {
        let mut cur: &Path = start.as_path();
        loop {
            let candidate = cur.join("runtime").join("rc.c");
            if candidate.exists() {
                return Ok(candidate);
            }
            match cur.parent() {
                Some(p) => cur = p,
                None => break,
            }
        }
    }
    anyhow::bail!(
        "could not locate `runtime/rc.c` (required for --alloc=rc). \
         Run `ail` from inside the AILang workspace."
    )
}

/// Locate the workspace-root `runtime/str.c` file relative to the
/// `ail` binary, mirroring [`locate_rc_runtime`]. Iter 23.2:
/// `runtime/str.c` houses Str-runtime primitives (`ail_str_eq`,
/// later `ail_str_compare`) and is linked unconditionally for every
/// alloc strategy — `@ail_str_eq` is declared in the emitted IR
/// header (codegen) without an alloc-strategy guard, so the symbol
/// must always be resolvable at link time.
fn locate_str_runtime() -> Result<PathBuf> {
    let candidates = [
        std::env::current_exe().ok(),
        std::env::current_dir().ok(),
    ];
    for start in candidates.iter().flatten() {
        let mut cur: &Path = start.as_path();
        loop {
            let candidate = cur.join("runtime").join("str.c");
            if candidate.exists() {
                return Ok(candidate);
            }
            match cur.parent() {
                Some(p) => cur = p,
                None => break,
            }
        }
    }
    anyhow::bail!(
        "could not locate `runtime/str.c` (linked unconditionally). \
         Run `ail` from inside the AILang workspace."
    )
}

/// Iter 9b: shared build helper for `Cmd::Build` and `Cmd::Run`.
/// Loads the workspace, runs the typechecker, emits IR, and links via
/// clang. On typecheck failure, prints diagnostics to stderr and exits
/// the process with code 1. On clang failure, returns a Result error
/// (the .ll path is preserved for post-mortem inspection).
///
/// Iter 16b.3: between `check_workspace` and `lower_workspace` we run
/// `ailang_check::lift_letrecs` per module. The lift eliminates any
/// `Term::LetRec` that the desugar pass left in place (specifically:
/// LetRecs that capture `Term::Let`-bound names, whose types are
/// only known after typecheck). The lifted workspace then goes to
/// codegen unchanged.
///
/// Bench iter: `alloc` selects the heap allocator the emitted IR
/// targets. Default `Gc` keeps the entire pipeline (IR text, link
/// command) byte-identical to pre-bench. `Bump` declares
/// `@bump_malloc` instead of `@GC_malloc` and links `runtime/bump.c`
/// in lieu of `-lgc`. `Rc` (Iter 18b) declares `@ailang_rc_alloc`
/// instead and links `runtime/rc.c` — RC runtime, alloc-only — Iter
/// 18b plumbing; inc/dec instrumentation arrives in 18c.
fn build_to(
    path: &Path,
    out: Option<PathBuf>,
    opt: &str,
    alloc: ailang_codegen::AllocStrategy,
) -> Result<PathBuf> {
    let ws = ailang_core::load_workspace(path)?;
    let diags = ailang_check::check_workspace(&ws);
    if !diags.is_empty() {
        for d in &diags {
            eprintln!(
                "{}: [{}] {}{}",
                match d.severity {
                    ailang_check::Severity::Error => "error",
                    ailang_check::Severity::Warning => "warning",
                },
                d.code,
                d.def
                    .as_ref()
                    .map(|n| format!("{n}: "))
                    .unwrap_or_default(),
                d.message,
            );
        }
        // Iter 19a: only Error-severity blocks the build. Warning-
        // severity diagnostics (e.g. `over-strict-mode`) print but
        // do not abort.
        if diags
            .iter()
            .any(|d| matches!(d.severity, ailang_check::Severity::Error))
        {
            std::process::exit(1);
        }
    }
    // Iter 16b.3: run `lift_letrecs` per module on the post-desugar
    // form. Codegen's internal desugar pass is idempotent on a
    // module that contains no `Term::LetRec`, so the lifted output
    // can be handed directly to `lower_workspace`.
    let mut lifted_modules = std::collections::BTreeMap::new();
    for (mname, m) in &ws.modules {
        let desugared = ailang_core::desugar::desugar_module(m);
        let lifted = ailang_check::lift_letrecs(&desugared)
            .map_err(|e| anyhow::anyhow!("lift_letrecs in module `{mname}`: {e}"))?;
        lifted_modules.insert(mname.clone(), lifted);
    }
    let ws = ailang_core::Workspace {
        entry: ws.entry.clone(),
        modules: lifted_modules,
        root_dir: ws.root_dir.clone(),
        // Iter 22b.1: pass the registry through the lift pass. Lift
        // only rewrites `Term::LetRec` into top-level fns; it does
        // not touch class/instance defs, so the registry built at
        // load-time remains valid.
        registry: ws.registry.clone(),
    };
    // Iter 22b.3: monomorphisation pass. After `lift_letrecs` and
    // before codegen, every (class-method, concrete-type) call-site
    // pair becomes a synthesised top-level fn; user call sites are
    // rewritten to target those names. Class-free workspaces flow
    // through bit-identically; see `ailang_check::monomorphise_workspace`.
    let ws = ailang_check::monomorphise_workspace(&ws)
        .map_err(|e| anyhow::anyhow!("monomorphise_workspace: {e}"))?;
    let ir = ailang_codegen::lower_workspace_with_alloc(&ws, alloc)?;
    let tmpdir = std::env::temp_dir().join(format!("ailang-{}", std::process::id()));
    std::fs::create_dir_all(&tmpdir)?;
    let ll_path = tmpdir.join(format!("{}.ll", ws.entry));
    std::fs::write(&ll_path, &ir)?;
    let out_bin = out.unwrap_or_else(|| Path::new(".").join(&ws.entry).with_extension(""));
    let mut clang = std::process::Command::new("clang");
    clang.arg(opt).arg("-o").arg(&out_bin).arg(&ll_path);
    // Iter 23.2: compile and link `runtime/str.c` unconditionally —
    // `@ail_str_eq` is emitted in the IR header without an alloc-strategy
    // guard (it backs the prelude `eq__Str` mono symbol). The .o is
    // cached at <tmpdir>/str.o per build invocation; if a program never
    // monomorphises `eq__Str`, clang -O2 dead-strips the symbol so the
    // linker overhead is fixed and negligible.
    let str_src = locate_str_runtime()?;
    let str_obj = tmpdir.join("str.o");
    let cstatus = std::process::Command::new("clang")
        .arg("-O2")
        .arg("-c")
        .arg(&str_src)
        .arg("-o")
        .arg(&str_obj)
        .status()
        .context("compiling runtime/str.c")?;
    if !cstatus.success() {
        anyhow::bail!(
            "clang failed compiling str.c (status {})",
            cstatus
        );
    }
    clang.arg(&str_obj);
    match alloc {
        ailang_codegen::AllocStrategy::Gc => {
            // Boehm conservative GC (Decision 9 / Iter 14f). The lowered
            // IR calls @GC_malloc; libgc supplies it. Pthread/dl are
            // pulled in transitively via libgc.so on Linux, so a single
            // -lgc suffices.
            clang.arg("-lgc");
        }
        ailang_codegen::AllocStrategy::Bump => {
            // Bench iter: link the no-free arena stub from
            // `runtime/bump.c` instead of libgc. We compile the stub
            // inline at -O2 (its body is small, the .o is cached at
            // <tmpdir>/bump.o per build invocation; no global cache
            // because the bench harness rebuilds binaries top-to-bottom
            // anyway).
            let bump_src = locate_bump_runtime()?;
            let bump_obj = tmpdir.join("bump.o");
            let cstatus = std::process::Command::new("clang")
                .arg("-O2")
                .arg("-c")
                .arg(&bump_src)
                .arg("-o")
                .arg(&bump_obj)
                .status()
                .context("compiling runtime/bump.c")?;
            if !cstatus.success() {
                anyhow::bail!(
                    "clang failed compiling bump.c (status {})",
                    cstatus
                );
            }
            clang.arg(&bump_obj);
        }
        ailang_codegen::AllocStrategy::Rc => {
            // Iter 18b: link the RC runtime stub from `runtime/rc.c`.
            // Provides `ailang_rc_alloc` / `inc` / `dec` against libc
            // malloc/free; no Boehm dependency, so we deliberately do
            // NOT pass `-lgc`. Compiled at -O2 to match the bump path
            // shape; cached at <tmpdir>/rc.o per build invocation.
            //
            // 18b only routes allocation here — codegen does not yet
            // emit `inc`/`dec` calls, so programs leak under this
            // mode. The point is to validate compiled-program
            // correctness and the runtime ABI before 18c wires up
            // inc/dec emission.
            let rc_src = locate_rc_runtime()?;
            let rc_obj = tmpdir.join("rc.o");
            let cstatus = std::process::Command::new("clang")
                .arg("-O2")
                .arg("-c")
                .arg(&rc_src)
                .arg("-o")
                .arg(&rc_obj)
                .status()
                .context("compiling runtime/rc.c")?;
            if !cstatus.success() {
                anyhow::bail!(
                    "clang failed compiling rc.c (status {})",
                    cstatus
                );
            }
            clang.arg(&rc_obj);
        }
    }
    let status = clang.status().context("running clang")?;
    if !status.success() {
        anyhow::bail!(
            "clang failed (status {}); ll at {}",
            status,
            ll_path.display()
        );
    }
    Ok(out_bin)
}

/// ct.1 migration helper: rewrite bare cross-module Type::Con names
/// and Term::Ctor.type_name to their qualified form. Sets
/// `*changed` to `true` if any rewrite happened. Uses the
/// first-match-in-imports-order disambiguation rule with prelude as
/// implicit last-resort fallback, matching iter 23.1.3's typecheck
/// imports-fallback so the rewrite never silently re-aims a fixture.
fn rewrite_def(
    def: &mut ailang_core::ast::Def,
    owning_module: &str,
    local_types: &std::collections::BTreeMap<String, std::collections::BTreeSet<String>>,
    import_names: &[String],
    changed: &mut bool,
) {
    use ailang_core::ast::{Def, Term, Type};

    fn rewrite_name(
        name: &mut String,
        owning_module: &str,
        local_types: &std::collections::BTreeMap<String, std::collections::BTreeSet<String>>,
        import_names: &[String],
        changed: &mut bool,
    ) {
        if name.contains('.') {
            return;
        }
        if matches!(
            name.as_str(),
            "Int" | "Bool" | "Str" | "Unit" | "Float"
        ) {
            return;
        }
        if local_types
            .get(owning_module)
            .map(|s| s.contains(name))
            .unwrap_or(false)
        {
            return;
        }
        // Bare cross-module — find owner via imports-in-order, with
        // prelude as last-resort fallback.
        let owner: Option<&str> = import_names
            .iter()
            .map(|s| s.as_str())
            .find(|imp| {
                local_types
                    .get(*imp)
                    .map(|s| s.contains(name))
                    .unwrap_or(false)
            })
            .or_else(|| {
                if local_types
                    .get("prelude")
                    .map(|s| s.contains(name))
                    .unwrap_or(false)
                {
                    Some("prelude")
                } else {
                    None
                }
            });
        if let Some(owner) = owner {
            *name = format!("{owner}.{name}");
            *changed = true;
        }
        // If no owner found, leave bare — validator will reject later.
    }

    fn rewrite_type(
        t: &mut Type,
        owning_module: &str,
        local_types: &std::collections::BTreeMap<String, std::collections::BTreeSet<String>>,
        import_names: &[String],
        changed: &mut bool,
    ) {
        match t {
            Type::Con { name, args } => {
                rewrite_name(
                    name,
                    owning_module,
                    local_types,
                    import_names,
                    changed,
                );
                for a in args {
                    rewrite_type(
                        a,
                        owning_module,
                        local_types,
                        import_names,
                        changed,
                    );
                }
            }
            Type::Fn { params, ret, .. } => {
                for p in params {
                    rewrite_type(
                        p,
                        owning_module,
                        local_types,
                        import_names,
                        changed,
                    );
                }
                rewrite_type(
                    ret,
                    owning_module,
                    local_types,
                    import_names,
                    changed,
                );
            }
            Type::Forall { body, constraints, .. } => {
                for c in constraints {
                    rewrite_type(
                        &mut c.type_,
                        owning_module,
                        local_types,
                        import_names,
                        changed,
                    );
                }
                rewrite_type(
                    body,
                    owning_module,
                    local_types,
                    import_names,
                    changed,
                );
            }
            Type::Var { .. } => {}
        }
    }

    fn rewrite_term(
        t: &mut Term,
        owning_module: &str,
        local_types: &std::collections::BTreeMap<String, std::collections::BTreeSet<String>>,
        import_names: &[String],
        changed: &mut bool,
    ) {
        match t {
            Term::Lit { .. } | Term::Var { .. } => {}
            Term::App { callee, args, .. } => {
                rewrite_term(
                    callee,
                    owning_module,
                    local_types,
                    import_names,
                    changed,
                );
                for a in args {
                    rewrite_term(
                        a,
                        owning_module,
                        local_types,
                        import_names,
                        changed,
                    );
                }
            }
            Term::Let { value, body, .. } => {
                rewrite_term(
                    value,
                    owning_module,
                    local_types,
                    import_names,
                    changed,
                );
                rewrite_term(
                    body,
                    owning_module,
                    local_types,
                    import_names,
                    changed,
                );
            }
            Term::LetRec { ty, body, in_term, .. } => {
                rewrite_type(
                    ty,
                    owning_module,
                    local_types,
                    import_names,
                    changed,
                );
                rewrite_term(
                    body,
                    owning_module,
                    local_types,
                    import_names,
                    changed,
                );
                rewrite_term(
                    in_term,
                    owning_module,
                    local_types,
                    import_names,
                    changed,
                );
            }
            Term::If { cond, then, else_ } => {
                rewrite_term(
                    cond,
                    owning_module,
                    local_types,
                    import_names,
                    changed,
                );
                rewrite_term(
                    then,
                    owning_module,
                    local_types,
                    import_names,
                    changed,
                );
                rewrite_term(
                    else_,
                    owning_module,
                    local_types,
                    import_names,
                    changed,
                );
            }
            Term::Do { args, .. } => {
                for a in args {
                    rewrite_term(
                        a,
                        owning_module,
                        local_types,
                        import_names,
                        changed,
                    );
                }
            }
            Term::Ctor { type_name, args, .. } => {
                rewrite_name(
                    type_name,
                    owning_module,
                    local_types,
                    import_names,
                    changed,
                );
                for a in args {
                    rewrite_term(
                        a,
                        owning_module,
                        local_types,
                        import_names,
                        changed,
                    );
                }
            }
            Term::Match { scrutinee, arms } => {
                rewrite_term(
                    scrutinee,
                    owning_module,
                    local_types,
                    import_names,
                    changed,
                );
                for arm in arms {
                    rewrite_term(
                        &mut arm.body,
                        owning_module,
                        local_types,
                        import_names,
                        changed,
                    );
                }
            }
            Term::Lam { param_tys, ret_ty, body, .. } => {
                for pt in param_tys {
                    rewrite_type(
                        pt,
                        owning_module,
                        local_types,
                        import_names,
                        changed,
                    );
                }
                rewrite_type(
                    ret_ty,
                    owning_module,
                    local_types,
                    import_names,
                    changed,
                );
                rewrite_term(
                    body,
                    owning_module,
                    local_types,
                    import_names,
                    changed,
                );
            }
            Term::Seq { lhs, rhs } => {
                rewrite_term(
                    lhs,
                    owning_module,
                    local_types,
                    import_names,
                    changed,
                );
                rewrite_term(
                    rhs,
                    owning_module,
                    local_types,
                    import_names,
                    changed,
                );
            }
            Term::Clone { value } => rewrite_term(
                value,
                owning_module,
                local_types,
                import_names,
                changed,
            ),
            Term::ReuseAs { source, body } => {
                rewrite_term(
                    source,
                    owning_module,
                    local_types,
                    import_names,
                    changed,
                );
                rewrite_term(
                    body,
                    owning_module,
                    local_types,
                    import_names,
                    changed,
                );
            }
        }
    }

    match def {
        Def::Fn(fd) => {
            rewrite_type(
                &mut fd.ty,
                owning_module,
                local_types,
                import_names,
                changed,
            );
            rewrite_term(
                &mut fd.body,
                owning_module,
                local_types,
                import_names,
                changed,
            );
        }
        Def::Const(cd) => {
            rewrite_type(
                &mut cd.ty,
                owning_module,
                local_types,
                import_names,
                changed,
            );
            rewrite_term(
                &mut cd.value,
                owning_module,
                local_types,
                import_names,
                changed,
            );
        }
        Def::Type(td) => {
            for c in &mut td.ctors {
                for fty in &mut c.fields {
                    rewrite_type(
                        fty,
                        owning_module,
                        local_types,
                        import_names,
                        changed,
                    );
                }
            }
        }
        Def::Class(cd) => {
            for cm in &mut cd.methods {
                rewrite_type(
                    &mut cm.ty,
                    owning_module,
                    local_types,
                    import_names,
                    changed,
                );
                if let Some(body) = &mut cm.default {
                    rewrite_term(
                        body,
                        owning_module,
                        local_types,
                        import_names,
                        changed,
                    );
                }
            }
        }
        Def::Instance(id) => {
            rewrite_type(
                &mut id.type_,
                owning_module,
                local_types,
                import_names,
                changed,
            );
            for im in &mut id.methods {
                rewrite_term(
                    &mut im.body,
                    owning_module,
                    local_types,
                    import_names,
                    changed,
                );
            }
        }
    }
}

#[cfg(test)]
mod tests {
    use super::compose_merge_prose_prompt;

    #[test]
    fn locate_str_runtime_finds_workspace_str_c() {
        let p = super::locate_str_runtime().expect("locate runtime/str.c");
        assert!(
            p.ends_with("runtime/str.c"),
            "expected path ending in runtime/str.c, got {}",
            p.display()
        );
        assert!(p.exists(), "located path must exist on disk: {}", p.display());
    }

    /// The composed prompt must contain all three payloads
    /// byte-for-byte — the LLM has to see exactly what the user wrote,
    /// the original module's Form-A bytes, and the embedded spec, with
    /// no re-encoding or stripping.
    #[test]
    fn merge_prose_prompt_contains_all_payloads_verbatim() {
        let orig = "(module foo\n  (fn main\n    (type (fn-type (params) (ret (con Unit))))\n    (params)\n    (body (lit-unit))))\n";
        let prose = "// module foo\n\nfn main() -> Unit { () }\n";
        let out = compose_merge_prose_prompt(orig, prose);
        assert!(
            out.contains(orig),
            "prompt missing original Form-A verbatim; got:\n{out}"
        );
        assert!(
            out.contains(prose),
            "prompt missing edited prose verbatim; got:\n{out}"
        );
        // The full FORM_A_SPEC must appear in the prompt.
        assert!(
            out.contains(ailang_core::FORM_A_SPEC),
            "prompt missing the FORM_A_SPEC body verbatim"
        );
    }

    /// The framing sections must all be present so the LLM has the
    /// full task definition. Asserted by substring on a representative
    /// landmark from each section.
    #[test]
    fn merge_prose_prompt_has_all_framing_sections() {
        let out = compose_merge_prose_prompt("(module x)", "");
        assert!(
            out.contains("ROLE") && out.contains("Form-A"),
            "prompt missing ROLE section"
        );
        assert!(
            out.contains("CONTRACT")
                && out.contains("preserve those")
                && out.contains("(own T)")
                && out.contains("(effects IO)"),
            "prompt missing CONTRACT section"
        );
        assert!(
            out.contains("OUTPUT")
                && out.contains("ONLY")
                && out.contains("no markdown"),
            "prompt missing OUTPUT section"
        );
        // The embedded language spec, by section header.
        assert!(
            out.contains("FORM-A SPECIFICATION"),
            "prompt missing FORM-A SPECIFICATION header"
        );
        // Heredoc-style markers around all three payloads.
        assert!(
            out.contains("<<<FORM_A_SPEC")
                && out.contains("FORM_A_SPEC\n")
                && out.contains("<<<ORIGINAL_FORM_A")
                && out.contains("ORIGINAL_FORM_A\n")
                && out.contains("<<<EDITED_PROSE")
                && out.contains("EDITED_PROSE\n"),
            "prompt missing payload markers"
        );
    }

    /// Pure / deterministic: same inputs must yield byte-identical
    /// output.
    #[test]
    fn merge_prose_prompt_is_deterministic() {
        let orig = "(module y)";
        let prose = "fn f() {}";
        let a = compose_merge_prose_prompt(orig, prose);
        let b = compose_merge_prose_prompt(orig, prose);
        assert_eq!(a, b);
    }
}
