//! Reusable precondition for the `ondaod` (ontoref Dao Discipline) procedure.
//!
//! Loads the named `'Tension` nodes from `.ontology/core.ncl` plus the
//! canonical `ontoref-dao-discipline` Q&A entry from `reflection/qa.ncl`, and
//! assembles them into an [`OndaodContext`]. Callers identify which tensions
//! a question engages and pass their characterization to [`evaluate`]; the
//! function validates the IDs against the loaded context and emits a typed
//! [`OndaodEvaluation`].
//!
//! Engagement detection is NOT automated — the discipline (see the procedure
//! prose in the qa entry) explicitly forbids collapsing the Spiral by
//! algorithm. The caller is responsible for naming engaged tensions and
//! characterizing synthesis state and motion direction.

use serde::{Deserialize, Serialize};

// NOTE: this loader intentionally does NOT use
// `ontoref_ontology::Core::from_value` because that constructor parses the
// `edges` array, and the existing `Edge.weight: f64` Rust type does not match
// the real `.ontology/core.ncl` edges (which carry `weight = 'High'` enum
// tags). Fixing the Edge weight type is tracked separately; O0 reads only the
// `nodes` array which is unaffected.

/// Canonical Q&A entry id for the protocol baseline ondaod procedure.
pub const ONDAOD_QA_ID: &str = "ontoref-dao-discipline";

/// Loaded ondaod context: all tension nodes plus the discipline procedure.
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct OndaodContext {
    /// All `level = 'Tension'` nodes from `.ontology/core.ncl`, in declaration
    /// order.
    pub tensions: Vec<TensionRef>,
    /// The id of the Q&A entry from which the procedure was loaded.
    pub discipline_qa_id: String,
    /// Procedure prose extracted from the Q&A entry's `answer` field.
    pub discipline_procedure: String,
}

/// Light projection of a tension node — id, name, description.
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
pub struct TensionRef {
    pub id: String,
    pub name: String,
    pub description: String,
}

/// Position on the synthesis flow named in the discipline procedure.
///
/// The five values correspond to the prose contract in the
/// `ontoref-dao-discipline` Q&A entry: a tension may be claim-only,
/// actively populating, realized as artefact, consumed by a later
/// resolution, or dormant.
#[derive(Debug, Clone, Copy, Serialize, Deserialize, PartialEq, Eq)]
pub enum SynthesisState {
    ClaimOnly,
    Populating,
    Realized,
    Consumed,
    Dormant,
}

/// Direction of motion on a tension's Spiral.
///
/// `Static` means no observable motion at this analysis moment — the
/// project sits between Yang and Yin without trending either way.
#[derive(Debug, Clone, Copy, Serialize, Deserialize, PartialEq, Eq)]
pub enum MotionDirection {
    TowardYang,
    TowardYin,
    Static,
}

/// Per-tension characterization the caller provides.
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct EngagedTensionInput {
    pub tension_id: String,
    pub synthesis_state: SynthesisState,
    pub motion_direction: MotionDirection,
}

/// Single engaged-tension record inside an evaluation result.
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
pub struct EngagedTension {
    pub tension_id: String,
    pub synthesis_state: SynthesisState,
    pub motion_direction: MotionDirection,
}

/// Typed result of applying ondaod to a question or proposed decision.
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct OndaodEvaluation {
    /// Tensions the caller named as engaged, with per-tension characterization.
    pub engaged_tensions: Vec<EngagedTension>,
    /// Overall synthesis-state summary; `"no Spiral tensions engaged"` when
    /// `engaged_tensions` is empty.
    pub synthesis_state: String,
    /// Direction of overall motion the caller declared for the decision.
    pub motion_direction: MotionDirection,
    /// The loaded context that backed the evaluation — kept so consumers can
    /// audit which tensions were available at evaluation time.
    pub context: OndaodContext,
}

