//! Materialise an existing NCL ontology into a content-addressed oplog.
//!
//! Takes the JSON export of a `.ontology/*.ncl` file (produced by
//! `nickel export --format json`) and writes one `EntityAssert`
//! operation per node and per edge into an oplog. The resulting
//! [`OntologyId`] is computed from the oplog's DAG heads and can be
//! recorded in `.ontology/_ontology_refs.ncl` so the daemon resolves it
//! via [`crate::fetch_cell_witness`].
//!
//! Extraction is **idempotent**: rerunning it against an already-extracted
//! oplog is a no-op because op ids are content-addressed; `OpLog::append`
//! returns the existing id when the bytes already exist.

use std::path::Path;

use ed25519_dalek::SigningKey;
use ontoref_oplog::OpLog;
use ontoref_types::operation::{OpBody, OpPayload};
use ontoref_types::{AttrId, EntityId, Hlc, Operation, PublicKey, Value};
use serde_json::Value as JsonValue;
use smol_str::SmolStr;

use crate::ontology_id::{compute_ontology_id, OntologyId};
use crate::{Error, Result};

/// Stats returned by [`extract_core_to_oplog`].
#[derive(Debug, Clone)]
pub struct ExtractStats {
    /// Number of `nodes` array entries materialised as ops.
    pub nodes_extracted: usize,
    /// Number of `edges` array entries materialised as ops.
    pub edges_extracted: usize,
    /// Content address of the resulting ontology — computed from the
    /// oplog's DAG heads after extraction.
    pub ontology_id: OntologyId,
}

/// Extract a core-ontology JSON document into an oplog at `oplog_path`.
///
/// Each node becomes an `EntityAssert` op keyed by its `id` field; each
/// edge becomes an op keyed by `edge:{from}:{to}:{kind}`. Field values
/// are translated via [`json_to_value`] — arrays and nested objects are
/// JSON-encoded into a [`Value::Str`] so the cell shape stays flat.
///
/// # Errors
///
/// - [`Error::OpenOplog`] when the oplog cannot be opened.
/// - [`Error::Append`] when the oplog rejects an op (bad signature,
///   non-monotonic HLC, IO).
pub fn extract_core_to_oplog(
    core_json: &JsonValue,
    oplog_path: &Path,
    signing_key: &SigningKey,
) -> Result<ExtractStats> {
    let log = OpLog::open(oplog_path).map_err(|e| Error::OpenOplog {
        path: oplog_path.display().to_string(),
        source: e,
    })?;
    let pubkey = PublicKey::from_signing_key(signing_key);

    let mut next_ts = 1u64;
    let mut nodes_extracted = 0usize;
    let mut edges_extracted = 0usize;

    if let Some(nodes) = core_json.get("nodes").and_then(|v| v.as_array()) {
        for node in nodes {
            let entity_id = node.get("id").and_then(|v| v.as_str()).unwrap_or_default();
            if entity_id.is_empty() {
                continue;
            }
            let attrs = build_attrs_from_object(node);
            let op = build_signed_op(signing_key, &pubkey, entity_id, attrs, Hlc::new(next_ts, 0));
            log.append(&op).map_err(Error::Append)?;
            next_ts += 1;
            nodes_extracted += 1;
        }
    }

    if let Some(edges) = core_json.get("edges").and_then(|v| v.as_array()) {
        for edge in edges {
            let from = edge.get("from").and_then(|v| v.as_str()).unwrap_or("");
            let to = edge.get("to").and_then(|v| v.as_str()).unwrap_or("");
            let kind = edge.get("kind").and_then(|v| v.as_str()).unwrap_or("");
            let entity_id = format!("edge:{from}:{to}:{kind}");
            let attrs = build_attrs_from_object(edge);
            let op = build_signed_op(
                signing_key,
                &pubkey,
                &entity_id,
                attrs,
                Hlc::new(next_ts, 0),
            );
            log.append(&op).map_err(Error::Append)?;
            next_ts += 1;
            edges_extracted += 1;
        }
    }

    let heads = log.heads().map_err(Error::Append)?;
    let ontology_id = compute_ontology_id(&heads);
    Ok(ExtractStats {
        nodes_extracted,
        edges_extracted,
        ontology_id,
    })
}

/// Write or replace `.ontology/_ontology_refs.ncl` with a single
/// registry entry. Phase 1 fully rewrites the file on each extraction;
/// multi-entry registries arrive when additional ontology extractions
/// (Type 2) happen.
///
/// # Errors
///
/// IO error on directory creation or file write.
pub fn write_ontology_refs(
    refs_path: &Path,
    name: &str,
    ontology_id: &OntologyId,
    oplog_path: &Path,
    classification: crate::OntologyType,
) -> Result<()> {
    if let Some(parent) = refs_path.parent() {
        std::fs::create_dir_all(parent).map_err(|e| Error::Io {
            context: format!("creating parent of {}", refs_path.display()),
            source: e,
        })?;
    }

    let entry = format!(
        "    {{\n      name = \"{name}\",\n      ontology_id = \"{}\",\n      oplog_path = \
         \"{}\",\n      classification = '{},\n    }},\n",
        ontology_id.to_hex(),
        oplog_path.display(),
        classification.tag(),
    );

    let content = format!(
        "# Auto-generated by `ontoref-daemon --extract-ontology`.\n# Re-run extraction to update; \
         do not edit by hand.\n\n{{\n  ontology_refs = [\n{entry}  ],\n}}\n"
    );

    std::fs::write(refs_path, content).map_err(|e| Error::Io {
        context: format!("writing {}", refs_path.display()),
        source: e,
    })
}

