//! Project discovery + the cdylib load boundary (cycle 0102, C13/C16).
//!
//! `discover_from` walks up to the nearest `Aura.toml` (the way cargo finds
//! `Cargo.toml`); `load` locates the project's cdylib via `cargo metadata`,
//! loads it **load-and-hold** (leaked, never unloaded), validates the C tier
//! of the descriptor BEFORE touching any Rust-ABI field, then applies the
//! vocabulary charter. `Env` is the per-invocation context every verb reads:
//! merged resolver, runs root, data path, provenance.

use aura_core::PrimitiveBuilder;
use aura_core::project::{
    AURA_DESCRIPTOR_MAGIC, AURA_DESCRIPTOR_VERSION, AURA_PROJECT_SYMBOL,
    CORE_VERSION, ProjectDescriptor, RUSTC_VERSION, StrSlice,
};
use aura_engine::ProjectProvenance;
use aura_registry::{Registry, TraceStore};
use aura_std::{std_vocabulary, std_vocabulary_types};
use sha2::{Digest, Sha256};
use std::fmt;
use std::path::{Path, PathBuf};
use std::time::SystemTime;

/// Parsed `Aura.toml` — static project context only (C17): paths, nothing else.
#[derive(Debug, Default, PartialEq, serde::Deserialize)]
pub struct AuraToml {
    #[serde(default)]
    pub paths: AuraPaths,
    #[serde(default)]
    pub nodes: AuraNodes,
}

#[derive(Debug, Default, PartialEq, serde::Deserialize)]
pub struct AuraPaths {
    /// Data archive root; default: the data-server default.
    #[serde(default)]
    pub data: Option<String>,
    /// Registry root, relative to the project root; default `"runs"`.
    #[serde(default)]
    pub runs: Option<PathBuf>,
}

/// Node-crate pointers (C17: static context). Paths resolve relative to the
/// project root (`Path::join`, so absolute paths pass through). The format
/// carries a list; this iteration loads exactly one entry and refuses more.
#[derive(Debug, Default, PartialEq, serde::Deserialize)]
pub struct AuraNodes {
    #[serde(default)]
    pub crates: Vec<String>,
}

#[derive(Debug)]
pub enum ProjectError {
    Toml(PathBuf, String),
    CargoMetadata(String),
    ArtifactMissing(PathBuf),
    Load(PathBuf, String),
    NotAProjectDylib(PathBuf),
    Incompatible { what: &'static str, dylib: String, host: String },
    Charter(String),
    MultiCrate(usize),
    NodesPointer { pointer: String, inner: Box<ProjectError> },
    /// #245: the `[nodes]` pointer target does not exist — checked before
    /// handing off to `load_crate`, so this never reaches the cargo-metadata
    /// probe (whose raw os-error text would otherwise leak through).
    PointerDirMissing(PathBuf),
}

impl fmt::Display for ProjectError {
    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
        match self {
            Self::Toml(p, e) => write!(f, "failed to parse {}: {e}", p.display()),
            Self::CargoMetadata(e) => write!(f, "cargo metadata failed: {e}"),
            Self::ArtifactMissing(p) => write!(
                f,
                "project dylib not found at {} — run `cargo build` in the \
                 project first (or pass --release to load the release build)",
                p.display()
            ),
            Self::Load(p, e) => write!(f, "failed to load {}: {e}", p.display()),
            Self::NotAProjectDylib(p) => write!(
                f,
                "{} exports no valid AURA_PROJECT descriptor (not an aura \
                 project dylib, or an incompatible descriptor layout)",
                p.display()
            ),
            Self::Incompatible { what, dylib, host } => write!(
                f,
                "project dylib is ABI-incompatible: {what} mismatch \
                 (dylib: {dylib}, host: {host}) — rebuild the project with \
                 the host's toolchain/aura-core"
            ),
            Self::Charter(e) => write!(f, "project vocabulary rejected: {e}"),
            Self::MultiCrate(n) => write!(
                f,
                "multi-crate loading is not yet supported (found {n} entries \
                 in [nodes].crates — only one is accepted)"
            ),
            Self::NodesPointer { pointer, inner } => write!(
                f,
                "node crate at `{pointer}` (from [nodes] in Aura.toml): {inner}"
            ),
            Self::PointerDirMissing(p) => write!(f, "{} does not exist", p.display()),
        }
    }
}

/// A discovered project. `native` is present only when a node crate is
/// loaded (the crate's `Library` is leaked — load-and-hold, 'static).
#[derive(Debug)]
pub struct ProjectEnv {
    pub root: PathBuf,
    pub toml: AuraToml,
    pub commit: Option<String>,
    pub native: Option<NativeEnv>,
}

