//! FIFA 17 Store / owned-item **mutation** handlers, Core-backed and fail-closed.
//!
//! These implement the three economy WRITERS against Core economy authority:
//!
//! * [`handle_store_buy`] — `PUT …/store/transaction` (open-on-buy): debit +
//!   mint the pack's cards atomically ([`CoreEconomy::purchase_items`]) and
//!   reveal them in the `createPackResponse` envelope.
//! * [`handle_pack_open`] — `POST …/purchased`: open a coin pack (as above) or
//!   redeem an owned reward pack's unopened entitlement
//!   ([`CoreEconomy::redeem_entitlement`], consume-once + atomic add).
//! * [`handle_quick_sell`] — `DELETE …/item/<id>` and `POST /ut/delete/…/item`:
//!   reverse-resolve the wire id, price the card server-side, and
//!   [`CoreEconomy::sell_item`].
//!
//! Every path is **fail-closed**: a Core error yields a controlled FIFA-shaped
//! response (503 / 461), NEVER a Python fallback (which would be a second
//! writer). The pack contents come from the pure adapter generator
//! ([`generate_pack_contents`]); the host owns the impure parts — building the
//! candidate pool, minting Core instance ids, and shaping the wire item.

use std::collections::HashSet;

use rand::Rng;
use serde_json::{json, Value};

use openfut_adapter_fifa17::fut::club_response::shape_club_response;
use openfut_adapter_fifa17::fut::economy_policy::pack_price;
use openfut_adapter_fifa17::fut::entities::Fifa17Entities;
use openfut_adapter_fifa17::fut::item::{shape_item, CoreOwnedItem, ItemIdentityResolver};
use openfut_adapter_fifa17::fut::pack_content::{
    generate_pack_contents, GeneratedCandidate, GeneratedCard,
};
use openfut_adapter_fifa17::fut::squad::SquadWireResolver;
use openfut_adapter_fifa17::fut::store_catalog::{
    owned_pack_id_for_definition, pack_by_id, PackDef,
};

use crate::{
    error_response, json_response, json_status, CoreAccess, CoreEconomy, CoreError,
    EconomyGrantItem, WireResponse,
};

// ───────────────────────────── Dependencies ─────────────────────────────────

/// Records a freshly-minted owned item into the FIFA "purchased" pile (the
/// reveal screen source; the client polls `GET /purchased` for it, and moving a
/// card to the club clears it). Injected so the pure store handlers stay
/// unaware of the durable async pile store; the production impl bridges to it.
/// A missing sink (`None`) simply skips reveal-pile tracking (unit tests).
pub trait PurchasedPileSink {
    fn record_purchased(&self, core_id: &str);
}

/// Dependencies for the Store buy / pack-open paths. Mirrors [`crate::ClubDeps`]:
/// the host owns the Core transport and the item resolver; the adapter supplies
/// the pure pack generator over an injected candidate `pool`.
pub struct StoreDeps<'a> {
    /// Core economy authority (single durable writer).
    pub econ: &'a dyn CoreEconomy,
    /// Definition + instance identity resolver (allocates the numeric wire id).
    pub assets: &'a (dyn ItemIdentityResolver + Send + Sync),
    /// FIFA entity reverse-resolver used by the shared item shaper.
    pub entities: &'a Fifa17Entities,
    /// Candidates that resolve in BOTH the FIFA catalogue and Core content. The
    /// host builds this so the generator stays pure. Empty → fail-closed.
    pub pool: &'a [GeneratedCandidate],
    /// Optional sink recording minted items into the "purchased" reveal pile.
    pub purchased: Option<&'a dyn PurchasedPileSink>,
}

/// A card drawn by a pack, paired with the freshly-minted Core instance id it
/// will own once committed.
struct Minted {
    core_id: String,
    card: GeneratedCard,
}

/// Why a pack could not be opened. Distinguishes the client-visible 461 from the
/// fail-closed 503.
enum PackError {
    /// Not enough coins; carries the (unchanged) balance to echo to the client.
    Insufficient(i64),
    /// A Core transport/status error, or an empty candidate pool. Fail-closed.
    Closed,
}

/// Mint a globally-unique opaque Core owned-instance id from the injected RNG —
/// 128 bits of entropy, the same identity shape `openfut-import-fifa17` mints.
/// Production MUST seed the RNG from entropy so ids never collide; a seeded test
/// RNG keeps them reproducible within a run.
fn mint_instance_id(rng: &mut impl Rng) -> String {
    format!("fifa17-owned-{:032x}", rng.gen::<u128>())
}

fn parse_body(body: &[u8]) -> Value {
    serde_json::from_slice(body).unwrap_or_else(|_| json!({}))
}

fn core_owned(m: &Minted) -> CoreOwnedItem {
    CoreOwnedItem {
        owned_card_id: m.core_id.clone(),
        card_id: m.card.card_id.clone(),
        rating: m.card.rating,
        position: m.card.position.clone(),
        nation: m.card.nation.clone(),
        league: m.card.league.clone(),
        club: m.card.club.clone(),
        attributes: m.card.attributes,
    }
}

fn grants_of(minted: &[Minted]) -> Vec<EconomyGrantItem> {
    minted
        .iter()
        .map(|m| EconomyGrantItem {
            item_id: m.core_id.clone(),
            card_id: m.card.card_id.clone(),
        })
        .collect()
}

