//! The **single** FIFA 17 squad projector: canonical Core squad + Fresh FIFA
//! extension → the FIFA 17 squad wire object.
//!
//! One projector serves every squad read shape. `userMassInfo.squad` embeds the
//! full object; `GET /squad/list` is a summary *subset* of it; a future
//! `/squad/active` is the same object again. Endpoint wrappers ([`user_mass_info_squad`],
//! [`squad_list`]) only shape the outer envelope — there is deliberately no
//! second squad domain model per endpoint.
//!
//! ## Purity / no N+1
//!
//! The projector touches no database, socket, or Core API. It consumes a
//! [`SquadProjectionInput`] the host assembles from ONE bounded batch — Core's
//! `read_squad_with_ext` (canonical squad + players + extension freshness) plus a
//! single "all owned cards for this club" fetch joined against the in-memory card
//! definitions. There is no per-slot lookup here or above.
//!
//! ## Item identity is shared, never reconstructed
//!
//! Each occupied slot is shaped by the shared [`crate::fut::item::shape_item`],
//! the same primitive `/club` uses — the projector never rebuilds the card shape
//! itself, so squad items and `/club` items cannot drift, and two owned copies of
//! one definition stay distinct (each carries its own resolved wire id).
//!
//! ## Fresh / Stale / Missing
//!
//! Freshness comes from Core and is surfaced, never buried in a default:
//! * **Fresh** → project the full object.
//! * **Stale** → NEVER overlay the stale extension on the newer canonical squad;
//!   return [`SquadProjection::Stale`] for the host to act on (e.g. fall back).
//! * **Missing** → return [`SquadProjection::Missing`]; the projector does NOT
//!   fabricate a manager/custom/kicktakers/kit numbers just to emit a response.

use std::collections::HashMap;

use serde_json::{json, Value};

use crate::fut::entities::ReverseEntityResolver;
use crate::fut::item::{shape_item, CoreOwnedItem, ItemIdentityResolver};
use crate::fut::squad::FIFA17_SQUAD_SLOTS;
use crate::fut::squad_ext::Fifa17SquadExtensionV1;

/// Freshness of the FIFA 17 extension relative to the current canonical squad,
/// as reported by Core's `read_squad_with_ext`. This is a game-independent mirror
/// the host populates from Core's `SquadExtState`; the adapter never computes the
/// canonical fingerprint itself (that is Core's server-side job).
#[derive(Debug, Clone)]
pub enum SquadExtInput {
    Fresh(Fifa17SquadExtensionV1),
    /// The stored extension whose fingerprint no longer matches the canonical
    /// squad. Carried so the host can log/inspect it, but the projector NEVER
    /// applies it over the newer canonical squad (mirrors Core's
    /// `SquadExtState::Stale { stored, .. }`).
    Stale(Fifa17SquadExtensionV1),
    Missing,
}

/// One canonical slot as read back from Core. `is_on_bench` is carried through
/// from Core, never re-derived from the formation.
#[derive(Debug, Clone)]
pub struct ProjectionSlot {
    pub owned_card_id: String,
    pub index: i64,
    pub is_captain: bool,
    pub is_on_bench: bool,
}

/// Everything the pure projector needs to render one full squad.
pub struct SquadProjectionInput<'a> {
    /// FIFA wire squad id (`0` = active).
    pub fifa_squad_id: i64,
    pub name: String,
    /// FIFA formation token, verbatim from the canonical squad (never mapped).
    pub formation: String,
    pub slots: Vec<ProjectionSlot>,
    pub ext: SquadExtInput,
    /// Every owned item a slot references, keyed by `owned_card_id`. Assembled by
    /// the host in one batch — the projector only reads from it.
    pub owned: &'a HashMap<String, CoreOwnedItem>,
    /// The owned instance assigned as this squad's **manager** (Core's
    /// ownership-backed `squad_managers` assignment, migration 0023), or `None`.
    /// Projected as the FIFA `manager` wire ref resolved from ownership — never a
    /// dangling wire id, and never fabricated when absent.
    pub manager: Option<CoreOwnedItem>,
}

/// Result of a projection, with the extension-freshness verdict surfaced.
#[derive(Debug, Clone, PartialEq, Eq)]
pub enum SquadProjection {
    /// A fully projected FIFA 17 squad object (the `userMassInfo.squad` shape,
    /// minus session envelope fields the endpoint wrapper adds).
    Projected(Value),
    /// The stored extension is stale vs the canonical squad — not applied.
    Stale,
    /// No extension stored — nothing fabricated.
    Missing,
}

/// Hard projection failures — a squad cannot be rendered faithfully. Never
/// silently degraded (a squad cannot drop a starter the way `/club` drops a card).
#[derive(Debug, Clone, PartialEq, Eq)]
pub enum SquadProjectError {
    /// A slot references an `owned_card_id` absent from the projection input.
    MissingOwnedItem(String),
    /// An occupied slot's owned item has no real FIFA asset identity — it cannot
    /// be rendered and MUST NOT be faked.
    NoFifaIdentity(String),
}

