//! Workspace loader: loads an entry module and recursively follows its
//! `imports`.
//!
//! Convention: an `import { module: "foo" }` is resolved relative to
//! the entry file's directory as `<root_dir>/foo.ail.json`. Module
//! names must match the file stem (the loader rejects mismatches).
//!
//! The single entry point is [`load_workspace`]; it returns a fully
//! populated [`Workspace`] or a structured [`WorkspaceLoadError`].
//! [`module_hash`] is the module-granularity counterpart to
//! [`crate::def_hash`] and is used both internally (to detect a module
//! re-loaded from disk with different content) and by the CLI.
//!
//! This module is responsible only for **finding** and consistently
//! **loading** all reachable modules. Cross-module typechecking lives
//! in `ailang-check`; codegen in `ailang-codegen`. Neither is run from
//! here.
//!
//! # Examples
//!
//! ```ignore
//! use ailang_core::load_workspace;
//! use std::path::Path;
//!
//! let ws = load_workspace(Path::new("examples/ws_main.ail.json"))?;
//! assert_eq!(ws.entry, "ws_main");
//! // All transitively imported modules are now in `ws.modules`.
//! for (name, m) in &ws.modules {
//!     println!("{name}: {} defs", m.defs.len());
//! }
//! # Ok::<(), ailang_core::workspace::WorkspaceLoadError>(())
//! ```

use crate::ast::{ClassDef, Def, InstanceDef, Module, Type};
use crate::canonical;
use crate::{load_module, Error as CoreError};
use std::collections::{BTreeMap, BTreeSet, HashSet};
use std::path::{Path, PathBuf};

/// Fully loaded workspace.
///
/// `entry` names the entry module (module name, **not** a path). All
/// transitively reachable modules are contained in `modules` and indexed
/// by `Module.name`. `root_dir` is the directory the entry file lives
/// in; all imports are resolved relative to it.
///
/// Iter 22b.1 (Decision 11): `registry` is the workspace-global
/// instance registry, built at the end of [`load_workspace`] after the
/// DFS over imports completes. It is empty for any workspace whose
/// modules contain no [`crate::ast::Def::Instance`] defs.
#[derive(Debug, Clone)]
pub struct Workspace {
    /// Name of the entry module (the one passed to [`load_workspace`]).
    pub entry: String,
    /// Every module reachable from `entry`, indexed by module name.
    /// `BTreeMap` is used so iteration order is deterministic, which
    /// matters for downstream codegen and reporting.
    pub modules: BTreeMap<String, Module>,
    /// Directory the entry file lives in; all imports are resolved
    /// relative to it.
    pub root_dir: PathBuf,
    /// Iter 22b.1: workspace-global typeclass instance registry.
    pub registry: Registry,
}

/// Iter 22b.1: workspace-global instance registry (Decision 11).
///
/// Built at the end of [`load_workspace`] after all modules are
/// loaded. Keyed by `(class-name, canonical-type-hash)`; values are
/// the matching [`crate::ast::InstanceDef`] plus the name of the
/// module it was declared in. The hash key uses
/// [`canonical::type_hash`], so the key is stable against unrelated
/// whitespace / field-order differences in the source JSON.
///
/// 22b.1 enforces three coherence checks during build:
///
/// 1. **Coherence (orphan-freedom).** Every `instance C T` lives in
///    the module of `C` or in the module of `T` (per Decision 11
///    axis 3). Otherwise → [`WorkspaceLoadError::OrphanInstance`].
/// 2. **Uniqueness.** No two entries share a key. Otherwise →
///    [`WorkspaceLoadError::DuplicateInstance`].
/// 3. **Method completeness.** Each instance specifies a body for
///    every required (non-default) method of its class. Otherwise →
///    [`WorkspaceLoadError::MissingMethod`].
#[derive(Debug, Clone, Default)]
pub struct Registry {
    /// Map from `(class-name, type-hash)` to the registry entry.
    pub entries: BTreeMap<(String, String), RegistryEntry>,
}

/// One entry in the [`Registry`].
#[derive(Debug, Clone)]
pub struct RegistryEntry {
    /// The instance declaration itself.
    pub instance: InstanceDef,
    /// Name of the module the instance was declared in.
    pub defining_module: String,
}

/// Structured errors of the workspace loader.
///
/// `Cycle.path` is the chain of module names in which the cycle was
/// closed — the last element is the name already present in `visiting`.
#[derive(Debug, thiserror::Error)]
pub enum WorkspaceLoadError {
    /// File I/O failed while reading a module file (typically: file
    /// missing, permission denied).
    #[error("io error for {path}: {source}")]
    Io {
        path: PathBuf,
        #[source]
        source: std::io::Error,
    },

    /// File contents failed to parse as a [`Module`] or had the wrong
    /// schema tag. Wraps a [`CoreError`] (the single-module loader's
    /// error type).
    #[error("schema/parse error in {path}: {source}")]
    Schema {
        path: PathBuf,
        #[source]
        source: CoreError,
    },

    /// An `import { module: "foo" }` did not resolve to an existing
    /// file at the expected path.
    #[error("module `{name}` not found (expected at {expected_path})")]
    ModuleNotFound { name: String, expected_path: PathBuf },

