use std::{collections::HashMap, path::Path};

use anyhow::{anyhow, Context, Result};
use serde_json::Value;
use tracing::debug;

use crate::{
    error::OntologyError,
    types::{
        AbstractionLevel, Audience, Campaign, Competitor, CoreConfig, Differentiator,
        DifusionMechanism, Dimension, Edge, GateConfig, Membrane, Node, Permeability,
        PositioningConfig, Proof, StateConfig, TensionLevel, ValueProp, ViabilityPath,
    },
};

/// Full project ontology: core DAG + state FSM + gate membranes.
#[derive(Debug)]
pub struct Ontology {
    pub core: Core,
    pub state: State,
    pub gate: Gate,
}

impl Ontology {
    /// Load all three sections from `ontology_dir/` (core.ncl, state.ncl,
    /// gate.ncl). Each file is exported via `nickel export --format json`.
    ///
    /// Prefer constructing from pre-fetched JSON via [`Core::from_value`],
    /// [`State::from_value`], [`Gate::from_value`] when a daemon or cache
    /// is available.
    #[deprecated(note = "use from_value() constructors with daemon-provided JSON instead")]
    pub fn load(ontology_dir: &Path) -> Result<Self> {
        #[allow(deprecated)]
        {
            let core = Core::load(&ontology_dir.join("core.ncl"))?;
            let state = State::load(&ontology_dir.join("state.ncl"))?;
            let gate = Gate::load(&ontology_dir.join("gate.ncl"))?;
            Ok(Self { core, state, gate })
        }
    }

    /// Construct from pre-fetched JSON values (from stratum-daemon, stratum-db,
    /// or any other source that provides the NCL export output).
    pub fn from_values(core_json: &Value, state_json: &Value, gate_json: &Value) -> Result<Self> {
        Ok(Self {
            core: Core::from_value(core_json)?,
            state: State::from_value(state_json)?,
            gate: Gate::from_value(gate_json)?,
        })
    }

    /// Reload all sections from disk (re-runs nickel export).
    #[deprecated(note = "use from_values() with daemon-provided JSON instead")]
    pub fn reload(&mut self, ontology_dir: &Path) -> Result<()> {
        #[allow(deprecated)]
        {
            self.core = Core::load(&ontology_dir.join("core.ncl"))?;
            self.state = State::load(&ontology_dir.join("state.ncl"))?;
            self.gate = Gate::load(&ontology_dir.join("gate.ncl"))?;
            Ok(())
        }
    }
}

// ── Core ──────────────────────────────────────────────────────────────────────

/// The core ontology DAG: nodes (axioms, tensions, practices) and edges.
#[derive(Debug)]
pub struct Core {
    nodes: Vec<Node>,
    edges: Vec<Edge>,
    by_id: HashMap<String, usize>,
}

impl Core {
    /// Construct from a pre-fetched JSON value (the output of `nickel export
    /// core.ncl`).
    pub fn from_value(value: &Value) -> Result<Self> {
        let cfg: CoreConfig =
            serde_json::from_value(value.clone()).map_err(|e| OntologyError::Parse {
                section: "core",
                source: e,
            })?;

        let by_id: HashMap<String, usize> = cfg
            .nodes
            .iter()
            .enumerate()
            .map(|(i, n)| (n.id.clone(), i))
            .collect();

        // `mut` is only needed under the `derive` feature, where
        // merge_contributors() takes &mut self. Suppress the unused_mut lint
        // when the feature is off.
        #[allow(unused_mut)]
        let mut core = Self {
            nodes: cfg.nodes,
            edges: cfg.edges,
            by_id,
        };
        #[cfg(feature = "derive")]
        core.merge_contributors();
        Ok(core)
    }

    #[deprecated(note = "use Core::from_value() with daemon-provided JSON instead")]
    fn load(path: &Path) -> Result<Self> {
        let raw = nickel_export(path, "core")?;
        let cfg: CoreConfig = serde_json::from_slice(&raw).map_err(|e| OntologyError::Parse {
            section: "core",
            source: e,
        })?;

        let by_id: HashMap<String, usize> = cfg
            .nodes
            .iter()
            .enumerate()
            .map(|(i, n)| (n.id.clone(), i))
            .collect();

        Ok(Self {
            nodes: cfg.nodes,
            edges: cfg.edges,
            by_id,
        })
    }

    pub fn nodes(&self) -> &[Node] {
        &self.nodes
    }

    pub fn edges(&self) -> &[Edge] {
        &self.edges
    }

    pub fn node_by_id(&self, id: &str) -> Option<&Node> {
        self.by_id.get(id).map(|&i| &self.nodes[i])
    }

    pub fn axioms(&self) -> impl Iterator<Item = &Node> {
        self.nodes
            .iter()
            .filter(|n| n.level == AbstractionLevel::Axiom)
    }

    pub fn tensions(&self) -> impl Iterator<Item = &Node> {
        self.nodes
            .iter()
            .filter(|n| n.level == AbstractionLevel::Tension)
    }

    pub fn practices(&self) -> impl Iterator<Item = &Node> {
        self.nodes
            .iter()
            .filter(|n| n.level == AbstractionLevel::Practice)
    }

