//! FIFA 17 **card-definition identity catalog** and owned-item **wire-id policy**.
//!
//! Two distinct identities (never conflate them):
//!
//! * **Card definition** — *what card is this?* A semantic OpenFUT
//!   `CardDefinitionId` maps to a FIFA 17 render identity here:
//!   `resource_id = (version << 24) | asset_id`. The client resolves
//!   `resource_id & 0xFFFFFF` (= `asset_id`) against its own local player DB;
//!   an invented id renders a blank card, so this catalog is authored from
//!   verified FIFA 17 data (`pool.json` player asset ids), never guessed.
//! * **Owned-item instance** — *which exact copy?* A monotonic integer wire id,
//!   allocated per account by the generic external-identity store; this module
//!   only holds the FIFA 17 numeric **policy** ([`Fifa17WireItemIdPolicy`]).
//!
//! The catalog is game DATA (a versioned JSON file), not deployment config, and
//! not a generic-Core concern. Unknown definitions resolve to `None` — callers
//! drop them, never fabricate an asset id.

use std::collections::HashMap;

use serde::Deserialize;

/// The FIFA 17 render identity of a card definition. `version` is the high byte
/// of `resource_id`; `asset_id` (the low 24 bits) is the real FIFA player id.
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub struct Fifa17CardIdentity {
    pub asset_id: u32,
    pub version: u8,
    pub resource_id: u32,
    /// FIFA wire `rareflag` (rare/special card TYPE). Carried so specials render
    /// as specials; observed metadata, not a guessed label.
    pub rareflag: i64,
}

/// The FIFA 17 numeric namespace policy for owned-item wire ids.
///
/// Owned-item ids are monotonic from `OWNED_ITEM_BASE + 1` (= 100_000_001,
/// matching the oracle's `ITEM_ID_BASE = 100_000_000` and its first minted id),
/// staying below the synthetic-overlay ranges the responder uses (≥ 9e8). The
/// generic store enforces monotonicity/uniqueness; this type supplies the game,
/// entity-kind and base floor.
pub struct Fifa17WireItemIdPolicy;

impl Fifa17WireItemIdPolicy {
    pub const GAME: &'static str = "fifa17";
    pub const OWNED_ITEM_KIND: &'static str = "owned-item";
    pub const OWNED_ITEM_BASE: i64 = 100_000_000;

    /// First owned-item wire id (`100_000_001`).
    pub fn owned_item_base_floor() -> i64 {
        Self::OWNED_ITEM_BASE + 1
    }
}

/// Highest representable asset id (24 bits); above this `version` would be
/// clobbered in `resource_id`.
const MAX_ASSET_ID: u32 = 0x00FF_FFFF;
const SCHEMA_VERSION: u32 = 1;

/// Catalog load/validation errors — all explicit, no silent fallback.
#[derive(Debug, PartialEq, Eq)]
pub enum CatalogError {
    BadSchemaVersion(u32),
    WrongGame(String),
    AssetTooLarge {
        card_id: String,
        asset_id: u32,
    },
    DuplicateResource {
        resource_id: u32,
        first: String,
        second: String,
    },
    Parse(String),
}

impl std::fmt::Display for CatalogError {
    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
        match self {
            CatalogError::BadSchemaVersion(v) => {
                write!(f, "unsupported catalog schema_version {v}")
            }
            CatalogError::WrongGame(g) => write!(f, "catalog game is '{g}', expected 'fifa17'"),
            CatalogError::AssetTooLarge { card_id, asset_id } => {
                write!(f, "card '{card_id}' asset_id {asset_id} exceeds 24 bits")
            }
            CatalogError::DuplicateResource {
                resource_id,
                first,
                second,
            } => write!(
                f,
                "resource_id {resource_id} claimed by both '{first}' and '{second}'"
            ),
            CatalogError::Parse(e) => write!(f, "catalog parse error: {e}"),
        }
    }
}
impl std::error::Error for CatalogError {}

