//! Save-on-NCL reconciler (Phase 1, O3.5).
//!
//! Diffs two `StateConfig` values and emits the catalogued op id +
//! inputs that would transform the OLD value into the NEW value. The
//! daemon's file watcher consumes this output, dispatches each op via
//! [`crate::dispatch_op`], and rolls the file write back if any diff
//! cannot be expressed via a registered op.

use ontoref_ontology::types::StateConfig;
use serde::{Deserialize, Serialize};

use crate::context::OpContext;
use crate::dispatch::dispatch_op;
use crate::error::OpError;
use crate::registry::OperationEntry;
use crate::runtime::PipelineResult;

/// One diff entry the reconciler emits.
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct StateDiff {
    /// Catalog id of the op that would apply this diff.
    pub op_id: String,
    /// Inputs to pass to [`crate::dispatch_op`].
    pub inputs: serde_json::Value,
    /// Human-readable description of the diff. Surfaces to the rollback
    /// notification when the op is missing from the catalog.
    pub summary: String,
}

/// Reconciler failure modes — distinguishes "unknown op (rollback the
/// save)" from runtime errors propagated from dispatch.
#[derive(Debug, thiserror::Error)]
pub enum ReconcileError {
    /// The NCL edit produced a diff that no registered op can apply.
    /// The caller (the watcher) MUST rollback the file write and
    /// surface this error to the editor.
    #[error("no registered op can apply diff: {0}")]
    UncatalogedDiff(String),

    /// A registered op failed during dispatch.
    #[error("dispatch '{op_id}': {source}")]
    Dispatch {
        /// Op id that failed.
        op_id: String,
        /// Underlying op error.
        #[source]
        source: OpError,
    },
}

/// Compute the set of diffs required to transform `old` into `new`.
///
/// Phase 1 covers `current_state` changes (mapped to `move_fsm_state`).
/// Other diffs return as [`StateDiff::op_id == ""`] (uncatalogued) so
/// the watcher knows to rollback rather than silently skip.
pub fn diff_state(old: &StateConfig, new: &StateConfig) -> Vec<StateDiff> {
    let mut diffs = Vec::new();

    // current_state mutations.
    for new_dim in &new.dimensions {
        let Some(old_dim) = old.dimensions.iter().find(|d| d.id == new_dim.id) else {
            diffs.push(StateDiff {
                op_id: String::new(),
                inputs: serde_json::json!({ "dimension_added": new_dim.id }),
                summary: format!(
                    "dimension '{}' added — no catalogued op covers this",
                    new_dim.id
                ),
            });
            continue;
        };
        if old_dim.current_state != new_dim.current_state {
            diffs.push(StateDiff {
                op_id: "move_fsm_state".to_owned(),
                inputs: serde_json::json!({
                    "dimension": new_dim.id,
                    "new_state": new_dim.current_state,
                }),
                summary: format!(
                    "dimension '{}' current_state: '{}' → '{}'",
                    new_dim.id, old_dim.current_state, new_dim.current_state
                ),
            });
        }
        if old_dim.desired_state != new_dim.desired_state {
            diffs.push(StateDiff {
                op_id: String::new(),
                inputs: serde_json::json!({
                    "dimension": new_dim.id,
                    "old_desired": old_dim.desired_state,
                    "new_desired": new_dim.desired_state,
                }),
                summary: format!(
                    "dimension '{}' desired_state: '{}' → '{}' — no catalogued op covers this in \
                     Phase 1",
                    new_dim.id, old_dim.desired_state, new_dim.desired_state
                ),
            });
        }
    }
    // Dimensions removed entirely.
    for old_dim in &old.dimensions {
        if !new.dimensions.iter().any(|d| d.id == old_dim.id) {
            diffs.push(StateDiff {
                op_id: String::new(),
                inputs: serde_json::json!({ "dimension_removed": old_dim.id }),
                summary: format!(
                    "dimension '{}' removed — no catalogued op covers this",
                    old_dim.id
                ),
            });
        }
    }

    diffs
}