/// The loaded node crate: vocabulary + identity stamps.
#[derive(Debug)]
pub struct NativeEnv {
    pub namespace: String,
    pub dylib_sha256: String,
    resolver: fn(&str) -> Option<PrimitiveBuilder>,
    type_id_list: &'static [&'static str],
}

impl ProjectEnv {
    fn resolve(&self, type_id: &str) -> Option<PrimitiveBuilder> {
        self.native.as_ref().and_then(|n| (n.resolver)(type_id))
    }
}

/// The per-invocation context every verb reads. Outside a project every
/// accessor collapses to today's literal defaults (byte-identical behaviour).
pub struct Env {
    project: Option<ProjectEnv>,
}

impl Env {
    pub fn std() -> Self {
        Self { project: None }
    }
    pub fn with_project(p: ProjectEnv) -> Self {
        Self { project: Some(p) }
    }

    /// The one resolver every verb consumes: project first, then std. The
    /// charter makes overlap impossible, so order is not load-bearing.
    pub fn resolve(&self, type_id: &str) -> Option<PrimitiveBuilder> {
        match &self.project {
            Some(p) => p.resolve(type_id).or_else(|| std_vocabulary(type_id)),
            None => std_vocabulary(type_id),
        }
    }

    /// project ∪ std type ids, project first (for `introspect --vocabulary`).
    pub fn type_ids(&self) -> Vec<&'static str> {
        let mut out: Vec<&'static str> = Vec::new();
        if let Some(n) = self.project.as_ref().and_then(|p| p.native.as_ref()) {
            out.extend_from_slice(n.type_id_list);
        }
        out.extend_from_slice(std_vocabulary_types());
        out
    }

    /// The registry root: `<project>/<paths.runs|"runs">` inside a project,
    /// the literal `runs` outside (today's behaviour).
    pub fn runs_root(&self) -> PathBuf {
        match &self.project {
            Some(p) => {
                let runs = p.toml.paths.runs.clone().unwrap_or_else(|| "runs".into());
                p.root.join(runs)
            }
            None => PathBuf::from("runs"),
        }
    }

    pub fn registry(&self) -> Registry {
        Registry::open(self.runs_root().join("runs.jsonl"))
    }

    pub fn trace_store(&self) -> TraceStore {
        TraceStore::open(self.runs_root())
    }

    /// The data archive root: `paths.data` inside a project when set
    /// (relative values resolved against the project root, same as
    /// `runs_root()` and the `[nodes]` pointers), the data-server default
    /// otherwise.
    pub fn data_path(&self) -> String {
        self.project
            .as_ref()
            .and_then(|p| {
                p.toml.paths.data.as_ref().map(|data| {
                    let path = Path::new(data);
                    if path.is_absolute() {
                        data.clone()
                    } else {
                        p.root.join(path).to_string_lossy().to_string()
                    }
                })
            })
            .unwrap_or_else(|| data_server::DEFAULT_DATA_PATH.to_string())
    }

    pub fn provenance(&self) -> Option<ProjectProvenance> {
        self.project.as_ref().map(|p| ProjectProvenance {
            namespace: p.native.as_ref().map(|n| n.namespace.clone()),
            dylib_sha256: p.native.as_ref().map(|n| n.dylib_sha256.clone()),
            commit: p.commit.clone(),
        })
    }
}

/// Walk up from `start` to the nearest directory containing `Aura.toml`.
pub fn discover_from(start: &Path) -> Option<PathBuf> {
    let mut dir = Some(start);
    while let Some(d) = dir {
        if d.join("Aura.toml").is_file() {
            return Some(d.to_path_buf());
        }
        dir = d.parent();
    }
    None
}

fn parse_aura_toml(root: &Path) -> Result<AuraToml, ProjectError> {
    let path = root.join("Aura.toml");
    let text = std::fs::read_to_string(&path)
        .map_err(|e| ProjectError::Toml(path.clone(), e.to_string()))?;
    toml::from_str(&text).map_err(|e| ProjectError::Toml(path, e.to_string()))
}

/// Read a project's `Aura.toml` (for scaffold-side tooling that must not
/// trigger a crate load, e.g. `aura nodes new`).
pub fn read_aura_toml(root: &Path) -> Result<AuraToml, ProjectError> {
    parse_aura_toml(root)
}

