//! Python-oracle differential economy harness.
//!
//! Boots the REAL Python UTAS oracle (`fifa17-recon/tools/utas_server.py`) as an
//! isolated subprocess (temp `fut_profile.json`, temp account file, chosen
//! loopback port, disposable log — never the production container, port, or
//! save) AND the REAL Rust economy stack (in-process Core seeded via
//! `start_core_seeded` + a real `Server` with `EconomyServices` from
//! `build_econ_server`). It then drives the SAME semantic op sequence against
//! BOTH and compares HTTP status, JSON structure, coin/entitlement/inventory
//! deltas, wire-significant ids, and pile/listing/reveal state — classifying
//! every op PARITY or DIFFERENT-BY-DESIGN.
//!
//! Starting fixtures are semantically aligned (both own one pack-70 entitlement;
//! Python's fresh profile seeds pack 70, Core is granted the `"70"` entitlement
//! via its economy API). The seed BALANCES differ by design — the oracle's
//! `_new_profile` grants 15 000 coins, `start_core_seeded` grants 100 000 — so
//! the differential compares coin DELTAS and structure, never absolute balances.
//! No Python source is modified; the classifier is untouched.
//!
//! ────────────────────────── PARITY / DIFFERENT-BY-DESIGN matrix ─────────────
//!
//! | op                     | class              | notes                                                        |
//! |------------------------|--------------------|--------------------------------------------------------------|
//! | credits                | PARITY             | identical body shape: `credits`+`currencies[coins/points]`   |
//! |                        |                    | (`funds==finalFunds==balance`) + `unopenedPacks.recovered=1`.|
//! | userMassInfo economy   | PARITY             | `userInfo.currencies[coins].funds == credits coins`; both    |
//! |                        |                    | carry `unopenedPacks.recoveredPacks==1`. Coins consistent    |
//! |                        |                    | across the credits & massinfo surfaces on each side.         |
//! | purchasegroup pack70   | DIFFERENT-BY-DESIGN| STORE DIVERGED: Rust serves the real 6-pack catalogue        |
//! |                        |                    | {1,2,3,4,5,6,70}; oracle (rollback) keeps {1,5,6,7,70}.      |
//! | purchasegroup sentinel | DIFFERENT-BY-DESIGN| empty My Packs + unverified -> rust {1..6,65534} vs oracle   |
//! |                        |                    | {1,5,6,7,65534}; sentinel `state:"active"` on both.          |
//! | purchasegroup clean-v1 | DIFFERENT-BY-DESIGN| empty + verified capability -> rust {1..6}, oracle {1,5,6,7};|
//! |                        |                    | sentinel stripped. Rust drives the REAL `SessionStore` state |
//! |                        |                    | machine (register_capability+open_session+freeze).          |
//! | Store BUY (pack 1)     | DIFFERENT-BY-DESIGN| 200; rust real Bronze Pack -> itemList(12),numberItems:12;   |
//! |                        |                    | oracle placeholder -> 5; both debit 400 + purchasedPackId 1. |
//! | POST /purchased open70 | PARITY             | 200; envelope `{packId:70,firstPartyStoreId,productId:"70",  |
//! |                        |                    | purchasePackType:"GOLD"}`; coin delta 0; entitlement -1.     |
//! | GET /purchased reveal  | PARITY             | Durable single-profile purchased pile on BOTH (Rust reveal   |
//! |                        |                    | = `PileStore.list_by_pile("purchased")` + Core inventory, NO |
//! |                        |                    | per-SID cache). Non-empty after open; idempotent on repeat.  |
//! |                        |                    | The hypothesised per-SID cache does NOT exist — VERIFIED.    |
//! | quick-sell DELETE/item | PARITY             | 200; `{items:[{id}],totalCredits}`; exactly one sold; coin   |
//! |                        |                    | delta > 0; `totalCredits`==absolute post-sale balance.       |
//! | quick-sell POST delete | PARITY             | batch form `{itemData:[{id}]}` -> same body shape/mechanism. |
//! | move-items PUT /item   | PARITY             | 200; `{itemData:[{id,pile,success:true}]}` verdict ack.      |
//! | match reward (WIN)     | PARITY             | 200; `{allCoins,matchCoins:400,gameModeAward{coins:400},     |
//! |                        |                    | seasonCoins,tournamentCoins,boostConis,participationAward,   |
//! |                        |                    | teamOfTournamentWinner}`; coin delta +400. Byte-shape match. |
//! | market list POST /ah   | PARITY             | 200; `{"id":<tradeId>}` (a positive listing id). Id SPACES   |
//! |                        |                    | differ (Rust MarketStore vs oracle 900500000+seq) — a        |
//! |                        |                    | wire-insignificant server-private handle.                    |
//! | market query tradePile | DIFFERENT-BY-DESIGN| after list -> `auctionInfo` len 1, `tradeState:"active"`.     |
//! |                        |                    | ONE field diverges deliberately: `itemData.itemState`. The    |
//! |                        |                    | oracle emits `listFS`, which does not exist in FIFA 17 (0 in  |
//! |                        |                    | CardsDLL, 0 in 4.26 GiB of client memory) and decodes to -1;  |
//! |                        |                    | Rust emits `forSale` (5), the client's own token. Parity here |
//! |                        |                    | passed while BOTH were wrong, which is why it survived.       |
//! | market buy POST /trade | DIFFERENT-BY-DESIGN| first buy debits exactly `buyNowPrice` & closes on BOTH, but |
//! |                        |                    | Rust's MarketStore is STATEFUL single-debit (a second buy of |
//! |                        |                    | a sold listing is a no-op: 0 delta, empty `auctionInfo`)     |
//! |                        |                    | whereas the oracle's buyable market is a STATELESS sample    |
//! |                        |                    | reconstruction (`_auction_by_tradeid`, id base 900000000)    |
//! |                        |                    | that RE-DEBITS on every repeat buy and is a DISTINCT id      |
//! |                        |                    | space from the user's own sale pile (900500000+). See below. |
//! | market cancel DELETE   | PARITY             | 200; a cancelled listing is no longer active/buyable on both |
//! |                        |                    | (Rust: buying it is a 0-delta empty `auctionInfo`; oracle:   |
//! |                        |                    | it drops out of `tradePile`). Response body differs (`{}`    |
//! |                        |                    | oracle vs closed-record Rust) — wire-insignificant.          |
//!
//! DIFFERENT-BY-DESIGN — market buy idempotency & id-space, in full:
//! * Python behaviour: `trade_route` reconstructs a buyable auction from the
//!   SAMPLE pool every call (`_auction_by_tradeid(tid)` = PACK_POOL[tid-900000000]);
//!   it holds no per-listing sold state, so POSTing the same sample tradeId twice
//!   debits `buyNowPrice` twice and grants twice. The user's OWN listings live in
//!   a separate id space (`list_for_sale` -> 900500000+seq) surfaced only by
//!   `/tradePile`, and are NOT buyable via `/trade/{id}` (that returns an empty
//!   auction). Evidence: probed live — buying sample tradeId 900000000 twice each
//!   returned `tradeState:"closed"` with a -buyNow coin delta both times.
//! * Rust behaviour: `handle_market_buy` reserves+sells against a durable
//!   `MarketStore` keyed by the listing's own tradeId (unified with the sale
//!   pile); a second buy of a `sold` listing is a no-op (0 coin delta, empty
//!   `auctionInfo`). Evidence: asserted below (second buy delta 0).
//! * Reason: the oracle is a single-file, single-profile emulator whose sample
//!   market exists only to make the client's Transfer Market browsable; it never
//!   needed durable auction state. The Rust cutover makes the market a real,
//!   crash-consistent, single-debit ledger backed by SQLite. Compatibility
//!   impact: NONE for the client — a buy-now is a one-shot UI action, so the
//!   re-debit path was never reachable in normal play; the Rust behaviour is a
//!   strict correctness improvement and is pinned by assertion so it cannot
//!   regress toward the stateless oracle shape.
//!
//! Safety: temp dir + `127.0.0.1` ephemeral ports only. The oracle subprocess is
//! killed on guard drop. Never touches the production Core DB/ports or `.105`.