    /// The `name` field in a loaded module file disagrees with the
    /// file-stem-derived name the loader expected (i.e. the
    /// `<name>.ail.json` convention is broken).
    #[error(
        "module name in file ({name_in_file:?}) does not match expected name from path ({name_from_path:?})"
    )]
    ModuleNameMismatch {
        name_in_file: String,
        name_from_path: String,
    },

    /// An import cycle was detected. `path` is the chain of module
    /// names in which the cycle was closed — the last element is the
    /// name that was already on the visit stack.
    #[error("import cycle detected: {}", path.join(" -> "))]
    Cycle { path: Vec<String> },

    /// A module was reachable through two import paths, and its
    /// on-disk content (compared via [`module_hash`]) differs between
    /// the two reads. This typically means the file changed mid-load.
    #[error(
        "module `{name}` was loaded twice with differing content (hashes differ)"
    )]
    ModuleHashMismatch { name: String },

    /// Iter 22b.1: an [`crate::ast::Def::Instance`] was declared in a
    /// module that is neither the class's defining module nor the
    /// instance type's defining module. Coherence violation per
    /// Decision 11 axis 3 ("orphan-freedom"). The lookup is hard:
    /// AILang does not provide a `--allow-orphans` flag.
    #[error(
        "orphan instance: `instance {class} {type_repr}` declared in module `{defining_module}`, \
        but neither `{class}` (in `{class_module}`) nor `{type_repr}` (in `{type_module}`) lives there"
    )]
    OrphanInstance {
        class: String,
        type_repr: String,
        defining_module: String,
        class_module: String,
        type_module: String,
    },

    /// Iter 22b.1: two [`crate::ast::Def::Instance`]s share the same
    /// `(class, canonical-type-hash)` key. Coherence requires
    /// uniqueness; the registry has no way to disambiguate at
    /// resolution time. Per Decision 11 there is no
    /// `AmbiguousInstance` diagnostic — coherence makes the lookup
    /// unambiguous by construction, and a duplicate is a workspace
    /// configuration error, not a per-call-site one.
    #[error(
        "duplicate instance: `instance {class} {type_repr}` declared in both `{first_module}` and `{second_module}`"
    )]
    DuplicateInstance {
        class: String,
        type_repr: String,
        first_module: String,
        second_module: String,
    },

    /// Iter 22b.1: an [`crate::ast::Def::Instance`] does not specify
    /// a body for a required (non-default) method of its class.
    /// Default-bearing methods may be inherited; non-default
    /// (abstract-required) methods must be specified by every
    /// instance. Per Decision 11 §"Defaults and superclasses".
    #[error(
        "instance `{class} {type_repr}` is missing a body for method `{method}` (no default)"
    )]
    MissingMethod {
        class: String,
        type_repr: String,
        method: String,
    },

    /// Iter 22b.2: class-schema validation. The class parameter
    /// appears in applied position (e.g. as the head of a
    /// `Type::Con { name == param, args.len() > 0 }`) inside a method
    /// signature. Decision 11 axis 5 forbids HKTs — class params are
    /// kind `*` only.
    #[error(
        "kind mismatch in class `{class}`: parameter `{param}` is used in applied position \
        inside method `{method}` (Decision 11 axis 5: class params are kind `*` only)"
    )]
    KindMismatch {
        class: String,
        param: String,
        method: String,
        defining_module: String,
    },

    /// Iter 22b.2: class-schema validation. A class's `superclass.type`
    /// does not equal its own `param`. Decision 11 single-superclass
    /// model requires the superclass to be applied to the same param
    /// (e.g. `class Ord a extends Eq a`, not `extends Eq b`).
    #[error(
        "class `{class}` declares superclass `{superclass} {got_type}`, but its own parameter is `{expected_param}` \
        — superclass `type` must equal class `param`"
    )]
    InvalidSuperclassParam {
        class: String,
        superclass: String,
        expected_param: String,
        got_type: String,
    },

    /// Iter 22b.2: class-schema validation. A class method's
    /// signature contains a constraint referencing a type variable
    /// that is neither bound by the method's `Forall.vars` nor equal
    /// to the class's `param`.
    #[error(
        "in class `{class}` method `{method}`: constraint `{constraint_class} {var}` references unbound type variable `{var}`"
    )]
    UnboundConstraintTypeVar {
        class: String,
        method: String,
        constraint_class: String,
        var: String,
    },

    /// Iter 22b.2: an instance specifies a body for a method name
    /// that the corresponding class does not declare. Symmetric to
    /// `MissingMethod` but in the opposite direction.
    #[error(
        "instance `{class} {type_repr}` provides body for method `{method}`, but class `{class}` does not declare it"
    )]
    OverridingNonExistentMethod {
        class: String,
        type_repr: String,
        method: String,
    },

    /// Iter 22b.2: a class-method name collides with another
    /// class-method or with a top-level fn. `kind` is
    /// `"class-class"` or `"class-fn"`.
    #[error(
        "method name `{method}` collides ({kind}): defined in `{first_origin}` and `{second_origin}`"
    )]
    MethodNameCollision {
        method: String,
        kind: &'static str,
        first_origin: String,
        second_origin: String,
    },

    /// Iter 22b.2: an instance `C T` was declared, but `C`'s
    /// superclass `S` does not have an instance for the same type
    /// `T`. Decision 11 single-superclass model requires `instance S
    /// T` to exist whenever `instance C T` exists.
    #[error(
        "instance `{class} {type_repr}` requires superclass instance `{superclass} {type_repr}`, but none was found"
    )]
    MissingSuperclassInstance {
        class: String,
        superclass: String,
        type_repr: String,
    },
}