    /// Nodes with `invariant = true` — must never be violated.
    pub fn invariants(&self) -> impl Iterator<Item = &Node> {
        self.nodes.iter().filter(|n| n.invariant)
    }

    /// All edges originating from `node_id`.
    pub fn edges_from(&self, node_id: &str) -> impl Iterator<Item = &Edge> {
        let id = node_id.to_owned();
        self.edges.iter().filter(move |e| e.from == id)
    }

    /// All edges pointing to `node_id`.
    pub fn edges_to(&self, node_id: &str) -> impl Iterator<Item = &Edge> {
        let id = node_id.to_owned();
        self.edges.iter().filter(move |e| e.to == id)
    }

    /// Returns all practice-level nodes whose id does not appear in any
    /// registered [`TestCoverage`] entry (via `#[onto_validates]`).
    ///
    /// Call this in a test after running the full test suite to surface
    /// practices that have no annotated test coverage. Returns an empty vec
    /// if the `derive` feature is inactive (no coverage registry
    /// available).
    ///
    /// [`TestCoverage`]: crate::contrib::TestCoverage
    #[cfg(feature = "derive")]
    pub fn uncovered_practices(&self) -> Vec<&Node> {
        let covered: std::collections::HashSet<&str> =
            inventory::iter::<crate::contrib::TestCoverage>()
                .filter_map(|tc| tc.practice_id)
                .collect();

        self.practices()
            .filter(|n| !covered.contains(n.id.as_str()))
            .collect()
    }

    /// Merge all statically registered [`NodeContribution`]s into this core.
    ///
    /// NCL-loaded nodes always win: if a contributor supplies a node whose id
    /// already exists in `by_id` (loaded from `.ontology/core.ncl`), the NCL
    /// version is kept and the contribution is silently skipped.
    ///
    /// Call this once after constructing `Core::from_value()` when you want
    /// Rust structs that `#[derive(OntologyNode)]` to participate in graph
    /// queries without a corresponding NCL entry.
    ///
    /// [`NodeContribution`]: crate::contrib::NodeContribution
    #[cfg(feature = "derive")]
    pub fn merge_contributors(&mut self) {
        for contrib in inventory::iter::<crate::contrib::NodeContribution>() {
            let node = (contrib.supplier)();
            if self.by_id.contains_key(&node.id) {
                continue;
            }
            let idx = self.nodes.len();
            self.by_id.insert(node.id.clone(), idx);
            self.nodes.push(node);
        }
    }
}

// ── State ─────────────────────────────────────────────────────────────────────

/// The state FSM: tracked dimensions and their transition graphs.
#[derive(Debug)]
pub struct State {
    dimensions: Vec<Dimension>,
    by_id: HashMap<String, usize>,
}

impl State {
    /// Construct from a pre-fetched JSON value (the output of `nickel export
    /// state.ncl`).
    pub fn from_value(value: &Value) -> Result<Self> {
        let cfg: StateConfig =
            serde_json::from_value(value.clone()).map_err(|e| OntologyError::Parse {
                section: "state",
                source: e,
            })?;

        let by_id: HashMap<String, usize> = cfg
            .dimensions
            .iter()
            .enumerate()
            .map(|(i, d)| (d.id.clone(), i))
            .collect();

        Ok(Self {
            dimensions: cfg.dimensions,
            by_id,
        })
    }

    #[deprecated(note = "use State::from_value() with daemon-provided JSON instead")]
    fn load(path: &Path) -> Result<Self> {
        let raw = nickel_export(path, "state")?;
        let cfg: StateConfig = serde_json::from_slice(&raw).map_err(|e| OntologyError::Parse {
            section: "state",
            source: e,
        })?;

        let by_id: HashMap<String, usize> = cfg
            .dimensions
            .iter()
            .enumerate()
            .map(|(i, d)| (d.id.clone(), i))
            .collect();

        Ok(Self {
            dimensions: cfg.dimensions,
            by_id,
        })
    }

    pub fn dimensions(&self) -> &[Dimension] {
        &self.dimensions
    }

    pub fn dimension_by_id(&self, id: &str) -> Option<&Dimension> {
        self.by_id.get(id).map(|&i| &self.dimensions[i])
    }

    /// Dimensions with high tension in their current state.
    pub fn high_tension_dimensions(&self) -> impl Iterator<Item = &Dimension> {
        self.dimensions.iter().filter(|d| {
            d.states
                .iter()
                .find(|e| e.id == d.current_state)
                .is_some_and(|e| e.tension == TensionLevel::High)
        })
    }

    /// Check if a transition from `current` to `target` is declared for
    /// dimension `dim_id`. Returns `Ok(())` if valid, `Err` with the
    /// declared blocker if not.
    pub fn can_transition(&self, dim_id: &str, to: &str) -> Result<(), String> {
        let dim = self
            .dimension_by_id(dim_id)
            .ok_or_else(|| format!("dimension '{dim_id}' not found"))?;

        let transition = dim
            .transitions
            .iter()
            .find(|t| t.from == dim.current_state && t.to == to);

        match transition {
            Some(t) if t.blocker.is_empty() => Ok(()),
            Some(t) => Err(format!("transition blocked: {}", t.blocker)),
            None => Err(format!(
                "no declared transition from '{}' to '{to}' in dimension '{dim_id}'",
                dim.current_state
            )),
        }
    }
}