use openfut_adapter_fifa17::fut::catalog::Fifa17CardCatalog;
use openfut_adapter_fifa17::fut::club_response::ItemIdentityResolver;
use openfut_adapter_fifa17::fut::entities::Fifa17Entities;
use openfut_adapter_fifa17::fut::non_economy::PERSONA_DISPLAY_NAME;
use openfut_adapter_fifa17::fut::store_session::{SessionStore, StoreMode, SENTINEL_PACK_ID};
use openfut_identity::JsonIdentityStore;
use openfut_utas_host::async_bridge::AsyncBridge;
use openfut_utas_host::market_store::MarketStore;
use openfut_utas_host::pile_store::PileStore;
use openfut_utas_host::{
    build_content_pool, handle_purchasegroup, overlay_massinfo_economy, CoreAccess, CoreEconomy,
    EconomyServices, Fifa17IdentityResolver, HttpCoreClient, PassClient, Server,
};
use serde_json::{json, Value};
use std::collections::HashMap;
use std::process::{Child, Command, Stdio};
use std::sync::Arc;

/// The persona the oracle's `ACCOUNT` defaults to (tier-2 built-in). The Rust
/// `SessionStore` clean-v1 handshake keys on the same value so both sides bind a
/// verified capability to the same `(ip, persona)`.
const PERSONA_ID: i64 = 33068179;

// ─────────────────────────── Python oracle subprocess ───────────────────────

/// A running, isolated Python UTAS oracle; killed on drop.
struct Oracle {
    child: Child,
    base: String,
    http: reqwest::blocking::Client,
}

impl Drop for Oracle {
    fn drop(&mut self) {
        let _ = self.child.kill();
        let _ = self.child.wait();
    }
}

impl Oracle {
    /// Spawn the oracle bound to a fresh loopback port with a disposable profile,
    /// account file, and log — fully isolated from any production instance.
    fn spawn(dir: &std::path::Path) -> Oracle {
        // A free loopback port: bind :0, read the assignment, release it, hand the
        // number to Python via FUT_PORT. A brief TOCTOU window is acceptable on a
        // loopback test host.
        let port = {
            let l = std::net::TcpListener::bind("127.0.0.1:0").expect("free port");
            l.local_addr().unwrap().port()
        };
        let tools = std::path::Path::new(env!("CARGO_MANIFEST_DIR"))
            .parent()
            .unwrap()
            .join("fifa17-recon/tools");
        assert!(
            tools.join("utas_server.py").exists(),
            "oracle source not found at {}",
            tools.display()
        );
        let child = Command::new("python3")
            .arg("utas_server.py")
            .current_dir(&tools)
            .env("FUT_PROFILE", dir.join("fut_profile.json"))
            .env("FUT_ACCOUNT_PATH", dir.join("fut_account.json"))
            .env("FUT_LOG", dir.join("oracle.log"))
            .env("FUT_PORT", port.to_string())
            .env("OPENFUT_BIND", "127.0.0.1")
            .stdout(Stdio::null())
            .stderr(Stdio::null())
            .spawn()
            .expect("spawn python oracle (is python3 on PATH?)");
        Oracle {
            child,
            base: format!("http://127.0.0.1:{port}"),
            http: reqwest::blocking::Client::new(),
        }
    }

    /// Poll `/user/credits` until the oracle answers 200 (or panic after ~15s).
    fn wait_ready(&mut self) {
        for _ in 0..750 {
            if let Some(status) = self.child.try_wait().expect("try_wait") {
                panic!("oracle exited before ready: {status}");
            }
            if let Ok(r) = self
                .http
                .get(format!("{}/ut/game/fifa17/user/credits", self.base))
                .header("X-OpenFUT-Game", "fifa17")
                .send()
            {
                if r.status().is_success() {
                    return;
                }
            }
            std::thread::sleep(std::time::Duration::from_millis(20));
        }
        panic!("oracle never became ready at {}", self.base);
    }

    /// One request against the oracle. Returns `(status, json_body)`; a missing or
    /// empty body decodes to `Value::Null`.
    fn req(
        &self,
        method: &str,
        path: &str,
        body: Option<Value>,
        sid: Option<&str>,
    ) -> (u16, Value) {
        let m = reqwest::Method::from_bytes(method.as_bytes()).expect("method");
        let mut rb = self
            .http
            .request(m, format!("{}{path}", self.base))
            .header("X-OpenFUT-Game", "fifa17");
        if let Some(s) = sid {
            rb = rb.header("X-UT-SID", s);
        }
        if let Some(b) = body {
            rb = rb.json(&b);
        }
        let resp = rb
            .send()
            .unwrap_or_else(|e| panic!("oracle {method} {path}: {e}"));
        let status = resp.status().as_u16();
        let text = resp.text().unwrap_or_default();
        let v: Value = if text.is_empty() {
            Value::Null
        } else {
            serde_json::from_str(&text).unwrap_or(Value::Null)
        };
        (status, v)
    }

    fn coins(&self) -> i64 {
        self.req("GET", "/ut/game/fifa17/user/credits", None, None)
            .1["currencies"][0]["funds"]
            .as_i64()
            .expect("oracle coins")
    }
}

// ─────────────────── Core boot + econ server (reference harness) ─────────────
// Replicated from `tests/economy_integration.rs` (separate test binary, no shared
// module) so this file is self-contained per the one-file-per-agent contract.

/// Boot a Core with FIFA17 dev CONTENT loaded and, on first boot, the dev
/// inventory SEEDED (a fifa17 profile + club with 100k coins + one owned
/// instance per definition). Ephemeral loopback port.
async fn start_core_seeded(db_url: &str, seed: bool) -> (tokio::task::JoinHandle<()>, String) {
    use openfut_core::config::Config;
    use openfut_core::seed::{seed_fifa17_dev, FIFA17_GAME};
    use openfut_core::services::card_db::CardDb;

    let data_dir = "../openfut-core/data";
    let pool = openfut_core::db::init_pool(db_url, 5)
        .await
        .expect("core pool");
    openfut_core::db::run_migrations(&pool)
        .await
        .expect("core migrations");
    if seed {
        let mut card_db = CardDb::load(data_dir).expect("card_db load");
        card_db
            .load_game_dev(data_dir, FIFA17_GAME)
            .expect("load fifa17 dev content");
        seed_fifa17_dev(&pool, &card_db)
            .await
            .expect("seed fifa17 dev inventory");
    }
    let cfg = Config {
        listen_addr: "127.0.0.1:0".into(),
        database_url: "sqlite::memory:".into(),
        data_dir: data_dir.into(),
        max_connections: 5,
        dev_content_games: vec![FIFA17_GAME.to_string()],
        content_packs: Vec::new(),
    };
    let app = openfut_core::app::build(pool, cfg)
        .await
        .expect("core app::build");
    let listener = tokio::net::TcpListener::bind("127.0.0.1:0")
        .await
        .expect("bind ephemeral");
    let addr = listener.local_addr().unwrap();
    let handle = tokio::spawn(async move {
        let _ = axum::serve(listener, app).await;
    });
    (handle, format!("http://{addr}"))
}

fn wait_ready(base: &str) {
    let http = reqwest::blocking::Client::new();
    for _ in 0..1500 {
        if let Ok(r) = http.get(format!("{base}/health")).send() {
            if r.status().is_success() {
                return;
            }
        }
        std::thread::sleep(std::time::Duration::from_millis(20));
    }
    panic!("core did not become ready at {base}");
}

fn core_post(http: &reqwest::blocking::Client, base: &str, path: &str, body: Value) -> Value {
    let resp = http
        .post(format!("{base}{path}"))
        .header("X-OpenFUT-Game", "fifa17")
        .json(&body)
        .send()
        .unwrap_or_else(|e| panic!("POST {path}: {e}"));
    let status = resp.status();
    let v: Value = resp.json().unwrap_or(Value::Null);
    assert!(status.is_success(), "POST {path} -> {status}: {v}");
    v
}

