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

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

use crate::{
    error::OntologyError,
    types::{
        AbstractionLevel, CoreConfig, Dimension, Edge, GateConfig, Membrane, Node, Permeability,
        StateConfig, TensionLevel,
    },
};

/// 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();

        let mut core = Self {
            nodes: cfg.nodes,
            edges: cfg.edges,
            by_id,
        };
        // Merge any NodeContribution registrations from #[derive(OntologyNode)].
        // NCL-loaded nodes win on id collision — contributor nodes only fill gaps.
        #[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))
    }
}

// ── 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": 1.0,
                    "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());
    }

    /// 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:?}"
        );
    }
}
