//! FIFA 17 authoritative economy engine — coins + unopened-pack entitlements +
//! owned inventory + stable item ids — with all-or-nothing transactional mutations.
//!
//! A faithful port of the Python oracle's `fut_store.Store` mutation primitives
//! (`spend`/`grant_coins`/`quick_sell`/`record_match`/`grant_unopened_pack`/
//! `consume_unopened_pack`/`open_pack`/`add_items`) — the single-writer engine the
//! eventual economy cutover needs.
//!
//! ## Status / why not wired (R3)
//!
//! This engine is deliberately **not wired** into the live host. The live FIFA 17
//! coin balance is one indivisible writer set — Store BUY (`spend`), pack-open,
//! quick-sell, match rewards (`record_match`) AND the transfer-market buy-now
//! (`spend`) all mutate the same `coins` + inventory in Python's `fut_profile.json`.
//! No single route can become Rust-authoritative without migrating the whole
//! cluster at once. The intended generic home (OpenFUT Core) is a preserved-dirty,
//! frozen submodule, so the generic currency/inventory/transaction primitives can't
//! land there yet. This engine + importer is therefore the coherent prerequisite:
//! wiring it (and migrating every coin/inventory writer in one cut) is the R1 task.
//!
//! Generic concepts (balance/inventory/transaction) belong in Core once unfrozen;
//! they are kept in the adapter meanwhile without distorting Core.
//!
//! ## Economy-parameter provenance
//!
//! Prices/odds/quick-sell values are current OpenFUT **PLACEHOLDER** economy, not
//! EA-authentic. The mutation *invariants* (atomic debit, consume-once, no partial
//! state, sentinel non-grantable) are the load-bearing contract this engine enforces.

use serde_json::{json, Value};

use crate::fut::store_catalog::pack_by_id;
use crate::fut::store_session::SENTINEL_PACK_ID;

/// A transactional failure. On any `Err`, the engine is left UNCHANGED (no partial
/// mutation) — the caller may retry or surface a wire error.
#[derive(Debug, Clone, PartialEq, Eq)]
pub enum EconomyError {
    /// Debit rejected: balance would go negative.
    InsufficientFunds { balance: i64, needed: i64 },
    /// Pack id is not in the catalogue (includes the 65534 sentinel).
    UnknownPack(u64),
    /// No owned instance of this pack to consume.
    NotOwned(u64),
    /// The 65534 sentinel can never be bought/granted/opened.
    SentinelRejected,
}

/// The authoritative per-profile economy state. Mirrors the load-bearing
/// `fut_profile.json` fields. Wrap in a profile-scoped `Mutex`/DB transaction at the
/// host boundary (as the eventual Core repository will); the methods here are the
/// atomic units.
#[derive(Debug, Clone, PartialEq)]
pub struct ProfileEconomy {
    coins: i64,
    unopened_pack_ids: Vec<u64>,
    items: Vec<Value>,
    next_item_id: i64,
}

impl ProfileEconomy {
    /// A fresh, empty economy (coins 0, no packs/items, ids from 1).
    pub fn new() -> Self {
        ProfileEconomy {
            coins: 0,
            unopened_pack_ids: Vec::new(),
            items: Vec::new(),
            next_item_id: 1,
        }
    }

    /// Import from a `fut_profile.json` object (the current authoritative store).
    /// Deterministic and idempotent on a fixture: reads coins, `unopenedPackIds`,
    /// `items`, `nextItemId`; missing fields take safe defaults. Never mutates the
    /// source. Production migration is NOT executed here.
    pub fn from_fut_profile(profile: &Value) -> Self {
        let coins = profile.get("coins").and_then(Value::as_i64).unwrap_or(0);
        let unopened_pack_ids = profile
            .get("unopenedPackIds")
            .and_then(Value::as_array)
            .map(|a| a.iter().filter_map(Value::as_u64).collect())
            .unwrap_or_default();
        let items = profile
            .get("items")
            .and_then(Value::as_array)
            .cloned()
            .unwrap_or_default();
        // Continue item-id allocation past the highest existing id so re-import can
        // never mint a duplicate (Python persists nextItemId; we also floor by it).
        let max_item_id = items
            .iter()
            .filter_map(|it| it.get("id").and_then(Value::as_i64))
            .max()
            .unwrap_or(0);
        let next_item_id = profile
            .get("nextItemId")
            .and_then(Value::as_i64)
            .unwrap_or(1)
            .max(max_item_id + 1);
        ProfileEconomy {
            coins,
            unopened_pack_ids,
            items,
            next_item_id,
        }
    }