/// Hash over the canonical bytes of a complete module.
///
/// Parallel to `def_hash`, but at module level. The workspace loader
/// uses this to verify double-loads; the CLI uses it to emit a stable
/// per-module identifier.
pub fn module_hash(m: &Module) -> String {
    let bytes = canonical::to_bytes(m);
    let h = blake3::hash(&bytes);
    h.to_hex().as_str()[..16].to_string()
}

/// Load entry module plus all transitively reachable modules.
///
/// Algorithm: DFS over `imports`, with two sets:
/// - `loaded` (= `modules` map): modules whose subtree is already fully
///   processed. On a re-hit only hash consistency is checked.
/// - `visiting`: stack of modules whose DFS descent is still running.
///   A hit here = cycle.
pub fn load_workspace(entry_path: &Path) -> Result<Workspace, WorkspaceLoadError> {
    let entry_path = entry_path.to_path_buf();
    let root_dir = entry_path
        .parent()
        .map(Path::to_path_buf)
        .unwrap_or_else(|| PathBuf::from("."));

    // Load entry module + check name<->filename convention.
    let entry_module = load_one(&entry_path)?;
    let expected_entry_name = module_name_from_path(&entry_path);
    if entry_module.name != expected_entry_name {
        return Err(WorkspaceLoadError::ModuleNameMismatch {
            name_in_file: entry_module.name.clone(),
            name_from_path: expected_entry_name,
        });
    }

    let entry_name = entry_module.name.clone();

    let mut modules: BTreeMap<String, Module> = BTreeMap::new();
    let mut visiting: Vec<String> = Vec::new();
    let mut visiting_set: HashSet<String> = HashSet::new();

    visit(
        entry_module,
        &root_dir,
        &mut modules,
        &mut visiting,
        &mut visiting_set,
    )?;

    // Iter 22b.2: class-schema validation runs before registry
    // construction so that ill-kinded class definitions are rejected
    // before any instance against them is registered.
    validate_classdefs(&modules)?;

    // Iter 22b.1: build the workspace-global instance registry. Three
    // coherence checks fire here (Orphan / Duplicate / Missing-method);
    // any violation is surfaced as a `WorkspaceLoadError`, not as a
    // per-call-site diagnostic.
    let registry = build_registry(&modules)?;

    Ok(Workspace {
        entry: entry_name,
        modules,
        root_dir,
        registry,
    })
}

