//! FIFA 17 Store pack catalogue + `/store/purchasegroup` wire shaping.
//!
//! Rust is the authoritative store owner: [`build_purchasegroup`] is served live
//! by the host via `EconomyRoute::PurchaseGroup` over Core economy authority (Core
//! owns coins + unopened packs), so there is no Python dependency and no
//! dual-write/split-brain.
//!
//! ## Relationship to the Python oracle
//!
//! The wire *shape* was RE'd from the client and cross-checked against the Python
//! oracle's `_pack_body`/`store_catalog`
//! (`fifa17-recon/tools/{fut_store,utas_server}.py`). Rust now diverges from the
//! oracle where the RE proved the oracle wrong: it does NOT emit `extPrice`, whose
//! parser side-effect creates an `"mtx"` currency row and switches on the broken
//! `or %1s` FIFA-Points tile line (plan-2026-08-05-store-subsystem.md §3.4). The
//! Python oracle stays the rollback baseline and is never modified; the
//! `tests/fixtures/purchasegroup_*.json` goldens pin Rust's authoritative output.
//!
//! ## Economy-parameter provenance
//!
//! Pack prices and tier composition are the real always-available FUT 17
//! regular-store packs (community-documented on fifauteam). Pack ODDS
//! (`special_chance`) are DESIGNED placeholders, NOT EA-authentic — EA never
//! published FUT 17 pack probabilities. The wire *shape* is EA-observed/RE-verified.

use serde_json::{json, Value};

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

/// A FIFA 17 Store pack definition. The wire *shape* is RE-verified; the price and
/// per-tier composition are the real always-available FUT 17 regular-store packs,
/// with DESIGNED (not EA-authentic) `special_chance` odds.
#[derive(Debug, Clone, Copy, PartialEq)]
pub struct PackDef {
    pub id: u64,
    pub name: &'static str,
    pub price: u64,
    /// Cards awarded per rating-tier band: bronze `< 65`, silver `65..=74`, gold
    /// `>= 75`. These are ALSO the wire `packContentInfo` per-tier quantities, so a
    /// pack's displayed composition matches what its generator draws.
    pub n_bronze: u64,
    pub n_silver: u64,
    pub n_gold: u64,
    /// `rareQuantity` shown on the tile (wire display only).
    pub rares: u64,
    /// StoreFront category token (`displayGroup.value`): one of the six hard-coded
    /// client tokens — here `"bronze"`, `"silver"` or `"gold"`.
    pub category: &'static str,
    /// Per-draw probability the awarded card is a special version (DESIGNED
    /// placeholder; FUT 17 odds are unrecoverable).
    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,
}

impl PackDef {
    /// Total cards awarded / wire `itemQuantity` — the sum of the per-tier counts.
    pub fn count(&self) -> u64 {
        self.n_bronze + self.n_silver + self.n_gold
    }
}