    /// Write this economy back onto a `fut_profile.json` object (round-trip / export).
    /// Only the economy fields are touched; every other key is preserved.
    pub fn apply_to_fut_profile(&self, profile: &mut Value) {
        let obj = profile
            .as_object_mut()
            .expect("fut_profile is a JSON object");
        obj.insert("coins".into(), json!(self.coins));
        obj.insert("unopenedPackIds".into(), json!(self.unopened_pack_ids));
        obj.insert("items".into(), Value::Array(self.items.clone()));
        obj.insert("nextItemId".into(), json!(self.next_item_id));
    }

    // ── reads ────────────────────────────────────────────────────────────────
    pub fn coins(&self) -> i64 {
        self.coins
    }
    pub fn unopened_pack_ids(&self) -> &[u64] {
        &self.unopened_pack_ids
    }
    pub fn next_item_id(&self) -> i64 {
        self.next_item_id
    }
    pub fn item_ids(&self) -> Vec<i64> {
        self.items
            .iter()
            .filter_map(|it| it.get("id").and_then(Value::as_i64))
            .collect()
    }

    // ── generic primitives (atomic building blocks) ───────────────────────────

    /// Credit coins (reward/quick-sell proceeds). Non-negative by type.
    pub fn credit(&mut self, amount: u64) {
        self.coins += amount as i64;
    }

    /// Debit coins. Fail-closed: insufficient balance leaves coins UNCHANGED.
    pub fn debit(&mut self, amount: u64) -> Result<(), EconomyError> {
        let needed = amount as i64;
        if self.coins < needed {
            return Err(EconomyError::InsufficientFunds {
                balance: self.coins,
                needed,
            });
        }
        self.coins -= needed;
        Ok(())
    }

    /// Grant one owned instance of a catalogue pack (reward/purchase entitlement).
    /// Rejects the 65534 sentinel and any non-catalogue id (mirrors
    /// `grant_unopened_pack`, which returns False for `pack_by_id() is None`).
    pub fn grant_pack(&mut self, pack_id: u64) -> Result<(), EconomyError> {
        if pack_id == SENTINEL_PACK_ID {
            return Err(EconomyError::SentinelRejected);
        }
        if pack_by_id(pack_id).is_none() {
            return Err(EconomyError::UnknownPack(pack_id));
        }
        self.unopened_pack_ids.push(pack_id);
        Ok(())
    }

    /// Consume exactly one owned instance of a pack. Fail-closed: not owned ⇒ Err,
    /// no mutation (consume-once; mirrors `consume_unopened_pack`).
    pub fn consume_pack(&mut self, pack_id: u64) -> Result<(), EconomyError> {
        match self.unopened_pack_ids.iter().position(|&p| p == pack_id) {
            Some(idx) => {
                self.unopened_pack_ids.remove(idx);
                Ok(())
            }
            None => Err(EconomyError::NotOwned(pack_id)),
        }
    }

    /// Allocate the next stable, monotonic, unique item id.
    pub fn allocate_item_id(&mut self) -> i64 {
        let id = self.next_item_id;
        self.next_item_id += 1;
        id
    }

    /// Add an owned item, stamping a fresh unique id (overwriting any incoming id),
    /// and return the assigned id.
    pub fn add_item(&mut self, mut item: Value) -> i64 {
        let id = self.allocate_item_id();
        if let Some(obj) = item.as_object_mut() {
            obj.insert("id".into(), json!(id));
        }
        self.items.push(item);
        id
    }

    // ── composed atomic transactions (validate-then-mutate) ────────────────────

    /// Store BUY as an entitlement: validate the pack + funds FIRST, then debit and
    /// grant the unopened pack — all-or-nothing. Rejects the sentinel. (The Python
    /// oracle opens on buy; the authoritative model separates buy→entitlement→open,
    /// which is why `open_pack` exists — a DIFFERENT-BY-DESIGN improvement over the
    /// reference, preserving the coin/entitlement invariants.)
    pub fn buy_pack(&mut self, pack_id: u64, price: u64) -> Result<(), EconomyError> {
        if pack_id == SENTINEL_PACK_ID {
            return Err(EconomyError::SentinelRejected);
        }
        if pack_by_id(pack_id).is_none() {
            return Err(EconomyError::UnknownPack(pack_id));
        }
        if self.coins < price as i64 {
            return Err(EconomyError::InsufficientFunds {
                balance: self.coins,
                needed: price as i64,
            });
        }
        // Both preconditions hold: commit.
        self.coins -= price as i64;
        self.unopened_pack_ids.push(pack_id);
        Ok(())
    }

