//! Deterministic world-state engine: replay a trajectory against a scenario.
//!
//! [`run_scenario`] walks the recorded tool calls in order, applies each
//! legal transition to a clone of the seeded world, and records every
//! invalid action, forbidden transition, recovery attempt, escalation, and
//! refusal. It never performs I/O and never calls a model, so it produces the
//! same report from a cassette in CI as from a live run. [`report_envelope`]
//! flattens the report into the assertion target object the `expect:` block
//! resolves against.

use serde_json::{json, Value};

use super::model::{
    EffectKind, EffectOp, ForbiddenHit, GoldenOutcome, GuardKind, GuardOp, InvalidAction,
    InvalidKind, Scenario, ScenarioReport, StateMismatch, Trajectory,
};
use super::world::{diff, get_path, set_path};
use crate::eval::{score_golden_path, GoldenPath};

/// Replay `trajectory` against `scenario`'s world and build the report.
pub fn run_scenario(scenario: &Scenario, trajectory: &Trajectory) -> ScenarioReport {
    let mut state = if scenario.seed.is_object() {
        scenario.seed.clone()
    } else {
        json!({})
    };
    let mut invalid_actions = Vec::new();
    let mut forbidden_transitions = Vec::new();
    let mut recovery_attempts = 0usize;
    let mut names = Vec::with_capacity(trajectory.actions.len());

    for (i, action) in trajectory.actions.iter().enumerate() {
        names.push(action.name.clone());
        if i > 0 && trajectory.actions[i - 1].error {
            recovery_attempts += 1;
        }
        if let Some(rule) = scenario
            .forbidden
            .iter()
            .find(|r| r.tool == action.name && guard_satisfied(&r.when, &state).is_ok())
        {
            forbidden_transitions.push(ForbiddenHit {
                index: i,
                tool: action.name.clone(),
                reason: rule.reason.clone(),
            });
            continue;
        }
        match scenario.transitions.iter().find(|t| t.tool == action.name) {
            None => invalid_actions.push(InvalidAction {
                index: i,
                tool: action.name.clone(),
                kind: InvalidKind::UnknownAction,
                detail: format!("no transition declares tool `{}`", action.name),
            }),
            Some(transition) => match guard_satisfied(&transition.when, &state) {
                Err(reason) => invalid_actions.push(InvalidAction {
                    index: i,
                    tool: action.name.clone(),
                    kind: InvalidKind::Precondition,
                    detail: reason,
                }),
                Ok(()) if !action.error => {
                    apply_effect(&mut state, transition, &action.args);
                }
                Ok(()) => {} // valid action, but the server errored: no state change
            },
        }
    }

    let state_mismatches = check_expected_state(scenario, &state);
    let golden = scenario.golden.as_ref().map(|g| golden_outcome(&names, g));
    let judge_evidence = scenario.rubric.as_ref().map(|_| {
        json!({
            "status": "deferred",
            "reason": "rubric configured; evaluated only with a live provider",
        })
    });

    ScenarioReport {
        name: scenario.name.clone(),
        turn_count: trajectory.final_responses.len(),
        invalid_actions,
        forbidden_transitions,
        recovery_attempts,
        escalations: count_escalations(scenario, trajectory),
        refusals: count_refusals(scenario, trajectory),
        state_diff: diff(&scenario.seed, &state),
        state_mismatches,
        golden,
        cost_micros: trajectory.cost_micros,
        judge_evidence,
        final_state: state,
        trajectory: names,
    }
}

/// Flatten a report into the assertion target object. Counts are integers so
/// `invalid_actions equals 0` resolves; the final world is exposed under
/// `state.*` and the call names under `tool_names`.
pub fn report_envelope(report: &ScenarioReport) -> Value {
    let mut envelope = json!({
        "turns": report.turn_count,
        "actions": report.trajectory.len(),
        "invalid_actions": report.invalid_actions.len(),
        "forbidden_transitions": report.forbidden_transitions.len(),
        "recovery_attempts": report.recovery_attempts,
        "escalations": report.escalations,
        "refusals": report.refusals,
        "state_matched": report.state_mismatches.is_empty(),
        "tool_names": report.trajectory,
        "state": report.final_state,
    });
    if let Some(cost) = report.cost_micros {
        envelope["cost_micros"] = json!(cost);
    }
    if let Some(golden) = &report.golden {
        envelope["golden"] = json!({
            "matched": golden.matched,
            "exact": golden.exact,
            "alternate": golden.alternate,
            "extra_steps": golden.score.extra_steps,
            "penalty": golden.score.penalty,
        });
    }
    envelope
}