// ── Gate ──────────────────────────────────────────────────────────────────────

/// The gate: membranes that filter incoming signals.
#[derive(Debug)]
pub struct Gate {
    membranes: Vec<Membrane>,
    by_id: HashMap<String, usize>,
}

impl Gate {
    /// Construct from a pre-fetched JSON value (the output of `nickel export
    /// gate.ncl`).
    pub fn from_value(value: &Value) -> Result<Self> {
        let cfg: GateConfig =
            serde_json::from_value(value.clone()).map_err(|e| OntologyError::Parse {
                section: "gate",
                source: e,
            })?;

        let by_id: HashMap<String, usize> = cfg
            .membranes
            .iter()
            .enumerate()
            .map(|(i, m)| (m.id.clone(), i))
            .collect();

        Ok(Self {
            membranes: cfg.membranes,
            by_id,
        })
    }

    #[deprecated(note = "use Gate::from_value() with daemon-provided JSON instead")]
    fn load(path: &Path) -> Result<Self> {
        let raw = nickel_export(path, "gate")?;
        let cfg: GateConfig = serde_json::from_slice(&raw).map_err(|e| OntologyError::Parse {
            section: "gate",
            source: e,
        })?;

        let by_id: HashMap<String, usize> = cfg
            .membranes
            .iter()
            .enumerate()
            .map(|(i, m)| (m.id.clone(), i))
            .collect();

        Ok(Self {
            membranes: cfg.membranes,
            by_id,
        })
    }

    pub fn membranes(&self) -> &[Membrane] {
        &self.membranes
    }

    pub fn membrane_by_id(&self, id: &str) -> Option<&Membrane> {
        self.by_id.get(id).map(|&i| &self.membranes[i])
    }

    /// Active membranes that are currently open.
    pub fn active_membranes(&self) -> impl Iterator<Item = &Membrane> {
        self.membranes.iter().filter(|m| m.active)
    }

    /// Membranes with `Closed` permeability — signals cannot enter.
    pub fn closed_membranes(&self) -> impl Iterator<Item = &Membrane> {
        self.membranes
            .iter()
            .filter(|m| m.permeability == Permeability::Closed)
    }

    /// Membranes that protect the node with the given id.
    pub fn protecting(&self, node_id: &str) -> impl Iterator<Item = &Membrane> {
        let id = node_id.to_owned();
        self.membranes
            .iter()
            .filter(move |m| m.protects.iter().any(|p| p == &id))
    }
}

// ── Positioning (ADR-035)
// ─────────────────────────────────────────────────────

/// Runtime view of a project's positioning layer.
///
/// Loaded from `.ontoref/positioning/{audiences,value-props,campaigns}/*.ncl`
/// (one artefact per file). The aggregate is held flat with three id maps
/// for O(1) lookup; cross-references (value-prop → audience, campaign →
/// value-prop) are validated at construction time and surfaced as
/// [`OntologyError::CrossRefViolation`] when dangling.
#[derive(Debug)]
pub struct Positioning {
    audiences: Vec<Audience>,
    value_props: Vec<ValueProp>,
    campaigns: Vec<Campaign>,
    differentiators: Vec<Differentiator>,
    competitors: Vec<Competitor>,
    mechanisms: Vec<DifusionMechanism>,
    proofs: Vec<Proof>,
    viability_paths: Vec<ViabilityPath>,
    audience_by_id: HashMap<String, usize>,
    value_prop_by_id: HashMap<String, usize>,
    campaign_by_id: HashMap<String, usize>,
    differentiator_by_id: HashMap<String, usize>,
    mechanism_by_id: HashMap<String, usize>,
    proof_by_id: HashMap<String, usize>,
    viability_path_by_id: HashMap<String, usize>,
}

