//! Behavior-replay conformance against the TLA+ models (model-based testing).
//!
//! `spec/tla/behaviors/*.json` are COMPLETE transition coverages of the
//! corresponding specs at their configured bounds (e.g. Conservation: 207
//! states / 678 transitions), generated by `scripts/tla-behaviors.py` from
//! TLC's state-graph dump. Every behavior is replayed here against the real
//! [`Inventory`], asserting after each step that the abstraction of the
//! implementation state equals the spec state, plus:
//!
//! - **Enabledness**: an action the model says is ENABLED must succeed in
//!   the implementation (a spec-legal `Reduce` may not `Err`) — the dual of
//!   the stutter obligation in `tla_proptest.rs`.
//! - **Outcome conformance** (STRICT): the model's `success` / `no_match` /
//!   `ambiguous` result must map to `Ok` / `NoMatchingLot` /
//!   `AmbiguousMatch`, and the failed outcomes must be stutters.
//!
//! This is exhaustive conformance *up to the model bound* — strictly
//! stronger than the sampled properties in `tla_proptest.rs`, one notch
//! below a full refinement proof. Corpora are committed so these tests are
//! hermetic (no Java/TLC); the TLA+ CI workflow regenerates each from its
//! spec and fails on drift, keeping model and corpus in lockstep.
//!
//! `AVERAGECorrect.tla` models the pool value as an EXACT rational
//! (`valueNum / valueDen`), so the AVERAGE replay checks value conformance
//! too. The implementation divides in 28-digit `Decimal`, so pool values are
//! compared within a rounding tolerance that is many orders of magnitude
//! below any genuine conformance bug at the model bounds.

use rust_decimal::Decimal;
use rustledger_core::{Amount, BookingError, BookingMethod, Cost, Inventory, Position};
use serde_json::Value;

const CURRENCY: &str = "AAPL";

fn load_corpus(spec: &str) -> Option<Value> {
    // RUSTLEDGER_TLA_BEHAVIORS_DIR points the replay at an alternative corpus
    // directory — used by the scheduled bounds-escalation workflow, which
    // regenerates corpora at larger model bounds and replays them without
    // committing the (much larger) files.
    let path = match std::env::var("RUSTLEDGER_TLA_BEHAVIORS_DIR") {
        Ok(dir) => std::path::PathBuf::from(dir).join(format!("{spec}.json")),
        Err(_) => std::path::Path::new(env!("CARGO_MANIFEST_DIR"))
            .join(format!("../../spec/tla/behaviors/{spec}.json")),
    };
    // Graceful skip when spec/ isn't packaged (published-crate builds strip
    // it, and the Nix flake's source filter does too — the #1659 lesson).
    let Ok(raw) = std::fs::read_to_string(&path) else {
        eprintln!(
            "skipping: {} not present (published-crate build)",
            path.display()
        );
        return None;
    };
    let corpus: Value = serde_json::from_str(&raw).expect("corpus is valid JSON");
    let behaviors = corpus["behaviors"]
        .as_array()
        .expect("behaviors array")
        .len() as u64;
    let declared = corpus["coverage"]["behaviors"]
        .as_u64()
        .expect("coverage.behaviors");
    // Guard against a vacuous or internally-inconsistent corpus. (An earlier
    // version compared behaviors against coverage.transitions with a 90%
    // heuristic, but the generator's canonical dedup legitimately collapses
    // more transitions at larger bounds — e.g. STRICTCorrect's derived
    // smallest-qualifying-currency steps — so the real invariant is exact
    // agreement with the corpus's own declared count, non-zero.)
    assert!(
        behaviors > 0 && behaviors == declared,
        "{spec}: corpus has {behaviors} behaviors but declares {declared} — \
         regenerate with scripts/tla-behaviors.py"
    );
    Some(corpus)
}

fn day(d: i64) -> rustledger_core::NaiveDate {
    rustledger_core::naive_date(2024, 1, u32::try_from(d).expect("small day"))
        .expect("valid model date")
}

fn dec(v: &Value) -> Decimal {
    Decimal::from(v.as_i64().expect("integer"))
}