/// Check every `when` entry against the current world. `Ok(())` when all
/// hold; `Err(reason)` naming the first failing path otherwise.
fn guard_satisfied(
    when: &std::collections::BTreeMap<String, GuardOp>,
    state: &Value,
) -> Result<(), String> {
    for (path, op) in when {
        let current = get_path(state, path);
        let ok = match op {
            GuardOp::Literal(v) | GuardOp::Kind(GuardKind::Eq(v)) => current.as_ref() == Some(v),
            GuardOp::Kind(GuardKind::Min(n)) => as_number(&current).is_some_and(|c| c >= *n),
            GuardOp::Kind(GuardKind::Max(n)) => as_number(&current).is_some_and(|c| c <= *n),
        };
        if !ok {
            return Err(format!(
                "precondition on `{path}` failed (state held {})",
                current.unwrap_or(Value::Null)
            ));
        }
    }
    Ok(())
}

/// Apply a transition's effects to the world in declaration order.
fn apply_effect(state: &mut Value, transition: &super::model::Transition, args: &Value) {
    for (path, op) in &transition.effect {
        let value = match op {
            EffectOp::Literal(v) | EffectOp::Kind(EffectKind::Set(v)) => v.clone(),
            EffectOp::Kind(EffectKind::Inc(n)) => {
                number_value(as_number(&get_path(state, path)).unwrap_or(0.0) + n)
            }
            EffectOp::Kind(EffectKind::Dec(n)) => {
                number_value(as_number(&get_path(state, path)).unwrap_or(0.0) - n)
            }
            EffectOp::Kind(EffectKind::FromArg(arg_path)) => {
                get_path(args, arg_path).unwrap_or(Value::Null)
            }
        };
        set_path(state, path, value);
    }
}

/// Compare the scenario's `expect_state` map against the final world.
fn check_expected_state(scenario: &Scenario, state: &Value) -> Vec<StateMismatch> {
    scenario
        .expect_state
        .iter()
        .filter_map(|(path, expected)| {
            let actual = get_path(state, path);
            if actual.as_ref() == Some(expected) {
                None
            } else {
                Some(StateMismatch {
                    path: path.clone(),
                    expected: expected.clone(),
                    actual,
                })
            }
        })
        .collect()
}

/// Score the recorded call names against the golden trajectory.
fn golden_outcome(names: &[String], golden: &super::model::GoldenTrajectory) -> GoldenOutcome {
    let exact = names == golden.calls.as_slice();
    let alternate = golden.alternates.iter().any(|a| a.as_slice() == names);
    let calls: Vec<Value> = names.iter().map(|n| json!({ "name": n })).collect();
    let score = score_golden_path(&calls, &GoldenPath::new(golden.calls.clone()));
    GoldenOutcome {
        exact,
        alternate,
        matched: exact || alternate,
        score,
    }
}

/// Count refusals: final responses containing any refusal marker.
fn count_refusals(scenario: &Scenario, trajectory: &Trajectory) -> usize {
    let Some(signals) = &scenario.refusal else {
        return 0;
    };
    trajectory
        .final_responses
        .iter()
        .filter(|resp| contains_any(resp, &signals.markers))
        .count()
}

/// Count escalations: escalation-tool calls plus marker phrases in responses.
fn count_escalations(scenario: &Scenario, trajectory: &Trajectory) -> usize {
    let Some(signals) = &scenario.escalation else {
        return 0;
    };
    let tool_hits = trajectory
        .actions
        .iter()
        .filter(|a| signals.tools.iter().any(|t| t == &a.name))
        .count();
    let marker_hits = trajectory
        .final_responses
        .iter()
        .filter(|resp| contains_any(resp, &signals.markers))
        .count();
    tool_hits + marker_hits
}

/// Case-insensitive substring search over a marker list.
fn contains_any(haystack: &str, markers: &[String]) -> bool {
    let lower = haystack.to_lowercase();
    markers.iter().any(|m| lower.contains(&m.to_lowercase()))
}

/// Read a JSON value as an f64 when it is numeric.
fn as_number(value: &Option<Value>) -> Option<f64> {
    value.as_ref().and_then(Value::as_f64)
}