impl Positioning {
    /// Build the runtime view from a pre-aggregated [`PositioningConfig`].
    ///
    /// Validates that every `audience_ids` reference on value-props and
    /// campaigns resolves to a known [`Audience`], and that every
    /// `value_prop_ids` reference on campaigns resolves to a known
    /// [`ValueProp`]. Returns [`OntologyError::CrossRefViolation`] listing
    /// all dangling references in one report.
    pub fn from_config(cfg: PositioningConfig) -> Result<Self> {
        let audience_by_id: HashMap<String, usize> = cfg
            .audiences
            .iter()
            .enumerate()
            .map(|(i, a)| (a.id.clone(), i))
            .collect();
        let value_prop_by_id: HashMap<String, usize> = cfg
            .value_props
            .iter()
            .enumerate()
            .map(|(i, v)| (v.id.clone(), i))
            .collect();
        let campaign_by_id: HashMap<String, usize> = cfg
            .campaigns
            .iter()
            .enumerate()
            .map(|(i, c)| (c.id.clone(), i))
            .collect();

        let differentiator_by_id: HashMap<String, usize> = cfg
            .differentiators
            .iter()
            .enumerate()
            .map(|(i, x)| (x.id.clone(), i))
            .collect();
        let mechanism_by_id: HashMap<String, usize> = cfg
            .mechanisms
            .iter()
            .enumerate()
            .map(|(i, m)| (m.id.clone(), i))
            .collect();
        let proof_by_id: HashMap<String, usize> = cfg
            .proofs
            .iter()
            .enumerate()
            .map(|(i, p)| (p.id.clone(), i))
            .collect();
        let viability_path_by_id: HashMap<String, usize> = cfg
            .viability_paths
            .iter()
            .enumerate()
            .map(|(i, v)| (v.id.clone(), i))
            .collect();

        let mut violations: Vec<String> = Vec::new();

        for vp in &cfg.value_props {
            for aid in &vp.audience_ids {
                if !audience_by_id.contains_key(aid) {
                    violations.push(format!(
                        "value-prop '{}' references unknown audience '{}'",
                        vp.id, aid
                    ));
                }
            }
        }
        for camp in &cfg.campaigns {
            for aid in &camp.audience_ids {
                if !audience_by_id.contains_key(aid) {
                    violations.push(format!(
                        "campaign '{}' references unknown audience '{}'",
                        camp.id, aid
                    ));
                }
            }
            for vpid in &camp.value_prop_ids {
                if !value_prop_by_id.contains_key(vpid) {
                    violations.push(format!(
                        "campaign '{}' references unknown value-prop '{}'",
                        camp.id, vpid
                    ));
                }
            }
        }
        // ADR-043 rule 4 (DanglingRef): a Proof is the seam — every id it
        // converges must resolve to a known differentiator, audience and
        // mechanism.
        for pf in &cfg.proofs {
            for did in &pf.converges.differentiator_ids {
                if !differentiator_by_id.contains_key(did) {
                    violations.push(format!(
                        "proof '{}' converges unknown differentiator '{}'",
                        pf.id, did
                    ));
                }
            }
            for aid in &pf.converges.audience_ids {
                if !audience_by_id.contains_key(aid) {
                    violations.push(format!(
                        "proof '{}' converges unknown audience '{}'",
                        pf.id, aid
                    ));
                }
            }
            for mid in &pf.converges.mechanism_ids {
                if !mechanism_by_id.contains_key(mid) {
                    violations.push(format!(
                        "proof '{}' converges unknown mechanism '{}'",
                        pf.id, mid
                    ));
                }
            }
        }
        // ADR-055 (DanglingConverges): a ViabilityPath is the sustainability seam
        // — every id it converges must resolve to a known audience, mechanism and
        // value-prop. Symmetric to the Proof seam's DanglingRef rule above.
        for vp in &cfg.viability_paths {
            for aid in &vp.converges.audience_ids {
                if !audience_by_id.contains_key(aid) {
                    violations.push(format!(
                        "viability-path '{}' converges unknown audience '{}'",
                        vp.id, aid
                    ));
                }
            }
            for mid in &vp.converges.mechanism_ids {
                if !mechanism_by_id.contains_key(mid) {
                    violations.push(format!(
                        "viability-path '{}' converges unknown mechanism '{}'",
                        vp.id, mid
                    ));
                }
            }
            for vid in &vp.converges.value_prop_ids {
                if !value_prop_by_id.contains_key(vid) {
                    violations.push(format!(
                        "viability-path '{}' converges unknown value-prop '{}'",
                        vp.id, vid
                    ));
                }
            }
        }

        if !violations.is_empty() {
            return Err(anyhow!(OntologyError::CrossRefViolation(
                violations.join("; ")
            )));
        }

        Ok(Self {
            audiences: cfg.audiences,
            value_props: cfg.value_props,
            campaigns: cfg.campaigns,
            differentiators: cfg.differentiators,
            competitors: cfg.competitors,
            mechanisms: cfg.mechanisms,
            proofs: cfg.proofs,
            viability_paths: cfg.viability_paths,
            audience_by_id,
            value_prop_by_id,
            campaign_by_id,
            differentiator_by_id,
            mechanism_by_id,
            proof_by_id,
            viability_path_by_id,
        })
    }

    /// Walk `positioning_dir/{audiences,value-props,campaigns}/*.ncl`,
    /// export each via `nickel`, and aggregate into a [`Positioning`].
    ///
    /// A missing `positioning_dir` is treated as zero adoption (returns an
    /// empty [`Positioning`]) — the layer is opt-in per ADR-035 and
    /// tier-orthogonal per ADR-029.
    ///
    /// Prefer constructing via [`Positioning::from_config`] with
    /// daemon-cached JSON when available; this method shells out to
    /// `nickel export` per file and is intended for first-load on cold
    /// start.
    pub fn load(positioning_dir: &Path) -> Result<Self> {
        let mut cfg = PositioningConfig::default();
        if !positioning_dir.exists() {
            return Self::from_config(cfg);
        }

        cfg.audiences = load_dir::<Audience>(&positioning_dir.join("audiences"), "audience")?;
        cfg.value_props =
            load_dir::<ValueProp>(&positioning_dir.join("value-props"), "value-prop")?;
        cfg.campaigns = load_dir::<Campaign>(&positioning_dir.join("campaigns"), "campaign")?;
        cfg.differentiators =
            load_dir::<Differentiator>(&positioning_dir.join("differentiators"), "differentiator")?;
        cfg.competitors =
            load_dir::<Competitor>(&positioning_dir.join("competitors"), "competitor")?;
        cfg.mechanisms =
            load_dir::<DifusionMechanism>(&positioning_dir.join("difusion"), "mechanism")?;
        cfg.proofs = load_dir::<Proof>(&positioning_dir.join("proofs"), "proof")?;
        cfg.viability_paths =
            load_dir::<ViabilityPath>(&positioning_dir.join("viability"), "viability-path")?;

        Self::from_config(cfg)
    }