/// Draw the pack, mint fresh Core ids, then debit + mint into Core atomically.
/// Pre-checks the balance (matching the oracle's `spend`) and also maps Core's
/// post-commit insufficient-funds status (400) onto [`PackError::Insufficient`]
/// so a lost race still fails as 461, never a phantom buy.
fn draw_and_mint_coins(
    deps: &StoreDeps<'_>,
    pack: &PackDef,
    price: i64,
    rng: &mut impl Rng,
) -> Result<Vec<Minted>, PackError> {
    let cards = generate_pack_contents(pack, rng, deps.pool);
    if cards.is_empty() {
        return Err(PackError::Closed); // empty pool / no content
    }
    let minted: Vec<Minted> = cards
        .into_iter()
        .map(|card| Minted {
            core_id: mint_instance_id(rng),
            card,
        })
        .collect();
    let balance = deps.econ.balance().map_err(|_| PackError::Closed)?;
    if balance < price {
        return Err(PackError::Insufficient(balance));
    }
    match deps.econ.purchase_items(price, &grants_of(&minted)) {
        Ok(_new_balance) => Ok(minted),
        Err(CoreError::Status(400)) => Err(PackError::Insufficient(
            deps.econ.balance().unwrap_or(balance),
        )),
        Err(_) => Err(PackError::Closed),
    }
}

/// Shape each minted card onto the wire, allocating its numeric instance id. A
/// candidate that fails to resolve is dropped (the pool guarantees resolution,
/// so this is defence-in-depth, never the normal path).
fn shape_minted(deps: &StoreDeps<'_>, minted: &[Minted]) -> Vec<Value> {
    minted
        .iter()
        .filter_map(|m| {
            let item = core_owned(m);
            let id = deps.assets.resolve(&item)?;
            Some(shape_item(&item, id, deps.entities))
        })
        .collect()
}

/// Allocate every minted card's numeric wire id (so a later `GET …/purchased`
/// or `/club` renders it) without building the full wire objects.
fn allocate_wire_ids(deps: &StoreDeps<'_>, minted: &[Minted]) {
    for m in minted {
        let _ = deps.assets.resolve(&core_owned(m));
    }
}

/// Record each minted item into the "purchased" reveal pile, if a sink is wired.
fn record_purchased(deps: &StoreDeps<'_>, minted: &[Minted]) {
    if let Some(sink) = deps.purchased {
        for m in minted {
            sink.record_purchased(&m.core_id);
        }
    }
}

fn insufficient_body(balance: i64) -> WireResponse {
    json_status(
        461,
        &json!({ "reason": "insufficient_coins", "credits": balance }),
    )
}

// ───────────────────────────── Store BUY ────────────────────────────────────

/// `PUT …/store/transaction` — the confirmed BUY (open-on-buy). A cancel, a
/// non-integer `packId`, or an unknown/owned-only pack is a no-op `200 {}`
/// (never a phantom buy). Otherwise debit `pack_price` + mint the pack's cards
/// atomically and reveal them. Insufficient coins → 461; any Core error → 503.
pub fn handle_store_buy(body: &[u8], deps: &StoreDeps<'_>, rng: &mut impl Rng) -> WireResponse {
    let v = parse_body(body);
    if v.get("state").and_then(Value::as_str) == Some("TRANSACTIONCANCEL") {
        return json_response(&json!({}));
    }
    let pid = match v.get("packId").and_then(Value::as_u64) {
        Some(p) => p,
        None => return json_response(&json!({})), // packId absent / not an int
    };
    // `pack_price` is the authoritative buyable gate + price: it is `None` for an
    // unknown pack AND for an owned-only reward pack (never coin-purchasable).
    let price = match pack_price(pid) {
        Some(p) => p as i64,
        None => return json_response(&json!({})),
    };
    let pack = match pack_by_id(pid) {
        Some(p) => p,
        None => return json_response(&json!({})),
    };
    match draw_and_mint_coins(deps, pack, price, rng) {
        Ok(minted) => {
            let items = shape_minted(deps, &minted);
            record_purchased(deps, &minted);
            let count = items.len();
            json_response(&json!({
                "createPackResponse": {
                    "itemList": items,
                    "numberItems": count,
                    "purchasedPackId": pid,
                    "duplicateItemIdList": [],
                }
            }))
        }
        Err(PackError::Insufficient(balance)) => insufficient_body(balance),
        Err(PackError::Closed) => error_response(503, "core_unavailable"),
    }
}

// ───────────────────────────── Pack OPEN ────────────────────────────────────

/// The `POST …/purchased` success body (`FutPurchaseItemsServerResponse`). The
/// awarded cards are polled separately via `GET …/purchased`; this body only
/// carries the pack metadata.
fn pack_open_body(pid: u64, pack: &PackDef) -> WireResponse {
    json_response(&json!({
        "packId": pid,
        "firstPartyStoreId": 0,
        "groupName": "fifa17",
        "productId": pid.to_string(),
        "purchasePackType": match pack.category {
            "gold" => "GOLD",
            "silver" => "SILVER",
            _ => "BRONZE",
        },
    }))
}