fn build_attrs_from_object(obj: &JsonValue) -> Vec<(AttrId, Value)> {
    let mut attrs = Vec::new();
    let Some(map) = obj.as_object() else {
        return attrs;
    };
    let mut keys: Vec<&String> = map.keys().collect();
    keys.sort();
    for k in keys {
        if k == "id" {
            continue;
        }
        let value = json_to_value(&map[k]);
        attrs.push((AttrId::new(k), value));
    }
    attrs
}

/// Translate one JSON value into the typed [`Value`] used by the
/// substrate. Arrays and nested objects are JSON-encoded into a
/// [`Value::Str`] — lossy, but adequate for content-addressing.
pub fn json_to_value(v: &JsonValue) -> Value {
    match v {
        JsonValue::Null => Value::Null,
        JsonValue::Bool(b) => Value::Bool(*b),
        JsonValue::Number(n) => {
            if let Some(i) = n.as_i64() {
                Value::Int(i)
            } else {
                Value::Str(SmolStr::new(n.to_string()))
            }
        }
        JsonValue::String(s) => Value::Str(SmolStr::new(s)),
        JsonValue::Array(_) | JsonValue::Object(_) => Value::Str(SmolStr::new(v.to_string())),
    }
}

fn build_signed_op(
    signing_key: &SigningKey,
    pubkey: &PublicKey,
    entity_id: &str,
    attrs: Vec<(AttrId, Value)>,
    timestamp: Hlc,
) -> Operation {
    let body = OpBody {
        actor: *pubkey,
        parents: vec![],
        payload: OpPayload::EntityAssert {
            attrs,
            entity: EntityId::new(entity_id),
            valid_from: None,
        },
        timestamp,
    };
    Operation::sign(body, signing_key)
}

#[cfg(test)]
mod tests {
    use super::*;
    use crate::{
        fetch_cell_witness, verify_cell, CellQuery, OntologyRegistry, OntologyType, RegistryEntry,
    };

    fn signing_key() -> SigningKey {
        SigningKey::from_bytes(&[7u8; 32])
    }

    #[test]
    fn extract_writes_one_op_per_node_and_edge() {
        let tmp = tempfile::tempdir().expect("tempdir");
        let oplog_path = tmp.path().join("ontoref-core.oplog");

        let core_json = serde_json::json!({
            "nodes": [
                { "id": "protocol-not-runtime", "name": "Protocol, Not Runtime", "level": "Axiom", "invariant": true, "description": "Onref is a protocol." },
                { "id": "self-describing", "name": "Self-Describing", "level": "Axiom", "invariant": true, "description": "Describes itself." },
            ],
            "edges": [
                { "from": "self-describing", "to": "protocol-not-runtime", "kind": "DependsOn", "weight": "High", "note": "" },
            ],
        });

        let stats =
            extract_core_to_oplog(&core_json, &oplog_path, &signing_key()).expect("extract ok");
        assert_eq!(stats.nodes_extracted, 2);
        assert_eq!(stats.edges_extracted, 1);

        // Idempotent: second extraction adds nothing.
        let stats2 = extract_core_to_oplog(&core_json, &oplog_path, &signing_key())
            .expect("second extract ok");
        assert_eq!(
            stats2.ontology_id, stats.ontology_id,
            "idempotent extraction must produce the same id"
        );
    }

    #[test]
    fn extracted_ontology_is_queryable_via_fetch_cell_witness() {
        let tmp = tempfile::tempdir().expect("tempdir");
        let oplog_path = tmp.path().join("ontoref-core.oplog");

        let core_json = serde_json::json!({
            "nodes": [
                { "id": "protocol-not-runtime", "name": "Protocol, Not Runtime", "level": "Axiom", "invariant": true, "description": "Onref is a protocol." },
            ],
            "edges": [],
        });

        let stats =
            extract_core_to_oplog(&core_json, &oplog_path, &signing_key()).expect("extract ok");

        let mut registry = OntologyRegistry::empty();
        registry.register(RegistryEntry {
            ontology_id: stats.ontology_id,
            oplog_path: oplog_path.clone(),
            classification: OntologyType::Type1Protocol,
            verified_state_root_hex: None,
        });

        // Project A can fetch the cell witness for protocol-not-runtime/name.
        let query = CellQuery::new("protocol-not-runtime", "name");
        let response = fetch_cell_witness(&registry, &stats.ontology_id, &query).expect("fetch ok");
        assert!(verify_cell(&response.witness, &response.state_root));
    }

    #[test]
    fn write_ontology_refs_emits_parseable_ncl() {
        let tmp = tempfile::tempdir().expect("tempdir");
        let refs = tmp.path().join("_ontology_refs.ncl");
        let oplog_path = tmp.path().join("ontoref-core.oplog");
        let id = OntologyId([0xAB; 32]);

        write_ontology_refs(
            &refs,
            "ontoref-core",
            &id,
            &oplog_path,
            OntologyType::Type1Protocol,
        )
        .expect("write ok");

        let content = std::fs::read_to_string(&refs).expect("read ok");
        assert!(content.contains("ontoref-core"));
        assert!(content.contains(&id.to_hex()));
        assert!(content.contains("Type1Protocol"));
        assert!(content.contains("ontology_refs"));
    }
}