/// Iter 22b.1: build the workspace-global typeclass instance registry.
///
/// Two passes:
///
/// 1. Scan every loaded module to build a "where is X defined" lookup
///    for class names and type names — needed for the coherence
///    (orphan) check below.
/// 2. For each [`crate::ast::Def::Instance`] in declaration order,
///    apply the three checks (coherence / uniqueness / method
///    completeness) and insert the entry on success.
///
/// Iteration is over the modules `BTreeMap` (alphabetical by name) so
/// the order in which collisions are detected is deterministic across
/// runs. Within a module, defs are scanned in source order.
fn build_registry(
    modules: &BTreeMap<String, Module>,
) -> Result<Registry, WorkspaceLoadError> {
    // Pass 1: collect "where is X defined" maps, plus a class lookup
    // by name (needed for the method-completeness check).
    let mut class_def_module: BTreeMap<String, String> = BTreeMap::new();
    let mut type_def_module: BTreeMap<String, String> = BTreeMap::new();
    let mut class_by_name: BTreeMap<String, &ClassDef> = BTreeMap::new();
    for (mod_name, m) in modules {
        for def in &m.defs {
            match def {
                Def::Class(c) => {
                    class_def_module.insert(c.name.clone(), mod_name.clone());
                    class_by_name.insert(c.name.clone(), c);
                }
                Def::Type(t) => {
                    type_def_module.insert(t.name.clone(), mod_name.clone());
                }
                _ => {}
            }
        }
    }

    // Iter 22b.2: method-name-collision pre-pass. Bare-name resolution
    // (`foo x` rather than `A.foo x`) requires that a method name
    // appears in at most one origin across the whole workspace. We
    // walk every `Def::Class` and `Def::Fn` once, building a
    // `method_origins` map; the first repeat fires the diagnostic.
    // The `kind` field distinguishes class-method ↔ class-method from
    // class-method ↔ top-level-fn; fn-fn collisions are a separate
    // concern surfaced by `CheckError::DuplicateDef` in `ailang-check`,
    // not here.
    //
    // Origins are kept structural (the `Origin` enum below) so the
    // `kind` discriminator is a `match` on variants rather than a
    // string-prefix check on a display form.
    enum Origin {
        Class { class_name: String, module: String },
        Fn { name: String, module: String },
    }
    impl Origin {
        fn format(&self) -> String {
            match self {
                Origin::Class { class_name, module } => {
                    format!("class {class_name} (in {module})")
                }
                Origin::Fn { name, module } => format!("fn {name} (in {module})"),
            }
        }
    }

    let mut method_origins: BTreeMap<String, Origin> = BTreeMap::new();
    for (mod_name, m) in modules {
        for def in &m.defs {
            match def {
                Def::Class(c) => {
                    for method in &c.methods {
                        let origin = Origin::Class {
                            class_name: c.name.clone(),
                            module: mod_name.clone(),
                        };
                        if let Some(prior) = method_origins.get(&method.name) {
                            let kind = match prior {
                                Origin::Class { .. } => "class-class",
                                Origin::Fn { .. } => "class-fn",
                            };
                            return Err(WorkspaceLoadError::MethodNameCollision {
                                method: method.name.clone(),
                                kind,
                                first_origin: prior.format(),
                                second_origin: origin.format(),
                            });
                        }
                        method_origins.insert(method.name.clone(), origin);
                    }
                }
                Def::Fn(f) => {
                    let origin = Origin::Fn {
                        name: f.name.clone(),
                        module: mod_name.clone(),
                    };
                    if let Some(prior) = method_origins.get(&f.name) {
                        // Only fire on class-fn collisions here. fn-fn
                        // collisions (two `Def::Fn` with the same name)
                        // are `CheckError::DuplicateDef`'s job in
                        // `ailang-check`; firing here with `kind:
                        // "class-fn"` would misreport.
                        if matches!(prior, Origin::Class { .. }) {
                            return Err(WorkspaceLoadError::MethodNameCollision {
                                method: f.name.clone(),
                                kind: "class-fn",
                                first_origin: prior.format(),
                                second_origin: origin.format(),
                            });
                        }
                        // prior is a fn: skip; do not overwrite.
                    } else {
                        method_origins.insert(f.name.clone(), origin);
                    }
                }
                _ => {}
            }
        }
    }

    // Pass 2: register each instance, with coherence / uniqueness /
    // method-completeness checks.
    let mut entries: BTreeMap<(String, String), RegistryEntry> = BTreeMap::new();
    for (mod_name, m) in modules {
        for def in &m.defs {
            if let Def::Instance(inst) = def {
                let type_repr = type_head_name(&inst.type_);

                // Coherence (orphan-freedom): instance's module must
                // equal the class's module or the type's module. A
                // class declared inside the same module as the
                // instance always satisfies the first leg; a
                // user-defined type declared in the instance's module
                // satisfies the second. Primitives have no
                // user-defined module — instances on primitives
                // therefore must live in the class's module.
                let class_mod = class_def_module
                    .get(&inst.class)
                    .cloned()
                    .unwrap_or_else(|| "<unknown-class>".into());
                let type_mod = type_def_module
                    .get(&type_repr)
                    .cloned()
                    .unwrap_or_else(|| "<primitive-or-unknown>".into());
                let coherent = mod_name == &class_mod || mod_name == &type_mod;
                if !coherent {
                    return Err(WorkspaceLoadError::OrphanInstance {
                        class: inst.class.clone(),
                        type_repr,
                        defining_module: mod_name.clone(),
                        class_module: class_mod,
                        type_module: type_mod,
                    });
                }

                // Uniqueness: a `(class, type-hash)` key must appear
                // at most once across the whole workspace.
                let type_hash = canonical::type_hash(&inst.type_);
                let key = (inst.class.clone(), type_hash);
                if let Some(prior) = entries.get(&key) {
                    return Err(WorkspaceLoadError::DuplicateInstance {
                        class: inst.class.clone(),
                        type_repr,
                        first_module: prior.defining_module.clone(),
                        second_module: mod_name.clone(),
                    });
                }

                // Method completeness: every non-default method of
                // the class must have a body in this instance.
                // Defaults may be inherited (no body required).
                // Missing class declaration is deferred to 22b.2's
                // typecheck arms — for 22b.1 we skip the
                // completeness check rather than firing a separate
                // diagnostic.
                if let Some(class_def) = class_by_name.get(&inst.class) {
                    let provided: BTreeSet<&str> =
                        inst.methods.iter().map(|m| m.name.as_str()).collect();
                    for class_method in &class_def.methods {
                        if class_method.default.is_none()
                            && !provided.contains(class_method.name.as_str())
                        {
                            return Err(WorkspaceLoadError::MissingMethod {
                                class: inst.class.clone(),
                                type_repr,
                                method: class_method.name.clone(),
                            });
                        }
                    }

                    // Symmetric to MissingMethod: an instance must
                    // not specify a body for a method name the class
                    // never declared. Decision 11 forbids ad-hoc
                    // additions to a class's method set at the
                    // instance site.
                    let declared: BTreeSet<&str> =
                        class_def.methods.iter().map(|m| m.name.as_str()).collect();
                    for inst_method in &inst.methods {
                        if !declared.contains(inst_method.name.as_str()) {
                            return Err(WorkspaceLoadError::OverridingNonExistentMethod {
                                class: inst.class.clone(),
                                type_repr: type_repr.clone(),
                                method: inst_method.name.clone(),
                            });
                        }
                    }
                }

                entries.insert(
                    key,
                    RegistryEntry {
                        instance: inst.clone(),
                        defining_module: mod_name.clone(),
                    },
                );
            }
        }
    }

    // Iter 22b.2: superclass-instance completeness. For every entry,
    // walk the class's superclass chain and require an entry for each
    // step at the same type-hash.
    for (key, entry) in entries.iter() {
        let (class_name, type_hash) = key;
        let type_repr = type_head_name(&entry.instance.type_);
        // Iter 22b.2 leaves superclass-cycle detection to a future arm;
        // here we just terminate the walk.
        let mut visited: BTreeSet<&str> = BTreeSet::new();
        let mut current = class_by_name.get(class_name.as_str()).copied();
        while let Some(c) = current {
            if !visited.insert(c.name.as_str()) {
                break;
            }
            if let Some(sc) = &c.superclass {
                let sc_key = (sc.class.clone(), type_hash.clone());
                if !entries.contains_key(&sc_key) {
                    return Err(WorkspaceLoadError::MissingSuperclassInstance {
                        class: class_name.clone(),
                        superclass: sc.class.clone(),
                        type_repr: type_repr.clone(),
                    });
                }
                current = class_by_name.get(sc.class.as_str()).copied();
            } else {
                break;
            }
        }
    }

    Ok(Registry { entries })
}

