//! `move_fsm_state` — transition an FSM dimension's `current_state`.
//!
//! First domain operation in the piloto (O3). Demonstrates the full
//! pipeline: load FSM slice, pre-validate transition allowed, mutate
//! both the typed StateConfig and the substrate commit layer, render
//! `state.ncl` byte-stable, post-validate invariants, emit a signed
//! `state_transition` witness whose Merkle proof covers the
//! `current_state` cell.

use ontoref_derive::onto_operation;
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};

/// Attribute name used for the `current_state` cell across all FSM
/// dimensions. Witness consumers grep this string to locate the cell.
pub const CURRENT_STATE_ATTR: &str = "current_state";

/// Witness payload kind emitted by [`move_fsm_state`].
pub const WITNESS_SHAPE: &str = "state_transition";

/// Move an FSM dimension to a new `current_state`.
///
/// Inputs:
/// - `dimension` (String, required): id of the dimension to mutate.
/// - `new_state` (String, required): target state.
///
/// The op asserts a single substrate cell `(dimension, current_state)
/// = Value::Str(new_state)` into the commit layer and mutates
/// `ctx.state.dimensions[i].current_state` in place so the renderer
/// can produce the corresponding `state.ncl` bytes.
#[onto_operation(
    id = "move_fsm_state",
    description = "Transition an FSM dimension's current_state. Pre-validates the transition \
                   exists and post-validates FSM invariants.",
    validation_sla = "Synchronous",
    precondition = "fsm_dimension_by_id",
    pre_validators = "fsm_transition_allowed",
    post_validators = "fsm_invariants_preserved",
    effects = "Update:FsmDimension:current_state",
    render_paths = ".ontology/state.ncl",
    witness_shape = "state_transition",
    actor_policy = "developer,admin,ci"
)]
fn move_fsm_state(inputs: &serde_json::Value, ctx: &mut OpContext) -> Result<OpOutput, OpError> {
    let dim_id = inputs
        .get("dimension")
        .and_then(|v| v.as_str())
        .ok_or_else(|| OpError::body("move_fsm_state", "missing 'dimension' input"))?
        .to_owned();
    let new_state = inputs
        .get("new_state")
        .and_then(|v| v.as_str())
        .ok_or_else(|| OpError::body("move_fsm_state", "missing 'new_state' input"))?
        .to_owned();

    let state = ctx
        .state
        .as_mut()
        .ok_or_else(|| OpError::body("move_fsm_state", "ctx.state is None"))?;
    let dimension = state
        .dimensions
        .iter_mut()
        .find(|d| d.id == dim_id)
        .ok_or_else(|| {
            OpError::body(
                "move_fsm_state",
                format!("dimension '{dim_id}' not present in ctx.state"),
            )
        })?;
    dimension.current_state.clone_from(&new_state);

    // Append the corresponding EntityAssert into the substrate commit
    // layer so the witness Merkle proof covers the cell.
    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: vec![(
                    AttrId::new(CURRENT_STATE_ATTR),
                    Value::Str(SmolStr::new(&new_state)),
                )],
                entity: EntityId::new(&dim_id),
                valid_from: None,
            },
            timestamp,
        },
        &signing_key,
    );
    ctx.commit_layer.apply(&op);

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

    Ok(OpOutput {
        effects: vec![EffectRecord {
            kind: "Update".to_owned(),
            entity: dim_id.clone(),
            attrs: vec![CURRENT_STATE_ATTR.to_owned()],
            dimensions: vec![dim_id],
        }],
        witness_payload,
    })
}

// ── Validators ──────────────────────────────────────────────────────────────

use ontoref_derive::onto_validator;

use crate::validation::traits::{Slice, ValidationCtx};
use crate::validation::verdict::Verdict;

/// Reject when the requested transition is not declared in the
/// dimension's `transitions` array. Slice shape: `{ dimension: <Dim>,
/// inputs: <op-inputs> }` (see `runtime::load_fsm_dimension_slice`).
#[onto_validator(
    id = "fsm_transition_allowed",
    description = "Reject move_fsm_state when no transition with the requested `to` state exists \
                   in the dimension.",
    category = "Contextual",
    slice_query = "fsm_dimension_by_id"
)]
fn fsm_transition_allowed(slice: &Slice, _ctx: &ValidationCtx) -> Verdict {
    let Some(inputs) = slice.get("inputs") else {
        return Verdict::Unknown;
    };
    let Some(new_state) = inputs.get("new_state").and_then(|v| v.as_str()) else {
        return Verdict::reject("missing 'new_state' input", "fsm_transition_allowed");
    };
    let Some(dimension) = slice.get("dimension") else {
        return Verdict::Unknown;
    };
    let Some(transitions) = dimension.get("transitions").and_then(|v| v.as_array()) else {
        return Verdict::reject(
            "dimension has no `transitions` array",
            "fsm_transition_allowed",
        );
    };
    let allowed = transitions.iter().any(|t| {
        t.get("to")
            .and_then(|v| v.as_str())
            .map(|s| s == new_state)
            .unwrap_or(false)
    });
    if allowed {
        Verdict::Accept
    } else {
        Verdict::reject(
            format!("no transition with to='{new_state}' declared on this dimension"),
            "fsm_transition_allowed",
        )
    }
}

/// Reject when the post-op state violates FSM invariants. For O3 the
/// invariant is: `current_state` MUST appear either as `from` or `to`
/// on some transition. (Future iterations layer in coupled-dimension
/// checks once those land.)
#[onto_validator(
    id = "fsm_invariants_preserved",
    description = "Reject if the dimension's current_state does not appear in any transition \
                   after move_fsm_state has run.",
    category = "Contextual",
    slice_query = "fsm_dimension_by_id"
)]
fn fsm_invariants_preserved(slice: &Slice, _ctx: &ValidationCtx) -> Verdict {
    let Some(dimension) = slice.get("dimension") else {
        return Verdict::Unknown;
    };
    let Some(current_state) = dimension.get("current_state").and_then(|v| v.as_str()) else {
        return Verdict::reject(
            "dimension has no `current_state` field",
            "fsm_invariants_preserved",
        );
    };
    let Some(transitions) = dimension.get("transitions").and_then(|v| v.as_array()) else {
        return Verdict::reject(
            "dimension has no `transitions` array",
            "fsm_invariants_preserved",
        );
    };
    let referenced = transitions.iter().any(|t| {
        t.get("from")
            .and_then(|v| v.as_str())
            .map(|s| s == current_state)
            .unwrap_or(false)
            || t.get("to")
                .and_then(|v| v.as_str())
                .map(|s| s == current_state)
                .unwrap_or(false)
    });
    if referenced {
        Verdict::Accept
    } else {
        Verdict::reject(
            format!("current_state '{current_state}' is not referenced by any transition"),
            "fsm_invariants_preserved",
        )
    }
}