impl std::fmt::Display for SquadProjectError {
    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
        match self {
            SquadProjectError::MissingOwnedItem(id) => {
                write!(f, "projection input missing owned item {id}")
            }
            SquadProjectError::NoFifaIdentity(id) => {
                write!(
                    f,
                    "owned item {id} has no real FIFA asset identity (cannot render)"
                )
            }
        }
    }
}
impl std::error::Error for SquadProjectError {}

/// Project a squad. On `Fresh`, returns the full FIFA squad object; on
/// `Stale`/`Missing`, returns that verdict without fabricating anything.
pub fn project_squad<I: ItemIdentityResolver + ?Sized>(
    input: &SquadProjectionInput<'_>,
    ident: &I,
    ent: &impl ReverseEntityResolver,
) -> Result<SquadProjection, SquadProjectError> {
    let ext = match &input.ext {
        SquadExtInput::Stale(_) => return Ok(SquadProjection::Stale),
        SquadExtInput::Missing => return Ok(SquadProjection::Missing),
        SquadExtInput::Fresh(ext) => ext,
    };

    // Index occupied slots by their FIFA array index for O(1) fill.
    let by_index: HashMap<i64, &ProjectionSlot> =
        input.slots.iter().map(|s| (s.index, s)).collect();

    let mut players = Vec::with_capacity(FIFA17_SQUAD_SLOTS as usize);
    let mut captain_wire: i64 = 0;

    for index in 0..FIFA17_SQUAD_SLOTS {
        match by_index.get(&index) {
            Some(slot) => {
                let item = input.owned.get(&slot.owned_card_id).ok_or_else(|| {
                    SquadProjectError::MissingOwnedItem(slot.owned_card_id.clone())
                })?;
                let id = ident
                    .resolve(item)
                    .ok_or_else(|| SquadProjectError::NoFifaIdentity(slot.owned_card_id.clone()))?;
                if slot.is_captain {
                    captain_wire = id.item_id as i64;
                }
                // kit follows the player: look it up by owned id, never by index.
                let kit = ext
                    .kit_numbers
                    .get(&slot.owned_card_id)
                    .copied()
                    .unwrap_or(0);
                players.push(json!({
                    "index": index,
                    "itemData": shape_item(item, id, ent),
                    "kitNumber": kit,
                }));
            }
            None => players.push(json!({
                "index": index,
                "itemData": { "id": 0, "dream": false },
                "kitNumber": 0,
            })),
        }
    }

    // Manager: the ownership-backed assignment, resolved to its FIFA wire ref.
    // Emitted as the STATIC_REVERSED `[{id, dream}]` shape (the same shape the
    // client sends on save). An owned manager with no resolvable FIFA identity
    // is omitted (non-fatal, like /club dropping an unrenderable card) rather
    // than emitted with a fabricated id. NOTE: a richer manager itemData
    // (contract/leagueId/nation) is INFERRED-only from the offline-season
    // diagnosis and deliberately NOT invented here pending wire reversal.
    let manager = match input.manager.as_ref().and_then(|m| ident.resolve(m)) {
        Some(id) => json!([{ "id": id.item_id, "dream": false }]),
        None => json!([]),
    };
    let squad = json!({
        "id": input.fifa_squad_id,
        "squadName": input.name,
        "formation": input.formation,
        "squadType": ext.squad_type,
        "chemistry": ext.client_reported.chemistry,
        "starRating": ext.client_reported.star_rating,
        "rating": ext.client_reported.rating,
        "captain": captain_wire,
        "manager": manager,
        "custom": ext.custom,
        "players": players,
        "kicktakers": ext.kicktakers,
    });
    Ok(SquadProjection::Projected(squad))
}

/// Wrap a projected squad object into the `userMassInfo.squad` shape, injecting
/// the session-envelope fields the projector does not own (`personaId`, plus the
/// observed constants `changed: 0`, `actives: []`).
pub fn user_mass_info_squad(projected: Value, persona_id: i64) -> Value {
    let mut obj = projected;
    if let Value::Object(map) = &mut obj {
        map.insert("personaId".into(), json!(persona_id));
        map.insert("changed".into(), json!(0));
        map.insert("actives".into(), json!([]));
    }
    obj
}

/// The `GET /squad/list` summary response — a subset of the SAME projected
/// object, wrapped in `{"squad":[ … ]}`. Not a separate domain projection.
pub fn squad_list(projected: &Value) -> Value {
    let summary = json!({
        "id": projected.get("id").cloned().unwrap_or(Value::Null),
        "squadName": projected.get("squadName").cloned().unwrap_or(Value::Null),
        "formation": projected.get("formation").cloned().unwrap_or(Value::Null),
        "squadType": projected.get("squadType").cloned().unwrap_or(Value::Null),
        "rating": projected.get("rating").cloned().unwrap_or(Value::Null),
        "chemistry": projected.get("chemistry").cloned().unwrap_or(Value::Null),
    });
    json!({ "squad": [summary] })
}

