//! FIFA 17 Store pack catalogue + `/store/purchasegroup` wire shaping.
//!
//! A faithful Rust port of the Python oracle's `PACK_CATALOG` + `_pack_body` +
//! `store_catalog` assembly (`fifa17-recon/tools/{fut_store,utas_server}.py`) at the
//! **production flag defaults** (`FUT_STORE_DISPLAYGROUP=1` on, `FUT_STORE_GROUPID=0`
//! off, `FUT_PRICE_PROBE=0` off). Parity is pinned by differential fixtures generated
//! from the Python oracle (`tests/fixtures/purchasegroup_*.json`).
//!
//! ## Scope / split-brain safety
//!
//! This is **pure wire shaping** — no economy state, no IO. [`build_purchasegroup`]
//! is a function of `(owned unopened pack ids, empty-My-Packs StoreMode)`. It is
//! deliberately **not yet wired** into the live host: serving purchasegroup from Rust
//! requires an authoritative Rust owner of `unopenedPackIds`, and today Python is the
//! single writer of coins + unopened packs (BUY, quick-sell, rewards). Wiring this
//! before that economy authority exists would create a dual-write/split-brain. See
//! the R3 economy-authority prerequisite in the vault (`Rust UTAS Migration`).
//!
//! ## Economy-parameter provenance
//!
//! Prices, counts and odds are the current OpenFUT **PLACEHOLDER** economy, NOT
//! EA-authentic (the overnight audit established the store economy is invented). The
//! wire *shape* is EA-observed/oracle-verified; the *numbers* are placeholders.

use serde_json::{json, Value};

use crate::fut::store_session::{StoreMode, SENTINEL_PACK_ID};

/// A FIFA 17 Store pack definition. Wire shape is oracle-verified; the economy
/// numbers (`price`/`count`/`special_chance`) are OpenFUT PLACEHOLDER, not EA-authentic.
#[derive(Debug, Clone, Copy, PartialEq)]
pub struct PackDef {
    pub id: u64,
    pub name: &'static str,
    pub price: u64,
    pub count: u64,
    pub gold: bool,
    pub special_chance: f64,
    /// Reward-only pack (no purchase path): excluded from the normal catalogue,
    /// rendered only when owned (in `unopenedPackIds`).
    pub owned_only: bool,
}

/// The current supported FIFA 17 pack catalogue (`fut_store.py:820`). Only observed/
/// currently-supported ids. The 65534 sentinel is deliberately ABSENT — it is a
/// compatibility shim, never a catalogue pack (never purchasable/openable).
pub const PACK_CATALOG: &[PackDef] = &[
    PackDef {
        id: 1,
        name: "Bronze Pack",
        price: 400,
        count: 5,
        gold: false,
        special_chance: 0.005,
        owned_only: false,
    },
    PackDef {
        id: 5,
        name: "Gold Pack",
        price: 5000,
        count: 7,
        gold: true,
        special_chance: 0.03,
        owned_only: false,
    },
    PackDef {
        id: 6,
        name: "Premium Gold",
        price: 15000,
        count: 11,
        gold: true,
        special_chance: 0.08,
        owned_only: false,
    },
    PackDef {
        id: 7,
        name: "Special Players Pack",
        price: 25000,
        count: 11,
        gold: true,
        special_chance: 1.0,
        owned_only: false,
    },
    PackDef {
        id: 70,
        name: "Reward Special Players Pack",
        price: 0,
        count: 11,
        gold: true,
        special_chance: 1.0,
        owned_only: true,
    },
];

/// Look up a catalogue pack by id (the 65534 sentinel is never present).
pub fn pack_by_id(id: u64) -> Option<&'static PackDef> {
    PACK_CATALOG.iter().find(|p| p.id == id)
}

/// The FIFA17 StoreFront category token for a NORMAL pack tile (`utas_server.py:3579`):
/// one of the six hard-coded tokens the client resolves.
fn category(p: &PackDef) -> &'static str {
    if p.special_chance >= 1.0 {
        "special"
    } else if p.gold {
        "gold"
    } else {
        "bronze"
    }
}

/// One `purchase[]` entry — the faithful `_pack_body` port (`utas_server.py:3474`) at
/// production flag defaults. `owned` packs (My Packs / reward / sentinel) drop the
/// purchase fields and take the `mypacks` display group.
pub fn pack_body(p: &PackDef, idx: u64, owned: bool) -> Value {
    let mtx = std::cmp::max(1, p.price / 100);
    let mut body = json!({
        "assetId": p.id,
        "id": p.id,
        "packType": if p.gold { "GOLD" } else { "BRONZE" },
        "description": p.name,
        "state": "active",
        "saleType": "promo",
        "limitType": "NONE",
        "quantity": 0,
        "purchaseLimit": 0,
        "purchaseCount": 0,
        "isPremium": false,
        "sortPriority": idx,
        "currencies": [{ "name": "coins", "funds": p.price, "finalFunds": p.price }],
        "extPrice": {
            "finalPrice": { "amount": mtx, "currency": "mtx" },
            "originalPrice": { "amount": mtx, "currency": "mtx" },
        },
        "packContentInfo": {
            "bronzeQuantity": if p.gold { 0 } else { p.count },
            "silverQuantity": 0,
            "goldQuantity": if p.gold { p.count } else { 0 },
            "rareQuantity": if p.gold { p.count } else { 0 },
            "itemQuantity": p.count,
        },
        "unopened": owned,
    });
    let obj = body.as_object_mut().expect("pack body is a JSON object");
    if owned {
        // Reward/My-Packs tiles have no purchase path; leaving zero-value coin/mtx
        // objects makes the client render the price label as literal "undefined".
        obj.remove("currencies");
        obj.remove("extPrice");
        obj.insert(
            "displayGroup".into(),
            json!({ "value": "mypacks", "priority": idx }),
        );
    } else {
        obj.insert("displayGroup".into(), json!({ "value": category(p) }));
    }
    body
}