/// Iter 22b.2: class-schema validation. Runs before `build_registry`.
/// Three diagnostics fire from here: `kind-mismatch`,
/// `invalid-superclass-param`, `constraint-references-unbound-type-var`.
fn validate_classdefs(
    modules: &BTreeMap<String, Module>,
) -> Result<(), WorkspaceLoadError> {
    for (mod_name, m) in modules {
        for def in &m.defs {
            if let Def::Class(c) = def {
                for method in &c.methods {
                    walk_kind_mismatch(&method.ty, &c.param)
                        .map_err(|()| WorkspaceLoadError::KindMismatch {
                            class: c.name.clone(),
                            param: c.param.clone(),
                            method: method.name.clone(),
                            defining_module: mod_name.clone(),
                        })?;
                }
                if let Some(sc) = &c.superclass {
                    if sc.type_ != c.param {
                        return Err(WorkspaceLoadError::InvalidSuperclassParam {
                            class: c.name.clone(),
                            superclass: sc.class.clone(),
                            expected_param: c.param.clone(),
                            got_type: sc.type_.clone(),
                        });
                    }
                }
                for method in &c.methods {
                    if let Type::Forall { vars, constraints, .. } = &method.ty {
                        let mut bound: BTreeSet<&str> =
                            vars.iter().map(String::as_str).collect();
                        bound.insert(c.param.as_str());
                        for constr in constraints {
                            if let Type::Var { name } = &constr.type_ {
                                if !bound.contains(name.as_str()) {
                                    return Err(WorkspaceLoadError::UnboundConstraintTypeVar {
                                        class: c.name.clone(),
                                        method: method.name.clone(),
                                        constraint_class: constr.class.clone(),
                                        var: name.clone(),
                                    });
                                }
                            }
                        }
                    }
                }
            }
        }
    }
    Ok(())
}

/// Walks a `Type` looking for any `Type::Con { name == param, args
/// non-empty }`. The class param is kind `*`; appearing as a
/// constructor with arguments is a kind-mismatch.
fn walk_kind_mismatch(t: &Type, param: &str) -> Result<(), ()> {
    match t {
        Type::Con { name, args } => {
            if name == param && !args.is_empty() {
                return Err(());
            }
            for a in args {
                walk_kind_mismatch(a, param)?;
            }
            Ok(())
        }
        Type::Fn { params, ret, .. } => {
            for p in params {
                walk_kind_mismatch(p, param)?;
            }
            walk_kind_mismatch(ret, param)
        }
        Type::Forall { body, .. } => walk_kind_mismatch(body, param),
        Type::Var { .. } => Ok(()),
    }
}

/// Iter 22b.1: extract the head-constructor name of a type, for the
/// "where is this type defined" lookup and for diagnostic-message
/// rendering.
///
/// For `Type::Con { name, .. }` (the only legal head shape for a
/// non-orphan instance) returns `name`. Other variants (`Var`,
/// `Forall`, `Fn`) are not legal as instance heads — Decision 11
/// requires a concrete type expression at the instance head — so we
/// emit a stable fallback string. The fallback prevents diagnostic
/// rendering from panicking on a malformed fixture; the "real"
/// rejection of non-concrete instance heads will arrive as a
/// schema-validation diagnostic in 22b.2.
fn type_head_name(t: &Type) -> String {
    match t {
        Type::Con { name, .. } => name.clone(),
        Type::Var { name } => format!("<var:{name}>"),
        Type::Forall { .. } => "<forall>".into(),
        Type::Fn { .. } => "<fn>".into(),
    }
}

fn visit(
    module: Module,
    root_dir: &Path,
    modules: &mut BTreeMap<String, Module>,
    visiting: &mut Vec<String>,
    visiting_set: &mut HashSet<String>,
) -> Result<(), WorkspaceLoadError> {
    let name = module.name.clone();

    // Already fully loaded? Then do nothing. Hash consistency is checked
    // on re-encounter via imports (see below in the loop).
    if modules.contains_key(&name) {
        return Ok(());
    }

    // Cycle: same name currently on the DFS stack.
    if visiting_set.contains(&name) {
        let mut path = visiting.clone();
        path.push(name);
        return Err(WorkspaceLoadError::Cycle { path });
    }

    visiting.push(name.clone());
    visiting_set.insert(name.clone());

    // Process imports recursively.
    let imports = module.imports.clone();
    for imp in &imports {
        let imp_path = root_dir.join(format!("{}.ail.json", imp.module));

        if let Some(existing) = modules.get(&imp.module) {
            // Already fully loaded — check hash consistency, in case the
            // file on disk has changed.
            let on_disk = match load_one(&imp_path) {
                Ok(m) => m,
                Err(WorkspaceLoadError::Io { .. }) => continue,
                Err(e) => return Err(e),
            };
            if module_hash(existing) != module_hash(&on_disk) {
                return Err(WorkspaceLoadError::ModuleHashMismatch {
                    name: imp.module.clone(),
                });
            }
            continue;
        }

        if visiting_set.contains(&imp.module) {
            let mut path = visiting.clone();
            path.push(imp.module.clone());
            return Err(WorkspaceLoadError::Cycle { path });
        }

        if !imp_path.exists() {
            return Err(WorkspaceLoadError::ModuleNotFound {
                name: imp.module.clone(),
                expected_path: imp_path,
            });
        }

        let imported = load_one(&imp_path)?;
        if imported.name != imp.module {
            return Err(WorkspaceLoadError::ModuleNameMismatch {
                name_in_file: imported.name,
                name_from_path: imp.module.clone(),
            });
        }
        visit(imported, root_dir, modules, visiting, visiting_set)?;
    }

    visiting.pop();
    visiting_set.remove(&name);
    modules.insert(name, module);

    Ok(())
}