/// Build a real `Server` with economy authority wired against the seeded Core.
/// Returns the server, a direct Core client for balance/entitlement assertions,
/// the resolver used to obtain stable wire instance ids, and a valid wire
/// `resourceId` (20000) that reverse-maps to a real Core card.
fn build_econ_server(
    base: &str,
    dir: &std::path::Path,
) -> (Server, HttpCoreClient, Arc<Fifa17IdentityResolver>, i64) {
    let probe = HttpCoreClient::new(base, "fifa17");
    let owned = probe.all_owned().expect("core collection");
    assert!(!owned.is_empty(), "seed must grant a starter collection");
    let mut entries = String::new();
    let mut seen = std::collections::HashSet::new();
    let mut asset = 20000u32;
    for it in &owned {
        if !seen.insert(it.card_id.clone()) {
            continue;
        }
        if !entries.is_empty() {
            entries.push(',');
        }
        entries.push_str(&format!("\"{}\":{{\"asset_id\":{asset}}}", it.card_id));
        asset += 1;
    }
    let doc = format!("{{\"schema_version\":1,\"game\":\"fifa17\",\"cards\":{{{entries}}}}}");
    let catalog = Fifa17CardCatalog::from_json_str(&doc).expect("catalog");
    let store = JsonIdentityStore::open(dir.join("identity.json").to_str().unwrap()).unwrap();
    let resolver = Arc::new(Fifa17IdentityResolver::new(catalog, Arc::new(store)));
    let entities = Arc::new(Fifa17Entities::from_maps(
        HashMap::new(),
        HashMap::new(),
        HashMap::new(),
    ));
    let core: Arc<dyn CoreAccess> = Arc::new(HttpCoreClient::new(base, "fifa17"));
    let bridge = Arc::new(AsyncBridge::new().unwrap());
    let market_path = dir.join("market.db").to_string_lossy().into_owned();
    let market = Arc::new(
        bridge
            .block_on(async move { MarketStore::open(&market_path).await })
            .expect("market store"),
    );
    let pile_path = dir.join("pile.db").to_string_lossy().into_owned();
    let piles = Arc::new(
        bridge
            .block_on(async move { PileStore::open(&pile_path).await })
            .expect("pile store"),
    );
    let econ: Arc<dyn CoreEconomy> = Arc::new(HttpCoreClient::new(base, "fifa17"));
    let pool = Arc::new(build_content_pool(core.as_ref(), resolver.as_ref()));
    assert!(
        !pool.is_empty(),
        "content pool derived from real Core content"
    );
    let services = Arc::new(EconomyServices {
        // Production default: the sold experiment is OFF.
        sold_experiment: openfut_utas_host::sold_experiment::SoldExperiment::OFF,
        econ,
        market,
        piles,
        bridge,
        pool,
    });
    let server = Server::new(
        core,
        entities,
        resolver.clone(),
        Arc::new(PassClient::new("http://127.0.0.1:9")),
        PERSONA_ID,
    )
    .with_economy(services);
    (server, probe, resolver, 20000)
}

// ─────────────────────────── differential comparison ────────────────────────

/// A single `try_handle_economy` call against the Rust dispatch. Panics if the
/// route is unhandled (all economy ops here are wired).
fn rust(server: &Server, method: &str, path: &str, body: &[u8], sid: Option<&str>) -> (u16, Value) {
    let headers: Vec<(String, String)> = match sid {
        Some(s) => vec![("X-UT-SID".into(), s.into())],
        None => vec![],
    };
    let resp = server
        .try_handle_economy(method, path, &headers, body, None)
        .unwrap_or_else(|| panic!("rust dispatch did not route {method} {path}"));
    let v: Value = if resp.body.is_empty() {
        Value::Null
    } else {
        serde_json::from_slice(&resp.body).unwrap_or(Value::Null)
    };
    (resp.status, v)
}

/// Sorted `purchase[].id` set from a purchasegroup body.
fn pack_ids(pg: &Value) -> Vec<u64> {
    let mut v: Vec<u64> = pg["purchase"]
        .as_array()
        .expect("purchase array")
        .iter()
        .map(|p| p["id"].as_u64().expect("pack id"))
        .collect();
    v.sort_unstable();
    v
}

/// Preserve every object key, array position, and JSON scalar kind while discarding
/// wire-insignificant values such as translated labels and live completion counts.
fn json_shape(value: &Value) -> Value {
    match value {
        Value::Null => json!("null"),
        Value::Bool(_) => json!("bool"),
        Value::Number(_) => json!("number"),
        Value::String(_) => json!("string"),
        Value::Array(values) => Value::Array(values.iter().map(json_shape).collect()),
        Value::Object(values) => Value::Object(
            values
                .iter()
                .map(|(key, value)| (key.clone(), json_shape(value)))
                .collect(),
        ),
    }
}