/// `POST …/purchased` — open a pack. For an owned-only reward pack, consume its
/// unopened entitlement (consume-once, atomic add). For a normal pack, debit +
/// mint like BUY. An unknown pack or an already-consumed reward is an honest
/// empty reveal (`200 {"itemData":[]}`); insufficient coins → 461; Core error →
/// 503. The entitlement survives a failed redeem (Core's add is all-or-nothing).
pub fn handle_pack_open(body: &[u8], deps: &StoreDeps<'_>, rng: &mut impl Rng) -> WireResponse {
    let v = parse_body(body);
    let pid = match v.get("packId").and_then(Value::as_u64) {
        Some(p) => p,
        None => return json_response(&json!({ "itemData": [] })),
    };
    let pack = match pack_by_id(pid) {
        Some(p) => p,
        None => return json_response(&json!({ "itemData": [] })),
    };

    if pack.owned_only {
        let ents = match deps.econ.entitlements() {
            Ok(e) => e,
            Err(_) => return error_response(503, "core_unavailable"),
        };
        // The unopened pack instance is an entitlement whose definition id resolves
        // to this owned-only pack id (a numeric id or a symbolic reward-pack name).
        // Absent → already consumed / never granted: honest empty reveal.
        let ent = match ents
            .into_iter()
            .find(|e| owned_pack_id_for_definition(&e.definition_id) == Some(pid))
        {
            Some(e) => e,
            None => return json_response(&json!({ "itemData": [] })),
        };
        let cards = generate_pack_contents(pack, rng, deps.pool);
        if cards.is_empty() {
            return error_response(503, "core_unavailable"); // empty pool
        }
        let minted: Vec<Minted> = cards
            .into_iter()
            .map(|card| Minted {
                core_id: mint_instance_id(rng),
                card,
            })
            .collect();
        match deps.econ.redeem_entitlement(&ent.id, &grants_of(&minted)) {
            Ok(_definition_id) => {
                allocate_wire_ids(deps, &minted);
                record_purchased(deps, &minted);
                pack_open_body(pid, pack)
            }
            // Entitlement stays (Core's consume+add is atomic): fail-closed.
            Err(_) => error_response(503, "core_unavailable"),
        }
    } else {
        match draw_and_mint_coins(deps, pack, pack.price as i64, rng) {
            Ok(minted) => {
                allocate_wire_ids(deps, &minted);
                record_purchased(deps, &minted);
                pack_open_body(pid, pack)
            }
            Err(PackError::Insufficient(balance)) => insufficient_body(balance),
            Err(PackError::Closed) => error_response(503, "core_unavailable"),
        }
    }
}

/// Shape the `GET …/purchased` reveal: the owned items currently in the FIFA
/// "purchased" pile (`purchased_ids`), rendered with the SAME shaper `/club`
/// uses. Pure — the caller supplies the pile membership (from the durable pile
/// store) and Core's owned inventory; this filters + shapes. Presentation only:
/// it grants nothing, consumes no entitlement, allocates no id, moves no coins.
/// Repeated calls return the same reveal until a card is moved to the club.
pub fn shape_purchased_reveal(
    owned: &[CoreOwnedItem],
    purchased_ids: &HashSet<String>,
    entities: &Fifa17Entities,
    resolver: &(dyn ItemIdentityResolver + Send + Sync),
) -> WireResponse {
    let filtered: Vec<CoreOwnedItem> = owned
        .iter()
        .filter(|it| purchased_ids.contains(&it.owned_card_id))
        .cloned()
        .collect();
    let (body, _stats) = shape_club_response(&filtered, entities, resolver);
    json_response(&body)
}

// ───────────────────────────── Quick-sell ───────────────────────────────────

/// Look up an owned item by its Core owned-instance id. Quick-sell needs the
/// card's rating to price it server-side; `None` = not owned (never sold).
pub trait OwnedItemLookup {
    fn owned_item(&self, core_id: &str) -> Option<CoreOwnedItem>;
}

/// Production lookup backed by Core's owned inventory. O(n) per call — acceptable
/// at the quick-sell rate, and it reuses the same `/collection` boundary `/club`
/// reads, so ownership is exactly Core's authoritative set.
pub struct CoreItemLookup<'a> {
    pub core: &'a dyn CoreAccess,
}

impl OwnedItemLookup for CoreItemLookup<'_> {
    fn owned_item(&self, core_id: &str) -> Option<CoreOwnedItem> {
        self.core
            .all_owned()
            .ok()?
            .into_iter()
            .find(|it| it.owned_card_id == core_id)
    }
}

/// Dependencies for the quick-sell paths.
pub struct QuickSellDeps<'a> {
    pub econ: &'a dyn CoreEconomy,
    /// Wire id → Core owned-instance id (the same reverse resolver the squad PUT
    /// uses). Identity only; ownership is authorized by [`OwnedItemLookup`].
    pub reverse: &'a dyn SquadWireResolver,
    pub items: &'a dyn OwnedItemLookup,
}

/// OPENFUT CURRENT quick-sell value by rating (PLACEHOLDER, not EA-authentic).
/// Mirrors the on-wire `discardValue` that
/// `openfut_adapter_fifa17::fut::item` stamps, so the coins credited equal the
/// value the client displayed. (The Python oracle used a *different* invented
/// fallback — 600/300/150/50, `fut_store.py:505` — which disagreed with the wire
/// `discardValue`; crediting the displayed figure keeps them consistent.)
fn quick_sell_value(rating: u8) -> i64 {
    match rating {
        r if r >= 85 => 1500,
        r if r >= 80 => 900,
        r if r >= 75 => 600,
        r if r >= 65 => 300,
        _ => 150,
    }
}