/// The always-available FIFA 17 FUT regular-store packs (real fifauteam-documented
/// prices + tier composition), plus the OpenFUT reward pack. Two packs per client
/// category (bronze/silver/gold), which the client renders as separate buyable tiles
/// on drill-in. The 65534 sentinel is deliberately ABSENT — a compatibility shim,
/// never purchasable/openable.
pub const PACK_CATALOG: &[PackDef] = &[
    // ── Bronze category ──
    PackDef {
        id: 1,
        name: "Bronze Pack",
        price: 400,
        n_bronze: 10,
        n_silver: 2,
        n_gold: 0,
        rares: 1,
        category: "bronze",
        special_chance: 0.01,
        owned_only: false,
    },
    PackDef {
        id: 2,
        name: "Premium Bronze Pack",
        price: 750,
        n_bronze: 10,
        n_silver: 2,
        n_gold: 0,
        rares: 3,
        category: "bronze",
        special_chance: 0.02,
        owned_only: false,
    },
    // ── Silver category ──
    PackDef {
        id: 3,
        name: "Silver Pack",
        price: 2500,
        n_bronze: 1,
        n_silver: 11,
        n_gold: 0,
        rares: 1,
        category: "silver",
        special_chance: 0.015,
        owned_only: false,
    },
    PackDef {
        id: 4,
        name: "Premium Silver Pack",
        price: 3750,
        n_bronze: 1,
        n_silver: 11,
        n_gold: 0,
        rares: 3,
        category: "silver",
        special_chance: 0.03,
        owned_only: false,
    },
    // ── Gold category ──
    PackDef {
        id: 5,
        name: "Gold Pack",
        price: 5000,
        n_bronze: 0,
        n_silver: 2,
        n_gold: 10,
        rares: 1,
        category: "gold",
        special_chance: 0.04,
        owned_only: false,
    },
    PackDef {
        id: 6,
        name: "Premium Gold Pack",
        price: 7500,
        n_bronze: 0,
        n_silver: 2,
        n_gold: 10,
        rares: 3,
        category: "gold",
        special_chance: 0.06,
        owned_only: false,
    },
    // ── Reward (owned-only; opened from My Packs, never coin-purchasable) ──
    PackDef {
        id: 70,
        name: "Reward Gold Pack",
        price: 0,
        n_bronze: 0,
        n_silver: 0,
        n_gold: 11,
        rares: 11,
        category: "gold",
        special_chance: 1.0,
        owned_only: true,
    },
    PackDef {
        id: 71,
        name: "Bronze Pack",
        price: 0,
        n_bronze: 10,
        n_silver: 2,
        n_gold: 0,
        rares: 1,
        category: "bronze",
        special_chance: 0.01,
        owned_only: true,
    },
    PackDef {
        id: 72,
        name: "Silver Pack",
        price: 0,
        n_bronze: 1,
        n_silver: 11,
        n_gold: 0,
        rares: 1,
        category: "silver",
        special_chance: 0.02,
        owned_only: true,
    },
    PackDef {
        id: 73,
        name: "Gold Pack",
        price: 0,
        n_bronze: 0,
        n_silver: 2,
        n_gold: 10,
        rares: 1,
        category: "gold",
        special_chance: 0.05,
        owned_only: true,
    },
    PackDef {
        id: 74,
        name: "Rare Gold Pack",
        price: 0,
        n_bronze: 0,
        n_silver: 2,
        n_gold: 10,
        rares: 3,
        category: "gold",
        special_chance: 0.10,
        owned_only: true,
    },
    PackDef {
        id: 75,
        name: "Icon Pack",
        price: 0,
        n_bronze: 0,
        n_silver: 0,
        n_gold: 12,
        rares: 12,
        category: "gold",
        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)
}

/// Resolve a Core entitlement's opaque `definition_id` to the FIFA 17 numeric
/// owned-only pack it renders and opens as. Accepts a numeric id (imported
/// entitlements, e.g. `"70"`) or one of the symbolic reward-pack names Core's
/// reward services grant (SBC, draft, season, check-in, FUT Champions). Only
/// owned-only packs qualify, so an unknown or non-reward entitlement resolves to
/// `None` and is simply not shown as an openable pack rather than faked.
pub fn owned_pack_id_for_definition(definition_id: &str) -> Option<u64> {
    if let Ok(numeric) = definition_id.parse::<u64>() {
        return pack_by_id(numeric)
            .filter(|pack| pack.owned_only)
            .map(|pack| pack.id);
    }
    let id = match definition_id {
        "bronze_pack" => 71,
        "silver_pack" => 72,
        "gold_pack" => 73,
        "rare_gold_pack" => 74,
        "icon_pack" => 75,
        _ => return None,
    };
    Some(id)
}

/// The FIFA 17 StoreFront category token for a pack tile (`displayGroup.value`):
/// one of the six hard-coded tokens the client resolves. Each catalogue pack
/// carries its own token; owned/reward packs take `mypacks` instead (see
/// [`pack_body`]).
fn category(p: &PackDef) -> &'static str {
    p.category
}

