//! `update_ontology_node` — upsert a node in the core DAG.
//!
//! Mutates `ctx.core` (the canonical `.ontology/core.ncl` snapshot) in
//! place and writes the resulting JSON-like NCL document back to disk
//! when a project root is available. Each node mutation appears as one
//! EntityAssert op in the commit layer, so the witness covers the
//! affected cells (name, description, pole, level, invariant).

use ontoref_derive::{onto_operation, onto_validator};
use ontoref_types::canonical;
use ontoref_types::operation::{OpBody, OpPayload};
use ontoref_types::{AttrId, EntityId, Operation, Value};
use smol_str::SmolStr;

use crate::context::OpContext;
use crate::error::OpError;
use crate::output::{EffectRecord, OpOutput};
use crate::validation::traits::{Slice, ValidationCtx};
use crate::validation::verdict::Verdict;

/// Witness payload kind emitted by this op.
pub const WITNESS_SHAPE: &str = "node_upsert";

/// Upsert (insert-or-update) one node in the core ontology DAG.
///
/// Inputs:
/// - `node_id` (String, required): canonical kebab-case node id.
/// - `name` (String, optional): display name.
/// - `description` (String, optional).
/// - `pole` (String, optional): `"Yang"`, `"Yin"`, `"Spiral"`.
/// - `level` (String, optional): `"Axiom"`, `"Tension"`, `"Practice"`, …
/// - `invariant` (Bool, optional).
#[onto_operation(
    id = "update_ontology_node",
    description = "Insert or update one node in .ontology/core.ncl. Mutates ctx.core in place and \
                   writes the file back via Phase 1 inline render.",
    validation_sla = "Synchronous",
    precondition = "ontology_node_by_id",
    pre_validators = "ontology_node_well_formed",
    effects = "Update:OntologyNode:name,description,pole,level,invariant",
    render_paths = "",
    witness_shape = "node_upsert",
    actor_policy = "developer,admin"
)]
fn update_ontology_node(
    inputs: &serde_json::Value,
    ctx: &mut OpContext,
) -> Result<OpOutput, OpError> {
    let node_id = inputs
        .get("node_id")
        .and_then(|v| v.as_str())
        .ok_or_else(|| OpError::body("update_ontology_node", "missing 'node_id' input"))?
        .to_owned();

    let mut attrs: Vec<(AttrId, Value)> = Vec::new();
    let mut mutated_attrs: Vec<String> = Vec::new();

    // Scope the mutable borrow of ctx.core so we can call ctx.next_hlc()
    // afterwards without violating the single-mut-borrow rule.
    let existing_idx = {
        let core = ctx
            .core
            .as_mut()
            .ok_or_else(|| OpError::body("update_ontology_node", "ctx.core is None"))?;
        let nodes = core
            .get_mut("nodes")
            .and_then(|n| n.as_array_mut())
            .ok_or_else(|| {
                OpError::body("update_ontology_node", "ctx.core.nodes is not an array")
            })?;

        let existing_idx = nodes.iter().position(|n| {
            n.get("id")
                .and_then(|v| v.as_str())
                .map(|s| s == node_id)
                .unwrap_or(false)
        });

        let target = if let Some(idx) = existing_idx {
            nodes[idx].as_object_mut().ok_or_else(|| {
                OpError::body("update_ontology_node", "existing node is not an object")
            })?
        } else {
            let mut fresh = serde_json::Map::new();
            fresh.insert("id".to_owned(), serde_json::Value::String(node_id.clone()));
            nodes.push(serde_json::Value::Object(fresh));
            nodes
                .last_mut()
                .and_then(|v| v.as_object_mut())
                .expect("just pushed an object")
        };

        for (key, attr_name) in [
            ("name", "name"),
            ("description", "description"),
            ("pole", "pole"),
            ("level", "level"),
        ] {
            if let Some(v) = inputs.get(key).and_then(|x| x.as_str()) {
                target.insert(key.to_owned(), serde_json::Value::String(v.to_owned()));
                attrs.push((AttrId::new(attr_name), Value::Str(SmolStr::new(v))));
                mutated_attrs.push(attr_name.to_owned());
            }
        }
        if let Some(invariant) = inputs.get("invariant").and_then(|v| v.as_bool()) {
            target.insert("invariant".to_owned(), serde_json::Value::Bool(invariant));
            attrs.push((AttrId::new("invariant"), Value::Bool(invariant)));
            mutated_attrs.push("invariant".to_owned());
        }
        existing_idx
    };

    // Apply the EntityAssert to the commit layer.
    let signing_key = ctx.signing_key.clone();
    let actor = ontoref_types::PublicKey::from_signing_key(&signing_key);
    let timestamp = ctx.next_hlc();
    let op = Operation::sign(
        OpBody {
            actor,
            parents: vec![],
            payload: OpPayload::EntityAssert {
                attrs,
                entity: EntityId::new(&node_id),
                valid_from: None,
            },
            timestamp,
        },
        &signing_key,
    );
    ctx.commit_layer.apply(&op);

    // Write the mutated core.ncl back. Phase 1 uses a minimal
    // canonical serialiser — full byte-stable render arrives when
    // the unified core renderer lands.
    if let Some(root) = ctx.project_root.as_ref() {
        let core_path = ontoref_ontology::layout::resolve_section(root, "ontology/core.ncl");
        if let Some(parent) = core_path.parent() {
            std::fs::create_dir_all(parent).map_err(|e| OpError::Render {
                path: parent.display().to_string(),
                reason: format!("creating parent dir: {e}"),
            })?;
        }
        let core_snapshot = ctx
            .core
            .as_ref()
            .expect("ctx.core was set above and not consumed");
        let body = render_core_stub(core_snapshot);
        std::fs::write(&core_path, body).map_err(|e| OpError::Render {
            path: core_path.display().to_string(),
            reason: format!("writing core.ncl: {e}"),
        })?;
    }

    let witness_payload =
        canonical::encode(&op).map_err(|e| OpError::body("update_ontology_node", e.to_string()))?;

    let effect_kind = if existing_idx.is_some() {
        "Update"
    } else {
        "Insert"
    };
    Ok(OpOutput {
        effects: vec![EffectRecord {
            kind: effect_kind.to_owned(),
            entity: node_id,
            attrs: mutated_attrs,
            dimensions: vec![],
        }],
        witness_payload,
    })
}