/// Render an f64 back into a JSON number, preferring an integer when whole so
/// `widgets: 6` does not become `6.0` in the report.
fn number_value(n: f64) -> Value {
    if n.fract() == 0.0 && n.abs() < i64::MAX as f64 {
        json!(n as i64)
    } else {
        json!(n)
    }
}

#[cfg(test)]
mod tests {
    use super::*;
    use crate::scenario::model::{
        EscalationSignals, ForbiddenRule, GoldenTrajectory, ScenarioAction, TextSignals, Transition,
    };
    use std::collections::BTreeMap;

    fn action(name: &str) -> ScenarioAction {
        ScenarioAction {
            name: name.into(),
            server: "inv".into(),
            args: json!({}),
            error: false,
        }
    }

    fn effect(path: &str, op: EffectOp) -> BTreeMap<String, EffectOp> {
        let mut m = BTreeMap::new();
        m.insert(path.into(), op);
        m
    }

    fn base_scenario() -> Scenario {
        Scenario {
            name: "restock".into(),
            seed: json!({ "inventory": { "widgets": 3 }, "shelf_full": false }),
            transitions: vec![
                Transition {
                    tool: "add_widget".into(),
                    when: BTreeMap::new(),
                    effect: effect("inventory.widgets", EffectOp::Kind(EffectKind::Inc(1.0))),
                },
                Transition {
                    tool: "mark_full".into(),
                    when: BTreeMap::new(),
                    effect: effect("shelf_full", EffectOp::Literal(json!(true))),
                },
            ],
            forbidden: vec![],
            refusal: None,
            escalation: None,
            golden: None,
            expect_state: BTreeMap::new(),
            expect: vec![],
            rubric: None,
            cassette: None,
            tags: vec![],
            skip: None,
        }
    }

    #[test]
    fn applies_legal_transitions_across_multiple_turns() {
        let scenario = base_scenario();
        let traj = Trajectory {
            actions: vec![
                action("add_widget"),
                action("add_widget"),
                action("mark_full"),
            ],
            final_responses: vec!["done".into()],
            cost_micros: Some(1200),
        };
        let report = run_scenario(&scenario, &traj);
        assert!(report.invalid_actions.is_empty());
        assert_eq!(
            get_path(&report.final_state, "inventory.widgets"),
            Some(json!(5))
        );
        assert_eq!(
            get_path(&report.final_state, "shelf_full"),
            Some(json!(true))
        );
        assert_eq!(report.cost_micros, Some(1200));
    }

    #[test]
    fn flags_unknown_action_as_invalid() {
        let scenario = base_scenario();
        let traj = Trajectory {
            actions: vec![action("teleport")],
            ..Default::default()
        };
        let report = run_scenario(&scenario, &traj);
        assert_eq!(report.invalid_actions.len(), 1);
        assert_eq!(report.invalid_actions[0].kind, InvalidKind::UnknownAction);
    }

    #[test]
    fn precondition_failure_is_invalid_and_skips_effect() {
        let mut scenario = base_scenario();
        let mut when = BTreeMap::new();
        when.insert(
            "inventory.widgets".into(),
            GuardOp::Kind(GuardKind::Min(1.0)),
        );
        scenario.transitions.push(Transition {
            tool: "remove_widget".into(),
            when,
            effect: effect("inventory.widgets", EffectOp::Kind(EffectKind::Dec(1.0))),
        });
        scenario.seed = json!({ "inventory": { "widgets": 0 } });
        let traj = Trajectory {
            actions: vec![action("remove_widget")],
            ..Default::default()
        };
        let report = run_scenario(&scenario, &traj);
        assert_eq!(report.invalid_actions.len(), 1);
        assert_eq!(report.invalid_actions[0].kind, InvalidKind::Precondition);
        assert_eq!(
            get_path(&report.final_state, "inventory.widgets"),
            Some(json!(0))
        );
    }

    #[test]
    fn forbidden_transition_is_recorded_and_never_mutates() {
        let mut scenario = base_scenario();
        scenario.forbidden.push(ForbiddenRule {
            tool: "drop_inventory".into(),
            when: BTreeMap::new(),
            reason: "destructive bulk delete".into(),
        });
        let traj = Trajectory {
            actions: vec![action("drop_inventory")],
            ..Default::default()
        };
        let report = run_scenario(&scenario, &traj);
        assert_eq!(report.forbidden_transitions.len(), 1);
        assert!(report.invalid_actions.is_empty());
        assert_eq!(
            get_path(&report.final_state, "inventory.widgets"),
            Some(json!(3))
        );
    }