fn load_one(path: &Path) -> Result<Module, WorkspaceLoadError> {
    match load_module(path) {
        Ok(m) => Ok(m),
        Err(CoreError::Io(e)) => Err(WorkspaceLoadError::Io {
            path: path.to_path_buf(),
            source: e,
        }),
        Err(e) => Err(WorkspaceLoadError::Schema {
            path: path.to_path_buf(),
            source: e,
        }),
    }
}

fn module_name_from_path(p: &Path) -> String {
    let file = p.file_name().and_then(|s| s.to_str()).unwrap_or("");
    // Convention: `<name>.ail.json`.
    if let Some(stripped) = file.strip_suffix(".ail.json") {
        return stripped.to_string();
    }
    // Fallback: strip only the last extension.
    Path::new(file)
        .file_stem()
        .and_then(|s| s.to_str())
        .unwrap_or(file)
        .to_string()
}

#[cfg(test)]
mod tests {
    use super::*;
    use std::fs;

    fn write_module(dir: &Path, name: &str, imports: &[&str]) -> PathBuf {
        let imports_json: Vec<serde_json::Value> = imports
            .iter()
            .map(|m| serde_json::json!({ "module": m }))
            .collect();
        let module = serde_json::json!({
            "schema": crate::SCHEMA,
            "name": name,
            "imports": imports_json,
            "defs": [],
        });
        let path = dir.join(format!("{name}.ail.json"));
        fs::write(&path, serde_json::to_vec_pretty(&module).unwrap()).unwrap();
        path
    }

    fn tmp_dir(tag: &str) -> PathBuf {
        let d = std::env::temp_dir().join(format!(
            "ailang_workspace_test_{tag}_{}",
            std::process::id()
        ));
        let _ = fs::remove_dir_all(&d);
        fs::create_dir_all(&d).unwrap();
        d
    }

    #[test]
    fn loads_example_workspace_happy_path() {
        // Uses the canonical example files under `examples/`. This
        // test documents that the loader relies on the committed
        // workspace example.
        let manifest_dir = env!("CARGO_MANIFEST_DIR");
        let workspace_root =
            Path::new(manifest_dir).parent().unwrap().parent().unwrap();
        let entry = workspace_root.join("examples").join("ws_main.ail.json");

        let ws = load_workspace(&entry).expect("load workspace");
        assert_eq!(ws.entry, "ws_main");
        assert!(ws.modules.contains_key("ws_main"));
        assert!(ws.modules.contains_key("ws_lib"));
        assert_eq!(ws.modules.len(), 2);
    }

    #[test]
    fn detects_import_cycle() {
        let dir = tmp_dir("cycle");
        write_module(&dir, "a", &["b"]);
        write_module(&dir, "b", &["a"]);
        let entry = dir.join("a.ail.json");

        let err = load_workspace(&entry).expect_err("must error on cycle");
        match err {
            WorkspaceLoadError::Cycle { path } => {
                assert!(path.contains(&"a".to_string()));
                assert!(path.contains(&"b".to_string()));
            }
            other => panic!("expected Cycle, got {other:?}"),
        }
    }

    #[test]
    fn module_not_found_yields_structured_error() {
        let dir = tmp_dir("notfound");
        write_module(&dir, "main", &["does_not_exist"]);
        let entry = dir.join("main.ail.json");

        let err = load_workspace(&entry).expect_err("must error on missing module");
        match err {
            WorkspaceLoadError::ModuleNotFound { name, expected_path } => {
                assert_eq!(name, "does_not_exist");
                assert!(expected_path.ends_with("does_not_exist.ail.json"));
            }
            other => panic!("expected ModuleNotFound, got {other:?}"),
        }
    }

    /// Iter 22b.1: a workspace whose modules contain no
    /// `Def::Instance` defs has an empty registry. This is the
    /// happy-path baseline for the registry-build pass — every
    /// pre-22b workspace falls into this case.
    #[test]
    fn iter22b1_workspace_with_no_classes_has_empty_registry() {
        let manifest_dir = env!("CARGO_MANIFEST_DIR");
        let workspace_root =
            Path::new(manifest_dir).parent().unwrap().parent().unwrap();
        let entry = workspace_root.join("examples").join("sum.ail.json");

        let ws = load_workspace(&entry).expect("sum.ail.json loads");
        assert!(
            ws.registry.entries.is_empty(),
            "pre-22b fixture has no class/instance defs, registry must be empty"
        );
    }

    fn examples_dir() -> PathBuf {
        let manifest_dir = env!("CARGO_MANIFEST_DIR");
        Path::new(manifest_dir)
            .parent()
            .unwrap()
            .parent()
            .unwrap()
            .join("examples")
    }

    /// Iter 22b.1: a coherent instance (in the class's module)
    /// loads cleanly and produces one registry entry.
    #[test]
    fn iter22b1_instance_in_class_module_loads_clean() {
        let entry = examples_dir().join("test_22b1_orphan_class.ail.json");
        let ws = load_workspace(&entry).expect("coherent instance loads");
        assert_eq!(ws.registry.entries.len(), 1);
        let (key, entry) = ws.registry.entries.iter().next().unwrap();
        assert_eq!(&key.0, "Show");
        assert_eq!(entry.defining_module, "test_22b1_orphan_class");
        assert_eq!(entry.instance.class, "Show");
    }