/// The synthetic empty-My-Packs sentinel `purchase[]` entry (id 65534): an owned-style
/// body forced to `state:"active"`, `unopened:false`. Compatibility shim ONLY — 65534
/// is absent from [`PACK_CATALOG`], so it can never be bought/opened/granted.
pub fn sentinel_body(idx: u64) -> Value {
    let sentinel = PackDef {
        id: SENTINEL_PACK_ID,
        name: "",
        price: 0,
        count: 0,
        gold: true,
        special_chance: 0.0,
        owned_only: true,
    };
    let mut body = pack_body(&sentinel, idx, true);
    let obj = body
        .as_object_mut()
        .expect("sentinel body is a JSON object");
    obj.insert("state".into(), json!("active"));
    obj.insert("unopened".into(), json!(false));
    body
}

/// Build the full `/store/purchasegroup` body from the authoritative unopened-pack ids
/// and the frozen empty-My-Packs mode. Pure — mirrors `store_catalog` (`3627`):
/// normal packs (1,5,6,7) first, then any owned packs, then the empty-My-Packs shim
/// (sentinel for [`StoreMode::Sentinel`], nothing for [`StoreMode::CleanV1`]).
pub fn build_purchasegroup(unopened_ids: &[u64], mode: StoreMode) -> Value {
    let mut packs: Vec<Value> = PACK_CATALOG
        .iter()
        .filter(|p| !p.owned_only)
        .enumerate()
        .map(|(i, p)| pack_body(p, i as u64 + 1, false))
        .collect();
    for (i, &pid) in unopened_ids.iter().enumerate() {
        if let Some(owned) = pack_by_id(pid) {
            packs.push(pack_body(owned, i as u64 + 1, true));
        }
    }
    if unopened_ids.is_empty() && mode == StoreMode::Sentinel {
        packs.push(sentinel_body(1));
    }
    json!({ "purchase": packs, "timestamp": 1596326400i64 })
}

#[cfg(test)]
mod tests {
    //! Differential parity against the Python oracle. The fixtures under
    //! `tests/fixtures/purchasegroup_*.json` are generated by calling the oracle's
    //! `_pack_body`/`store_catalog` at production flag defaults; Rust must match
    //! them semantically (object key order is irrelevant to `serde_json::Value` eq).
    use super::*;

    fn parse(s: &str) -> Value {
        serde_json::from_str(s).expect("fixture parses")
    }

    #[test]
    fn purchasegroup_zero_sentinel_matches_oracle() {
        let got = build_purchasegroup(&[], StoreMode::Sentinel);
        let want = parse(include_str!(
            "../../tests/fixtures/purchasegroup_zero_sentinel.json"
        ));
        assert_eq!(got, want);
    }

    #[test]
    fn purchasegroup_zero_clean_matches_oracle() {
        let got = build_purchasegroup(&[], StoreMode::CleanV1);
        let want = parse(include_str!(
            "../../tests/fixtures/purchasegroup_zero_clean.json"
        ));
        assert_eq!(got, want);
    }

    #[test]
    fn purchasegroup_pack70_matches_oracle() {
        // Owned pack present -> no sentinel regardless of mode.
        let got = build_purchasegroup(&[70], StoreMode::Sentinel);
        let want = parse(include_str!(
            "../../tests/fixtures/purchasegroup_pack70.json"
        ));
        assert_eq!(got, want);
    }

    #[test]
    fn sentinel_absent_from_catalog() {
        assert!(pack_by_id(SENTINEL_PACK_ID).is_none());
        assert!(PACK_CATALOG.iter().all(|p| p.id != SENTINEL_PACK_ID));
    }

    #[test]
    fn clean_v1_empty_emits_no_mypacks_group() {
        let got = build_purchasegroup(&[], StoreMode::CleanV1);
        let ids: Vec<u64> = got["purchase"]
            .as_array()
            .unwrap()
            .iter()
            .map(|e| e["id"].as_u64().unwrap())
            .collect();
        assert_eq!(ids, vec![1, 5, 6, 7]);
    }

    #[test]
    fn category_tokens_are_canonical() {
        assert_eq!(category(pack_by_id(1).unwrap()), "bronze");
        assert_eq!(category(pack_by_id(5).unwrap()), "gold");
        assert_eq!(category(pack_by_id(7).unwrap()), "special");
    }
}