#[derive(Deserialize)]
struct RawCatalog {
    schema_version: u32,
    game: String,
    #[serde(default)]
    cards: std::collections::BTreeMap<String, RawCard>,
}

#[derive(Deserialize)]
struct RawCard {
    asset_id: u32,
    #[serde(default)]
    version: u8,
    /// Absent in a base-only catalog → default 1 (rare), preserving prior wire
    /// behaviour; the production catalog carries the observed value.
    #[serde(default = "default_rareflag")]
    rareflag: i64,
}

fn default_rareflag() -> i64 {
    1
}

/// A loaded, validated FIFA 17 card-definition identity catalog.
#[derive(Debug, Default, Clone)]
pub struct Fifa17CardCatalog {
    by_card: HashMap<String, Fifa17CardIdentity>,
}

impl Fifa17CardCatalog {
    /// Parse + validate a catalog document. Rejects wrong schema/game, an
    /// asset id that would overflow into the version byte, and two card ids
    /// claiming the same `resource_id` (a semantic-vs-FIFA identity conflict).
    pub fn from_json_str(s: &str) -> Result<Self, CatalogError> {
        let raw: RawCatalog =
            serde_json::from_str(s).map_err(|e| CatalogError::Parse(e.to_string()))?;
        if raw.schema_version != SCHEMA_VERSION {
            return Err(CatalogError::BadSchemaVersion(raw.schema_version));
        }
        if raw.game != Fifa17WireItemIdPolicy::GAME {
            return Err(CatalogError::WrongGame(raw.game));
        }
        let mut by_card = HashMap::new();
        let mut by_resource: HashMap<u32, String> = HashMap::new();
        for (card_id, rc) in raw.cards {
            if rc.asset_id > MAX_ASSET_ID {
                return Err(CatalogError::AssetTooLarge {
                    card_id,
                    asset_id: rc.asset_id,
                });
            }
            let resource_id = ((rc.version as u32) << 24) | rc.asset_id;
            if let Some(first) = by_resource.get(&resource_id) {
                return Err(CatalogError::DuplicateResource {
                    resource_id,
                    first: first.clone(),
                    second: card_id,
                });
            }
            by_resource.insert(resource_id, card_id.clone());
            by_card.insert(
                card_id,
                Fifa17CardIdentity {
                    asset_id: rc.asset_id,
                    version: rc.version,
                    resource_id,
                    rareflag: rc.rareflag,
                },
            );
        }
        Ok(Fifa17CardCatalog { by_card })
    }

    /// Load a catalog from a JSON file.
    pub fn from_file(path: &std::path::Path) -> Result<Self, CatalogError> {
        let raw = std::fs::read_to_string(path)
            .map_err(|e| CatalogError::Parse(format!("reading {}: {e}", path.display())))?;
        Self::from_json_str(&raw)
    }

    /// The FIFA 17 identity for a definition, or `None` (never a fabricated id).
    pub fn lookup(&self, card_id: &str) -> Option<Fifa17CardIdentity> {
        self.by_card.get(card_id).copied()
    }