fn render_core_stub(core: &serde_json::Value) -> String {
    // Phase 1 stub: emit a JSON-shaped Nickel document. JSON is a
    // strict subset of Nickel's record syntax once `:` is rewritten to
    // `=` and string keys are unquoted. For piloto self-host we keep
    // it pragmatic — full byte-stable render lands later.
    let mut out = String::new();
    out.push_str("# Generated by `update_ontology_node` op (O4). Re-run to update.\n\n");
    out.push_str(&serde_json::to_string_pretty(core).unwrap_or_else(|_| "{}".to_owned()));
    out.push('\n');
    out
}

/// Reject when the proposed node is missing required identity fields.
#[onto_validator(
    id = "ontology_node_well_formed",
    description = "Reject update_ontology_node when the proposed node lacks id, or when both name \
                   and description are missing on an insert.",
    category = "Contextual",
    slice_query = "ontology_node_by_id"
)]
fn ontology_node_well_formed(slice: &Slice, _ctx: &ValidationCtx) -> Verdict {
    let Some(inputs) = slice.get("inputs") else {
        return Verdict::Unknown;
    };
    let node_id = inputs.get("node_id").and_then(|v| v.as_str()).unwrap_or("");
    if node_id.is_empty() {
        return Verdict::reject("missing 'node_id' input", "ontology_node_well_formed");
    }
    let is_insert = slice
        .get("existing_node")
        .map(|v| v.is_null())
        .unwrap_or(true);
    if is_insert {
        let has_name = inputs
            .get("name")
            .and_then(|v| v.as_str())
            .map(|s| !s.is_empty())
            .unwrap_or(false);
        let has_description = inputs
            .get("description")
            .and_then(|v| v.as_str())
            .map(|s| !s.is_empty())
            .unwrap_or(false);
        if !has_name && !has_description {
            return Verdict::reject(
                "inserting a new node requires at least one of name or description",
                "ontology_node_well_formed",
            );
        }
    }
    Verdict::Accept
}