/// Sorted per-key unit totals over the inventory, keyed by an extractor —
/// the shared abstraction the lot-family specs are compared through.
fn totals_by<K: Ord + Clone>(inv: &Inventory, key: impl Fn(&Position) -> K) -> Vec<(K, Decimal)> {
    let mut acc: std::collections::BTreeMap<K, Decimal> = std::collections::BTreeMap::new();
    for p in inv.positions() {
        *acc.entry(key(p)).or_default() += p.units.number;
    }
    acc.into_iter().filter(|(_, n)| !n.is_zero()).collect()
}

fn spec_totals(state: &Value) -> Vec<(i64, Decimal)> {
    state["totals"]
        .as_array()
        .expect("totals")
        .iter()
        .map(|pair| (pair[0].as_i64().expect("key"), dec(&pair[1])))
        .collect()
}

/// Replay every behavior of a lot-selection spec (FIFO/LIFO by date, HIFO by
/// cost). `lot_cost` maps the spec's lot key to a Cost; `key_of` extracts it
/// back from an implementation position; `selected_of` extracts the matched
/// lot's key from a reduction result.
fn replay_lot_selection(
    spec: &str,
    method: BookingMethod,
    lot_cost: impl Fn(i64) -> Cost,
    key_of: impl Fn(&Position) -> i64 + Copy,
) {
    let Some(corpus) = load_corpus(spec) else {
        return;
    };
    for (bi, behavior) in corpus["behaviors"]
        .as_array()
        .expect("array")
        .iter()
        .enumerate()
    {
        let mut inv = Inventory::new();
        for (si, step) in behavior.as_array().expect("behavior").iter().enumerate() {
            let action = step[0].as_str().expect("action");
            let params = &step[1];
            let state = &step[2];
            match action {
                "AddLot" => {
                    let units = dec(&params["units"]);
                    let key = params
                        .get("date")
                        .or_else(|| params.get("cost"))
                        .and_then(Value::as_i64)
                        .expect("lot key");
                    inv.add(Position::with_cost(
                        Amount::new(units, CURRENCY),
                        lot_cost(key),
                    ))
                    .expect("fixture fits in Decimal");
                }
                "Reduce" => {
                    let units = dec(&params["units"]);
                    let expected_key = params
                        .get("date")
                        .or_else(|| params.get("cost"))
                        .and_then(Value::as_i64)
                        .expect("selected key");
                    let result = inv
                        .reduce(&Amount::new(-units, CURRENCY), None, method)
                        .unwrap_or_else(|e| {
                            panic!(
                                "{spec} behavior {bi} step {si}: model-enabled Reduce failed: {e}"
                            )
                        });
                    let matched_key = key_of(&result.matched[0]);
                    assert_eq!(
                        matched_key, expected_key,
                        "{spec} behavior {bi} step {si}: selected lot key diverged from the model"
                    );
                }
                other => panic!("{spec}: unknown action {other:?}"),
            }
            assert_eq!(
                totals_by(&inv, key_of),
                spec_totals(state),
                "{spec} behavior {bi} step {si} ({action}): totals diverged from the model"
            );
        }
    }
    println!(
        "{spec}: replayed {} behaviors",
        corpus["behaviors"].as_array().expect("array").len()
    );
}

#[test]
fn replay_every_fifo_behavior() {
    replay_lot_selection(
        "FIFOCorrect",
        BookingMethod::Fifo,
        |d| Cost::new(Decimal::ONE, "USD").with_date(day(d)),
        |p| {
            i64::from(
                p.cost
                    .as_ref()
                    .and_then(|c| c.date)
                    .expect("dated lot")
                    .day(),
            )
        },
    );
}

#[test]
fn replay_every_lifo_behavior() {
    replay_lot_selection(
        "LIFOCorrect",
        BookingMethod::Lifo,
        |d| Cost::new(Decimal::ONE, "USD").with_date(day(d)),
        |p| {
            i64::from(
                p.cost
                    .as_ref()
                    .and_then(|c| c.date)
                    .expect("dated lot")
                    .day(),
            )
        },
    );
}