    pub fn len(&self) -> usize {
        self.by_card.len()
    }
    pub fn is_empty(&self) -> bool {
        self.by_card.is_empty()
    }
}

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

    #[test]
    fn resource_id_composition_base_and_special() {
        let cat = Fifa17CardCatalog::from_json_str(
            r#"{"schema_version":1,"game":"fifa17","cards":{
                "card_base":{"asset_id":20801},
                "card_totw":{"asset_id":20801,"version":3}
            }}"#,
        )
        .unwrap();
        // version 0 -> resource_id == asset_id
        let base = cat.lookup("card_base").unwrap();
        assert_eq!(base.version, 0);
        assert_eq!(base.resource_id, 20801);
        assert_eq!(base.resource_id, base.asset_id);
        // version 3 -> high byte set; same base player, different FIFA card
        let totw = cat.lookup("card_totw").unwrap();
        assert_eq!(totw.asset_id, 20801, "asset_id (base player) unchanged");
        assert_eq!(totw.resource_id, (3u32 << 24) | 20801);
        assert_ne!(base.resource_id, totw.resource_id);
    }

    #[test]
    fn unknown_card_is_none() {
        let cat = Fifa17CardCatalog::from_json_str(
            r#"{"schema_version":1,"game":"fifa17","cards":{"card_base":{"asset_id":1}}}"#,
        )
        .unwrap();
        assert_eq!(cat.lookup("card_missing"), None);
    }

    #[test]
    fn two_cards_same_resource_is_a_conflict() {
        let err = Fifa17CardCatalog::from_json_str(
            r#"{"schema_version":1,"game":"fifa17","cards":{
                "card_a":{"asset_id":20801},
                "card_b":{"asset_id":20801}
            }}"#,
        )
        .unwrap_err();
        assert!(matches!(
            err,
            CatalogError::DuplicateResource {
                resource_id: 20801,
                ..
            }
        ));
    }

    #[test]
    fn schema_and_game_are_validated() {
        assert_eq!(
            Fifa17CardCatalog::from_json_str(r#"{"schema_version":2,"game":"fifa17","cards":{}}"#)
                .unwrap_err(),
            CatalogError::BadSchemaVersion(2)
        );
        assert_eq!(
            Fifa17CardCatalog::from_json_str(r#"{"schema_version":1,"game":"fifa23","cards":{}}"#)
                .unwrap_err(),
            CatalogError::WrongGame("fifa23".into())
        );
    }

    #[test]
    fn asset_exceeding_24_bits_is_rejected() {
        let err = Fifa17CardCatalog::from_json_str(
            r#"{"schema_version":1,"game":"fifa17","cards":{"c":{"asset_id":16777216}}}"#,
        )
        .unwrap_err();
        assert!(matches!(
            err,
            CatalogError::AssetTooLarge {
                asset_id: 16_777_216,
                ..
            }
        ));
    }

    #[test]
    fn malformed_json_is_a_parse_error() {
        assert!(matches!(
            Fifa17CardCatalog::from_json_str("{not json").unwrap_err(),
            CatalogError::Parse(_)
        ));
    }

    #[test]
    fn deterministic_reload() {
        let doc = r#"{"schema_version":1,"game":"fifa17","cards":{"a":{"asset_id":10},"b":{"asset_id":20,"version":1}}}"#;
        let c1 = Fifa17CardCatalog::from_json_str(doc).unwrap();
        let c2 = Fifa17CardCatalog::from_json_str(doc).unwrap();
        assert_eq!(c1.lookup("a"), c2.lookup("a"));
        assert_eq!(c1.lookup("b"), c2.lookup("b"));
        assert_eq!(c1.len(), 2);
    }

    #[test]
    fn wire_id_policy_constants() {
        assert_eq!(Fifa17WireItemIdPolicy::GAME, "fifa17");
        assert_eq!(Fifa17WireItemIdPolicy::OWNED_ITEM_KIND, "owned-item");
        assert_eq!(Fifa17WireItemIdPolicy::owned_item_base_floor(), 100_000_001);
    }

    #[test]
    fn loads_the_committed_generated_catalog() {
        // The generated base-card catalog (scripts/seed_fifa17_cards.py) must be
        // loadable by this adapter and carry real asset identities.
        let path = std::path::Path::new(env!("CARGO_MANIFEST_DIR"))
            .join("data/fifa17-card-identities.json");
        if !path.exists() {
            eprintln!("skip: {} not generated", path.display());
            return;
        }
        let cat = Fifa17CardCatalog::from_file(&path).expect("load committed catalog");
        assert!(
            cat.len() > 17_000,
            "full FIFA17 base pool, got {}",
            cat.len()
        );
        // Ronaldo (asset 20801), version 0 => resource_id == asset_id.
        let ron = cat
            .lookup("fifa17_20801")
            .expect("known base asset present");
        assert_eq!(ron.asset_id, 20801);
        assert_eq!(ron.version, 0);
        assert_eq!(ron.resource_id, 20801);
    }
}