/// 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 pack_type = match p.category {
        "gold" => "GOLD",
        "silver" => "SILVER",
        _ => "BRONZE",
    };
    // Group background texture: FIFA 17's all-groups landing (shown on first store
    // entry) renders one tile per group whose art is the client-bundled
    // `packs_backgrounds_%d.dds` selected by this field. LIVE-PROBED 2026-08-18:
    // index 0 is blank; 1/2/3 render real pack art — so assign non-zero per
    // category and that landing shows native art instead of blank shields. The
    // persistent tabbed store draws its pack art from the packs themselves and
    // does not depend on this.
    let group_bg: u64 = if owned {
        3
    } else {
        match p.category {
            "gold" => 3,
            "silver" => 2,
            _ => 1,
        }
    };
    // My Packs cover art. LIVE-MAPPED on the retail client 2026-08-18 across all
    // three store tabs and two reward tiles:
    //   * `assetId` only gates whether art renders AT ALL. A reward pack's own id
    //     (70-75) is not a known client asset, so its tile renders BLANK; any valid
    //     catalogue asset (1-6) makes art appear.
    //   * WHICH art is drawn comes from `packType` + `packContentInfo.rareQuantity`,
    //     not from `assetId`: BRONZE+1rare -> bronze card, BRONZE+3 -> silver,
    //     SILVER+1 -> gold, SILVER+3 -> silver trio, GOLD+1 -> blue special,
    //     GOLD+3 -> red inform. (Remapping assetId 5->3 and 3->2 left both frames
    //     unchanged and only rotated the featured player, which proves this.)
    // So a reward tile automatically shows the same art as the equivalent
    // purchasable pack; we only need a valid asset, and we use the tier's own store
    // pack for clarity. `id` stays the pack's own id (the open packId / SERVER_ID).
    let art_asset: u64 = if owned {
        match p.category {
            "gold" => 5,
            "silver" => 3,
            _ => 1,
        }
    } else {
        p.id
    };
    let mut body = json!({
        "assetId": art_asset,
        "id": p.id,
        "packType": pack_type,
        "description": p.name,
        "state": "active",
        "saleType": "promo",
        "limitType": "NONE",
        "quantity": 0,
        "purchaseLimit": 0,
        "purchaseCount": 0,
        "isPremium": false,
        "sortPriority": idx,
        "displayGroupAssetId": group_bg,
        // The tile renders `finalFunds` as its coin price; the HUD balance reads
        // `funds` from the /credits currencies array. We keep the pair equal.
        //
        // NO `extPrice`. Its parser has a SIDE EFFECT: `finalPrice`/`originalPrice`
        // both CREATE an `"mtx"` currency row, which the tile adapter reads as "has
        // a real-money price" and switches on the broken `or %1s` label — the
        // Origin/Dime commerce catalogue that would fill it no longer exists
        // offline, so every string stays at its constructor default. Omitting the
        // key is the documented fix (plan-2026-08-05-store-subsystem.md §3.4 /
        // experiment #4): it strictly reduces executed client code and leaves every
        // tile buyable. LIVE-observed `or %1s` on the Store tiles, 2026-08-18.
        "currencies": [{ "name": "coins", "funds": p.price, "finalFunds": p.price }],
        "packContentInfo": {
            "bronzeQuantity": p.n_bronze,
            "silverQuantity": p.n_silver,
            "goldQuantity": p.n_gold,
            "rareQuantity": p.rares,
            "itemQuantity": p.count(),
        },
        "unopened": owned,
    });
    let obj = body.as_object_mut().expect("pack body is a JSON object");
    if owned {
        // Reward/My-Packs tiles KEEP the coins currency at the pack price (0 for
        // reward packs). My Packs opens through the store purchase flow, so a tile
        // with no currency row is not actionable — clicking navigates instead of
        // opening. With a free coin row the client sends POST /purchased and the
        // server (owned-only) opens it for free. No extPrice (that is the `or %1s`
        // mtx bug), so the free coin row formats as "0", not an unavailable label.
        // LIVE-PROVEN 2026-08-18: a reward Silver Pack opened and revealed cards.
        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,
        n_bronze: 0,
        n_silver: 0,
        n_gold: 0,
        rares: 0,
        category: "gold",
        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));
    // The sentinel must stay non-openable (it is only a resolve-without-crash shim
    // for an empty My Packs), so it keeps no purchase path.
    obj.remove("currencies");
    // Keep the sentinel's own id as its asset: it must render as an inert blank
    // placeholder, never borrow a real pack's cover.
    obj.insert("assetId".into(), json!(SENTINEL_PACK_ID));
    body
}