    pub fn audiences(&self) -> &[Audience] {
        &self.audiences
    }
    pub fn value_props(&self) -> &[ValueProp] {
        &self.value_props
    }
    pub fn campaigns(&self) -> &[Campaign] {
        &self.campaigns
    }
    pub fn differentiators(&self) -> &[Differentiator] {
        &self.differentiators
    }
    pub fn competitors(&self) -> &[Competitor] {
        &self.competitors
    }
    pub fn mechanisms(&self) -> &[DifusionMechanism] {
        &self.mechanisms
    }
    pub fn viability_paths(&self) -> &[ViabilityPath] {
        &self.viability_paths
    }
    pub fn proofs(&self) -> &[Proof] {
        &self.proofs
    }

    pub fn audience_by_id(&self, id: &str) -> Option<&Audience> {
        self.audience_by_id.get(id).map(|&i| &self.audiences[i])
    }
    pub fn differentiator_by_id(&self, id: &str) -> Option<&Differentiator> {
        self.differentiator_by_id
            .get(id)
            .map(|&i| &self.differentiators[i])
    }
    pub fn mechanism_by_id(&self, id: &str) -> Option<&DifusionMechanism> {
        self.mechanism_by_id.get(id).map(|&i| &self.mechanisms[i])
    }
    pub fn proof_by_id(&self, id: &str) -> Option<&Proof> {
        self.proof_by_id.get(id).map(|&i| &self.proofs[i])
    }
    pub fn viability_path_by_id(&self, id: &str) -> Option<&ViabilityPath> {
        self.viability_path_by_id
            .get(id)
            .map(|&i| &self.viability_paths[i])
    }
    pub fn value_prop_by_id(&self, id: &str) -> Option<&ValueProp> {
        self.value_prop_by_id.get(id).map(|&i| &self.value_props[i])
    }
    pub fn campaign_by_id(&self, id: &str) -> Option<&Campaign> {
        self.campaign_by_id.get(id).map(|&i| &self.campaigns[i])
    }

    /// All value-props targeting `audience_id`.
    pub fn value_props_for_audience<'a>(
        &'a self,
        audience_id: &'a str,
    ) -> impl Iterator<Item = &'a ValueProp> {
        self.value_props
            .iter()
            .filter(move |vp| vp.audience_ids.iter().any(|a| a == audience_id))
    }
}

/// Walk `dir/*.ncl`, `nickel export` each file, deserialize into `T`.
///
/// Files prefixed with `_` are skipped (template / smoke-test convention,
/// e.g. `_template.ncl`, `_smoke-test.ncl`). A missing `dir` yields an
/// empty Vec — sub-directories are independently optional within the
/// positioning layer (a project may have audiences but no campaigns yet).
fn load_dir<T: serde::de::DeserializeOwned>(dir: &Path, section: &'static str) -> Result<Vec<T>> {
    if !dir.exists() {
        return Ok(Vec::new());
    }
    let mut items: Vec<T> = Vec::new();
    let entries = std::fs::read_dir(dir)
        .with_context(|| format!("reading positioning dir '{}'", dir.display()))?;
    for entry in entries {
        let entry = entry?;
        let path = entry.path();
        if path.extension().and_then(|s| s.to_str()) != Some("ncl") {
            continue;
        }
        let fname = path
            .file_name()
            .and_then(|s| s.to_str())
            .unwrap_or_default();
        if fname.starts_with('_') {
            continue;
        }
        let raw = nickel_export(&path, section)?;
        let item: T = serde_json::from_slice(&raw)
            .map_err(|e| OntologyError::Parse { section, source: e })?;
        items.push(item);
    }
    Ok(items)
}

// ── Shared ────────────────────────────────────────────────────────────────────