    #[test]
    fn counts_recovery_attempts_after_an_errored_call() {
        let scenario = base_scenario();
        let mut first = action("add_widget");
        first.error = true;
        let traj = Trajectory {
            actions: vec![first, action("add_widget")],
            ..Default::default()
        };
        let report = run_scenario(&scenario, &traj);
        assert_eq!(report.recovery_attempts, 1);
        // The errored call did not mutate; only the retry did.
        assert_eq!(
            get_path(&report.final_state, "inventory.widgets"),
            Some(json!(4))
        );
    }

    #[test]
    fn detects_refusals_and_escalations_from_signals() {
        let mut scenario = base_scenario();
        scenario.refusal = Some(TextSignals {
            markers: vec!["I cannot".into()],
        });
        scenario.escalation = Some(EscalationSignals {
            tools: vec!["escalate".into()],
            markers: vec!["handing off".into()],
        });
        let traj = Trajectory {
            actions: vec![action("escalate")],
            final_responses: vec!["I cannot do that, handing off to a human".into()],
            cost_micros: None,
        };
        let report = run_scenario(&scenario, &traj);
        assert_eq!(report.refusals, 1);
        assert_eq!(report.escalations, 2); // one tool call + one marker
    }

    #[test]
    fn golden_outcome_reports_exact_and_alternate_matches() {
        let mut scenario = base_scenario();
        scenario.golden = Some(GoldenTrajectory {
            calls: vec!["add_widget".into(), "mark_full".into()],
            alternates: vec![vec![
                "add_widget".into(),
                "add_widget".into(),
                "mark_full".into(),
            ]],
        });
        let exact = Trajectory {
            actions: vec![action("add_widget"), action("mark_full")],
            ..Default::default()
        };
        let report = run_scenario(&scenario, &exact);
        let golden = report.golden.expect("golden present");
        assert!(golden.exact && golden.matched);

        let alt = Trajectory {
            actions: vec![
                action("add_widget"),
                action("add_widget"),
                action("mark_full"),
            ],
            ..Default::default()
        };
        let golden = run_scenario(&scenario, &alt)
            .golden
            .expect("golden present");
        assert!(!golden.exact && golden.alternate && golden.matched);
    }

    #[test]
    fn expected_state_mismatch_fails_the_deterministic_oracle() {
        let mut scenario = base_scenario();
        scenario
            .expect_state
            .insert("inventory.widgets".into(), json!(99));
        let traj = Trajectory {
            actions: vec![action("add_widget")],
            ..Default::default()
        };
        let report = run_scenario(&scenario, &traj);
        assert_eq!(report.state_mismatches.len(), 1);
        assert!(!report.deterministic_pass());
    }

    #[test]
    fn from_arg_effect_reads_the_call_arguments() {
        let mut scenario = base_scenario();
        scenario.transitions.push(Transition {
            tool: "set_label".into(),
            when: BTreeMap::new(),
            effect: effect("label", EffectOp::Kind(EffectKind::FromArg("name".into()))),
        });
        let mut act = action("set_label");
        act.args = json!({ "name": "shelf-A" });
        let traj = Trajectory {
            actions: vec![act],
            ..Default::default()
        };
        let report = run_scenario(&scenario, &traj);
        assert_eq!(
            get_path(&report.final_state, "label"),
            Some(json!("shelf-A"))
        );
    }

    #[test]
    fn envelope_exposes_counts_and_state_for_assertions() {
        let scenario = base_scenario();
        let traj = Trajectory {
            actions: vec![action("add_widget")],
            final_responses: vec!["ok".into()],
            cost_micros: Some(50),
        };
        let envelope = report_envelope(&run_scenario(&scenario, &traj));
        assert_eq!(envelope["invalid_actions"], json!(0));
        assert_eq!(envelope["actions"], json!(1));
        assert_eq!(envelope["turns"], json!(1));
        assert_eq!(envelope["state"]["inventory"]["widgets"], json!(4));
        assert_eq!(envelope["cost_micros"], json!(50));
    }
}