#[test]
fn replay_every_hifo_behavior() {
    replay_lot_selection(
        "HIFOCorrect",
        BookingMethod::Hifo,
        |c| Cost::new(Decimal::from(c), "USD").with_date(day(1)),
        |p| {
            p.cost
                .as_ref()
                .expect("costed lot")
                .number
                .try_into()
                .expect("small model cost")
        },
    );
}

#[test]
fn replay_every_strict_behavior() {
    let Some(corpus) = load_corpus("STRICTCorrect") else {
        return;
    };
    let commodity = |c: i64| format!("CUR{c}");
    for (bi, behavior) in corpus["behaviors"]
        .as_array()
        .expect("array")
        .iter()
        .enumerate()
    {
        let mut inv = Inventory::new();
        // The model's ambiguity is over LOT IDENTITY: any two lots of the
        // same currency make an unqualified reduction ambiguous. The
        // implementation deliberately treats same-cost lots as financially
        // interchangeable (documented FIFO fallback in `reduce_strict`), so a
        // faithful mapping must make every added lot financially DISTINCT —
        // a fresh cost number per add (which also prevents lot merging).
        let mut next_cost: i64 = 1;
        for (si, step) in behavior.as_array().expect("behavior").iter().enumerate() {
            let action = step[0].as_str().expect("action");
            let params = &step[1];
            let state = &step[2];
            match action {
                "AddLot" => {
                    let units = dec(&params["units"]);
                    let cur = params["currency"].as_i64().expect("currency");
                    inv.add(Position::with_cost(
                        Amount::new(units, commodity(cur)),
                        Cost::new(Decimal::from(next_cost), "USD").with_date(day(1)),
                    ))
                    .expect("fixture fits in Decimal");
                    next_cost += 1;
                }
                "Reduce" => {
                    let cur = params["currency"].as_i64().expect("currency");
                    let expect = params["expect"].as_str().expect("expect");
                    let result = inv.reduce(
                        &Amount::new(-Decimal::ONE, commodity(cur)),
                        None,
                        BookingMethod::Strict,
                    );
                    match (expect, result) {
                        ("success", Ok(_)) => {}
                        (
                            "no_match",
                            Err(
                                BookingError::NoMatchingLot { .. }
                                | BookingError::CurrencyMismatch { .. },
                            ),
                        )
                        | ("ambiguous", Err(BookingError::AmbiguousMatch { .. })) => {}
                        (want, got) => panic!(
                            "STRICTCorrect behavior {bi} step {si}: model expects {want}, \
                             implementation returned {got:?}"
                        ),
                    }
                }
                other => panic!("STRICTCorrect: unknown action {other:?}"),
            }
            let impl_totals: Vec<(i64, Decimal)> = totals_by(&inv, |p| {
                p.units
                    .currency
                    .as_ref()
                    .strip_prefix("CUR")
                    .expect("model commodity")
                    .parse::<i64>()
                    .expect("small currency id")
            });
            assert_eq!(
                impl_totals,
                spec_totals(state),
                "STRICTCorrect behavior {bi} step {si} ({action}): totals diverged"
            );
        }
    }
    println!(
        "STRICTCorrect: replayed {} behaviors",
        corpus["behaviors"].as_array().expect("array").len()
    );
}