/// Derive the cdylib artifact path from `cargo metadata` output (pure —
/// unit-testable on a canned JSON document).
fn artifact_from_metadata(
    metadata_json: &str,
    release: bool,
) -> Result<PathBuf, ProjectError> {
    let v: serde_json::Value = serde_json::from_str(metadata_json)
        .map_err(|e| ProjectError::CargoMetadata(e.to_string()))?;
    let target_dir = v["target_directory"]
        .as_str()
        .ok_or_else(|| ProjectError::CargoMetadata("no target_directory".into()))?;
    let packages = v["packages"]
        .as_array()
        .ok_or_else(|| ProjectError::CargoMetadata("no packages".into()))?;
    let lib_name = packages
        .iter()
        .flat_map(|p| p["targets"].as_array().into_iter().flatten())
        .find(|t| {
            t["kind"]
                .as_array()
                .is_some_and(|k| k.iter().any(|s| s.as_str() == Some("cdylib")))
        })
        .and_then(|t| t["name"].as_str())
        .ok_or_else(|| {
            ProjectError::CargoMetadata(
                "no cdylib target in the project crate (is `crate-type = \
                 [\"cdylib\"]` set?)"
                    .into(),
            )
        })?;
    let profile = if release { "release" } else { "debug" };
    let file = format!(
        "{}{}{}",
        std::env::consts::DLL_PREFIX,
        lib_name.replace('-', "_"),
        std::env::consts::DLL_SUFFIX
    );
    Ok(Path::new(target_dir).join(profile).join(file))
}

fn artifact_path(root: &Path, release: bool) -> Result<PathBuf, ProjectError> {
    let out = std::process::Command::new("cargo")
        .args(["metadata", "--format-version", "1", "--no-deps"])
        .current_dir(root)
        .output()
        .map_err(|e| ProjectError::CargoMetadata(e.to_string()))?;
    if !out.status.success() {
        return Err(ProjectError::CargoMetadata(
            String::from_utf8_lossy(&out.stderr).trim().to_string(),
        ));
    }
    artifact_from_metadata(&String::from_utf8_lossy(&out.stdout), release)
}

/// The vocabulary charter (decision log on the milestone reference issue):
/// non-empty namespace; every listed id `\<ns\>::`-prefixed; no duplicate in
/// the merged project ∪ std set; every listed id resolves (list↔resolver
/// cross-check). Pure — unit-testable without a dylib.
fn check_charter(
    namespace: &str,
    ids: &[&str],
    resolve: &dyn Fn(&str) -> Option<PrimitiveBuilder>,
) -> Result<(), ProjectError> {
    if namespace.is_empty() {
        return Err(ProjectError::Charter("empty namespace".into()));
    }
    let prefix = format!("{namespace}::");
    let mut seen = std::collections::BTreeSet::new();
    for id in ids {
        if !id.starts_with(&prefix) {
            return Err(ProjectError::Charter(format!(
                "type id `{id}` lacks the project prefix `{prefix}`"
            )));
        }
        if !seen.insert(*id) {
            return Err(ProjectError::Charter(format!("duplicate type id `{id}`")));
        }
        if std_vocabulary(id).is_some() || std_vocabulary_types().contains(id) {
            return Err(ProjectError::Charter(format!(
                "type id `{id}` collides with the std vocabulary"
            )));
        }
        if resolve(id).is_none() {
            return Err(ProjectError::Charter(format!(
                "listed type id `{id}` does not resolve through the project \
                 resolver (list/resolver drift)"
            )));
        }
    }
    Ok(())
}

fn project_commit(root: &Path) -> Option<String> {
    let head = std::process::Command::new("git")
        .args(["-C"])
        .arg(root)
        .args(["rev-parse", "HEAD"])
        .output()
        .ok()
        .filter(|o| o.status.success())?;
    let mut sha = String::from_utf8_lossy(&head.stdout).trim().to_string();
    let dirty = std::process::Command::new("git")
        .args(["-C"])
        .arg(root)
        .args(["status", "--porcelain"])
        .output()
        .ok()
        .filter(|o| o.status.success())?;
    if !dirty.stdout.is_empty() {
        sha.push_str("-dirty");
    }
    Some(sha)
}

/// Validate the C tier of a descriptor's already-extracted fields — pure
/// value checks, no dylib/pointer work (the raw reads happen once, in
/// `load`, before this runs). Front-to-back: magic, descriptor version,
/// rustc stamp, aura-core stamp, then the namespace. Returns the namespace
/// on success. Unit-testable without a dylib by constructing the fields
/// directly, the same way `check_charter` is testable without a resolver.
fn validate_c_tier(
    magic: u64,
    descriptor_version: u32,
    rustc_version: StrSlice,
    aura_core_version: StrSlice,
    namespace: StrSlice,
    dylib_path: &Path,
) -> Result<String, ProjectError> {
    if magic != AURA_DESCRIPTOR_MAGIC {
        return Err(ProjectError::NotAProjectDylib(dylib_path.to_path_buf()));
    }
    if descriptor_version != AURA_DESCRIPTOR_VERSION {
        return Err(ProjectError::Incompatible {
            what: "descriptor version",
            dylib: descriptor_version.to_string(),
            host: AURA_DESCRIPTOR_VERSION.to_string(),
        });
    }
    let dylib_rustc = unsafe { rustc_version.as_str() }
        .ok_or_else(|| ProjectError::NotAProjectDylib(dylib_path.to_path_buf()))?;
    if dylib_rustc != RUSTC_VERSION {
        return Err(ProjectError::Incompatible {
            what: "rustc version",
            dylib: dylib_rustc.to_string(),
            host: RUSTC_VERSION.to_string(),
        });
    }
    let dylib_core = unsafe { aura_core_version.as_str() }
        .ok_or_else(|| ProjectError::NotAProjectDylib(dylib_path.to_path_buf()))?;
    if dylib_core != CORE_VERSION {
        return Err(ProjectError::Incompatible {
            what: "aura-core version",
            dylib: dylib_core.to_string(),
            host: CORE_VERSION.to_string(),
        });
    }
    unsafe { namespace.as_str() }
        .ok_or_else(|| ProjectError::NotAProjectDylib(dylib_path.to_path_buf()))
        .map(str::to_string)
}