    /// Open an owned pack: consume exactly one entitlement FIRST (so a failed/absent
    /// entitlement grants nothing), then add the generated items with fresh ids.
    /// Returns the ids granted. Content generation/odds are the caller's concern and
    /// are current OpenFUT PLACEHOLDER.
    pub fn open_pack(&mut self, pack_id: u64, items: Vec<Value>) -> Result<Vec<i64>, EconomyError> {
        self.consume_pack(pack_id)?; // fail-closed: no items on a missing entitlement
        Ok(items.into_iter().map(|it| self.add_item(it)).collect())
    }

    /// Quick-sell owned items by id: remove them and credit the caller-computed value
    /// total (values are FIFA17 policy, placeholder). Only ids actually owned are
    /// sold/credited (mirrors `quick_sell`). Returns `(sold_count, coins_credited)`.
    pub fn quick_sell(&mut self, ids: &[i64], value_of: impl Fn(&Value) -> u64) -> (u64, u64) {
        let want: std::collections::HashSet<i64> = ids.iter().copied().collect();
        let mut credited = 0u64;
        let mut sold = 0u64;
        let mut kept = Vec::with_capacity(self.items.len());
        for it in std::mem::take(&mut self.items) {
            let owned_id = it.get("id").and_then(Value::as_i64);
            if owned_id.is_some_and(|id| want.contains(&id)) {
                credited += value_of(&it);
                sold += 1;
            } else {
                kept.push(it);
            }
        }
        self.items = kept;
        if sold > 0 {
            self.credit(credited);
        }
        (sold, credited)
    }

    /// Transfer-market buy-now: debit the price FIRST, then acquire the item — the
    /// market shares the SAME authoritative coin balance (that is why it is inside
    /// this engine's boundary). All-or-nothing.
    pub fn market_buy_now(&mut self, price: u64, item: Value) -> Result<i64, EconomyError> {
        self.debit(price)?;
        Ok(self.add_item(item))
    }

    /// Match/reward coin credit (`record_match` coin part; SBC/objective grants).
    pub fn grant_reward(&mut self, coins: u64) {
        self.credit(coins);
    }
}

impl Default for ProfileEconomy {
    fn default() -> Self {
        Self::new()
    }
}

#[cfg(test)]
mod tests {
    use super::*;

    fn eco(coins: i64, unopened: &[u64]) -> ProfileEconomy {
        ProfileEconomy {
            coins,
            unopened_pack_ids: unopened.to_vec(),
            items: Vec::new(),
            next_item_id: 1,
        }
    }

    #[test]
    fn debit_insufficient_is_fail_closed() {
        let mut e = eco(100, &[]);
        assert_eq!(
            e.debit(101),
            Err(EconomyError::InsufficientFunds {
                balance: 100,
                needed: 101
            })
        );
        assert_eq!(e.coins(), 100, "no mutation on failure");
        assert_eq!(e.debit(100), Ok(()));
        assert_eq!(e.coins(), 0);
    }

    #[test]
    fn buy_pack_is_atomic() {
        // Insufficient funds: neither coins nor entitlements change.
        let mut poor = eco(399, &[]);
        assert!(matches!(
            poor.buy_pack(1, 400),
            Err(EconomyError::InsufficientFunds { .. })
        ));
        assert_eq!(poor.coins(), 399);
        assert!(poor.unopened_pack_ids().is_empty());
        // Enough funds: debit + grant together.
        let mut ok = eco(1000, &[]);
        assert_eq!(ok.buy_pack(1, 400), Ok(()));
        assert_eq!(ok.coins(), 600);
        assert_eq!(ok.unopened_pack_ids(), &[1]);
    }

    #[test]
    fn sentinel_can_never_be_bought_or_granted() {
        let mut e = eco(1_000_000, &[]);
        assert_eq!(
            e.buy_pack(SENTINEL_PACK_ID, 0),
            Err(EconomyError::SentinelRejected)
        );
        assert_eq!(
            e.grant_pack(SENTINEL_PACK_ID),
            Err(EconomyError::SentinelRejected)
        );
        // Never openable either (no entitlement can exist for it).
        assert_eq!(
            e.open_pack(SENTINEL_PACK_ID, vec![json!({})]),
            Err(EconomyError::NotOwned(SENTINEL_PACK_ID))
        );
        assert_eq!(e.coins(), 1_000_000, "sentinel ops never mutate economy");
        assert!(e.item_ids().is_empty());
    }