#[test]
fn replay_every_average_behavior() {
    let Some(corpus) = load_corpus("AVERAGECorrect") else {
        return;
    };
    for (bi, behavior) in corpus["behaviors"]
        .as_array()
        .expect("array")
        .iter()
        .enumerate()
    {
        let mut inv = Inventory::new();
        for (si, step) in behavior.as_array().expect("behavior").iter().enumerate() {
            let action = step[0].as_str().expect("action");
            let params = &step[1];
            let state = &step[2];
            match action {
                "AddUnits" => {
                    inv.add(Position::with_cost(
                        Amount::new(dec(&params["units"]), CURRENCY),
                        Cost::new(dec(&params["cost"]), "USD").with_date(day(1)),
                    ))
                    .expect("fixture fits in Decimal");
                }
                "Reduce" => {
                    inv.reduce(
                        &Amount::new(-dec(&params["units"]), CURRENCY),
                        None,
                        BookingMethod::Average,
                    )
                    .unwrap_or_else(|e| {
                        panic!(
                            "AVERAGECorrect behavior {bi} step {si}: model-enabled \
                             Reduce failed: {e}"
                        )
                    });
                }
                other => panic!("AVERAGECorrect: unknown action {other:?}"),
            }
            assert_eq!(
                inv.units(CURRENCY),
                dec(&state["units"]),
                "AVERAGECorrect behavior {bi} step {si} ({action}): units diverged"
            );
            // Value conformance against the model's EXACT rational
            // valueNum/valueDen: compare value*den to num. The implementation
            // divides in 28-digit Decimal, so allow its rounding — 1e-12 is
            // ~17 orders above accumulated Decimal error and ~7 below the
            // smallest genuine conformance discrepancy at these bounds
            // (>= 1/(den*den')).
            let pool_value: Decimal = inv
                .positions()
                .filter(|p| p.units.currency.as_ref() == CURRENCY)
                .map(|p| p.units.number * p.cost.as_ref().map(|c| c.number).unwrap_or_default())
                .sum();
            let num = dec(&state["value_num"]);
            let den = dec(&state["value_den"]);
            let diff = (pool_value * den - num).abs();
            assert!(
                diff <= Decimal::new(1, 12),
                "AVERAGECorrect behavior {bi} step {si} ({action}): pool value \
                 diverged — impl {pool_value} vs model {num}/{den} (diff*den = {diff})"
            );
        }
    }
    println!(
        "AVERAGECorrect: replayed {} behaviors",
        corpus["behaviors"].as_array().expect("array").len()
    );
}

#[test]
fn replay_every_none_behavior() {
    let Some(corpus) = load_corpus("NONECorrect") else {
        return;
    };
    for (bi, behavior) in corpus["behaviors"]
        .as_array()
        .expect("array")
        .iter()
        .enumerate()
    {
        let mut inv = Inventory::new();
        let mut added = Decimal::ZERO;
        let mut reduced = Decimal::ZERO;
        for (si, step) in behavior.as_array().expect("behavior").iter().enumerate() {
            let action = step[0].as_str().expect("action");
            let params = &step[1];
            let state = &step[2];
            match action {
                "AddUnits" => {
                    let units = dec(&params["units"]);
                    inv.add(Position::simple(Amount::new(units, CURRENCY)))
                        .expect("fixture fits in Decimal");
                    added += units;
                }
                "Reduce" => {
                    let units = dec(&params["units"]);
                    // NONE performs no booking: always enabled, shorts allowed
                    // from ANY balance (the +N → negative case was rejected
                    // until #1686 — found by this suite's first run).
                    inv.reduce(&Amount::new(-units, CURRENCY), None, BookingMethod::None)
                        .unwrap_or_else(|e| {
                            panic!(
                                "NONECorrect behavior {bi} step {si}: model-enabled \
                                 Reduce failed: {e}"
                            )
                        });
                    reduced += units;
                }
                other => panic!("NONECorrect: unknown action {other:?}"),
            }
            assert_eq!(
                inv.units(CURRENCY),
                dec(&state["balance"]),
                "NONECorrect behavior {bi} step {si} ({action}): balance diverged"
            );
            assert_eq!(
                added,
                dec(&state["added"]),
                "NONECorrect: totalAdded diverged"
            );
            assert_eq!(
                reduced,
                dec(&state["reduced"]),
                "NONECorrect: totalReduced diverged"
            );
        }
    }
    println!(
        "NONECorrect: replayed {} behaviors",
        corpus["behaviors"].as_array().expect("array").len()
    );
}