/// Render a `SystemTime` as a human-readable UTC timestamp with a 4-digit
/// year (`YYYY-MM-DD HH:MM:SS UTC`). Hand-rolled (Howard Hinnant's
/// civil-from-days algorithm) rather than pulling in a date-formatting
/// dependency for this one warning line.
fn format_mtime(t: SystemTime) -> String {
    let secs = t
        .duration_since(std::time::UNIX_EPOCH)
        .map(|d| d.as_secs() as i64)
        .unwrap_or(0);
    let days = secs.div_euclid(86_400);
    let day_secs = secs.rem_euclid(86_400);
    let (y, m, d) = civil_from_days(days);
    format!(
        "{y:04}-{m:02}-{d:02} {:02}:{:02}:{:02} UTC",
        day_secs / 3600,
        (day_secs % 3600) / 60,
        day_secs % 60
    )
}

/// Days-since-epoch -> (year, month, day), UTC civil calendar. See
/// <https://howardhinnant.github.io/date_algorithms.html#civil_from_days>.
fn civil_from_days(z: i64) -> (i64, u32, u32) {
    let z = z + 719_468;
    let era = if z >= 0 { z } else { z - 146_096 } / 146_097;
    let doe = (z - era * 146_097) as u64; // [0, 146096]
    let yoe = (doe - doe / 1460 + doe / 36_524 - doe / 146_096) / 365; // [0, 399]
    let y = yoe as i64 + era * 400;
    let doy = doe - (365 * yoe + yoe / 4 - yoe / 100); // [0, 365]
    let mp = (5 * doy + 2) / 153; // [0, 11]
    let d = (doy - (153 * mp + 2) / 5 + 1) as u32; // [1, 31]
    let m = if mp < 10 { mp + 3 } else { mp - 9 } as u32; // [1, 12]
    (if m <= 2 { y + 1 } else { y }, m, d)
}

/// The role-2 authoring loop's staleness check (#237): if `source_mtime` is
/// strictly newer than `dylib_mtime`, a forgotten `cargo build` would run the
/// previous dylib and report plausible-but-stale numbers. Returns the warning
/// line naming both timestamps, or `None` when the dylib is not stale (the
/// only case where this fires — a fresh build stays silent).
fn stale_warning(
    dylib_path: &Path,
    dylib_mtime: SystemTime,
    source_mtime: SystemTime,
) -> Option<String> {
    if source_mtime > dylib_mtime {
        Some(format!(
            "warning: {} may be stale — dylib built {} but a project source \
             file was modified {} (run `cargo build` to refresh); proceeding \
             with the existing dylib",
            dylib_path.display(),
            format_mtime(dylib_mtime),
            format_mtime(source_mtime),
        ))
    } else {
        None
    }
}

/// The newest modification time among the project's `Cargo.toml` and every
/// file under its `src/` tree (the set that, changed, invalidates the built
/// dylib). `None` if neither exists / is readable — never fatal, staleness
/// is a warning, not a refusal.
fn newest_source_mtime(root: &Path) -> Option<SystemTime> {
    let mut newest: Option<SystemTime> = None;
    let mut consider = |t: SystemTime| {
        if newest.is_none_or(|n| t > n) {
            newest = Some(t);
        }
    };
    if let Ok(meta) = std::fs::metadata(root.join("Cargo.toml"))
        && let Ok(t) = meta.modified()
    {
        consider(t);
    }
    let mut stack = vec![root.join("src")];
    while let Some(dir) = stack.pop() {
        let Ok(entries) = std::fs::read_dir(&dir) else { continue };
        for entry in entries.flatten() {
            let path = entry.path();
            match entry.file_type() {
                Ok(ft) if ft.is_dir() => stack.push(path),
                Ok(_) => {
                    if let Ok(t) = entry.metadata().and_then(|m| m.modified()) {
                        consider(t);
                    }
                }
                Err(_) => {}
            }
        }
    }
    newest
}