#[cfg(test)]
mod tests {
    use super::*;
    use crate::fut::entities::Fifa17Entities;
    use crate::fut::item::Fifa17Identity;

    // A resolver that mints a distinct wire id per owned item and a fixed asset.
    struct TableIdentity(HashMap<String, Fifa17Identity>);
    impl ItemIdentityResolver for TableIdentity {
        fn resolve(&self, it: &CoreOwnedItem) -> Option<Fifa17Identity> {
            self.0.get(&it.owned_card_id).copied()
        }
    }

    fn ent() -> Fifa17Entities {
        Fifa17Entities::from_maps(HashMap::new(), HashMap::new(), HashMap::new())
    }

    fn owned_item(id: &str, card: &str) -> CoreOwnedItem {
        CoreOwnedItem {
            owned_card_id: id.into(),
            card_id: card.into(),
            rating: 84,
            position: "ST".into(),
            nation: "n".into(),
            league: "l".into(),
            club: "c".into(),
            attributes: [80, 80, 80, 80, 40, 80],
        }
    }

    fn one_slot_input<'a>(
        owned: &'a HashMap<String, CoreOwnedItem>,
        ext: SquadExtInput,
    ) -> SquadProjectionInput<'a> {
        SquadProjectionInput {
            fifa_squad_id: 0,
            name: "OpenFUT".into(),
            formation: "f442".into(),
            slots: vec![ProjectionSlot {
                owned_card_id: "oc1".into(),
                index: 0,
                is_captain: true,
                is_on_bench: false,
            }],
            ext,
            owned,
            manager: None,
        }
    }

    fn fresh_ext() -> Fifa17SquadExtensionV1 {
        let mut kit = std::collections::BTreeMap::new();
        kit.insert("oc1".to_string(), 9);
        Fifa17SquadExtensionV1 {
            custom: Some("[1,2,3]".into()),
            squad_type: Some("REGULAR_SQUAD".into()),
            kit_numbers: kit,
            kicktakers: vec![],
            client_reported: Default::default(),
        }
    }

    #[test]
    fn fresh_projects_full_23_slot_array_with_captain_wire_id() {
        let mut owned = HashMap::new();
        owned.insert("oc1".to_string(), owned_item("oc1", "card_x"));
        let ident = TableIdentity(HashMap::from([(
            "oc1".to_string(),
            Fifa17Identity {
                item_id: 100000042,
                asset_id: 20801,
                resource_id: 20801,
                rareflag: 1,
            },
        )]));
        let input = one_slot_input(&owned, SquadExtInput::Fresh(fresh_ext()));
        let SquadProjection::Projected(v) = project_squad(&input, &ident, &ent()).unwrap() else {
            panic!("expected Projected");
        };
        assert_eq!(
            v["players"].as_array().unwrap().len(),
            23,
            "fixed 23-slot array"
        );
        assert_eq!(
            v["players"][0]["itemData"]["id"], 100000042,
            "wire id, not resourceId"
        );
        assert_eq!(v["players"][0]["itemData"]["resourceId"], 20801);
        assert_eq!(v["players"][0]["kitNumber"], 9, "kit from ext by owned id");
        assert_eq!(v["players"][1]["itemData"]["id"], 0, "empty slot");
        assert_eq!(v["captain"], 100000042, "captain is the resolved WIRE id");
        assert_ne!(v["captain"], 20801, "captain must NOT be the resourceId");
        assert_eq!(v["custom"], "[1,2,3]");
        assert_eq!(v["formation"], "f442");
    }

    #[test]
    fn stale_is_never_applied() {
        let owned = HashMap::new();
        // A stale extension IS carried (host may log it) but must not be applied.
        let input = one_slot_input(&owned, SquadExtInput::Stale(fresh_ext()));
        let ident = TableIdentity(HashMap::new());
        assert_eq!(
            project_squad(&input, &ident, &ent()).unwrap(),
            SquadProjection::Stale
        );
    }

    #[test]
    fn missing_is_explicit_never_fabricated() {
        let owned = HashMap::new();
        let input = one_slot_input(&owned, SquadExtInput::Missing);
        let ident = TableIdentity(HashMap::new());
        assert_eq!(
            project_squad(&input, &ident, &ent()).unwrap(),
            SquadProjection::Missing
        );
    }

    #[test]
    fn occupied_starter_without_asset_identity_is_refused_not_faked() {
        let mut owned = HashMap::new();
        owned.insert("oc1".to_string(), owned_item("oc1", "card_x"));
        let ident = TableIdentity(HashMap::new()); // resolves nothing
        let input = one_slot_input(&owned, SquadExtInput::Fresh(fresh_ext()));
        assert_eq!(
            project_squad(&input, &ident, &ent()),
            Err(SquadProjectError::NoFifaIdentity("oc1".into()))
        );
    }

    #[test]
    fn two_owned_copies_of_one_definition_project_as_distinct_players() {
        // Same card definition -> same resourceId; two distinct owned instances
        // in two slots with distinct kits must stay distinct on the wire.
        let mut owned = HashMap::new();
        owned.insert("oc-a".to_string(), owned_item("oc-a", "fifa17_101490"));
        owned.insert("oc-b".to_string(), owned_item("oc-b", "fifa17_101490"));
        let ident = TableIdentity(HashMap::from([
            (
                "oc-a".to_string(),
                Fifa17Identity {
                    item_id: 100000030,
                    asset_id: 101490,
                    resource_id: 101490,
                    rareflag: 1,
                },
            ),
            (
                "oc-b".to_string(),
                Fifa17Identity {
                    item_id: 100000031,
                    asset_id: 101490,
                    resource_id: 101490,
                    rareflag: 1,
                },
            ),
        ]));
        let mut kit = std::collections::BTreeMap::new();
        kit.insert("oc-a".to_string(), 7);
        kit.insert("oc-b".to_string(), 19);
        let ext = Fifa17SquadExtensionV1 {
            kit_numbers: kit,
            ..fresh_ext()
        };
        let owned_ref = &owned;
        let input = SquadProjectionInput {
            fifa_squad_id: 0,
            name: "OpenFUT".into(),
            formation: "f442".into(),
            slots: vec![
                ProjectionSlot {
                    owned_card_id: "oc-a".into(),
                    index: 0,
                    is_captain: false,
                    is_on_bench: false,
                },
                ProjectionSlot {
                    owned_card_id: "oc-b".into(),
                    index: 1,
                    is_captain: false,
                    is_on_bench: false,
                },
            ],
            ext: SquadExtInput::Fresh(ext),
            owned: owned_ref,
            manager: None,
        };
        let SquadProjection::Projected(v) = project_squad(&input, &ident, &ent()).unwrap() else {
            panic!();
        };
        let a = &v["players"][0]["itemData"];
        let b = &v["players"][1]["itemData"];
        assert_eq!(
            a["resourceId"], b["resourceId"],
            "same definition => same asset"
        );
        assert_ne!(
            a["id"], b["id"],
            "distinct owned copies keep distinct wire ids"
        );
        assert_eq!(v["players"][0]["kitNumber"], 7);
        assert_eq!(
            v["players"][1]["kitNumber"], 19,
            "kit stays with the instance"
        );
    }

    #[test]
    fn manager_projected_from_ownership_as_wire_ref() {
        let mut owned = HashMap::new();
        owned.insert("oc1".to_string(), owned_item("oc1", "card_x"));
        let ident = TableIdentity(HashMap::from([
            (
                "oc1".to_string(),
                Fifa17Identity {
                    item_id: 100000042,
                    asset_id: 20801,
                    resource_id: 20801,
                    rareflag: 1,
                },
            ),
            (
                "oc-mgr".to_string(),
                Fifa17Identity {
                    item_id: 100000427,
                    asset_id: 5001,
                    resource_id: 5001,
                    rareflag: 1,
                },
            ),
        ]));
        let mut input = one_slot_input(&owned, SquadExtInput::Fresh(fresh_ext()));
        input.manager = Some(owned_item("oc-mgr", "fifa17_mgr"));
        let SquadProjection::Projected(v) = project_squad(&input, &ident, &ent()).unwrap() else {
            panic!("expected Projected");
        };
        assert_eq!(
            v["manager"],
            json!([{ "id": 100000427, "dream": false }]),
            "manager is the ownership-backed wire ref, resolved from the owned item"
        );
    }

    #[test]
    fn absent_manager_projects_empty_array_never_fabricated() {
        let mut owned = HashMap::new();
        owned.insert("oc1".to_string(), owned_item("oc1", "card_x"));
        let ident = TableIdentity(HashMap::from([(
            "oc1".to_string(),
            Fifa17Identity {
                item_id: 100000042,
                asset_id: 20801,
                resource_id: 20801,
                rareflag: 1,
            },
        )]));
        // one_slot_input leaves manager: None.
        let input = one_slot_input(&owned, SquadExtInput::Fresh(fresh_ext()));
        let SquadProjection::Projected(v) = project_squad(&input, &ident, &ent()).unwrap() else {
            panic!("expected Projected");
        };
        assert_eq!(
            v["manager"],
            json!([]),
            "no manager assignment => empty array, nothing fabricated"
        );
    }
}