#[test]
fn replay_every_conservation_behavior() {
    let Some(corpus) = load_corpus("Conservation") else {
        return;
    };
    let behaviors = corpus["behaviors"].as_array().expect("behaviors array");
    let cost = Cost::new(Decimal::ONE, "USD").with_date(day(1));

    for (bi, behavior) in behaviors.iter().enumerate() {
        let mut inv = Inventory::new();
        let mut total_added = Decimal::ZERO;
        let mut total_reduced = Decimal::ZERO;

        for (si, step) in behavior
            .as_array()
            .expect("behavior array")
            .iter()
            .enumerate()
        {
            let step = step.as_array().expect("step array");
            let action = step[0].as_str().expect("action");
            let units = dec(&step[1]);
            let spec_inventory = dec(&step[2]);
            let spec_added = dec(&step[3]);
            let spec_reduced = dec(&step[4]);

            match action {
                "Add" => {
                    inv.add(Position::with_cost(
                        Amount::new(units, CURRENCY),
                        cost.clone(),
                    ))
                    .expect("fixture fits in Decimal");
                    total_added += units;
                }
                "Reduce" => {
                    // Enabledness obligation: the model only emits this
                    // transition when the guard held, so the implementation
                    // must be able to take it.
                    let result = inv
                        .reduce(&Amount::new(-units, CURRENCY), None, BookingMethod::Fifo)
                        .unwrap_or_else(|e| {
                            panic!(
                                "behavior {bi} step {si}: model-enabled Reduce({units}) \
                                 failed in the implementation: {e}"
                            )
                        });
                    let matched: Decimal =
                        result.matched.iter().map(|p| p.units.number.abs()).sum();
                    assert_eq!(
                        matched, units,
                        "behavior {bi} step {si}: Reduce matched {matched}, spec reduced {units}"
                    );
                    total_reduced += units;
                }
                other => panic!("behavior {bi} step {si}: unknown action {other:?}"),
            }

            // Abstraction check: implementation state == spec state, after
            // EVERY step of EVERY behavior.
            assert_eq!(
                inv.units(CURRENCY),
                spec_inventory,
                "behavior {bi} step {si} ({action} {units}): inventory diverged from the model"
            );
            assert_eq!(
                total_added, spec_added,
                "behavior {bi} step {si}: totalAdded diverged from the model"
            );
            assert_eq!(
                total_reduced, spec_reduced,
                "behavior {bi} step {si}: totalReduced diverged from the model"
            );
        }
    }

    println!("Conservation: replayed {} behaviors", behaviors.len());
}

/// `MultiCurrency.tla` — per-currency Conservation over a multi-commodity
/// inventory: `inventory[c] + totalReduced[c] = totalAdded[c]` for every
/// currency independently, catching currency-mixing bugs where units leak
/// across commodities.
#[test]
fn replay_every_multi_currency_behavior() {
    let Some(corpus) = load_corpus("MultiCurrency") else {
        eprintln!("skipping: MultiCurrency corpus not available");
        return;
    };
    let behaviors = corpus["behaviors"].as_array().expect("behaviors");

    for (bi, behavior) in behaviors.iter().enumerate() {
        let mut inv = Inventory::new();
        for (si, step) in behavior.as_array().expect("steps").iter().enumerate() {
            let (action, params, state) = (step[0].as_str().expect("action"), &step[1], &step[2]);
            let currency = params["currency"].as_str().expect("currency");
            let units = dec(&params["units"]);
            match action {
                "Add" => inv
                    .add(Position::simple(Amount::new(units, currency)))
                    .expect("fixture fits in Decimal"),
                "Reduce" => {
                    inv.reduce(&Amount::new(-units, currency), None, BookingMethod::Fifo)
                        .unwrap_or_else(|e| {
                            panic!(
                                "MultiCurrency behavior {bi} step {si}: model-enabled \
                                 Reduce({currency}) failed: {e}"
                            )
                        });
                }
                other => panic!("MultiCurrency: unknown action {other:?}"),
            }
            // Per-currency inventory abstraction — including currencies the
            // step didn't touch (cross-commodity leaks fail here).
            for entry in state["inventory"].as_array().expect("inventory") {
                let (c, u) = (entry[0].as_str().expect("currency"), dec(&entry[1]));
                assert_eq!(
                    inv.units(c),
                    u,
                    "MultiCurrency behavior {bi} step {si} ({action}): {c} diverged"
                );
            }
        }
    }
    println!("MultiCurrency: replayed {} behaviors", behaviors.len());
}
