//! Integration coverage for the save-on-NCL reconciler (O3.5).
//!
//! Exercises the full simulated edit → diff → dispatch path: an
//! initial StateConfig is constructed in-memory, a "user edit" mutates
//! it, the reconciler diffs the two values, and the runtime dispatches
//! the resulting ops. Uncatalogued edits surface as
//! [`ReconcileError::UncatalogedDiff`] so the watcher can rollback.

use ed25519_dalek::SigningKey;
use ontoref_ontology::types::{
    Dimension, DimensionState, Horizon, StateConfig, TensionLevel, Transition,
};
use ontoref_ops::reconcile::{apply_diffs, diff_state, ReconcileError};
use ontoref_ops::{OpContext, OpError};

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

fn sample_state(current: &str) -> StateConfig {
    StateConfig {
        dimensions: vec![Dimension {
            id: "adoption".to_owned(),
            name: "Adoption".to_owned(),
            description: "Sample dimension for save-reconciler O3.5 coverage.".to_owned(),
            current_state: current.to_owned(),
            desired_state: "c".to_owned(),
            horizon: Horizon::Weeks,
            states: vec![
                DimensionState {
                    id: "a".to_owned(),
                    name: "A".to_owned(),
                    description: String::new(),
                    tension: TensionLevel::Low,
                },
                DimensionState {
                    id: "b".to_owned(),
                    name: "B".to_owned(),
                    description: String::new(),
                    tension: TensionLevel::Medium,
                },
                DimensionState {
                    id: "c".to_owned(),
                    name: "C".to_owned(),
                    description: String::new(),
                    tension: TensionLevel::Low,
                },
            ],
            transitions: vec![
                Transition {
                    from: "a".to_owned(),
                    to: "b".to_owned(),
                    condition: "ready".to_owned(),
                    catalyst: "trigger".to_owned(),
                    blocker: "none".to_owned(),
                    horizon: Horizon::Weeks,
                },
                Transition {
                    from: "b".to_owned(),
                    to: "c".to_owned(),
                    condition: "ready".to_owned(),
                    catalyst: "trigger".to_owned(),
                    blocker: "none".to_owned(),
                    horizon: Horizon::Weeks,
                },
            ],
            coupled_with: vec![],
        }],
    }
}

#[test]
fn diff_yields_one_move_fsm_state_per_current_state_change() {
    let old = sample_state("a");
    let new = sample_state("b");
    let diffs = diff_state(&old, &new);
    assert_eq!(diffs.len(), 1);
    assert_eq!(diffs[0].op_id, "move_fsm_state");
    assert_eq!(diffs[0].inputs["dimension"], "adoption");
    assert_eq!(diffs[0].inputs["new_state"], "b");
}

#[test]
fn apply_diffs_dispatches_via_runtime() {
    let tmp = tempfile::tempdir().expect("tempdir");
    std::fs::create_dir_all(tmp.path().join(".ontoref/ontology")).expect("mkdir");

    let old = sample_state("a");
    let new = sample_state("b");
    let diffs = diff_state(&old, &new);

    let mut ctx = OpContext::new_in_memory("developer", "", signing_key())
        .with_state(old)
        .with_project_root(tmp.path().to_path_buf());

    let results = apply_diffs(&diffs, &mut ctx).expect("apply ok");
    assert_eq!(results.len(), 1);
    let result = &results[0];
    assert_eq!(result.witness.op_id, "move_fsm_state");
    result.witness.verify().expect("witness verifies");
    assert_eq!(ctx.state.as_ref().unwrap().dimensions[0].current_state, "b");
}

#[test]
fn apply_diffs_rolls_back_on_uncatalogued_edit() {
    // Adding a new dimension is not catalogued in Phase 1 — the
    // reconciler refuses the whole batch BEFORE any dispatch fires.
    let tmp = tempfile::tempdir().expect("tempdir");
    std::fs::create_dir_all(tmp.path().join(".ontoref/ontology")).expect("mkdir");

    let mut new = sample_state("a");
    new.dimensions.push(Dimension {
        id: "new-dim".to_owned(),
        name: "New".to_owned(),
        description: String::new(),
        current_state: "x".to_owned(),
        desired_state: "y".to_owned(),
        horizon: Horizon::Weeks,
        states: vec![],
        transitions: vec![],
        coupled_with: vec![],
    });

    let diffs = diff_state(&sample_state("a"), &new);
    let mut ctx = OpContext::new_in_memory("developer", "", signing_key())
        .with_state(sample_state("a"))
        .with_project_root(tmp.path().to_path_buf());

    let err = apply_diffs(&diffs, &mut ctx).expect_err("uncatalogued edit must reject");
    match err {
        ReconcileError::UncatalogedDiff(msg) => {
            assert!(
                msg.contains("new-dim"),
                "diff summary must reference the new dim: {msg}"
            );
        }
        other => panic!("expected UncatalogedDiff, got {other:?}"),
    }

    // State left untouched — first-pass refusal preserves the
    // pre-edit substrate.
    assert_eq!(ctx.state.as_ref().unwrap().dimensions[0].current_state, "a");
}

#[test]
fn apply_diffs_propagates_validator_rejection() {
    let tmp = tempfile::tempdir().expect("tempdir");
    std::fs::create_dir_all(tmp.path().join(".ontoref/ontology")).expect("mkdir");

    // A "save" that transitions to a state with no declared transition
    // — the diff IS catalogued (current_state changed) but the
    // pre-validator will reject.
    let mut new = sample_state("a");
    new.dimensions[0].current_state = "nonexistent".to_owned();

    let diffs = diff_state(&sample_state("a"), &new);
    assert_eq!(diffs.len(), 1);
    assert_eq!(diffs[0].op_id, "move_fsm_state");

    let mut ctx = OpContext::new_in_memory("developer", "", signing_key())
        .with_state(sample_state("a"))
        .with_project_root(tmp.path().to_path_buf());

    let err = apply_diffs(&diffs, &mut ctx).expect_err("validator must reject");
    match err {
        ReconcileError::Dispatch { op_id, source } => {
            assert_eq!(op_id, "move_fsm_state");
            assert!(
                matches!(source, OpError::ValidatorRejected { .. }),
                "expected validator rejection, got {source:?}"
            );
        }
        other => panic!("expected Dispatch error, got {other:?}"),
    }
}