/// Every op runs on a plain OS thread with NO ambient Tokio runtime (the blocking
/// Core client + `reqwest::blocking` require this), exactly like the
/// thread-per-connection server — the bridge takes its direct `block_on` path.
fn run_differential(core_base: &str, oracle: &Oracle, dir: &std::path::Path) {
    wait_ready(core_base);
    let http = reqwest::blocking::Client::new();
    let (server, client, _resolver, _sample_resource) = build_econ_server(core_base, dir);

    // ── Fixture alignment: both sides own exactly one pack-70 entitlement. ──
    // Oracle: fresh profile already owns pack 70. Core: grant the "70" entitlement
    // through its real economy API (cost 0, no debit).
    core_post(
        &http,
        core_base,
        "/economy/purchase-entitlement",
        json!({ "cost": 0, "definition_id": "70" }),
    );
    assert_eq!(
        oracle
            .req("GET", "/ut/game/fifa17/user/credits", None, None)
            .0,
        200
    );

    let mut matrix: Vec<(&str, &str)> = Vec::new();

    // ── OP 1: credits ──────────────────────────────────────────────────────
    let (os, ob) = oracle.req("GET", "/ut/game/fifa17/user/credits", None, None);
    let (rs, rb) = rust(&server, "GET", "/ut/game/fifa17/user/credits", b"", None);
    assert_eq!(os, 200);
    assert_eq!(rs, 200, "credits status parity");
    for (tag, b) in [("oracle", &ob), ("rust", &rb)] {
        assert_eq!(
            b["currencies"][0]["name"], "coins",
            "{tag} currencies[0]=coins"
        );
        assert_eq!(
            b["currencies"][1]["name"], "points",
            "{tag} currencies[1]=points"
        );
        let coins = b["currencies"][0]["funds"].as_i64().unwrap();
        assert_eq!(
            b["currencies"][0]["finalFunds"].as_i64().unwrap(),
            coins,
            "{tag} funds==finalFunds"
        );
        assert_eq!(
            b["credits"].as_i64().unwrap(),
            coins,
            "{tag} credits scalar == coins"
        );
        assert_eq!(
            b["unopenedPacks"]["recoveredPacks"].as_i64().unwrap(),
            1,
            "{tag} recoveredPacks==1"
        );
    }
    matrix.push(("credits", "PARITY"));

    // ── OP 2: userMassInfo economy ─────────────────────────────────────────
    let om = oracle
        .req("GET", "/ut/game/fifa17/userMassInfo", None, None)
        .1;
    let o_mass_coins = om["userInfo"]["currencies"][0]["funds"].as_i64().unwrap();
    assert_eq!(om["userInfo"]["currencies"][0]["name"], "coins");
    assert_eq!(
        o_mass_coins,
        ob["currencies"][0]["funds"].as_i64().unwrap(),
        "oracle massinfo coins == credits coins"
    );
    assert_eq!(
        om["userInfo"]["unopenedPacks"]["recoveredPacks"]
            .as_i64()
            .unwrap(),
        1,
        "oracle massinfo recoveredPacks==1"
    );
    // Rust: overlay the authoritative Core economy onto a massinfo body in place.
    let mut mass =
        json!({ "userInfo": { "currencies": [ {"name":"coins","funds":0,"finalFunds":0} ] } });
    let r_coins = client.balance().unwrap();
    let r_ents = client.entitlements().unwrap().len();
    assert!(overlay_massinfo_economy(&mut mass, r_coins, r_ents));
    assert_eq!(
        mass["userInfo"]["currencies"][0]["funds"].as_i64().unwrap(),
        r_coins,
        "rust overlay coins == Core balance"
    );
    assert_eq!(
        mass["userInfo"]["currencies"][0]["funds"],
        mass["userInfo"]["currencies"][0]["finalFunds"]
    );
    assert_eq!(
        mass["userInfo"]["unopenedPacks"]["recoveredPacks"]
            .as_i64()
            .unwrap(),
        r_ents as i64,
        "rust overlay recoveredPacks == entitlements"
    );
    assert_eq!(
        rb["currencies"][0]["funds"].as_i64().unwrap(),
        r_coins,
        "rust massinfo coins == credits coins"
    );
    matrix.push(("userMassInfo economy", "PARITY"));

    // ── OP 3a: purchasegroup — pack70 owned (no sentinel) ──────────────────
    let opg = oracle
        .req("GET", "/ut/game/fifa17/store/purchasegroup", None, None)
        .1;
    let rpg = rust(
        &server,
        "GET",
        "/ut/game/fifa17/store/purchasegroup",
        b"",
        None,
    )
    .1;
    // STORE DIVERGED: Rust is the authoritative store owner and serves the real
    // 6-pack regular catalogue (ids 1..6 + owned 70). The Python oracle (rollback
    // baseline, never modified) still serves the old placeholder set {1,5,6,7,70},
    // so this is Rust-authoritative, NOT oracle parity.
    assert_eq!(
        pack_ids(&opg),
        vec![1, 5, 6, 7, 70],
        "oracle (rollback) placeholder id set"
    );
    assert_eq!(
        pack_ids(&rpg),
        vec![1, 2, 3, 4, 5, 6, 70],
        "rust authoritative real-catalogue id set"
    );
    for b in [&opg, &rpg] {
        assert!(
            !pack_ids(b).contains(&SENTINEL_PACK_ID),
            "no 65534 sentinel while a pack is owned"
        );
    }
    // packType by side: oracle BRONZE for 1 else GOLD; rust by real category
    // (1,2 bronze / 3,4 silver / 5,6,70 gold).
    for p in opg["purchase"].as_array().unwrap() {
        let id = p["id"].as_u64().unwrap();
        let want = if id == 1 { "BRONZE" } else { "GOLD" };
        assert_eq!(p["packType"], want, "oracle packType for pack {id}");
    }
    for p in rpg["purchase"].as_array().unwrap() {
        let id = p["id"].as_u64().unwrap();
        let want = match id {
            1 | 2 => "BRONZE",
            3 | 4 => "SILVER",
            _ => "GOLD",
        };
        assert_eq!(p["packType"], want, "rust packType for pack {id}");
    }
    matrix.push(("purchasegroup pack70", "DIFFERENT-BY-DESIGN"));

    // ── OP 4: Store BUY (pack 1 Bronze, price 400, 5 cards) ────────────────
    let o_bal0 = oracle.coins();
    let (o_bs, o_buy) = oracle.req(
        "PUT",
        "/ut/game/fifa17/store/transaction",
        Some(json!({"packId":1})),
        None,
    );
    let r_bal0 = client.balance().unwrap();
    let (r_bs, r_buy) = rust(
        &server,
        "PUT",
        "/ut/game/fifa17/store/transaction",
        br#"{"packId":1}"#,
        None,
    );
    assert_eq!(o_bs, 200);
    assert_eq!(r_bs, 200, "BUY status parity");
    let o_items = o_buy["createPackResponse"]["itemList"]
        .as_array()
        .expect("oracle itemList")
        .clone();
    let r_items = r_buy["createPackResponse"]["itemList"]
        .as_array()
        .expect("rust itemList")
        .clone();
    // STORE DIVERGED: rust serves the real Bronze Pack (10+2 = 12 cards); the
    // oracle placeholder awards 5. Both debit the same 400-coin price.
    assert_eq!(o_items.len(), 5, "oracle (rollback) pack1 -> 5 cards");
    assert_eq!(r_items.len(), 12, "rust pack1 real Bronze Pack -> 12 cards");
    assert_eq!(
        o_buy["createPackResponse"]["numberItems"].as_i64().unwrap(),
        5,
        "oracle numberItems==5"
    );
    assert_eq!(
        r_buy["createPackResponse"]["numberItems"].as_i64().unwrap(),
        12,
        "rust numberItems==12"
    );
    for b in [&o_buy, &r_buy] {
        let cpr = &b["createPackResponse"];
        assert_eq!(
            cpr["purchasedPackId"].as_i64().unwrap(),
            1,
            "purchasedPackId==1"
        );
        assert!(
            cpr["duplicateItemIdList"].is_array(),
            "duplicateItemIdList is an array"
        );
    }
    assert_eq!(oracle.coins() - o_bal0, -400, "oracle BUY debits 400");
    assert_eq!(
        client.balance().unwrap() - r_bal0,
        -400,
        "rust BUY debits 400 (price parity)"
    );
    matrix.push(("Store BUY (pack1)", "DIFFERENT-BY-DESIGN"));
    // Minted wire ids for the item ops that follow (both sides put them in the
    // pending purchased pile).
    let o_wire: Vec<i64> = o_items.iter().map(|i| i["id"].as_i64().unwrap()).collect();
    let r_wire: Vec<i64> = r_items.iter().map(|i| i["id"].as_i64().unwrap()).collect();
    assert!(
        r_wire[0] >= 100_000_000,
        "rust mints above the FIFA wire floor"
    );

    // ── OP 5: POST /purchased — owned pack-70 open ─────────────────────────
    let o_bal_p = oracle.coins();
    let (o_ps, o_open) = oracle.req(
        "POST",
        "/ut/game/fifa17/purchased",
        Some(json!({"packId":70})),
        None,
    );
    let r_ent_before = client.entitlements().unwrap().len();
    let r_bal_p = client.balance().unwrap();
    let (r_ps, r_open) = rust(
        &server,
        "POST",
        "/ut/game/fifa17/purchased",
        br#"{"packId":70}"#,
        None,
    );
    assert_eq!(o_ps, 200);
    assert_eq!(r_ps, 200, "pack-70 open status parity");
    for b in [&o_open, &r_open] {
        assert_eq!(b["packId"].as_i64().unwrap(), 70, "open echoes pack id 70");
        assert_eq!(b["productId"], "70", "productId echo");
        assert_eq!(b["purchasePackType"], "GOLD", "reward pack is GOLD");
    }
    assert_eq!(oracle.coins() - o_bal_p, 0, "oracle pack-70 open is free");
    assert_eq!(
        client.balance().unwrap() - r_bal_p,
        0,
        "rust pack-70 open is free"
    );
    assert_eq!(
        client.entitlements().unwrap().len(),
        r_ent_before - 1,
        "rust consumes the 70 entitlement"
    );
    matrix.push(("POST /purchased open70", "PARITY"));

    // ── OP 6: GET /purchased reveal (durable single-profile pile; idempotent) ─
    let o_rev1 = oracle.req("GET", "/ut/game/fifa17/purchased", None, None).1["itemData"]
        .as_array()
        .unwrap()
        .len();
    let r_rev1 = rust(&server, "GET", "/ut/game/fifa17/purchased", b"", None).1["itemData"]
        .as_array()
        .unwrap()
        .len();
    assert!(o_rev1 > 0, "oracle reveal shows opened items ({o_rev1})");
    assert!(r_rev1 > 0, "rust reveal shows opened items ({r_rev1})");
    // Idempotent + durable: a repeat GET (with a DIFFERENT SID) returns the same
    // reveal — there is no per-SID cache; the pile is single-profile durable.
    let o_rev2 = oracle
        .req(
            "GET",
            "/ut/game/fifa17/purchased",
            None,
            Some("OPENFUT-SID-OTHER1"),
        )
        .1["itemData"]
        .as_array()
        .unwrap()
        .len();
    let r_rev2 = rust(
        &server,
        "GET",
        "/ut/game/fifa17/purchased",
        b"",
        Some("OPENFUT-SID-OTHER1"),
    )
    .1["itemData"]
        .as_array()
        .unwrap()
        .len();
    assert_eq!(
        o_rev2, o_rev1,
        "oracle reveal idempotent & SID-independent (durable single-profile)"
    );
    assert_eq!(
        r_rev2, r_rev1,
        "rust reveal idempotent & SID-independent (durable single-profile, NO per-SID cache)"
    );
    matrix.push(("GET /purchased reveal", "PARITY"));

    // ── OP 7: quick-sell (single, DELETE /item/<wireId>) ───────────────────
    let o_qbal = oracle.coins();
    let (o_qs, o_q) = oracle.req(
        "DELETE",
        &format!("/ut/game/fifa17/item/{}", o_wire[0]),
        None,
        None,
    );
    let r_qbal = client.balance().unwrap();
    let (r_qs, r_q) = rust(
        &server,
        "DELETE",
        &format!("/ut/game/fifa17/item/{}", r_wire[0]),
        b"",
        None,
    );
    assert_eq!(o_qs, 200);
    assert_eq!(r_qs, 200, "quick-sell status parity");
    let o_qafter = oracle.coins();
    let r_qafter = client.balance().unwrap();
    for (tag, b, bal) in [("oracle", &o_q, o_qafter), ("rust", &r_q, r_qafter)] {
        assert_eq!(
            b["items"].as_array().unwrap().len(),
            1,
            "{tag} sold exactly one"
        );
        assert_eq!(
            b["totalCredits"].as_i64().unwrap(),
            bal,
            "{tag} totalCredits == absolute balance"
        );
    }
    assert!(o_qafter - o_qbal > 0, "oracle quick-sell credits coins");
    assert!(r_qafter - r_qbal > 0, "rust quick-sell credits coins");
    matrix.push(("quick-sell DELETE/item", "PARITY"));

    // ── OP 8: quick-sell (batch, POST /ut/delete/.../item) ─────────────────
    let (o_qbs, o_qb) = oracle.req(
        "POST",
        "/ut/delete/game/fifa17/item",
        Some(json!({"itemData":[{"id":o_wire[3]}]})),
        None,
    );
    let (r_qbs, r_qb) = rust(
        &server,
        "POST",
        "/ut/delete/game/fifa17/item",
        format!(r#"{{"itemData":[{{"id":{}}}]}}"#, r_wire[3]).as_bytes(),
        None,
    );
    assert_eq!(o_qbs, 200);
    assert_eq!(r_qbs, 200, "batch quick-sell status parity");
    for (tag, b, bal) in [
        ("oracle", &o_qb, oracle.coins()),
        ("rust", &r_qb, client.balance().unwrap()),
    ] {
        assert_eq!(
            b["items"].as_array().unwrap().len(),
            1,
            "{tag} batch sold one"
        );
        assert_eq!(
            b["totalCredits"].as_i64().unwrap(),
            bal,
            "{tag} batch totalCredits == balance"
        );
    }
    matrix.push(("quick-sell POST delete", "PARITY"));

    // ── OP 9: move-items (PUT /item, still-owned card -> trade pile) ────────
    let (o_ms, o_mv) = oracle.req(
        "PUT",
        "/ut/game/fifa17/item",
        Some(json!({"itemData":[{"id":o_wire[1],"pile":"trade"}]})),
        None,
    );
    let (r_ms, r_mv) = rust(
        &server,
        "PUT",
        "/ut/game/fifa17/item",
        format!(r#"{{"itemData":[{{"id":{},"pile":"trade"}}]}}"#, r_wire[1]).as_bytes(),
        None,
    );
    assert_eq!(o_ms, 200);
    assert_eq!(r_ms, 200, "move status parity");
    for (tag, b, id) in [("oracle", &o_mv, o_wire[1]), ("rust", &r_mv, r_wire[1])] {
        let v = &b["itemData"][0];
        assert_eq!(v["success"], true, "{tag} move recorded success");
        assert_eq!(v["pile"], "trade", "{tag} move pile echo");
        assert_eq!(v["id"].as_i64().unwrap(), id, "{tag} move id echo");
    }
    matrix.push(("move-items PUT /item", "PARITY"));

    // ── OP 10: match reward (WIN = +400) ───────────────────────────────────
    let o_mbal = oracle.coins();
    let (o_mms, o_mm) = oracle.req(
        "POST",
        "/ut/delete/game/fifa17/match",
        Some(json!({"endReason":"WIN"})),
        None,
    );
    let r_mbal = client.balance().unwrap();
    let (r_mms, r_mm) = rust(
        &server,
        "POST",
        "/ut/delete/game/fifa17/match",
        br#"{"endReason":"WIN"}"#,
        None,
    );
    assert_eq!(o_mms, 200);
    assert_eq!(r_mms, 200, "match status parity");
    for (tag, b, bal) in [
        ("oracle", &o_mm, oracle.coins()),
        ("rust", &r_mm, client.balance().unwrap()),
    ] {
        assert_eq!(
            b["matchCoins"].as_i64().unwrap(),
            400,
            "{tag} WIN matchCoins==400"
        );
        assert_eq!(
            b["gameModeAward"]["coins"].as_i64().unwrap(),
            400,
            "{tag} gameModeAward.coins==400"
        );
        assert_eq!(
            b["allCoins"].as_i64().unwrap(),
            bal,
            "{tag} allCoins == post-credit balance"
        );
        assert_eq!(b["seasonCoins"].as_i64().unwrap(), 0, "{tag} seasonCoins");
        assert_eq!(
            b["tournamentCoins"].as_i64().unwrap(),
            0,
            "{tag} tournamentCoins"
        );
        assert_eq!(
            b["boostConis"].as_i64().unwrap(),
            0,
            "{tag} boostConis (EA typo key)"
        );
        assert_eq!(
            b["teamOfTournamentWinner"], false,
            "{tag} teamOfTournamentWinner"
        );
        assert!(
            b["participationAward"].is_i64(),
            "{tag} participationAward present"
        );
    }
    assert_eq!(oracle.coins() - o_mbal, 400, "oracle WIN +400");
    assert_eq!(client.balance().unwrap() - r_mbal, 400, "rust WIN +400");
    matrix.push(("match reward (WIN)", "PARITY"));

    // ── OP 11: market list (POST /auctionhouse) ────────────────────────────
    // Oracle lists an OWNED CLUB item: the moved card (OP 9) is now in the club,
    // so it is a valid, tradePile-visible listing. Rust lists a valid wire id
    // against its MarketStore.
    let (o_ls, o_list) = oracle.req(
        "POST",
        "/ut/game/fifa17/auctionhouse",
        Some(json!({"itemData":{"id":o_wire[1]},"buyNowPrice":1000,"startingBid":500})),
        None,
    );
    let o_trade_id = o_list["id"].as_i64().expect("oracle trade id");
    // Same body shape as the oracle: the wire id ALONE (the server resolves the
    // owned card's card_id + resourceId from inventory). `r_wire[1]` is the card
    // moved to the trade pile in OP 9 — the Rust parallel of the oracle's o_wire[1].
    let (r_ls, r_list) = rust(
        &server,
        "POST",
        "/ut/game/fifa17/auctionhouse",
        format!(
            r#"{{"itemData":{{"id":{}}},"buyNowPrice":1000,"startingBid":500}}"#,
            r_wire[1]
        )
        .as_bytes(),
        None,
    );
    let r_trade_id = r_list["id"].as_i64().expect("rust trade id");
    assert_eq!(o_ls, 200);
    assert_eq!(r_ls, 200, "market list status parity");
    assert!(
        o_trade_id > 0 && r_trade_id > 0,
        "both return a positive listing id"
    );
    matrix.push(("market list POST /ah", "PARITY"));

    // ── OP 12: market query (GET /tradePile) ───────────────────────────────
    let o_tp = oracle.req("GET", "/ut/game/fifa17/tradePile", None, None).1;
    let r_tp = rust(&server, "GET", "/ut/game/fifa17/tradePile", b"", None).1;
    assert_eq!(
        o_tp["auctionInfo"].as_array().unwrap().len(),
        1,
        "oracle tradePile shows the listing"
    );
    assert_eq!(
        r_tp["auctionInfo"].as_array().unwrap().len(),
        1,
        "rust tradePile shows the listing"
    );
    assert_eq!(
        o_tp["auctionInfo"][0]["tradeState"], "active",
        "oracle listing active"
    );
    assert_eq!(
        r_tp["auctionInfo"][0]["tradeState"], "active",
        "rust listing active"
    );
    // Compare the record FIELD-FOR-FIELD, not merely its length and trade state.
    // The client reads the seller identity to decide whether a transfer-pile row is
    // the player's OWN — and therefore whether Remove / Re-list exist at all — and
    // a len+tradeState check is blind to that. A real client silently offered NO
    // action on the player's own listing (pressing it opened no dialog) because we
    // stamped EA's house name as the seller while the oracle stamps the persona.
    let o_rec = &o_tp["auctionInfo"][0];
    let r_rec = &r_tp["auctionInfo"][0];
    let keys = |v: &Value| {
        let mut k: Vec<String> = v
            .as_object()
            .expect("auction record is an object")
            .keys()
            .cloned()
            .collect();
        k.sort();
        k
    };
    // Both sides emit exactly FIFA 17's twelve auctionInfo atoms, so this is a
    // strict key-set equality. NOTE the limit of that: parity here proves we match
    // the oracle, NOT that either side is complete -- a field absent from BOTH is
    // invisible to this check. That is exactly how the Transfer List Actions-panel
    // bug hid, and the client binary's atom table is the authority that settled it
    // (see docs/FIFA17_TRANSFER_MARKET_WIRE.md).
    assert_eq!(
        keys(o_rec),
        keys(r_rec),
        "tradePile auction-record key set parity"
    );
    for f in [
        "sellerName",
        "bidState",
        "currentBid",
        "sellerEstablished",
        "watched",
        "coinsProcessed",
    ] {
        assert_eq!(o_rec[f], r_rec[f], "tradePile record field `{f}` parity");
    }
    assert_eq!(
        r_rec["sellerName"], PERSONA_DISPLAY_NAME,
        "the player's own listing is sold BY the player, never by EA"
    );
    // `expires` is seconds remaining on a live clock, so it need not equal the
    // oracle's constant; it must be a positive 64-bit count for an active auction.
    assert!(
        r_rec["expires"].as_i64().is_some_and(|e| e > 0),
        "an active auction has positive seconds remaining"
    );
    // itemData must be the full shaped card on both sides; a stub cannot render.
    //
    // DELIBERATE DIVERGENCE — the one field on this route where the oracle is
    // WRONG. It stamps `listFS`, which is not a FIFA 17 token at all: zero
    // occurrences in `CardsDLL_Win64_retail.dll` (md5
    // 4de3493131d7d2ff7f8b360c5ac9b655), zero in 4.26 GiB of live client memory,
    // and it decodes to -1 through the itemState table walk, so the client is
    // handed an unrecognised `CARD_OFFERSTATE`. FIFA 17's value for an item
    // offered for sale is `forSale` (5), from the 12-row table at 0x180229cc0.
    //
    // This assertion used to demand parity, and passed while BOTH sides were
    // wrong — the reason the defect survived every differential run. Oracle parity
    // is necessary but not sufficient; where the binary contradicts the oracle,
    // the binary wins.
    assert_eq!(
        o_rec["itemData"]["itemState"], "listFS",
        "pins what the oracle actually emits, so this divergence stays visible"
    );
    assert_eq!(
        r_rec["itemData"]["itemState"], "forSale",
        "Rust emits FIFA 17's own token, not the oracle's non-existent one"
    );
    assert!(
        r_rec["itemData"]["rating"].is_i64() && r_rec["itemData"]["attributeList"].is_array(),
        "rust tradePile itemData is the full card, not a stub"
    );
    matrix.push(("market query tradePile", "DIFFERENT-BY-DESIGN"));

    // ── OP 13: market buy (POST /trade/<id>) — DIFFERENT-BY-DESIGN ─────────
    // Shared invariant: the first buy debits exactly buyNowPrice and closes the
    // auction on BOTH. Rust buys its own (unified) listing; the oracle's buyable
    // market is the STATELESS sample pool, so we buy a sample auction there.
    let o_sample = oracle
        .req("GET", "/ut/game/fifa17/auctionhouse", None, None)
        .1;
    let o_sample_tid = o_sample["auctionInfo"][0]["tradeId"]
        .as_i64()
        .expect("sample tradeId");
    let o_sample_bn = o_sample["auctionInfo"][0]["buyNowPrice"]
        .as_i64()
        .expect("sample buyNow");
    let o_bbal = oracle.coins();
    let (o_bys, o_buy1) = oracle.req(
        "POST",
        &format!("/ut/game/fifa17/trade/{o_sample_tid}"),
        Some(json!({})),
        None,
    );
    assert_eq!(o_bys, 200);
    assert_eq!(
        o_buy1["auctionInfo"][0]["tradeState"], "closed",
        "oracle buy closes the auction"
    );
    assert_eq!(
        oracle.coins() - o_bbal,
        -o_sample_bn,
        "oracle first buy debits exactly buyNowPrice"
    );

    let r_bbal = client.balance().unwrap();
    let (r_bys, r_buy1) = rust(
        &server,
        "POST",
        &format!("/ut/game/fifa17/trade/{r_trade_id}"),
        b"{}",
        None,
    );
    assert_eq!(r_bys, 200);
    assert_eq!(
        r_buy1["auctionInfo"][0]["tradeState"], "closed",
        "rust buy closes the auction"
    );
    assert_eq!(
        client.balance().unwrap() - r_bbal,
        -1000,
        "rust first buy debits exactly buyNowPrice"
    );

    // The divergence, PINNED. Rust: a second buy of a sold listing is a no-op
    // (0 delta, empty auctionInfo). Oracle: a repeat sample buy RE-DEBITS.
    let r_before2 = client.balance().unwrap();
    let (_, r_buy2) = rust(
        &server,
        "POST",
        &format!("/ut/game/fifa17/trade/{r_trade_id}"),
        b"{}",
        None,
    );
    assert_eq!(
        client.balance().unwrap(),
        r_before2,
        "RUST single-debit: second buy of a sold listing does NOT re-debit"
    );
    assert_eq!(
        r_buy2["auctionInfo"].as_array().unwrap().len(),
        0,
        "rust second buy sees a closed/absent auction"
    );
    // rust tradePile no longer active after sale.
    let r_tp2 = rust(&server, "GET", "/ut/game/fifa17/tradePile", b"", None).1;
    assert_eq!(
        r_tp2["auctionInfo"].as_array().unwrap().len(),
        0,
        "rust listing sold: no longer active"
    );

    let o_before2 = oracle.coins();
    let (_, o_buy2) = oracle.req(
        "POST",
        &format!("/ut/game/fifa17/trade/{o_sample_tid}"),
        Some(json!({})),
        None,
    );
    assert_eq!(
        o_buy2["auctionInfo"][0]["tradeState"], "closed",
        "oracle repeat buy re-closes a fresh reconstruction"
    );
    assert_eq!(
        oracle.coins() - o_before2,
        -o_sample_bn,
        "ORACLE stateless: sample-market buy RE-DEBITS on repeat (by design)"
    );
    matrix.push(("market buy POST /trade", "DIFFERENT-BY-DESIGN"));

    // ── OP 14: market cancel (DELETE /ut/delete/.../trade/<id>) ────────────
    // Oracle: cancel the OWN listing (OP 11) -> it drops out of tradePile.
    let (o_cs, _) = oracle.req(
        "DELETE",
        &format!("/ut/delete/game/fifa17/trade/{o_trade_id}"),
        None,
        None,
    );
    assert_eq!(o_cs, 200, "oracle cancel 200");
    let o_tp_after = oracle.req("GET", "/ut/game/fifa17/tradePile", None, None).1;
    assert_eq!(
        o_tp_after["auctionInfo"].as_array().unwrap().len(),
        0,
        "oracle cancelled listing gone from tradePile"
    );
    // Rust: list a fresh item, cancel it, then a buy is a 0-delta empty auction.
    // A still-owned card (r_wire[0]/[3] were quick-sold, [1] is listed above).
    let clist = rust(
        &server,
        "POST",
        "/ut/game/fifa17/auctionhouse",
        format!(
            r#"{{"itemData":{{"id":{}}},"buyNowPrice":1000,"startingBid":500}}"#,
            r_wire[2]
        )
        .as_bytes(),
        None,
    )
    .1;
    let r_cancel_id = clist["id"].as_i64().unwrap();
    let (r_cs, _) = rust(
        &server,
        "DELETE",
        &format!("/ut/delete/game/fifa17/trade/{r_cancel_id}"),
        b"",
        None,
    );
    assert_eq!(r_cs, 200, "rust cancel 200");
    let r_bc = client.balance().unwrap();
    let cbuy = rust(
        &server,
        "POST",
        &format!("/ut/game/fifa17/trade/{r_cancel_id}"),
        b"{}",
        None,
    )
    .1;
    assert_eq!(
        cbuy["auctionInfo"].as_array().unwrap().len(),
        0,
        "rust cancelled listing not buyable"
    );
    assert_eq!(
        client.balance().unwrap(),
        r_bc,
        "rust buying a cancelled listing does not debit"
    );
    matrix.push(("market cancel DELETE", "PARITY"));

    // ── OP 3b: purchasegroup — sentinel (empty My Packs, unverified session) ─
    // Both sides now own zero packs (pack 70 was consumed in OP 5). Oracle: clear
    // the opened-pack grace via /hub, then an unverified session gets the sentinel.
    oracle.req("GET", "/ut/game/fifa17/hub", None, None);
    let o_pg_s = oracle
        .req("GET", "/ut/game/fifa17/store/purchasegroup", None, None)
        .1;
    let r_pg_s = rust(
        &server,
        "GET",
        "/ut/game/fifa17/store/purchasegroup",
        b"",
        None,
    )
    .1;
    assert_eq!(
        pack_ids(&o_pg_s),
        vec![1, 5, 6, 7, SENTINEL_PACK_ID],
        "oracle empty -> sentinel"
    );
    assert_eq!(
        pack_ids(&r_pg_s),
        vec![1, 2, 3, 4, 5, 6, SENTINEL_PACK_ID],
        "rust empty (unknown SID) -> real catalogue + sentinel"
    );
    for b in [&o_pg_s, &r_pg_s] {
        let s = b["purchase"]
            .as_array()
            .unwrap()
            .iter()
            .find(|p| p["id"].as_u64() == Some(SENTINEL_PACK_ID))
            .unwrap();
        assert_eq!(s["state"], "active", "sentinel state active");
    }
    matrix.push(("purchasegroup sentinel", "DIFFERENT-BY-DESIGN"));

    // ── OP 3c: purchasegroup — clean-v1 (empty + verified capability session) ─
    // Oracle: register the launcher capability, open a session (auth), present the
    // SID -> clean topology (no sentinel).
    oracle.req(
        "POST",
        "/openfut/fifa17/capability",
        Some(json!({"capability":"empty_mypacks_resolver","version":1,"personaId":PERSONA_ID})),
        None,
    );
    let auth = oracle
        .req(
            "POST",
            "/ut/auth",
            Some(json!({"nucleusPersonaId":PERSONA_ID,"nucleusPersonaDisplayName":"CAGE"})),
            None,
        )
        .1;
    let sid = auth["sid"].as_str().expect("oracle sid").to_string();
    let o_pg_c = oracle
        .req(
            "GET",
            "/ut/game/fifa17/store/purchasegroup",
            None,
            Some(&sid),
        )
        .1;
    assert_eq!(
        pack_ids(&o_pg_c),
        vec![1, 5, 6, 7],
        "oracle clean-v1 strips the sentinel"
    );
    // Rust: drive the REAL SessionStore state machine identically (capability
    // before login -> pending, open the session, freeze the mode), then the real
    // Core-backed builder. Core owns zero entitlements now, so this is the empty
    // clean case.
    let mut sessions = SessionStore::new();
    let now = 10.0;
    sessions.register_capability(Some("127.0.0.1".into()), PERSONA_ID, 1, now);
    let rsid = "OPENFUT-SID-DIFFERENTIAL01";
    sessions.open_session(rsid, Some("127.0.0.1".into()), PERSONA_ID, now);
    let mode = sessions.empty_mypacks_mode(rsid, Some("127.0.0.1"), now);
    assert_eq!(
        mode,
        StoreMode::CleanV1,
        "verified capability -> clean-v1 mode"
    );
    let r_pg_c_resp = handle_purchasegroup(&client, mode);
    let r_pg_c: Value = serde_json::from_slice(&r_pg_c_resp.body).unwrap();
    assert_eq!(
        pack_ids(&r_pg_c),
        vec![1, 2, 3, 4, 5, 6],
        "rust clean-v1 real catalogue, sentinel stripped"
    );
    assert!(!pack_ids(&r_pg_c).contains(&SENTINEL_PACK_ID));
    matrix.push(("purchasegroup clean-v1", "DIFFERENT-BY-DESIGN"));

    // ── Emit the matrix for the run log. ────────────────────────────────────
    eprintln!("\n===== economy_differential PARITY / DIFFERENT-BY-DESIGN matrix =====");
    for (op, class) in &matrix {
        eprintln!("  {op:<26} {class}");
    }
    let dbd = matrix
        .iter()
        .filter(|(_, c)| *c == "DIFFERENT-BY-DESIGN")
        .count();
    eprintln!(
        "  ({} ops compared: {} PARITY, {dbd} DIFFERENT-BY-DESIGN)\n",
        matrix.len(),
        matrix.len() - dbd
    );
    assert_eq!(matrix.len(), 16, "all economy ops classified");
}

/// Compare the complete reversed `/sbs/*` response family against the Python oracle.
/// Listing labels and counters deliberately come from Core, so parity is defined as the
/// exact key/container/scalar-kind graph. Submission is the one semantic deviation:
/// Python acknowledges any body without consuming cards; Rust rejects an empty squad.
fn run_sbc_differential(core_base: &str, oracle: &Oracle, dir: &std::path::Path) {
    wait_ready(core_base);
    let (server, client, resolver, _sample_resource) = build_econ_server(core_base, dir);
    let cases = [
        ("GET", "/ut/game/fifa17/sbs/sets", None),
        ("GET", "/ut/game/fifa17/sbs/setId/1/challenges", None),
        ("GET", "/ut/game/fifa17/sbs/setId/2/challenges", None),
        ("GET", "/ut/game/fifa17/sbs/setId/999/challenges", None),
        ("POST", "/ut/game/fifa17/sbs/sets/tag", Some(json!({}))),
        ("PUT", "/ut/game/fifa17/sbs/sets/tag", Some(json!({}))),
        ("POST", "/ut/game/fifa17/sbs/challenge/101", None),
        ("GET", "/ut/game/fifa17/sbs/challenge/101/squad", None),
        (
            "PUT",
            "/ut/game/fifa17/sbs/challenge/101/squad",
            Some(json!({ "squad": [] })),
        ),
    ];

    for (method, path, body) in cases {
        let oracle_result = oracle.req(method, path, body.clone(), None);
        let bytes = body
            .as_ref()
            .map(|value| serde_json::to_vec(value).unwrap())
            .unwrap_or_default();
        let rust_result = rust(&server, method, path, &bytes, None);
        assert_eq!(
            rust_result.0, oracle_result.0,
            "{method} {path} status parity"
        );
        assert_eq!(
            json_shape(&rust_result.1),
            json_shape(&oracle_result.1),
            "{method} {path} wire shape parity\nRust: {}\nOracle: {}",
            rust_result.1,
            oracle_result.1
        );
        match (method, path) {
            ("GET", "/ut/game/fifa17/sbs/sets") => {
                assert_eq!(rust_result.1["categories"][0]["categoryId"], 1);
                assert_eq!(oracle_result.1["categories"][0]["categoryId"], 1);
                let rust_set_ids: Vec<i64> = rust_result.1["categories"][0]["sets"]
                    .as_array()
                    .unwrap()
                    .iter()
                    .map(|set| set["setId"].as_i64().unwrap())
                    .collect();
                let oracle_set_ids: Vec<i64> = oracle_result.1["categories"][0]["sets"]
                    .as_array()
                    .unwrap()
                    .iter()
                    .map(|set| set["setId"].as_i64().unwrap())
                    .collect();
                assert_eq!(rust_set_ids, [1, 2]);
                assert_eq!(oracle_set_ids, [1, 2]);
            }
            ("GET", "/ut/game/fifa17/sbs/setId/1/challenges") => {
                for body in [&rust_result.1, &oracle_result.1] {
                    assert_eq!(body["challenges"][0]["challengeId"], 101);
                    assert_eq!(body["challenges"][0]["setId"], 1);
                    assert_eq!(body["challenges"][0]["categoryId"], 1);
                }
            }
            ("GET", "/ut/game/fifa17/sbs/setId/2/challenges") => {
                for body in [&rust_result.1, &oracle_result.1] {
                    assert_eq!(body["challenges"][0]["challengeId"], 201);
                    assert_eq!(body["challenges"][0]["setId"], 2);
                    assert_eq!(body["challenges"][0]["categoryId"], 1);
                }
            }
            ("GET", "/ut/game/fifa17/sbs/setId/999/challenges") => {
                assert_eq!(rust_result.1["challenges"], json!([]));
                assert_eq!(oracle_result.1["challenges"], json!([]));
            }
            ("POST", "/ut/game/fifa17/sbs/challenge/101") => {
                assert_eq!(rust_result.1["challengeId"], 101);
                assert_eq!(oracle_result.1["challengeId"], 101);
            }
            (method, "/ut/game/fifa17/sbs/challenge/101/squad") => {
                assert!(method == "GET" || method == "PUT");
                assert_eq!(rust_result.1["id"], 101);
                assert_eq!(oracle_result.1["id"], 101);
            }
            _ => {}
        }
    }

    let submit = json!({ "squad": [] });
    let oracle_submit = oracle.req(
        "PUT",
        "/ut/game/fifa17/sbs/challenge/101",
        Some(submit.clone()),
        None,
    );
    let rust_submit = rust(
        &server,
        "PUT",
        "/ut/game/fifa17/sbs/challenge/101",
        &serde_json::to_vec(&submit).unwrap(),
        None,
    );
    assert_eq!(oracle_submit.0, 200, "oracle preserves its no-op submit");
    assert_eq!(
        json_shape(&oracle_submit.1),
        json_shape(&json!({
            "challengeId": 0,
            "setId": 0,
            "credits": 0,
            "preOrderPacks": 0,
            "recoveredPacks": 0,
            "grantedChallengeAwards": [],
            "grantedSetAwards": []
        })),
        "oracle submit response remains freeze-safe"
    );
    assert_eq!(
        rust_submit.0, 400,
        "Rust must validate instead of copying the oracle's no-op acceptance"
    );

    let owned = client.all_owned().expect("SBC differential inventory");
    let mut selected = Vec::new();
    for nation in ["Argentina", "Brazil"] {
        selected.push(
            owned
                .iter()
                .find(|item| item.rating >= 70 && item.nation == nation)
                .unwrap_or_else(|| panic!("missing {nation} SBC fixture")),
        );
    }
    for item in &owned {
        if selected.len() == 11 {
            break;
        }
        if item.rating >= 70
            && !selected
                .iter()
                .any(|existing| existing.owned_card_id == item.owned_card_id)
        {
            selected.push(item);
        }
    }
    assert_eq!(selected.len(), 11);
    let successful = json!({
        "squad": selected
            .iter()
            .enumerate()
            .map(|(index, item)| json!({
                "index": index,
                "itemData": {
                    "id": i64::from(
                        resolver.resolve(item).expect("SBC wire identity").item_id
                    )
                }
            }))
            .collect::<Vec<_>>()
    });
    let before_balance = client.balance().unwrap();
    let before_packs = client.entitlements().unwrap().len();
    let oracle_success = oracle.req(
        "POST",
        "/ut/game/fifa17/sbs/challenge/201",
        Some(successful.clone()),
        None,
    );
    let rust_success = rust(
        &server,
        "POST",
        "/ut/game/fifa17/sbs/challenge/201",
        &serde_json::to_vec(&successful).unwrap(),
        None,
    );
    assert_eq!(oracle_success.0, 200);
    assert_eq!(rust_success.0, 200);
    assert_eq!(
        json_shape(&rust_success.1),
        json_shape(&oracle_success.1),
        "successful Rust submit preserves the oracle response class"
    );
    assert_eq!(rust_success.1["challengeId"], 201);
    assert_eq!(rust_success.1["setId"], 2);
    assert!(
        client.balance().unwrap() > before_balance,
        "Core applies challenge and achievement coin rewards"
    );
    assert_eq!(
        client.entitlements().unwrap().len(),
        before_packs + 1,
        "Core grants one Hybrid Nations pack"
    );
}

#[tokio::test(flavor = "multi_thread", worker_threads = 4)]
async fn economy_differential_python_oracle() {
    let dir = std::env::temp_dir().join(format!(
        "openfut-econ-diff-{}-{}",
        std::process::id(),
        std::time::SystemTime::now()
            .duration_since(std::time::UNIX_EPOCH)
            .unwrap()
            .as_nanos()
    ));
    std::fs::create_dir_all(&dir).unwrap();
    let db_url = format!("sqlite://{}/econ.db", dir.display());

    // Boot the seeded in-process Core.
    let (core_handle, core_base) = start_core_seeded(&db_url, true).await;

    // Everything below runs off-runtime on a plain OS thread. This is mandatory:
    // the blocking Core client, `reqwest::blocking` (including the oracle client,
    // whose construction spins up + tears down a temporary runtime), and the
    // AsyncBridge's direct `block_on` all panic if a Tokio runtime is entered on
    // the thread. spawn_blocking joins a std::thread — a truly runtime-free
    // context, exactly like the thread-per-connection server. The oracle guard is
    // created AND owned inside that thread, so the subprocess is killed when the
    // thread finishes or unwinds on panic.
    let core_base_run = core_base.clone();
    let dir_run = dir.clone();
    let outcome = tokio::task::spawn_blocking(move || {
        std::thread::spawn(move || {
            let mut oracle = Oracle::spawn(&dir_run);
            oracle.wait_ready();
            run_differential(&core_base_run, &oracle, &dir_run);
        })
        .join()
    })
    .await
    .expect("join spawn_blocking");

    core_handle.abort();
    std::fs::remove_dir_all(&dir).ok();
    outcome.expect("differential thread panicked");
}

#[tokio::test(flavor = "multi_thread", worker_threads = 4)]
async fn sbc_differential_python_oracle() {
    let dir = std::env::temp_dir().join(format!(
        "openfut-sbc-diff-{}-{}",
        std::process::id(),
        std::time::SystemTime::now()
            .duration_since(std::time::UNIX_EPOCH)
            .unwrap()
            .as_nanos()
    ));
    std::fs::create_dir_all(&dir).unwrap();
    let db_url = format!("sqlite://{}/sbc.db", dir.display());
    let (core_handle, core_base) = start_core_seeded(&db_url, true).await;

    let core_base_run = core_base.clone();
    let dir_run = dir.clone();
    let outcome = tokio::task::spawn_blocking(move || {
        std::thread::spawn(move || {
            let mut oracle = Oracle::spawn(&dir_run);
            oracle.wait_ready();
            run_sbc_differential(&core_base_run, &oracle, &dir_run);
        })
        .join()
    })
    .await
    .expect("join spawn_blocking");

    core_handle.abort();
    std::fs::remove_dir_all(&dir).ok();
    outcome.expect("SBC differential thread panicked");
}