/// Quick-sell every owned card in `wire_ids` (server-priced). Skips ids that do
/// not reverse-resolve or are not owned (no phantom credit). `totalCredits` is
/// the absolute post-sale balance; `items` echoes only the accounted-for ids,
/// de-duplicated in request order. A Core error fails closed (503).
pub fn handle_quick_sell(wire_ids: &[i64], deps: &QuickSellDeps<'_>) -> WireResponse {
    let mut seen = HashSet::new();
    let mut sold_ids: Vec<i64> = Vec::new();
    let mut last_balance: Option<i64> = None;
    for &wire in wire_ids {
        if wire <= 0 || !seen.insert(wire) {
            continue;
        }
        let core_id = match deps.reverse.owned_id_for_wire(wire) {
            Some(c) => c,
            None => continue,
        };
        let item = match deps.items.owned_item(&core_id) {
            Some(i) => i,
            None => continue, // resolvable id, but not owned: never sold
        };
        match deps.econ.sell_item(&core_id, quick_sell_value(item.rating)) {
            Ok(balance) => {
                last_balance = Some(balance);
                sold_ids.push(wire);
            }
            Err(_) => return error_response(503, "core_unavailable"),
        }
    }
    let total = match last_balance {
        Some(b) => b,
        None => match deps.econ.balance() {
            Ok(b) => b,
            Err(_) => return error_response(503, "core_unavailable"),
        },
    };
    let items: Vec<Value> = sold_ids.iter().map(|id| json!({ "id": id })).collect();
    json_response(&json!({ "items": items, "totalCredits": total }))
}

/// `DELETE …/item/<id>` — single-card quick-sell (the id is in the path).
pub fn handle_quick_sell_path(wire_id: i64, deps: &QuickSellDeps<'_>) -> WireResponse {
    handle_quick_sell(&[wire_id], deps)
}

/// `POST /ut/delete/…/item` — bulk quick-sell. Accepts the retail
/// `{"itemData":[{"id":..}]}` form and the `{"itemId":[..]}` / `{"itemIds":[..]}`
/// aliases (oracle `quick_sell_route`).
pub fn handle_quick_sell_body(body: &[u8], deps: &QuickSellDeps<'_>) -> WireResponse {
    let v = parse_body(body);
    let mut ids: Vec<i64> = Vec::new();
    if let Some(arr) = v.get("itemData").and_then(Value::as_array) {
        for it in arr {
            if let Some(id) = it.get("id").and_then(Value::as_i64) {
                ids.push(id);
            }
        }
    }
    if ids.is_empty() {
        for key in ["itemId", "itemIds"] {
            if let Some(arr) = v.get(key).and_then(Value::as_array) {
                ids.extend(arr.iter().filter_map(Value::as_i64));
            }
        }
    }
    handle_quick_sell(&ids, deps)
}

#[cfg(test)]
mod tests {
    use super::*;
    use openfut_adapter_fifa17::fut::item::Fifa17Identity;
    use parking_lot::Mutex;
    use std::collections::HashMap;
    use std::sync::atomic::{AtomicI64, AtomicU32, Ordering};

    use crate::{
        CoreMatchCompletion, CoreMatchReceipt, EconomyEntitlement, EconomyPurchase, EconomySale,
        EconomySaleReceipt,
    };

    // ── Recording economy double ────────────────────────────────────────────

    /// A Core economy double that records every mutation and moves a real
    /// balance, so tests assert exact coin/entitlement/item deltas. Interior
    /// mutability via atomics/mutex keeps it `Send + Sync` (the trait bound).
    struct RecEcon {
        balance: AtomicI64,
        entitlements: Mutex<Vec<EconomyEntitlement>>,
        fail: bool,
        purchased: Mutex<Vec<(i64, Vec<EconomyGrantItem>)>>,
        redeemed: Mutex<Vec<(String, Vec<EconomyGrantItem>)>>,
        sold: Mutex<Vec<(String, i64)>>,
    }
    impl RecEcon {
        fn new(balance: i64) -> Self {
            RecEcon {
                balance: AtomicI64::new(balance),
                entitlements: Mutex::new(Vec::new()),
                fail: false,
                purchased: Mutex::new(Vec::new()),
                redeemed: Mutex::new(Vec::new()),
                sold: Mutex::new(Vec::new()),
            }
        }
        fn with_entitlements(balance: i64, defs: &[&str]) -> Self {
            let s = RecEcon::new(balance);
            *s.entitlements.lock() = defs
                .iter()
                .enumerate()
                .map(|(i, d)| EconomyEntitlement {
                    id: format!("e{i}"),
                    definition_id: (*d).into(),
                })
                .collect();
            s
        }
        fn failing() -> Self {
            let mut s = RecEcon::new(0);
            s.fail = true;
            s
        }
        fn coins(&self) -> i64 {
            self.balance.load(Ordering::SeqCst)
        }
    }
    impl CoreEconomy for RecEcon {
        fn balance(&self) -> Result<i64, CoreError> {
            if self.fail {
                Err(CoreError::Status(500))
            } else {
                Ok(self.coins())
            }
        }
        fn entitlements(&self) -> Result<Vec<EconomyEntitlement>, CoreError> {
            if self.fail {
                return Err(CoreError::Status(500));
            }
            Ok(self.entitlements.lock().clone())
        }
        fn purchase_entitlement(
            &self,
            _cost: i64,
            definition_id: &str,
        ) -> Result<EconomyPurchase, CoreError> {
            if self.fail {
                return Err(CoreError::Status(500));
            }
            Ok(EconomyPurchase {
                balance: self.coins(),
                entitlement_id: format!("bought:{definition_id}"),
            })
        }
        fn redeem_entitlement(
            &self,
            entitlement_id: &str,
            items: &[EconomyGrantItem],
        ) -> Result<String, CoreError> {
            if self.fail {
                return Err(CoreError::Status(500));
            }
            let mut ents = self.entitlements.lock();
            let idx = ents.iter().position(|e| e.id == entitlement_id);
            let definition_id = match idx {
                Some(i) => ents.remove(i).definition_id, // consume-once
                None => return Err(CoreError::Status(404)),
            };
            self.redeemed
                .lock()
                .push((entitlement_id.to_string(), items.to_vec()));
            Ok(definition_id)
        }
        fn sell_item(&self, item_id: &str, price: i64) -> Result<i64, CoreError> {
            if self.fail {
                return Err(CoreError::Status(500));
            }
            let new = self.balance.fetch_add(price, Ordering::SeqCst) + price;
            self.sold.lock().push((item_id.to_string(), price));
            Ok(new)
        }
        fn grant_reward(&self, amount: i64) -> Result<i64, CoreError> {
            if self.fail {
                return Err(CoreError::Status(500));
            }
            Ok(self.balance.fetch_add(amount, Ordering::SeqCst) + amount)
        }
        fn purchase_item(
            &self,
            cost: i64,
            _item_id: &str,
            _card_id: &str,
        ) -> Result<i64, CoreError> {
            if self.fail {
                return Err(CoreError::Status(500));
            }
            if self.coins() < cost {
                return Err(CoreError::Status(400));
            }
            Ok(self.balance.fetch_sub(cost, Ordering::SeqCst) - cost)
        }
        fn purchase_items(&self, cost: i64, items: &[EconomyGrantItem]) -> Result<i64, CoreError> {
            if self.fail {
                return Err(CoreError::Status(500));
            }
            if self.coins() < cost {
                return Err(CoreError::Status(400));
            }
            self.purchased.lock().push((cost, items.to_vec()));
            Ok(self.balance.fetch_sub(cost, Ordering::SeqCst) - cost)
        }
        fn settle_sale(&self, _sale: &EconomySale<'_>) -> Result<EconomySaleReceipt, CoreError> {
            // Sale settlement is not exercised by the Store/quick-sell paths.
            Err(CoreError::Status(501))
        }
        fn complete_match(
            &self,
            _m: &CoreMatchCompletion<'_>,
        ) -> Result<CoreMatchReceipt, CoreError> {
            // Match completion is not exercised by the Store/quick-sell paths.
            Err(CoreError::Status(501))
        }
    }