fn nickel_export(path: &Path, section: &'static str) -> Result<Vec<u8>> {
    if !path.exists() {
        return Err(OntologyError::MissingFile(
            path.parent().unwrap_or(path).display().to_string(),
            path.file_name()
                .unwrap_or_default()
                .to_string_lossy()
                .into_owned(),
        )
        .into());
    }

    debug!(section, path = %path.display(), "running nickel export");

    let output = std::process::Command::new("nickel")
        .arg("export")
        .arg("--format")
        .arg("json")
        .arg(path)
        .output()
        .with_context(|| format!("running nickel export on '{}'", path.display()))?;

    if !output.status.success() {
        let stderr = String::from_utf8_lossy(&output.stderr).trim().to_owned();
        return Err(anyhow!(OntologyError::NickelExport {
            path: path.display().to_string(),
            stderr,
        }));
    }

    Ok(output.stdout)
}

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

    #[test]
    fn core_from_value_parses_valid_json() {
        let json = serde_json::json!({
            "nodes": [
                {
                    "id": "test-axiom",
                    "name": "Test Axiom",
                    "pole": "Yang",
                    "level": "Axiom",
                    "description": "A test axiom",
                    "invariant": true
                }
            ],
            "edges": [
                {
                    "from": "test-axiom",
                    "to": "test-axiom",
                    "kind": "Contains",
                    "weight": "Medium",
                    "note": ""
                }
            ]
        });

        let core = Core::from_value(&json).unwrap();
        assert_eq!(core.nodes().len(), 1);
        assert_eq!(core.edges().len(), 1);
        assert!(core.node_by_id("test-axiom").is_some());
        assert_eq!(core.axioms().count(), 1);
        assert_eq!(core.invariants().count(), 1);
    }

    #[test]
    fn state_from_value_parses_and_transitions() {
        let json = serde_json::json!({
            "dimensions": [
                {
                    "id": "test-dim",
                    "name": "Test",
                    "description": "",
                    "current_state": "a",
                    "desired_state": "b",
                    "horizon": "Weeks",
                    "states": [],
                    "transitions": [
                        {
                            "from": "a",
                            "to": "b",
                            "condition": "ready",
                            "catalyst": "",
                            "blocker": "",
                            "horizon": "Weeks"
                        }
                    ],
                    "coupled_with": []
                }
            ]
        });

        let state = State::from_value(&json).unwrap();
        assert_eq!(state.dimensions().len(), 1);
        assert!(state.can_transition("test-dim", "b").is_ok());
        assert!(state.can_transition("test-dim", "c").is_err());
    }

    #[test]
    fn gate_from_value_parses_membranes() {
        let json = serde_json::json!({
            "membranes": [
                {
                    "id": "test-gate",
                    "name": "Test Gate",
                    "description": "A test membrane",
                    "permeability": "High",
                    "accepts": ["HardBug"],
                    "protects": ["test-axiom"],
                    "opening_condition": {
                        "max_tension_dimensions": 2,
                        "pending_transitions": 1,
                        "core_stable": true,
                        "description": "test"
                    },
                    "closing_condition": "done",
                    "protocol": "Observe",
                    "max_duration": "Weeks",
                    "active": true
                }
            ]
        });

        let gate = Gate::from_value(&json).unwrap();
        assert_eq!(gate.membranes().len(), 1);
        assert_eq!(gate.active_membranes().count(), 1);
        assert_eq!(gate.protecting("test-axiom").count(), 1);
    }

    #[test]
    fn ontology_from_values_composes_all_three() {
        let core_json = serde_json::json!({
            "nodes": [{
                "id": "ax", "name": "Ax", "pole": "Yang",
                "level": "Axiom", "description": "d", "invariant": false
            }],
            "edges": []
        });
        let state_json = serde_json::json!({ "dimensions": [] });
        let gate_json = serde_json::json!({ "membranes": [] });

        let ont = Ontology::from_values(&core_json, &state_json, &gate_json).unwrap();
        assert_eq!(ont.core.nodes().len(), 1);
        assert!(ont.state.dimensions().is_empty());
        assert!(ont.gate.membranes().is_empty());
    }

    #[test]
    fn from_value_rejects_invalid_json() {
        let bad = serde_json::json!({"nodes": "not_an_array"});
        assert!(Core::from_value(&bad).is_err());
    }

    // ── Positioning tests ────────────────────────────────────────────────

    fn mk_audience(id: &str) -> Audience {
        Audience {
            id: id.to_owned(),
            name: id.to_owned(),
            description: String::new(),
            primary_pain: String::new(),
            context: String::new(),
            channels: vec![],
            evidence: vec![],
            linked_nodes: vec![],
            status: crate::types::AudienceStatus::Hypothesis,
        }
    }

    fn mk_value_prop(id: &str, audience_ids: Vec<&str>) -> ValueProp {
        ValueProp {
            id: id.to_owned(),
            claim: String::new(),
            audience_ids: audience_ids.into_iter().map(String::from).collect(),
            differentiator_ids: vec![],
            problem_solved: String::new(),
            alternatives: vec![],
            unique_angle: String::new(),
            evidence_adrs: vec![],
            proof_points: vec![],
            narrative_path: None,
            launch_criteria: vec![],
            status: crate::types::PositioningStatus::Draft,
            retired_at: None,
            superseded_by: None,
        }
    }

    fn mk_campaign(id: &str, aids: Vec<&str>, vpids: Vec<&str>) -> Campaign {
        Campaign {
            id: id.to_owned(),
            title: String::new(),
            audience_ids: aids.into_iter().map(String::from).collect(),
            value_prop_ids: vpids.into_iter().map(String::from).collect(),
            mechanism_ids: vec![],
            goal: String::new(),
            channels: vec![],
            launch_criteria: vec![],
            narrative_path: None,
            status: crate::types::PositioningStatus::Draft,
            starts_at: None,
            ends_at: None,
        }
    }

    #[test]
    fn positioning_from_config_accepts_valid_cross_refs() {
        let cfg = PositioningConfig {
            audiences: vec![mk_audience("aud-1")],
            value_props: vec![mk_value_prop("vp-1", vec!["aud-1"])],
            campaigns: vec![mk_campaign("camp-1", vec!["aud-1"], vec!["vp-1"])],
            ..Default::default()
        };
        let p = Positioning::from_config(cfg).expect("valid cross-refs");
        assert_eq!(p.audiences().len(), 1);
        assert_eq!(p.value_props().len(), 1);
        assert_eq!(p.campaigns().len(), 1);
        assert!(p.audience_by_id("aud-1").is_some());
        assert!(p.value_prop_by_id("vp-1").is_some());
        assert!(p.campaign_by_id("camp-1").is_some());
    }

    #[test]
    fn positioning_from_config_empty_is_valid() {
        let p = Positioning::from_config(PositioningConfig::default())
            .expect("empty config is valid (opt-in layer)");
        assert!(p.audiences().is_empty());
        assert!(p.value_props().is_empty());
        assert!(p.campaigns().is_empty());
    }

    #[test]
    fn positioning_rejects_dangling_audience_on_value_prop() {
        let cfg = PositioningConfig {
            audiences: vec![mk_audience("aud-1")],
            value_props: vec![mk_value_prop("vp-1", vec!["aud-missing"])],
            campaigns: vec![],
            ..Default::default()
        };
        let err = Positioning::from_config(cfg).expect_err("must reject");
        let msg = format!("{err:#}");
        assert!(
            msg.contains("vp-1") && msg.contains("aud-missing"),
            "error must name the offending vp and audience: {msg}"
        );
    }

    #[test]
    fn positioning_rejects_dangling_vp_on_campaign() {
        let cfg = PositioningConfig {
            audiences: vec![mk_audience("aud-1")],
            value_props: vec![mk_value_prop("vp-1", vec!["aud-1"])],
            campaigns: vec![mk_campaign("camp-1", vec!["aud-1"], vec!["vp-missing"])],
            ..Default::default()
        };
        let err = Positioning::from_config(cfg).expect_err("must reject");
        let msg = format!("{err:#}");
        assert!(
            msg.contains("camp-1") && msg.contains("vp-missing"),
            "error must name the offending campaign and vp: {msg}"
        );
    }

    #[test]
    fn positioning_rejects_dangling_audience_on_campaign() {
        let cfg = PositioningConfig {
            audiences: vec![mk_audience("aud-1")],
            value_props: vec![mk_value_prop("vp-1", vec!["aud-1"])],
            campaigns: vec![mk_campaign("camp-1", vec!["aud-missing"], vec!["vp-1"])],
            ..Default::default()
        };
        let err = Positioning::from_config(cfg).expect_err("must reject");
        let msg = format!("{err:#}");
        assert!(
            msg.contains("camp-1") && msg.contains("aud-missing"),
            "error must name the offending campaign and audience: {msg}"
        );
    }

    #[test]
    fn positioning_aggregates_multiple_violations() {
        let cfg = PositioningConfig {
            audiences: vec![],
            value_props: vec![
                mk_value_prop("vp-1", vec!["aud-a"]),
                mk_value_prop("vp-2", vec!["aud-b"]),
            ],
            campaigns: vec![],
            ..Default::default()
        };
        let err = Positioning::from_config(cfg).expect_err("must reject");
        let msg = format!("{err:#}");
        assert!(msg.contains("vp-1") && msg.contains("aud-a"));
        assert!(msg.contains("vp-2") && msg.contains("aud-b"));
        assert!(
            msg.contains(";"),
            "multiple violations must be joined with ';': {msg}"
        );
    }

    #[test]
    fn positioning_value_props_for_audience_filters_correctly() {
        let cfg = PositioningConfig {
            audiences: vec![mk_audience("aud-1"), mk_audience("aud-2")],
            value_props: vec![
                mk_value_prop("vp-1", vec!["aud-1"]),
                mk_value_prop("vp-2", vec!["aud-2"]),
                mk_value_prop("vp-3", vec!["aud-1", "aud-2"]),
            ],
            campaigns: vec![],
            ..Default::default()
        };
        let p = Positioning::from_config(cfg).unwrap();
        let for_aud_1: Vec<_> = p
            .value_props_for_audience("aud-1")
            .map(|v| v.id.as_str())
            .collect();
        assert_eq!(for_aud_1, vec!["vp-1", "vp-3"]);
        let for_aud_2: Vec<_> = p
            .value_props_for_audience("aud-2")
            .map(|v| v.id.as_str())
            .collect();
        assert_eq!(for_aud_2, vec!["vp-2", "vp-3"]);
    }

    #[test]
    fn positioning_load_missing_dir_yields_empty() {
        let tmp = tempfile::tempdir().unwrap();
        let missing = tmp.path().join("does-not-exist");
        let p = Positioning::load(&missing).expect("missing dir = empty, not error");
        assert!(p.audiences().is_empty());
        assert!(p.value_props().is_empty());
        assert!(p.campaigns().is_empty());
    }

    /// merge_contributors: an overlay node is inserted when its id is absent.
    #[cfg(feature = "derive")]
    #[test]
    fn merge_contributors_inserts_absent_node() {
        use crate::types::*;

        // Register a contribution for this test.
        // inventory::submit! is a static initialiser — we can't call it from
        // within a test body. Instead we call merge_contributors with a
        // synthesised node to test the insertion logic directly.
        let json = serde_json::json!({ "nodes": [], "edges": [] });
        let mut core = Core::from_value(&json).unwrap();

        // Manually push a NodeContribution-equivalent node to validate logic.
        // Because inventory submissions are link-time, we test the underlying
        // HashMap/Vec state after a direct push to confirm the contract holds.
        let node = Node {
            id: "contributed-node".to_owned(),
            name: "Contributed".to_owned(),
            pole: Pole::Yang,
            level: AbstractionLevel::Practice,
            description: "A contributed practice".to_owned(),
            invariant: false,
            artifact_paths: vec![],
            adrs: vec![],
        };

        assert!(!core.by_id.contains_key("contributed-node"));
        let idx = core.nodes.len();
        core.by_id.insert(node.id.clone(), idx);
        core.nodes.push(node);

        assert!(core.node_by_id("contributed-node").is_some());
        assert_eq!(core.nodes().len(), 1);
    }

    /// merge_contributors: NCL-loaded node wins on id collision.
    #[cfg(feature = "derive")]
    #[test]
    fn merge_contributors_ncl_wins_on_collision() {
        let json = serde_json::json!({
            "nodes": [{
                "id": "dag-formalized",
                "name": "DAG Formalized (NCL)",
                "pole": "Yang",
                "level": "Practice",
                "description": "loaded from NCL",
                "invariant": false
            }],
            "edges": []
        });
        let core = Core::from_value(&json).unwrap();

        // Simulate what merge_contributors does when id already exists: skip.
        let id = "dag-formalized";
        if !core.by_id.contains_key(id) {
            panic!("test setup error");
        }
        // NCL version is already there — no insertion.
        assert_eq!(core.nodes().len(), 1);
        assert_eq!(core.node_by_id(id).unwrap().description, "loaded from NCL");
    }

    /// merge_contributors: empty inventory → no change.
    #[cfg(feature = "derive")]
    #[test]
    fn merge_contributors_empty_inventory_is_noop() {
        let json = serde_json::json!({
            "nodes": [{ "id": "ax", "name": "Ax", "pole": "Yang",
                        "level": "Axiom", "description": "d", "invariant": false }],
            "edges": []
        });
        let mut core = Core::from_value(&json).unwrap();
        let before_len = core.nodes().len();
        // merge_contributors with empty inventory changes nothing.
        core.merge_contributors();
        assert_eq!(core.nodes().len(), before_len);
    }

    /// uncovered_practices: Practice nodes without a matching TestCoverage
    /// inventory entry are all reported as uncovered. Axiom/Tension nodes
    /// are excluded.
    ///
    /// NOTE: `#[onto_validates]` cannot be used inside `ontoref-ontology`
    /// itself because the macro emits `::ontoref_ontology::TestCoverage` —
    /// a path that doesn't resolve within the defining crate. Tests that
    /// exercise the covered-vs-uncovered split belong in integration tests
    /// or consumer crates that link `ontoref-ontology` as an external
    /// dependency.
    #[cfg(feature = "derive")]
    #[test]
    fn uncovered_practices_excludes_non_practice_nodes() {
        use serde_json::json;
        let json = json!({
            "project": "test",
            "nodes": [
                {
                    "id":          "practice-alpha",
                    "name":        "practice-alpha",
                    "level":       "Practice",
                    "pole":        "Yang",
                    "description": "",
                    "invariant":   false,
                    "artifact_paths": [],
                    "adrs":        []
                },
                {
                    "id":          "practice-beta",
                    "name":        "practice-beta",
                    "level":       "Practice",
                    "pole":        "Yin",
                    "description": "",
                    "invariant":   false,
                    "artifact_paths": [],
                    "adrs":        []
                },
                {
                    "id":          "axiom-one",
                    "name":        "axiom-one",
                    "level":       "Axiom",
                    "pole":        "Yang",
                    "description": "",
                    "invariant":   true,
                    "artifact_paths": [],
                    "adrs":        []
                }
            ],
            "edges": []
        });
        let core = Core::from_value(&json).unwrap();

        // No TestCoverage submissions exist in this test binary for these ids,
        // so all Practice nodes must be reported as uncovered.
        let uncovered = core.uncovered_practices();
        let uncovered_ids: Vec<&str> = uncovered.iter().map(|n| n.id.as_str()).collect();

        assert!(
            uncovered_ids.contains(&"practice-alpha"),
            "practice-alpha must be uncovered; got: {uncovered_ids:?}"
        );
        assert!(
            uncovered_ids.contains(&"practice-beta"),
            "practice-beta must be uncovered; got: {uncovered_ids:?}"
        );
        // Axiom nodes are not practices — must never appear in practice coverage.
        assert!(
            !uncovered_ids.contains(&"axiom-one"),
            "axiom-one must not appear in practice coverage; got: {uncovered_ids:?}"
        );
    }
}