    /// Iter 22b.1: an instance declared in a module that is neither
    /// the class's module nor the type's module fires `OrphanInstance`.
    /// The fixture imports `test_22b1_orphan_third_classmod` (which
    /// owns `class Show`) and the entry module declares `instance
    /// Show Int` itself — but the entry is not the class's module
    /// and `Int` is primitive, so neither leg of coherence is
    /// satisfied.
    #[test]
    fn iter22b1_orphan_instance_fires_diagnostic() {
        let entry = examples_dir().join("test_22b1_orphan_third.ail.json");
        let err = load_workspace(&entry).expect_err("must fire orphan");
        match err {
            WorkspaceLoadError::OrphanInstance {
                class,
                type_repr,
                defining_module,
                ..
            } => {
                assert_eq!(class, "Show");
                assert_eq!(type_repr, "Int");
                assert_eq!(defining_module, "test_22b1_orphan_third");
            }
            other => panic!("expected OrphanInstance, got {other:?}"),
        }
    }

    /// Iter 22b.1: two instances of the same `(class, type)` pair
    /// declared from coherent positions (one in the class's module,
    /// one in the type's module) collide on the registry's
    /// uniqueness check. Setup per the JOURNAL hint:
    /// - module A defines `class Show` and declares
    ///   `instance Show MyInt` (legal, A is class's module).
    /// - module B defines `type MyInt` and declares
    ///   `instance Show MyInt` (legal, B is type's module).
    /// - entry module imports both A and B.
    /// The build_registry pass sees both instances under the same
    /// `(Show, hash-of-MyInt)` key and fires `DuplicateInstance`.
    #[test]
    fn iter22b1_duplicate_instance_fires_diagnostic() {
        let entry = examples_dir().join("test_22b1_dup_entry.ail.json");
        let err = load_workspace(&entry).expect_err("must fire duplicate");
        match err {
            WorkspaceLoadError::DuplicateInstance {
                class,
                type_repr,
                first_module,
                second_module,
            } => {
                assert_eq!(class, "Show");
                assert_eq!(type_repr, "MyInt");
                assert_ne!(first_module, second_module);
                let modules: BTreeSet<&str> =
                    [first_module.as_str(), second_module.as_str()].iter().copied().collect();
                assert!(modules.contains("test_22b1_dup_a"));
                assert!(modules.contains("test_22b1_dup_b"));
            }
            other => panic!("expected DuplicateInstance, got {other:?}"),
        }
    }

    /// Iter 22b.1: an instance that omits a required (non-default)
    /// method of its class fires `MissingMethod`. The fixture's
    /// `class Eq` declares `eq` and `ne` as both non-default; the
    /// instance only specifies `ne`, leaving `eq` missing.
    #[test]
    fn iter22b1_missing_method_fires_diagnostic() {
        let entry = examples_dir().join("test_22b1_missing_method.ail.json");
        let err = load_workspace(&entry).expect_err("must fire missing-method");
        match err {
            WorkspaceLoadError::MissingMethod {
                class,
                type_repr,
                method,
            } => {
                assert_eq!(class, "Eq");
                assert_eq!(type_repr, "Int");
                assert_eq!(method, "eq");
            }
            other => panic!("expected MissingMethod, got {other:?}"),
        }
    }

    /// Iter 22b.2: a class whose parameter `f` appears in applied
    /// position (`Type::Con { name == "f", args.len() > 0 }`) inside
    /// a method signature must fire `KindMismatch`. Decision 11 axis
    /// 5 forbids HKTs — class params are kind `*` only, never
    /// constructors.
    #[test]
    fn class_param_in_applied_position_fires_kind_mismatch() {
        let entry = std::path::PathBuf::from(
            "../../examples/test_22b2_kind_mismatch.ail.json",
        );
        let err = load_workspace(&entry)
            .expect_err("must fire kind-mismatch");
        match err {
            WorkspaceLoadError::KindMismatch { class, param, method, .. } => {
                assert_eq!(class, "Functor");
                assert_eq!(param, "f");
                assert_eq!(method, "fmap");
            }
            other => panic!("expected KindMismatch, got {other:?}"),
        }
    }

    /// Iter 22b.2: a class whose `superclass.type` differs from its
    /// own `param` (e.g. `class Ord a extends Eq b`) must fire
    /// `InvalidSuperclassParam`. Decision 11 axis 1 ("single
    /// superclass, applied to the same param") makes the only legal
    /// shape `extends Eq a` when the parent class has `param: "a"`.
    #[test]
    fn superclass_with_wrong_param_fires_invalid_superclass_param() {
        let entry = std::path::PathBuf::from(
            "../../examples/test_22b2_invalid_superclass_param.ail.json",
        );
        let err = load_workspace(&entry)
            .expect_err("must fire invalid-superclass-param");
        match err {
            WorkspaceLoadError::InvalidSuperclassParam {
                class, superclass, expected_param, got_type,
            } => {
                assert_eq!(class, "Ord");
                assert_eq!(superclass, "Eq");
                assert_eq!(expected_param, "a");
                assert_eq!(got_type, "b");
            }
            other => panic!("expected InvalidSuperclassParam, got {other:?}"),
        }
    }