/// Failures distinguishing missing artefacts from malformed inputs.
#[derive(Debug, thiserror::Error)]
pub enum OndaodError {
    #[error("failed loading core ontology: {0}")]
    Core(#[source] anyhow::Error),
    #[error("failed loading qa store: {0}")]
    Qa(#[source] anyhow::Error),
    #[error("qa entry '{0}' not found — protocol baseline ondaod procedure missing")]
    DisciplineMissing(String),
    #[error("engaged tension '{0}' is not a 'Tension' node in core.ncl")]
    UnknownTension(String),
}

/// Build an [`OndaodContext`] from pre-fetched core and qa JSON values.
///
/// Filters `core.ncl` nodes by `level == "Tension"` and extracts the
/// canonical [`ONDAOD_QA_ID`] entry from `qa.ncl`. The returned context is
/// pure data — no IO happens after this call.
///
/// # Errors
///
/// - [`OndaodError::Core`] when `core_json` cannot be deserialized into the
///   `nodes` shape required for tension extraction.
/// - [`OndaodError::Qa`] when `qa_json` is not a valid `QaStore` envelope.
/// - [`OndaodError::DisciplineMissing`] when no Q&A entry with id
///   `ontoref-dao-discipline` is present.
///
/// # Examples
///
/// ```ignore
/// # use ontoref_ops::precondition::ondaod::load;
/// let core_json = serde_json::json!({ "nodes": [] });
/// let qa_json = serde_json::json!({ "entries": [
///     { "id": "ontoref-dao-discipline", "answer": "..." }
/// ]});
/// let ctx = load(&core_json, &qa_json)?;
/// assert_eq!(ctx.discipline_qa_id, "ontoref-dao-discipline");
/// # Ok::<(), ontoref_ops::precondition::ondaod::OndaodError>(())
/// ```
pub fn load(
    core_json: &serde_json::Value,
    qa_json: &serde_json::Value,
) -> Result<OndaodContext, OndaodError> {
    let core: CoreNodesOnly = serde_json::from_value(core_json.clone())
        .map_err(|e| OndaodError::Core(anyhow::anyhow!(e)))?;

    let tensions: Vec<TensionRef> = core
        .nodes
        .into_iter()
        .filter(|n| n.level == "Tension")
        .map(|n| TensionRef {
            id: n.id,
            name: n.name,
            description: n.description,
        })
        .collect();

    let qa: QaStore =
        serde_json::from_value(qa_json.clone()).map_err(|e| OndaodError::Qa(anyhow::anyhow!(e)))?;

    let entry = qa
        .entries
        .into_iter()
        .find(|e| e.id == ONDAOD_QA_ID)
        .ok_or_else(|| OndaodError::DisciplineMissing(ONDAOD_QA_ID.to_string()))?;

    Ok(OndaodContext {
        tensions,
        discipline_qa_id: entry.id,
        discipline_procedure: entry.answer,
    })
}

/// Combine a loaded context with caller-supplied engaged tensions.
///
/// Returns a typed [`OndaodEvaluation`] that records which tensions the
/// caller named as engaged, the synthesis state and motion per tension, and
/// an overall motion direction for the decision. Empty `engaged` is allowed
/// (and meaningful — it records "no Spiral tensions engaged").
///
/// # Errors
///
/// - [`OndaodError::UnknownTension`] when any `tension_id` in `engaged` is not
///   present in `context.tensions`. The evaluation rejects entirely; partial
///   application is not supported because the discipline requires the
///   engagement set to be coherent with the loaded tensions snapshot.
///
/// # Examples
///
/// ```ignore
/// # use ontoref_ops::precondition::ondaod::{evaluate, EngagedTensionInput, MotionDirection, SynthesisState};
/// # let context = todo!();
/// let evaluation = evaluate(
///     context,
///     vec![EngagedTensionInput {
///         tension_id: "ontology-vs-reflection".to_string(),
///         synthesis_state: SynthesisState::Realized,
///         motion_direction: MotionDirection::Static,
///     }],
///     MotionDirection::Static,
/// )?;
/// # Ok::<(), ontoref_ops::precondition::ondaod::OndaodError>(())
/// ```
pub fn evaluate(
    context: OndaodContext,
    engaged: Vec<EngagedTensionInput>,
    overall_motion: MotionDirection,
) -> Result<OndaodEvaluation, OndaodError> {
    for input in &engaged {
        if !context.tensions.iter().any(|t| t.id == input.tension_id) {
            return Err(OndaodError::UnknownTension(input.tension_id.clone()));
        }
    }

    let engaged_tensions: Vec<EngagedTension> = engaged
        .into_iter()
        .map(|i| EngagedTension {
            tension_id: i.tension_id,
            synthesis_state: i.synthesis_state,
            motion_direction: i.motion_direction,
        })
        .collect();

    let synthesis_state = if engaged_tensions.is_empty() {
        "no Spiral tensions engaged".to_string()
    } else {
        format!("{} tension(s) engaged", engaged_tensions.len())
    };

    Ok(OndaodEvaluation {
        engaged_tensions,
        synthesis_state,
        motion_direction: overall_motion,
        context,
    })
}

#[derive(Debug, Deserialize)]
struct QaStore {
    entries: Vec<QaEntry>,
}

#[derive(Debug, Deserialize)]
struct QaEntry {
    id: String,
    answer: String,
}

#[derive(Debug, Deserialize)]
struct CoreNodesOnly {
    nodes: Vec<NodeRow>,
}

#[derive(Debug, Deserialize)]
struct NodeRow {
    id: String,
    name: String,
    level: String,
    description: String,
}

#[cfg(test)]
mod tests {
    use std::path::{Path, PathBuf};
    use std::process::Command;

    use super::*;

    fn workspace_root() -> PathBuf {
        // CARGO_MANIFEST_DIR = crates/ontoref-ops; parent twice = workspace root.
        PathBuf::from(env!("CARGO_MANIFEST_DIR"))
            .parent()
            .and_then(Path::parent)
            .expect("workspace root resolvable from CARGO_MANIFEST_DIR")
            .to_path_buf()
    }

    fn nickel_export(path: &Path) -> serde_json::Value {
        let output = Command::new("nickel")
            .arg("export")
            .arg("--format")
            .arg("json")
            .arg(path)
            .output()
            .expect("nickel binary must be on PATH for this test");
        assert!(
            output.status.success(),
            "nickel export failed for {}: {}",
            path.display(),
            String::from_utf8_lossy(&output.stderr)
        );
        serde_json::from_slice(&output.stdout).expect("nickel output is valid JSON")
    }

    #[test]
    fn load_discipline_and_evaluate() {
        let root = workspace_root();
        let core_json = nickel_export(&ontoref_ontology::layout::resolve_section(
            &root,
            "ontology/core.ncl",
        ));
        let qa_json = nickel_export(&ontoref_ontology::layout::resolve_section(
            &root,
            "reflection/qa.ncl",
        ));

        let context = load(&core_json, &qa_json).expect("load ondaod context");

        // The two named Spiral tensions live in core.ncl.
        assert!(context
            .tensions
            .iter()
            .any(|t| t.id == "formalization-vs-adoption"));
        assert!(context
            .tensions
            .iter()
            .any(|t| t.id == "ontology-vs-reflection"));
        assert_eq!(context.discipline_qa_id, ONDAOD_QA_ID);
        // Procedure prose must contain the canonical step names.
        assert!(context.discipline_procedure.contains("READ"));
        assert!(context.discipline_procedure.contains("IDENTIFY"));
        assert!(context.discipline_procedure.contains("CHARACTERIZE"));

        let evaluation = evaluate(
            context,
            vec![EngagedTensionInput {
                tension_id: "ontology-vs-reflection".to_string(),
                synthesis_state: SynthesisState::Realized,
                motion_direction: MotionDirection::Static,
            }],
            MotionDirection::Static,
        )
        .expect("evaluate against known tension");

        assert_eq!(evaluation.engaged_tensions.len(), 1);
        assert_eq!(
            evaluation.engaged_tensions[0].synthesis_state,
            SynthesisState::Realized
        );
        assert_eq!(evaluation.motion_direction, MotionDirection::Static);
        assert!(evaluation.synthesis_state.contains("1 tension"));
    }

    #[test]
    fn empty_engagement_is_explicit() {
        let root = workspace_root();
        let core_json = nickel_export(&ontoref_ontology::layout::resolve_section(
            &root,
            "ontology/core.ncl",
        ));
        let qa_json = nickel_export(&ontoref_ontology::layout::resolve_section(
            &root,
            "reflection/qa.ncl",
        ));

        let context = load(&core_json, &qa_json).expect("load ondaod context");
        let evaluation = evaluate(context, vec![], MotionDirection::Static)
            .expect("empty engagement is allowed");

        assert!(evaluation.engaged_tensions.is_empty());
        assert_eq!(evaluation.synthesis_state, "no Spiral tensions engaged");
    }

    #[test]
    fn unknown_tension_rejected() {
        let root = workspace_root();
        let core_json = nickel_export(&ontoref_ontology::layout::resolve_section(
            &root,
            "ontology/core.ncl",
        ));
        let qa_json = nickel_export(&ontoref_ontology::layout::resolve_section(
            &root,
            "reflection/qa.ncl",
        ));

        let context = load(&core_json, &qa_json).expect("load ondaod context");
        let err = evaluate(
            context,
            vec![EngagedTensionInput {
                tension_id: "non-existent-tension".to_string(),
                synthesis_state: SynthesisState::Populating,
                motion_direction: MotionDirection::Static,
            }],
            MotionDirection::Static,
        )
        .expect_err("unknown tension must error");
        assert!(matches!(err, OndaodError::UnknownTension(ref s) if s == "non-existent-tension"));
    }
}