/// Discover the project's tier and load it. Data-only (no `[nodes]`, no root
/// crate) loads nothing; a `[nodes]` pointer or a root Cargo.toml routes into
/// the unchanged crate pipeline (`load_crate`).
pub fn load(root: &Path, release: bool) -> Result<ProjectEnv, ProjectError> {
    let toml = parse_aura_toml(root)?;
    let native = match toml.nodes.crates.as_slice() {
        [] => {
            if root.join("Cargo.toml").is_file() {
                Some(load_crate(root, release)?)
            } else {
                None
            }
        }
        [pointer] => {
            let dir = root.join(pointer);
            if !dir.is_dir() {
                return Err(ProjectError::NodesPointer {
                    pointer: pointer.clone(),
                    inner: Box::new(ProjectError::PointerDirMissing(dir)),
                });
            }
            Some(load_crate(&dir, release).map_err(|e| ProjectError::NodesPointer {
                pointer: pointer.clone(),
                inner: Box::new(e),
            })?)
        }
        many => return Err(ProjectError::MultiCrate(many.len())),
    };
    Ok(ProjectEnv {
        root: root.to_path_buf(),
        toml,
        commit: project_commit(root),
        native,
    })
}

/// Locate, load (load-and-hold), verify, and charter-check a node crate's
/// dylib.
fn load_crate(crate_root: &Path, release: bool) -> Result<NativeEnv, ProjectError> {
    let dylib_path = artifact_path(crate_root, release)?;
    if !dylib_path.is_file() {
        return Err(ProjectError::ArtifactMissing(dylib_path));
    }
    if let Ok(dylib_mtime) = std::fs::metadata(&dylib_path).and_then(|m| m.modified())
        && let Some(source_mtime) = newest_source_mtime(crate_root)
        && let Some(warning) = stale_warning(&dylib_path, dylib_mtime, source_mtime)
    {
        eprintln!("{warning}");
    }
    let bytes = std::fs::read(&dylib_path)
        .map_err(|e| ProjectError::Load(dylib_path.clone(), e.to_string()))?;
    let dylib_sha256 = format!("{:x}", Sha256::digest(&bytes));

    // Load-and-hold: leak the Library so every pointer read from the
    // descriptor is valid for 'static. There is deliberately no unload path
    // (one-shot process; ledger C13 note).
    let lib = unsafe { libloading::Library::new(&dylib_path) }
        .map_err(|e| ProjectError::Load(dylib_path.clone(), e.to_string()))?;
    let lib: &'static libloading::Library = Box::leak(Box::new(lib));

    let sym = unsafe { lib.get::<*const ProjectDescriptor>(AURA_PROJECT_SYMBOL) }
        .map_err(|_| ProjectError::NotAProjectDylib(dylib_path.clone()))?;
    let desc_ptr: *const ProjectDescriptor = *sym;
    if desc_ptr.is_null() {
        return Err(ProjectError::NotAProjectDylib(dylib_path));
    }

    // ---- C tier, validated front-to-back; no Rust-ABI field touched yet ----
    let magic = unsafe { (*desc_ptr).magic };
    let descriptor_version = unsafe { (*desc_ptr).descriptor_version };
    let rustc_version = unsafe { (*desc_ptr).rustc_version };
    let aura_core_version = unsafe { (*desc_ptr).aura_core_version };
    let namespace_stamp = unsafe { (*desc_ptr).namespace };
    let namespace = validate_c_tier(
        magic,
        descriptor_version,
        rustc_version,
        aura_core_version,
        namespace_stamp,
        &dylib_path,
    )?;

    // ---- Rust tier: stamps match, the ABI is trusted from here on ----
    let desc: &'static ProjectDescriptor = unsafe { &*desc_ptr };
    let resolver = desc.vocabulary;
    let type_id_list = (desc.type_ids)();
    check_charter(&namespace, type_id_list, &|t| resolver(t))?;

    Ok(NativeEnv { namespace, dylib_sha256, resolver, type_id_list })
}

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

    #[test]
    fn discover_walks_up_to_aura_toml() {
        let tmp = std::path::Path::new(concat!(env!("CARGO_MANIFEST_DIR"), "/../../target/tmp"))
            .join("aura-disc");
        let _ = std::fs::remove_dir_all(&tmp);
        let nested = tmp.join("a/b/c");
        std::fs::create_dir_all(&nested).unwrap();
        assert_eq!(discover_from(&nested), None);
        std::fs::write(tmp.join("Aura.toml"), "").unwrap();
        assert_eq!(discover_from(&nested), Some(tmp.clone()));
        assert_eq!(discover_from(&tmp), Some(tmp.clone()));
        std::fs::remove_dir_all(&tmp).unwrap();
    }

    #[test]
    fn aura_toml_parses_a_nodes_pointer_list() {
        let t: AuraToml = toml::from_str("[nodes]\ncrates = [\"../my-nodes\"]").unwrap();
        assert_eq!(t.nodes.crates, vec!["../my-nodes".to_string()]);
        let t: AuraToml = toml::from_str("").unwrap();
        assert!(t.nodes.crates.is_empty());
    }

    /// #241: an Aura.toml with neither a [nodes] pointer nor a root crate is
    /// the data-only tier — it loads with no native env, resolves std-only,
    /// and stamps commit-only provenance.
    #[test]
    fn data_only_project_loads_without_a_crate() {
        let tmp = std::path::Path::new(concat!(env!("CARGO_MANIFEST_DIR"), "/../../target/tmp"))
            .join("aura-dataonly");
        let _ = std::fs::remove_dir_all(&tmp);
        std::fs::create_dir_all(&tmp).unwrap();
        std::fs::write(tmp.join("Aura.toml"), "[paths]\nruns = \"runs\"\n").unwrap();
        let p = load(&tmp, false).expect("data-only load");
        assert!(p.native.is_none());
        let env = Env::with_project(p);
        assert!(env.resolve("SMA").is_some());
        assert!(env.resolve("demo::Identity").is_none());
        assert_eq!(env.runs_root(), tmp.join("runs"));
        let prov = env.provenance().expect("a data-only project is a project");
        assert!(prov.namespace.is_none());
        assert!(prov.dylib_sha256.is_none());
        std::fs::remove_dir_all(&tmp).ok();
    }

    /// #254: a relative `paths.data` must resolve against the project root
    /// (`p.root`), matching `runs_root()`'s and the `[nodes]` pointers'
    /// resolution — not against the invoking process's cwd, which silently
    /// changes the data location depending on the caller's working directory.
    #[test]
    fn data_path_resolves_a_relative_paths_data_against_the_project_root() {
        let tmp = std::path::Path::new(concat!(env!("CARGO_MANIFEST_DIR"), "/../../target/tmp"))
            .join("aura-datarel");
        let _ = std::fs::remove_dir_all(&tmp);
        std::fs::create_dir_all(&tmp).unwrap();
        std::fs::write(tmp.join("Aura.toml"), "[paths]\ndata = \"data\"\n").unwrap();
        let p = load(&tmp, false).expect("data-only load");
        let env = Env::with_project(p);
        assert_eq!(env.data_path(), tmp.join("data").to_string_lossy().to_string());
        std::fs::remove_dir_all(&tmp).ok();
    }

    #[test]
    fn two_nodes_pointers_refuse_with_multi_crate() {
        let tmp = std::path::Path::new(concat!(env!("CARGO_MANIFEST_DIR"), "/../../target/tmp"))
            .join("aura-multi");
        let _ = std::fs::remove_dir_all(&tmp);
        std::fs::create_dir_all(&tmp).unwrap();
        std::fs::write(tmp.join("Aura.toml"), "[nodes]\ncrates = [\"a\", \"b\"]\n").unwrap();
        let e = load(&tmp, false).unwrap_err();
        assert!(e.to_string().contains("multi-crate loading is not yet supported"), "{e}");
        std::fs::remove_dir_all(&tmp).ok();
    }

    #[test]
    fn a_broken_nodes_pointer_names_the_pointer() {
        let tmp = std::path::Path::new(concat!(env!("CARGO_MANIFEST_DIR"), "/../../target/tmp"))
            .join("aura-badptr");
        let _ = std::fs::remove_dir_all(&tmp);
        std::fs::create_dir_all(&tmp).unwrap();
        std::fs::write(tmp.join("Aura.toml"), "[nodes]\ncrates = [\"../nope\"]\n").unwrap();
        let e = load(&tmp, false).unwrap_err();
        let msg = e.to_string();
        assert!(msg.contains("../nope"), "{msg}");
        assert!(msg.contains("[nodes]"), "{msg}");
        std::fs::remove_dir_all(&tmp).ok();
    }

    /// #245: a `[nodes]` pointer at a directory that does not exist refuses
    /// with a clean message — it names the missing directory and keeps the
    /// `[nodes]` pointer context, but does NOT leak the raw `cargo metadata`
    /// toolchain error. That wrap is reserved for a directory that exists but
    /// is not a valid crate; a plainly-absent directory should say exactly
    /// that (`load` still refuses → CLI exit 1).
    #[test]
    fn a_missing_nodes_pointer_dir_refuses_without_the_cargo_metadata_leak() {
        let tmp = std::path::Path::new(concat!(env!("CARGO_MANIFEST_DIR"), "/../../target/tmp"))
            .join("aura-missingptr");
        let _ = std::fs::remove_dir_all(&tmp);
        std::fs::create_dir_all(&tmp).unwrap();
        // `missing-node-crate` is never created — the pointer target is absent.
        std::fs::write(
            tmp.join("Aura.toml"),
            "[nodes]\ncrates = [\"missing-node-crate\"]\n",
        )
        .unwrap();
        let e = load(&tmp, false).unwrap_err();
        let msg = e.to_string();
        // names the offending pointer path ...
        assert!(msg.contains("missing-node-crate"), "names the pointer path: {msg}");
        // ... says plainly that the directory is not there ...
        assert!(msg.contains("does not exist"), "states the directory is absent: {msg}");
        // ... keeps the [nodes] pointer context ...
        assert!(msg.contains("[nodes]"), "keeps the pointer context: {msg}");
        // ... and does not leak the raw toolchain error for a simple absence.
        assert!(
            !msg.contains("cargo metadata"),
            "no cargo-metadata leak for an absent directory: {msg}"
        );
        std::fs::remove_dir_all(&tmp).ok();
    }

    #[test]
    fn aura_toml_parses_empty_partial_and_unknown_keys() {
        let t: AuraToml = toml::from_str("").unwrap();
        assert_eq!(t, AuraToml::default());
        let t: AuraToml = toml::from_str("[paths]\nruns = \"r\"").unwrap();
        assert_eq!(t.paths.runs, Some(PathBuf::from("r")));
        assert_eq!(t.paths.data, None);
        // unknown keys tolerated (forward-compat; serde default is lenient)
        let t: AuraToml = toml::from_str("[future]\nx = 1").unwrap();
        assert_eq!(t, AuraToml::default());
    }

    #[test]
    fn artifact_path_derives_from_metadata_json() {
        let json = r#"{
            "target_directory": "/tmp/proj/target",
            "packages": [{"targets": [
                {"kind": ["cdylib"], "name": "demo-project"}
            ]}]
        }"#;
        let p = artifact_from_metadata(json, false).unwrap();
        let expect = format!(
            "/tmp/proj/target/debug/{}demo_project{}",
            std::env::consts::DLL_PREFIX,
            std::env::consts::DLL_SUFFIX
        );
        assert_eq!(p, PathBuf::from(expect));
        let p = artifact_from_metadata(json, true).unwrap();
        assert!(p.to_string_lossy().contains("/release/"));
        // no cdylib target -> named error
        let bad = r#"{"target_directory":"/t","packages":[{"targets":[{"kind":["lib"],"name":"x"}]}]}"#;
        assert!(matches!(
            artifact_from_metadata(bad, false),
            Err(ProjectError::CargoMetadata(_))
        ));
    }

    fn none_resolver(_: &str) -> Option<PrimitiveBuilder> {
        None
    }

    #[test]
    fn charter_rejects_each_violation_and_accepts_valid() {
        let ok_resolver = |t: &str| {
            if t == "p::A" || t == "p::B" { std_vocabulary("SMA") } else { None }
        };
        // valid
        assert!(check_charter("p", &["p::A", "p::B"], &ok_resolver).is_ok());
        // empty namespace
        assert!(matches!(
            check_charter("", &[], &none_resolver),
            Err(ProjectError::Charter(_))
        ));
        // unprefixed id
        let e = check_charter("p", &["A"], &ok_resolver).unwrap_err();
        assert!(e.to_string().contains("lacks the project prefix"));
        // duplicate id
        let e = check_charter("p", &["p::A", "p::A"], &ok_resolver).unwrap_err();
        assert!(e.to_string().contains("duplicate"));
        // list/resolver drift
        let e = check_charter("p", &["p::C"], &ok_resolver).unwrap_err();
        assert!(e.to_string().contains("does not resolve"));
    }

    fn matching_stamps() -> (u64, u32, StrSlice, StrSlice) {
        (
            AURA_DESCRIPTOR_MAGIC,
            AURA_DESCRIPTOR_VERSION,
            StrSlice::new(RUSTC_VERSION),
            StrSlice::new(CORE_VERSION),
        )
    }

    #[test]
    fn validate_c_tier_accepts_matching_stamps() {
        let (magic, version, rustc, core) = matching_stamps();
        let path = PathBuf::from("/tmp/x.so");
        let ns = validate_c_tier(magic, version, rustc, core, StrSlice::new("demo"), &path)
            .unwrap();
        assert_eq!(ns, "demo");
    }

    /// A magic mismatch means the symbol is not an `AURA_PROJECT` descriptor
    /// at all (foreign dylib) — refused as `NotAProjectDylib`, not
    /// `Incompatible` (there is no known-good stamp to compare against).
    #[test]
    fn validate_c_tier_rejects_magic_mismatch() {
        let (_, version, rustc, core) = matching_stamps();
        let path = PathBuf::from("/tmp/x.so");
        let err = validate_c_tier(0xdead_beef, version, rustc, core, StrSlice::new("demo"), &path)
            .unwrap_err();
        assert!(matches!(err, ProjectError::NotAProjectDylib(_)));
    }

    #[test]
    fn validate_c_tier_rejects_descriptor_version_mismatch() {
        let (magic, _, rustc, core) = matching_stamps();
        let path = PathBuf::from("/tmp/x.so");
        let err = validate_c_tier(magic, 99, rustc, core, StrSlice::new("demo"), &path)
            .unwrap_err();
        assert!(matches!(
            err,
            ProjectError::Incompatible { what: "descriptor version", .. }
        ));
    }

    #[test]
    fn validate_c_tier_rejects_rustc_version_mismatch() {
        let (magic, version, _, core) = matching_stamps();
        let path = PathBuf::from("/tmp/x.so");
        let bad_rustc = StrSlice::new("rustc 0.0.0-fake");
        let err = validate_c_tier(magic, version, bad_rustc, core, StrSlice::new("demo"), &path)
            .unwrap_err();
        assert!(matches!(
            err,
            ProjectError::Incompatible { what: "rustc version", .. }
        ));
    }

    #[test]
    fn validate_c_tier_rejects_aura_core_version_mismatch() {
        let (magic, version, rustc, _) = matching_stamps();
        let path = PathBuf::from("/tmp/x.so");
        let bad_core = StrSlice::new("9.9.9-fake");
        let err = validate_c_tier(magic, version, rustc, bad_core, StrSlice::new("demo"), &path)
            .unwrap_err();
        assert!(matches!(
            err,
            ProjectError::Incompatible { what: "aura-core version", .. }
        ));
    }

    /// A null stamp pointer (e.g. a zeroed/corrupt descriptor) refuses rather
    /// than dereferencing it, same as the standalone `StrSlice::as_str` test
    /// in aura-core — here exercised through the loader's own refusal path.
    #[test]
    fn validate_c_tier_rejects_null_stamp() {
        let (magic, version, _, core) = matching_stamps();
        let path = PathBuf::from("/tmp/x.so");
        let null_rustc = StrSlice { ptr: std::ptr::null(), len: 0 };
        let err = validate_c_tier(magic, version, null_rustc, core, StrSlice::new("demo"), &path)
            .unwrap_err();
        assert!(matches!(err, ProjectError::NotAProjectDylib(_)));
    }

    /// A non-UTF-8 stamp (foreign/corrupt bytes at the pointer) refuses
    /// rather than mangling a comparison or panicking.
    #[test]
    fn validate_c_tier_rejects_non_utf8_stamp() {
        let (magic, version, rustc, _) = matching_stamps();
        let path = PathBuf::from("/tmp/x.so");
        static BAD: [u8; 2] = [0xFF, 0xFE];
        let bad_core = StrSlice { ptr: BAD.as_ptr(), len: BAD.len() };
        let err = validate_c_tier(magic, version, rustc, bad_core, StrSlice::new("demo"), &path)
            .unwrap_err();
        assert!(matches!(err, ProjectError::NotAProjectDylib(_)));
    }

    #[test]
    fn env_std_collapses_to_current_defaults() {
        let env = Env::std();
        assert_eq!(env.runs_root(), PathBuf::from("runs"));
        assert_eq!(env.data_path(), data_server::DEFAULT_DATA_PATH.to_string());
        assert!(env.provenance().is_none());
        assert!(env.resolve("SMA").is_some());
        assert!(env.resolve("demo::Identity").is_none());
        assert!(env.type_ids().contains(&"SMA"));
    }

    /// The role-2 authoring-loop staleness check (#237) is a pure comparison:
    /// a source mtime strictly newer than the dylib's produces a warning
    /// naming both timestamps in human-readable (4-digit-year) form.
    #[test]
    fn stale_warning_fires_and_names_both_timestamps_when_source_is_newer() {
        use std::time::{Duration, UNIX_EPOCH};
        let dylib_mtime = UNIX_EPOCH + Duration::from_secs(993_859_200); // 2001-06-30
        let source_mtime = UNIX_EPOCH + Duration::from_secs(2_003_702_400); // 2033-06-30
        let path = PathBuf::from("/tmp/x.so");
        let msg = stale_warning(&path, dylib_mtime, source_mtime).expect("source is newer");
        assert!(msg.contains("2001"), "names the dylib's mtime: {msg}");
        assert!(msg.contains("2033"), "names the source's newer mtime: {msg}");
    }

    /// The inverse of the above: a fresh (or equally-timed) dylib produces no
    /// warning at all — staleness is the only trigger, never routine builds.
    #[test]
    fn stale_warning_is_silent_when_source_is_not_newer() {
        use std::time::{Duration, UNIX_EPOCH};
        let t = UNIX_EPOCH + Duration::from_secs(1_000_000_000);
        let path = PathBuf::from("/tmp/x.so");
        assert!(stale_warning(&path, t, t).is_none(), "equal mtimes: not stale");
        assert!(
            stale_warning(&path, t, t - Duration::from_secs(10)).is_none(),
            "older source: not stale"
        );
    }
}