    /// Iter 22b.2: an instance that specifies a body for a method
    /// name the class never declared must fire
    /// `OverridingNonExistentMethod`. Symmetric counterpart to
    /// `MissingMethod`: the latter fires when the class declares a
    /// non-default method that the instance omits; this one fires
    /// when the instance provides a body the class did not ask for.
    /// Decision 11 forbids ad-hoc additions to a class's method set
    /// at the instance site.
    #[test]
    fn instance_overriding_nonexistent_method_fires() {
        let entry = std::path::PathBuf::from(
            "../../examples/test_22b2_overriding_nonexistent.ail.json",
        );
        let err = load_workspace(&entry)
            .expect_err("must fire overriding-non-existent-method");
        match err {
            WorkspaceLoadError::OverridingNonExistentMethod {
                class, type_repr, method,
            } => {
                assert_eq!(class, "Eq");
                assert_eq!(type_repr, "Int");
                assert_eq!(method, "ne");
            }
            other => panic!("expected OverridingNonExistentMethod, got {other:?}"),
        }
    }

    /// Iter 22b.2: a class method's `Type::Forall` whose `constraints`
    /// reference a type variable that is neither bound by `Forall.vars`
    /// nor equal to the class's `param` must fire
    /// `UnboundConstraintTypeVar`. The fixture's `class Foo a` declares
    /// method `foo` with `forall a. (Bar z) => a -> Unit`; `z` is
    /// bound nowhere, so the constraint is unsatisfiable by
    /// construction.
    #[test]
    fn constraint_with_unbound_var_fires_unbound_constraint_type_var() {
        let entry = std::path::PathBuf::from(
            "../../examples/test_22b2_unbound_constraint_var.ail.json",
        );
        let err = load_workspace(&entry)
            .expect_err("must fire constraint-references-unbound-type-var");
        match err {
            WorkspaceLoadError::UnboundConstraintTypeVar {
                class, method, var, ..
            } => {
                assert_eq!(class, "Foo");
                assert_eq!(method, "foo");
                assert_eq!(var, "z");
            }
            other => panic!("expected UnboundConstraintTypeVar, got {other:?}"),
        }
    }

    /// Iter 22b.2: two classes that declare a method with the same
    /// name must fire `MethodNameCollision` with `kind == "class-class"`.
    /// Decision 11 keeps method names workspace-unique so that bare
    /// method-name resolution (`foo x` rather than `A.foo x`) is
    /// unambiguous; if two classes both export `foo`, the registry has
    /// no way to choose between them at a use site.
    #[test]
    fn class_class_method_name_collision_fires() {
        let entry = examples_dir()
            .join("test_22b2_method_name_collision_class_class.ail.json");
        let err = load_workspace(&entry)
            .expect_err("must fire method-name-collision");
        match err {
            WorkspaceLoadError::MethodNameCollision {
                method,
                kind,
                first_origin,
                second_origin,
            } => {
                assert_eq!(method, "foo");
                assert_eq!(kind, "class-class");
                assert!(
                    first_origin.starts_with("class A"),
                    "first_origin = {first_origin:?}",
                );
                assert!(
                    second_origin.starts_with("class B"),
                    "second_origin = {second_origin:?}",
                );
            }
            other => panic!("expected MethodNameCollision, got {other:?}"),
        }
    }

    /// Iter 22b.2: a class-method name that collides with a top-level
    /// `fn` of the same name must fire `MethodNameCollision` with
    /// `kind == "class-fn"`. Same rationale as the class-class case:
    /// bare-name resolution must be unambiguous, and a class method
    /// shadowing (or being shadowed by) a free function silently is
    /// the worst possible failure mode.
    #[test]
    fn class_fn_method_name_collision_fires() {
        let entry = examples_dir()
            .join("test_22b2_method_name_collision_class_fn.ail.json");
        let err = load_workspace(&entry)
            .expect_err("must fire method-name-collision");
        match err {
            WorkspaceLoadError::MethodNameCollision {
                method,
                kind,
                first_origin,
                second_origin,
            } => {
                assert_eq!(method, "greet");
                assert_eq!(kind, "class-fn");
                assert!(
                    first_origin.starts_with("class Greet"),
                    "first_origin = {first_origin:?}",
                );
                assert!(
                    second_origin.starts_with("fn greet"),
                    "second_origin = {second_origin:?}",
                );
            }
            other => panic!("expected MethodNameCollision, got {other:?}"),
        }
    }

    /// Iter 22b.2: an instance `C T` whose class `C` declares a
    /// superclass `S` requires that `instance S T` also exist in the
    /// workspace. The fixture declares `class Eq a`, `class Ord a
    /// extends Eq a`, and `instance Ord Int` — but no `instance Eq
    /// Int` — so registry build must fire
    /// `MissingSuperclassInstance`. Decision 11 single-superclass
    /// model requires `instance S T` whenever `instance C T` exists.
    #[test]
    fn instance_without_superclass_instance_fires() {
        let entry = examples_dir().join("test_22b2_missing_superclass_instance.ail.json");
        let err = load_workspace(&entry)
            .expect_err("must fire missing-superclass-instance");
        match err {
            WorkspaceLoadError::MissingSuperclassInstance {
                class, superclass, type_repr,
            } => {
                assert_eq!(class, "Ord");
                assert_eq!(superclass, "Eq");
                assert_eq!(type_repr, "Int");
            }
            other => panic!("expected MissingSuperclassInstance, got {other:?}"),
        }
    }
}