/// Build the full `/store/purchasegroup` body from the authoritative unopened-pack
/// ids and the frozen empty-My-Packs mode. Pure: the six regular packs (ids 1–6)
/// 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 {
    //! Golden tests pinning the authoritative Rust `/store/purchasegroup` body. The
    //! fixtures under `tests/fixtures/purchasegroup_*.json` are Rust's own output
    //! (object key order is irrelevant to `serde_json::Value` eq). They track the
    //! RE-driven divergence from the Python oracle — notably no `extPrice` (see the
    //! module header).
    use super::*;

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

    #[test]
    fn purchasegroup_zero_sentinel_matches_golden() {
        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_golden() {
        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_golden() {
        // 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 reward_pack_definitions_resolve_to_openable_owned_packs() {
        // Core reward services grant symbolic pack names; each must resolve to an
        // owned-only catalogue pack so it renders as an openable My Packs tile.
        for (def, want) in [
            ("bronze_pack", 71),
            ("silver_pack", 72),
            ("gold_pack", 73),
            ("rare_gold_pack", 74),
            ("icon_pack", 75),
        ] {
            let id = owned_pack_id_for_definition(def).expect("reward def resolves");
            assert_eq!(id, want);
            assert!(
                pack_by_id(id).unwrap().owned_only,
                "a reward pack must be owned-only"
            );
        }
        // Imported numeric owned-pack ids resolve to themselves.
        assert_eq!(owned_pack_id_for_definition("70"), Some(70));
        // Purchasable (non-owned) numeric ids and unknown names never resolve.
        assert_eq!(owned_pack_id_for_definition("5"), None);
        assert_eq!(owned_pack_id_for_definition("mystery_pack"), None);
    }

    #[test]
    fn reward_tiles_carry_a_renderable_cover_asset() {
        // A reward pack's own id is not a client art asset, so its My Packs tile
        // renders blank. Each reward tile must therefore carry a valid catalogue
        // assetId (its tier's store pack) while `id` stays the open packId.
        for (reward_id, want_asset) in [(71, 1), (72, 3), (73, 5), (74, 5), (75, 5)] {
            let pack = pack_by_id(reward_id).expect("reward pack in catalogue");
            let tile = pack_body(pack, 1, true);
            assert_eq!(tile["id"], reward_id, "open packId stays the pack's own id");
            assert_eq!(
                tile["assetId"], want_asset,
                "reward tile borrows its tier's store-pack cover asset"
            );
            assert!(
                pack_by_id(tile["assetId"].as_u64().unwrap()).is_some_and(|p| !p.owned_only),
                "the cover asset must be a real purchasable catalogue pack"
            );
        }
        // The sentinel must NOT borrow a real cover — it stays an inert placeholder.
        assert_eq!(sentinel_body(1)["assetId"], SENTINEL_PACK_ID);
    }

    #[test]
    fn symbolic_reward_pack_renders_as_openable_my_packs_tile() {
        // A granted silver reward pack (resolved to id 72) must appear as an owned
        // My Packs tile, not the non-openable sentinel shim.
        let got = build_purchasegroup(&[72], StoreMode::Sentinel);
        let packs = got["purchase"].as_array().unwrap();
        let reward = packs
            .iter()
            .find(|p| p["id"] == 72)
            .expect("reward pack tile present");
        assert_eq!(reward["displayGroup"]["value"], "mypacks");
        assert!(reward["unopened"].as_bool().unwrap());
        assert!(packs.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, 2, 3, 4, 5, 6]);
    }

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