    // ── Identity / entity / lookup doubles ──────────────────────────────────

    /// Resolves every pool card to a real asset and hands out distinct, stable
    /// instance ids. `Send + Sync` (atomic counter) for `StoreDeps::assets`.
    struct FakeAssets {
        by_card: HashMap<String, (u32, u32, i64)>, // card_id -> (asset, resource, rareflag)
        next: AtomicU32,
    }
    impl FakeAssets {
        fn for_pool(pool: &[GeneratedCandidate]) -> Self {
            let by_card = pool
                .iter()
                .enumerate()
                .map(|(i, c)| (c.card_id.clone(), (1000 + i as u32, 1000 + i as u32, 1i64)))
                .collect();
            FakeAssets {
                by_card,
                next: AtomicU32::new(0),
            }
        }
    }
    impl ItemIdentityResolver for FakeAssets {
        fn resolve(&self, item: &CoreOwnedItem) -> Option<Fifa17Identity> {
            let (asset, resource, rareflag) = *self.by_card.get(&item.card_id)?;
            let n = self.next.fetch_add(1, Ordering::SeqCst);
            Some(Fifa17Identity {
                item_id: 100_000_000 + n,
                asset_id: asset,
                resource_id: resource,
                rareflag,
            })
        }
    }

    struct FakeReverse(HashMap<i64, String>);
    impl SquadWireResolver for FakeReverse {
        fn owned_id_for_wire(&self, wire: i64) -> Option<String> {
            self.0.get(&wire).cloned()
        }
    }

    struct FakeItems(HashMap<String, CoreOwnedItem>);
    impl OwnedItemLookup for FakeItems {
        fn owned_item(&self, core_id: &str) -> Option<CoreOwnedItem> {
            self.0.get(core_id).cloned()
        }
    }

    // ── Fixtures ─────────────────────────────────────────────────────────────

    fn cand(card: &str, rating: u8, gold: bool, special: bool) -> GeneratedCandidate {
        GeneratedCandidate {
            card_id: card.into(),
            rating,
            position: "ST".into(),
            nation: "Brazil".into(),
            league: "Premier League".into(),
            club: "Arsenal".into(),
            attributes: [rating; 6],
            gold,
            special,
        }
    }

    fn pool() -> Vec<GeneratedCandidate> {
        vec![
            cand("g-1", 88, true, false),
            cand("g-2", 84, true, false),
            cand("g-3", 80, true, false),
            cand("g-sp", 90, true, true),
            cand("b-1", 64, false, false),
            cand("b-2", 62, false, false),
        ]
    }

    fn rng(seed: u64) -> rand::rngs::StdRng {
        <rand::rngs::StdRng as rand::SeedableRng>::seed_from_u64(seed)
    }