    #[test]
    fn unknown_pack_rejected() {
        let mut e = eco(1_000_000, &[]);
        assert_eq!(e.buy_pack(999, 0), Err(EconomyError::UnknownPack(999)));
        assert_eq!(e.grant_pack(999), Err(EconomyError::UnknownPack(999)));
    }

    #[test]
    fn open_pack_consumes_exactly_once() {
        let mut e = eco(0, &[70]);
        let granted = e.open_pack(70, vec![json!({"rating": 84}), json!({"rating": 90})]);
        assert_eq!(granted, Ok(vec![1, 2]));
        assert!(e.unopened_pack_ids().is_empty(), "entitlement consumed");
        assert_eq!(e.item_ids(), vec![1, 2], "unique ids assigned");
        // Second open of the same (now-absent) entitlement grants nothing.
        assert_eq!(
            e.open_pack(70, vec![json!({})]),
            Err(EconomyError::NotOwned(70))
        );
        assert_eq!(e.item_ids(), vec![1, 2], "no items added on failed open");
    }

    #[test]
    fn quick_sell_removes_owned_and_credits() {
        let mut e = eco(100, &[]);
        let a = e.add_item(json!({"rating": 84}));
        let b = e.add_item(json!({"rating": 90}));
        // sell only `a`; an unknown id is ignored (not owned).
        let (sold, credited) = e.quick_sell(&[a, 99999], |_| 300);
        assert_eq!((sold, credited), (1, 300));
        assert_eq!(e.coins(), 400);
        assert_eq!(e.item_ids(), vec![b], "only the sold item removed");
    }

    #[test]
    fn market_buy_now_is_atomic() {
        let mut poor = eco(50, &[]);
        assert!(matches!(
            poor.market_buy_now(100, json!({"rating": 84})),
            Err(EconomyError::InsufficientFunds { .. })
        ));
        assert_eq!(poor.coins(), 50);
        assert!(poor.item_ids().is_empty(), "no item acquired on failed buy");
        let mut ok = eco(500, &[]);
        let id = ok.market_buy_now(100, json!({"rating": 84})).unwrap();
        assert_eq!(ok.coins(), 400);
        assert_eq!(ok.item_ids(), vec![id]);
    }

    #[test]
    fn item_ids_are_unique_and_monotonic() {
        let mut e = ProfileEconomy::new();
        let ids: Vec<i64> = (0..5).map(|_| e.allocate_item_id()).collect();
        assert_eq!(ids, vec![1, 2, 3, 4, 5]);
        assert_eq!(e.next_item_id(), 6);
    }

    #[test]
    fn fut_profile_import_export_round_trip() {
        let mut profile = json!({
            "personaId": 33068179,
            "coins": 29876776,
            "unopenedPackIds": [70],
            "items": [{"id": 41, "rating": 84}, {"id": 42, "rating": 90}],
            "nextItemId": 43,
            "clubName": "OpenFUT"
        });
        let e = ProfileEconomy::from_fut_profile(&profile);
        assert_eq!(e.coins(), 29876776);
        assert_eq!(e.unopened_pack_ids(), &[70]);
        assert_eq!(e.next_item_id(), 43, "past the highest existing id");
        // Export preserves unrelated keys and reflects the economy exactly.
        e.apply_to_fut_profile(&mut profile);
        assert_eq!(
            profile["clubName"],
            json!("OpenFUT"),
            "unrelated key preserved"
        );
        assert_eq!(profile["coins"], json!(29876776));
        assert_eq!(profile["nextItemId"], json!(43));
        // Re-import is stable.
        let e2 = ProfileEconomy::from_fut_profile(&profile);
        assert_eq!(e, e2);
    }

    #[test]
    fn import_floors_next_item_id_past_existing_ids() {
        // A stale/low nextItemId must never mint a duplicate id.
        let profile = json!({
            "coins": 0,
            "items": [{"id": 500}],
            "nextItemId": 10
        });
        let mut e = ProfileEconomy::from_fut_profile(&profile);
        assert_eq!(e.next_item_id(), 501);
        assert_eq!(e.allocate_item_id(), 501);
    }
}