/// Apply every diff via [`dispatch_op`]. Returns one pipeline result
/// per diff. The function aborts on the first failure — pipeline
/// errors propagate so the caller can rollback partial state.
///
/// # Errors
///
/// - [`ReconcileError::UncatalogedDiff`] when any diff carries an empty `op_id`
///   (no registered op covers the edit).
/// - [`ReconcileError::Dispatch`] for runtime errors propagated from the
///   underlying [`dispatch_op`].
pub fn apply_diffs(
    diffs: &[StateDiff],
    ctx: &mut OpContext,
) -> Result<Vec<PipelineResult>, ReconcileError> {
    // First pass: confirm every diff is catalogued. We refuse to apply
    // any diff if even one cannot be expressed — leaves the substrate
    // untouched when the editor's save needs a rollback.
    for diff in diffs {
        if diff.op_id.is_empty() {
            return Err(ReconcileError::UncatalogedDiff(diff.summary.clone()));
        }
        if !inventory::iter::<OperationEntry>().any(|e| e.id == diff.op_id) {
            return Err(ReconcileError::UncatalogedDiff(format!(
                "op '{}' is not in the registered catalog (summary: {})",
                diff.op_id, diff.summary
            )));
        }
    }

    // Second pass: dispatch each.
    let mut results = Vec::with_capacity(diffs.len());
    for diff in diffs {
        let result = dispatch_op(&diff.op_id, &diff.inputs, ctx).map_err(|source| {
            ReconcileError::Dispatch {
                op_id: diff.op_id.clone(),
                source,
            }
        })?;
        results.push(result);
    }
    Ok(results)
}

#[cfg(test)]
mod tests {
    use ontoref_ontology::types::{
        Dimension, DimensionState, Horizon, StateConfig, TensionLevel, Transition,
    };

    use super::*;

    fn dim(id: &str, current: &str) -> Dimension {
        Dimension {
            id: id.to_owned(),
            name: id.to_owned(),
            description: String::new(),
            current_state: current.to_owned(),
            desired_state: "z".to_owned(),
            horizon: Horizon::Weeks,
            states: vec![DimensionState {
                id: current.to_owned(),
                name: current.to_owned(),
                description: String::new(),
                tension: TensionLevel::Low,
            }],
            transitions: vec![Transition {
                from: "a".to_owned(),
                to: "b".to_owned(),
                condition: "ready".to_owned(),
                catalyst: String::new(),
                blocker: String::new(),
                horizon: Horizon::Weeks,
            }],
            coupled_with: vec![],
        }
    }

    #[test]
    fn no_diff_when_states_match() {
        let s1 = StateConfig {
            dimensions: vec![dim("d1", "a")],
        };
        let s2 = StateConfig {
            dimensions: vec![dim("d1", "a")],
        };
        assert!(diff_state(&s1, &s2).is_empty());
    }

    #[test]
    fn current_state_change_maps_to_move_fsm_state() {
        let s1 = StateConfig {
            dimensions: vec![dim("d1", "a")],
        };
        let s2 = StateConfig {
            dimensions: vec![dim("d1", "b")],
        };
        let diffs = diff_state(&s1, &s2);
        assert_eq!(diffs.len(), 1);
        assert_eq!(diffs[0].op_id, "move_fsm_state");
        assert_eq!(diffs[0].inputs["dimension"], "d1");
        assert_eq!(diffs[0].inputs["new_state"], "b");
    }

    #[test]
    fn added_dimension_is_uncatalogued() {
        let s1 = StateConfig {
            dimensions: vec![dim("d1", "a")],
        };
        let s2 = StateConfig {
            dimensions: vec![dim("d1", "a"), dim("d2", "a")],
        };
        let diffs = diff_state(&s1, &s2);
        assert_eq!(diffs.len(), 1);
        assert!(diffs[0].op_id.is_empty());
        assert!(diffs[0].summary.contains("dimension 'd2' added"));
    }

    #[test]
    fn removed_dimension_is_uncatalogued() {
        let s1 = StateConfig {
            dimensions: vec![dim("d1", "a"), dim("d2", "a")],
        };
        let s2 = StateConfig {
            dimensions: vec![dim("d1", "a")],
        };
        let diffs = diff_state(&s1, &s2);
        assert_eq!(diffs.len(), 1);
        assert!(diffs[0].op_id.is_empty());
        assert!(diffs[0].summary.contains("dimension 'd2' removed"));
    }

    #[test]
    fn desired_state_change_is_uncatalogued_in_phase_1() {
        let mut a = dim("d1", "a");
        a.desired_state = "z".to_owned();
        let mut b = dim("d1", "a");
        b.desired_state = "q".to_owned();
        let s1 = StateConfig {
            dimensions: vec![a],
        };
        let s2 = StateConfig {
            dimensions: vec![b],
        };
        let diffs = diff_state(&s1, &s2);
        assert_eq!(diffs.len(), 1);
        assert!(diffs[0].op_id.is_empty());
        assert!(diffs[0].summary.contains("desired_state"));
    }
}