    fn store_deps<'a>(
        econ: &'a RecEcon,
        assets: &'a FakeAssets,
        entities: &'a Fifa17Entities,
        pool: &'a [GeneratedCandidate],
    ) -> StoreDeps<'a> {
        StoreDeps {
            econ,
            assets,
            entities,
            pool,
            purchased: None,
        }
    }

    fn body(v: Value) -> Vec<u8> {
        serde_json::to_vec(&v).unwrap()
    }

    // ── Store BUY ──────────────────────────────────────────────────────────

    #[test]
    fn buy_pack1_debits_mints_and_reveals_twelve_cards() {
        let econ = RecEcon::new(10_000);
        let pool = pool();
        let assets = FakeAssets::for_pool(&pool);
        let ent = Fifa17Entities::default();
        let deps = store_deps(&econ, &assets, &ent, &pool);
        let resp = handle_store_buy(&body(json!({ "packId": 1 })), &deps, &mut rng(1));
        assert_eq!(resp.status, 200);
        let b: Value = serde_json::from_slice(&resp.body).unwrap();
        let cpr = &b["createPackResponse"];
        assert_eq!(cpr["numberItems"], 12); // pack 1 count (10 bronze + 2 silver)
        assert_eq!(cpr["itemList"].as_array().unwrap().len(), 12);
        assert_eq!(cpr["purchasedPackId"], 1);
        assert_eq!(cpr["duplicateItemIdList"], json!([]));
        // Exactly one atomic debit of the pack price (400) minting 12 items.
        assert_eq!(econ.coins(), 10_000 - 400);
        let purchased = econ.purchased.lock();
        assert_eq!(purchased.len(), 1);
        assert_eq!(purchased[0].0, 400);
        assert_eq!(purchased[0].1.len(), 12);
        for g in &purchased[0].1 {
            assert!(pool.iter().any(|c| c.card_id == g.card_id));
        }
    }

    #[test]
    fn buy_pack5_debits_gold_price_and_mints_twelve() {
        let econ = RecEcon::new(10_000);
        let pool = pool();
        let assets = FakeAssets::for_pool(&pool);
        let ent = Fifa17Entities::default();
        let deps = store_deps(&econ, &assets, &ent, &pool);
        let resp = handle_store_buy(&body(json!({ "packId": 5 })), &deps, &mut rng(2));
        let b: Value = serde_json::from_slice(&resp.body).unwrap();
        assert_eq!(b["createPackResponse"]["numberItems"], 12); // pack 5 count (10 gold + 2 silver)
        assert_eq!(econ.coins(), 10_000 - 5000);
    }

    #[test]
    fn buy_insufficient_returns_461_with_balance_and_no_debit() {
        let econ = RecEcon::new(100);
        let pool = pool();
        let assets = FakeAssets::for_pool(&pool);
        let ent = Fifa17Entities::default();
        let deps = store_deps(&econ, &assets, &ent, &pool);
        let resp = handle_store_buy(&body(json!({ "packId": 5 })), &deps, &mut rng(3));
        assert_eq!(resp.status, 461);
        let b: Value = serde_json::from_slice(&resp.body).unwrap();
        assert_eq!(b["reason"], "insufficient_coins");
        assert_eq!(b["credits"], 100);
        assert_eq!(econ.coins(), 100); // untouched
        assert!(econ.purchased.lock().is_empty());
    }

    #[test]
    fn buy_unknown_pack_is_noop_200_empty() {
        let econ = RecEcon::new(10_000);
        let pool = pool();
        let assets = FakeAssets::for_pool(&pool);
        let ent = Fifa17Entities::default();
        let deps = store_deps(&econ, &assets, &ent, &pool);
        let resp = handle_store_buy(&body(json!({ "packId": 9999 })), &deps, &mut rng(4));
        assert_eq!(resp.status, 200);
        let b: Value = serde_json::from_slice(&resp.body).unwrap();
        assert_eq!(b, json!({}));
        assert_eq!(econ.coins(), 10_000);
    }

    #[test]
    fn buy_sentinel_is_noop_200_empty() {
        let econ = RecEcon::new(10_000);
        let pool = pool();
        let assets = FakeAssets::for_pool(&pool);
        let ent = Fifa17Entities::default();
        let deps = store_deps(&econ, &assets, &ent, &pool);
        // 65534 is absent from the catalogue -> pack_price None -> no-op.
        let resp = handle_store_buy(&body(json!({ "packId": 65534 })), &deps, &mut rng(5));
        assert_eq!(resp.status, 200);
        assert_eq!(
            serde_json::from_slice::<Value>(&resp.body).unwrap(),
            json!({})
        );
    }

    #[test]
    fn buy_owned_only_pack_is_noop_200_empty() {
        let econ = RecEcon::new(10_000);
        let pool = pool();
        let assets = FakeAssets::for_pool(&pool);
        let ent = Fifa17Entities::default();
        let deps = store_deps(&econ, &assets, &ent, &pool);
        // pack 70 is owned-only -> not coin-purchasable via BUY.
        let resp = handle_store_buy(&body(json!({ "packId": 70 })), &deps, &mut rng(6));
        assert_eq!(resp.status, 200);
        assert_eq!(
            serde_json::from_slice::<Value>(&resp.body).unwrap(),
            json!({})
        );
        assert!(econ.purchased.lock().is_empty());
    }

    #[test]
    fn buy_cancel_is_noop_200_empty() {
        let econ = RecEcon::new(10_000);
        let pool = pool();
        let assets = FakeAssets::for_pool(&pool);
        let ent = Fifa17Entities::default();
        let deps = store_deps(&econ, &assets, &ent, &pool);
        let resp = handle_store_buy(
            &body(json!({ "packId": 5, "state": "TRANSACTIONCANCEL" })),
            &deps,
            &mut rng(7),
        );
        assert_eq!(resp.status, 200);
        assert_eq!(
            serde_json::from_slice::<Value>(&resp.body).unwrap(),
            json!({})
        );
        assert_eq!(econ.coins(), 10_000);
    }

    #[test]
    fn buy_non_integer_packid_is_noop() {
        let econ = RecEcon::new(10_000);
        let pool = pool();
        let assets = FakeAssets::for_pool(&pool);
        let ent = Fifa17Entities::default();
        let deps = store_deps(&econ, &assets, &ent, &pool);
        let resp = handle_store_buy(&body(json!({ "packId": "5" })), &deps, &mut rng(8));
        assert_eq!(resp.status, 200);
        assert_eq!(
            serde_json::from_slice::<Value>(&resp.body).unwrap(),
            json!({})
        );
    }

    #[test]
    fn buy_fails_closed_503_on_core_error() {
        let econ = RecEcon::failing();
        let pool = pool();
        let assets = FakeAssets::for_pool(&pool);
        let ent = Fifa17Entities::default();
        let deps = store_deps(&econ, &assets, &ent, &pool);
        let resp = handle_store_buy(&body(json!({ "packId": 1 })), &deps, &mut rng(9));
        assert_eq!(resp.status, 503);
    }

    // ── Pack OPEN ────────────────────────────────────────────────────────────

    #[test]
    fn open_normal_pack_debits_and_returns_post_body() {
        let econ = RecEcon::new(10_000);
        let pool = pool();
        let assets = FakeAssets::for_pool(&pool);
        let ent = Fifa17Entities::default();
        let deps = store_deps(&econ, &assets, &ent, &pool);
        let resp = handle_pack_open(&body(json!({ "packId": 5 })), &deps, &mut rng(10));
        assert_eq!(resp.status, 200);
        let b: Value = serde_json::from_slice(&resp.body).unwrap();
        assert_eq!(b["packId"], 5);
        assert_eq!(b["productId"], "5");
        assert_eq!(b["groupName"], "fifa17");
        assert_eq!(b["firstPartyStoreId"], 0);
        assert_eq!(b["purchasePackType"], "GOLD"); // pack 5 is gold
        assert_eq!(econ.coins(), 10_000 - 5000);
        assert_eq!(econ.purchased.lock()[0].1.len(), 12);
    }

    #[test]
    fn open_owned_70_redeems_entitlement_without_debit() {
        let econ = RecEcon::with_entitlements(4600, &["70"]);
        let pool = pool();
        let assets = FakeAssets::for_pool(&pool);
        let ent = Fifa17Entities::default();
        let deps = store_deps(&econ, &assets, &ent, &pool);
        let resp = handle_pack_open(&body(json!({ "packId": 70 })), &deps, &mut rng(11));
        assert_eq!(resp.status, 200);
        let b: Value = serde_json::from_slice(&resp.body).unwrap();
        assert_eq!(b["packId"], 70);
        assert_eq!(b["productId"], "70");
        assert_eq!(b["purchasePackType"], "GOLD");
        // No coin debit; entitlement consumed; items added atomically.
        assert_eq!(econ.coins(), 4600);
        assert!(econ.purchased.lock().is_empty());
        assert!(econ.entitlements.lock().is_empty()); // consumed
        let redeemed = econ.redeemed.lock();
        assert_eq!(redeemed.len(), 1);
        assert_eq!(redeemed[0].0, "e0");
        assert_eq!(redeemed[0].1.len(), 11); // pack 70 count
    }

    #[test]
    fn open_symbolic_silver_reward_redeems_entitlement_without_debit() {
        // A Core reward grant ("silver_pack") resolves to owned-only pack 72 and
        // opens for free by consuming its entitlement — the SBC reward-pack fix.
        let econ = RecEcon::with_entitlements(4600, &["silver_pack"]);
        let pool = pool();
        let assets = FakeAssets::for_pool(&pool);
        let ent = Fifa17Entities::default();
        let deps = store_deps(&econ, &assets, &ent, &pool);
        let resp = handle_pack_open(&body(json!({ "packId": 72 })), &deps, &mut rng(12));
        assert_eq!(resp.status, 200);
        let b: Value = serde_json::from_slice(&resp.body).unwrap();
        assert_eq!(b["packId"], 72);
        assert_eq!(b["purchasePackType"], "SILVER");
        assert_eq!(econ.coins(), 4600, "a reward pack opens for free");
        assert!(
            econ.entitlements.lock().is_empty(),
            "the reward entitlement is consumed once"
        );
        let redeemed = econ.redeemed.lock();
        assert_eq!(redeemed.len(), 1);
        assert_eq!(redeemed[0].0, "e0");
        assert_eq!(redeemed[0].1.len(), 12); // 1 bronze + 11 silver
    }

    #[test]
    fn open_owned_70_twice_is_consume_once() {
        let econ = RecEcon::with_entitlements(4600, &["70"]);
        let pool = pool();
        let assets = FakeAssets::for_pool(&pool);
        let ent = Fifa17Entities::default();
        let deps = store_deps(&econ, &assets, &ent, &pool);
        let first = handle_pack_open(&body(json!({ "packId": 70 })), &deps, &mut rng(12));
        assert_eq!(first.status, 200);
        // The entitlement is gone: a second open is an honest empty reveal.
        let second = handle_pack_open(&body(json!({ "packId": 70 })), &deps, &mut rng(13));
        assert_eq!(second.status, 200);
        assert_eq!(
            serde_json::from_slice::<Value>(&second.body).unwrap(),
            json!({ "itemData": [] })
        );
        assert_eq!(econ.redeemed.lock().len(), 1); // only the first
    }

    #[test]
    fn open_unknown_pack_is_empty_reveal() {
        let econ = RecEcon::new(10_000);
        let pool = pool();
        let assets = FakeAssets::for_pool(&pool);
        let ent = Fifa17Entities::default();
        let deps = store_deps(&econ, &assets, &ent, &pool);
        let resp = handle_pack_open(&body(json!({ "packId": 424242 })), &deps, &mut rng(14));
        assert_eq!(resp.status, 200);
        assert_eq!(
            serde_json::from_slice::<Value>(&resp.body).unwrap(),
            json!({ "itemData": [] })
        );
        assert_eq!(econ.coins(), 10_000);
    }

    #[test]
    fn open_insufficient_returns_461() {
        let econ = RecEcon::new(100);
        let pool = pool();
        let assets = FakeAssets::for_pool(&pool);
        let ent = Fifa17Entities::default();
        let deps = store_deps(&econ, &assets, &ent, &pool);
        let resp = handle_pack_open(&body(json!({ "packId": 5 })), &deps, &mut rng(15));
        assert_eq!(resp.status, 461);
        assert_eq!(econ.coins(), 100);
    }

    // ── Quick-sell ─────────────────────────────────────────────────────────

    fn owned(core: &str, rating: u8) -> CoreOwnedItem {
        CoreOwnedItem {
            owned_card_id: core.into(),
            card_id: "card-x".into(),
            rating,
            position: "ST".into(),
            nation: "Brazil".into(),
            league: "Premier League".into(),
            club: "Arsenal".into(),
            attributes: [rating; 6],
        }
    }

    fn qs_deps<'a>(
        econ: &'a RecEcon,
        reverse: &'a FakeReverse,
        items: &'a FakeItems,
    ) -> QuickSellDeps<'a> {
        QuickSellDeps {
            econ,
            reverse,
            items,
        }
    }

    #[test]
    fn quick_sell_path_form_sells_and_credits() {
        let econ = RecEcon::new(1000);
        let reverse = FakeReverse(HashMap::from([(100_000_001, "c1".to_string())]));
        let items = FakeItems(HashMap::from([("c1".to_string(), owned("c1", 88))]));
        let deps = qs_deps(&econ, &reverse, &items);
        let resp = handle_quick_sell_path(100_000_001, &deps);
        assert_eq!(resp.status, 200);
        let b: Value = serde_json::from_slice(&resp.body).unwrap();
        assert_eq!(b["items"], json!([{ "id": 100_000_001i64 }]));
        // rating 88 -> 1500 credited; balance 1000 + 1500.
        assert_eq!(b["totalCredits"], 2500);
        let sold = econ.sold.lock();
        assert_eq!(sold.len(), 1);
        assert_eq!(sold[0], ("c1".to_string(), 1500));
    }

    #[test]
    fn quick_sell_body_form_and_alias_match_path_form() {
        for req in [
            json!({ "itemData": [{ "id": 100_000_001i64 }] }),
            json!({ "itemId": [100_000_001i64] }),
            json!({ "itemIds": [100_000_001i64] }),
        ] {
            let econ = RecEcon::new(1000);
            let reverse = FakeReverse(HashMap::from([(100_000_001, "c1".to_string())]));
            let items = FakeItems(HashMap::from([("c1".to_string(), owned("c1", 88))]));
            let deps = qs_deps(&econ, &reverse, &items);
            let resp = handle_quick_sell_body(&body(req.clone()), &deps);
            assert_eq!(resp.status, 200, "form {req} failed");
            let b: Value = serde_json::from_slice(&resp.body).unwrap();
            assert_eq!(b["items"], json!([{ "id": 100_000_001i64 }]));
            assert_eq!(b["totalCredits"], 2500);
        }
    }

    #[test]
    fn quick_sell_unknown_id_credits_nothing() {
        let econ = RecEcon::new(1000);
        let reverse = FakeReverse(HashMap::new()); // resolves nothing
        let items = FakeItems(HashMap::new());
        let deps = qs_deps(&econ, &reverse, &items);
        let resp = handle_quick_sell_path(555, &deps);
        assert_eq!(resp.status, 200);
        let b: Value = serde_json::from_slice(&resp.body).unwrap();
        assert_eq!(b["items"], json!([]));
        assert_eq!(b["totalCredits"], 1000); // unchanged
        assert!(econ.sold.lock().is_empty());
    }

    #[test]
    fn quick_sell_resolvable_but_not_owned_is_not_sold() {
        let econ = RecEcon::new(1000);
        let reverse = FakeReverse(HashMap::from([(100_000_009, "ghost".to_string())]));
        let items = FakeItems(HashMap::new()); // "ghost" is not owned
        let deps = qs_deps(&econ, &reverse, &items);
        let resp = handle_quick_sell_path(100_000_009, &deps);
        let b: Value = serde_json::from_slice(&resp.body).unwrap();
        assert_eq!(b["items"], json!([]));
        assert_eq!(b["totalCredits"], 1000);
        assert!(econ.sold.lock().is_empty());
    }

    #[test]
    fn quick_sell_bulk_dedups_and_totals_final_balance() {
        let econ = RecEcon::new(1000);
        let reverse = FakeReverse(HashMap::from([
            (100_000_001, "c1".to_string()),
            (100_000_002, "c2".to_string()),
        ]));
        let items = FakeItems(HashMap::from([
            ("c1".to_string(), owned("c1", 88)), // 1500
            ("c2".to_string(), owned("c2", 70)), // 300
        ]));
        let deps = qs_deps(&econ, &reverse, &items);
        // c1 appears twice (dedup) plus an unknown id (skipped).
        let resp = handle_quick_sell(&[100_000_001, 100_000_001, 100_000_002, 42], &deps);
        let b: Value = serde_json::from_slice(&resp.body).unwrap();
        assert_eq!(
            b["items"],
            json!([{ "id": 100_000_001i64 }, { "id": 100_000_002i64 }])
        );
        assert_eq!(b["totalCredits"], 1000 + 1500 + 300);
        assert_eq!(econ.sold.lock().len(), 2);
    }

    #[test]
    fn quick_sell_fails_closed_503_on_core_error() {
        let econ = RecEcon::failing();
        let reverse = FakeReverse(HashMap::from([(100_000_001, "c1".to_string())]));
        let items = FakeItems(HashMap::from([("c1".to_string(), owned("c1", 88))]));
        let deps = qs_deps(&econ, &reverse, &items);
        let resp = handle_quick_sell_path(100_000_001, &deps);
        assert_eq!(resp.status, 503);
    }
}
