//! FIFA 17 squad **full-replacement** wire (`PUT /ut/game/fifa17/squad/<id>`) →
//! a game-independent proposed replacement OpenFUT Core can apply.
//!
//! ## Scope (deliberately preparatory — nothing is routed yet)
//!
//! This module parses the captured squad-save wire and reverse-maps each slot's
//! FIFA wire item id to a Core owned-instance id, producing a [`ProposedSquad`].
//! It does **not** open a socket, call Core, or mutate state — squad is a
//! *stateful* slice and its GET (retrieval) and PUT (save) must migrate together
//! as one authority, which needs more captured evidence first. Until then this is
//! pure, unit-tested scaffolding.
//!
//! ## What the wire proves (2 captured retail saves, `fixtures/utas/`)
//!
//! * The client sends the **whole** squad on every save — a fixed 23-slot array
//!   plus `formation`, `captain`, `kicktakers`, a 33-int `custom` string, and
//!   client-reported `chemistry`/`rating`/`starRating`. A two-player edit changed
//!   nine slots, so slot deltas never describe intent: the only honest operation
//!   is *this is the squad now*.
//! * Each occupied slot carries its FIFA **wire item id** (`itemData.id`, the same
//!   namespace as `/club` and the identity store); an **empty** slot is `id == 0`.
//! * `captain` and `kicktakers` reference the same wire item id space.
//!
//! ## What it does NOT prove (kept UNKNOWN, never invented)
//!
//! * The `index → (slot, bench)` layout for formations other than **f442**, and
//!   the meaning/stability of the `custom` 33-int array. `custom` is preserved
//!   **opaquely** so it can round-trip unchanged; its integers are not decoded.
//! * FIFA's chemistry/rating algorithm — client-reported values are carried in
//!   [`ClientReportedSquadEval`] and never reconciled with Core's own evaluation.

use serde::{Deserialize, Serialize};

/// A `{ "id": <wire item id>, "dream": bool }` reference (player, manager, …).
#[derive(Debug, Clone, Deserialize)]
#[serde(rename_all = "camelCase")]
pub struct SquadEntityRef {
    pub id: i64,
    #[serde(default)]
    pub dream: bool,
}

/// One entry of the fixed-length `players` array.
#[derive(Debug, Clone, Deserialize)]
#[serde(rename_all = "camelCase")]
pub struct SquadSlotWire {
    pub index: i64,
    pub item_data: SquadEntityRef,
    #[serde(default)]
    pub kit_number: i64,
}

/// A `kicktakers` entry (penalty/corner/free-kick roles). Carries a wire item id;
/// role semantics are UNKNOWN and not modelled.
#[derive(Debug, Clone, Deserialize)]
#[serde(rename_all = "camelCase")]
pub struct SquadKicktaker {
    pub index: i64,
    pub id: i64,
    #[serde(default)]
    pub dream: bool,
}

/// The squad-save body exactly as FIFA 17 sends it. FIFA-only fields
/// (`custom`, `kicktakers`, `kit_number`, `manager`, `squad_type`) are captured
/// verbatim; their persistence/reconstruction rules await more evidence.
#[derive(Debug, Clone, Deserialize)]
#[serde(rename_all = "camelCase")]
pub struct Fifa17SquadPut {
    /// Squad id (the path `…/squad/0` and the body agree; `0` = active).
    #[serde(default)]
    pub id: i64,
    #[serde(default)]
    pub squad_name: Option<String>,
    #[serde(default)]
    pub formation: Option<String>,
    #[serde(default)]
    pub squad_type: Option<String>,
    #[serde(default)]
    pub chemistry: Option<i64>,
    #[serde(default)]
    pub rating: Option<i64>,
    #[serde(default)]
    pub star_rating: Option<i64>,
    /// Wire item id of the captain (must be one of the occupied slots).
    #[serde(default)]
    pub captain: Option<i64>,
    /// Opaque 33-int array as a JSON-encoded string. Semantics UNKNOWN — carried
    /// verbatim, never parsed or interpreted.
    #[serde(default)]
    pub custom: Option<String>,
    #[serde(default)]
    pub manager: Vec<SquadEntityRef>,
    #[serde(default)]
    pub players: Vec<SquadSlotWire>,
    #[serde(default)]
    pub kicktakers: Vec<SquadKicktaker>,
}

/// Reverse-maps a FIFA wire item id to a Core owned-instance id. Implemented by
/// the host over `Fifa17IdentityResolver`; `None` = unknown id (never guessed).
pub trait SquadWireResolver {
    fn owned_id_for_wire(&self, wire: i64) -> Option<String>;
}

/// Client-reported squad evaluation. Kept DISTINCT from Core's authoritative
/// evaluation and never reconciled — FIFA's chemistry/rating algorithm is UNKNOWN.
#[derive(Debug, Clone, Default, PartialEq, Eq, Serialize, Deserialize)]
pub struct ClientReportedSquadEval {
    pub chemistry: Option<i64>,
    pub rating: Option<i64>,
    pub star_rating: Option<i64>,
}

/// One resolved slot of a proposed replacement (game-independent).
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct ProposedSlot {
    pub owned_card_id: String,
    /// FIFA player-array index (0-based). Core imposes its own slot numbering;
    /// the adapter carries the index plus a bench flag from the f442 convention.
    pub index: i64,
    pub kit_number: i64,
    pub is_captain: bool,
    pub is_on_bench: bool,
}

/// A full-squad replacement in **canonical** (game-independent) terms, ready for
/// the host to map onto Core's `SquadReplacement`/`SaveSquadRequest`. Carries no
/// FIFA-only state (that is [`crate::fut::squad_ext::Fifa17SquadExtensionV1`])
/// and no `openfut-core` dependency. No FIFA wire integer survives into a slot —
/// every player is a Core `owned_card_id`.
#[derive(Debug, Clone)]
pub struct ProposedSquad {
    /// The FIFA wire squad id the PUT targeted (`0` = the active squad). Routing
    /// only — it selects which Core squad to replace; it is never a Core field.
    pub squad_id: i64,
    pub name: Option<String>,
    /// The FIFA formation token exactly as sent (e.g. `"f442"`, `"f433"`). Core
    /// stores it verbatim as its opaque formation token and never interprets it,
    /// so it round-trips exactly — never mapped to a second representation and
    /// never used to derive slot layout.
    pub formation: Option<String>,
    pub slots: Vec<ProposedSlot>,
    /// Occupied wire item ids the resolver could not map. A caller MUST refuse the
    /// replacement if this is non-empty — a save must never silently drop an
    /// owned player it failed to identify.
    pub unresolved_wire_ids: Vec<i64>,
}

/// Parse errors — explicit, never a silent empty squad.
#[derive(Debug, PartialEq, Eq)]
pub enum SquadError {
    Parse(String),
}

impl std::fmt::Display for SquadError {
    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
        match self {
            SquadError::Parse(e) => write!(f, "squad wire parse error: {e}"),
        }
    }
}
impl std::error::Error for SquadError {}

/// The FIFA 17 squad wire is a fixed 23-slot array: indices `0..=10` are the
/// pitch (the 11 starters), `11..=22` are bench/reserves. This layout is a
/// property of the array, not of the formation — the captured f442 and f433
/// saves both place their 11 starters at `0..=10`. Bench membership is therefore
/// derived from the index alone, NEVER from the formation token.
pub const FIFA17_STARTER_SLOTS: i64 = 11;

/// Length of the fixed FIFA 17 squad slot array (evidence: every captured save
/// and read carries exactly 23 slots).
pub const FIFA17_SQUAD_SLOTS: i64 = 23;

/// Parse a squad-save body into the typed wire form. Structural only.
pub fn parse_squad_put(body: &[u8]) -> Result<Fifa17SquadPut, SquadError> {
    serde_json::from_slice(body).map_err(|e| SquadError::Parse(e.to_string()))
}

/// Resolve a parsed save into a **canonical** [`ProposedSquad`]: drop empty
/// (`id == 0`) slots, reverse-map each occupied slot's wire id to a Core
/// `owned_card_id`, flag the captain, and derive the bench split from the fixed
/// 23-slot array. FIFA-only state (`custom`, manager, kicktakers, kit numbers,
/// squadType) and client-reported evaluation are NOT canonical — they are built
/// separately into [`crate::fut::squad_ext::Fifa17SquadExtensionV1`]. The
/// formation token is carried verbatim (never mapped). Unresolvable occupied ids
/// are reported, never guessed or dropped.
pub fn to_proposed(put: &Fifa17SquadPut, resolver: &dyn SquadWireResolver) -> ProposedSquad {
    let captain = put.captain.unwrap_or(0);
    let mut slots = Vec::new();
    let mut unresolved = Vec::new();
    for p in &put.players {
        if p.item_data.id == 0 {
            continue; // empty slot — never a Core player
        }
        match resolver.owned_id_for_wire(p.item_data.id) {
            Some(owned_card_id) => slots.push(ProposedSlot {
                owned_card_id,
                index: p.index,
                kit_number: p.kit_number,
                is_captain: captain != 0 && p.item_data.id == captain,
                is_on_bench: p.index >= FIFA17_STARTER_SLOTS,
            }),
            None => unresolved.push(p.item_data.id),
        }
    }
    ProposedSquad {
        squad_id: put.id,
        name: put.squad_name.clone(),
        formation: put.formation.clone(),
        slots,
        unresolved_wire_ids: unresolved,
    }
}

/// The save acknowledgement FIFA expects: just the squad id (matches the oracle's
/// `{"id": <n>}`, 9 bytes — it does NOT echo the squad).
pub fn save_ack(squad_id: i64) -> serde_json::Value {
    serde_json::json!({ "id": squad_id })
}

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

    /// The real captured f442 save body (decoded from `session-001.jsonl:21`).
    const PUT_F442: &str = include_str!("../../fixtures/utas/squad_put_f442.json");

    /// A resolver mapping every occupied wire id in the fixture to a Core id.
    struct MapResolver(HashMap<i64, String>);
    impl SquadWireResolver for MapResolver {
        fn owned_id_for_wire(&self, wire: i64) -> Option<String> {
            self.0.get(&wire).cloned()
        }
    }
    fn full_resolver() -> MapResolver {
        // indices 0..=10 (11 starters); the rest of the 23 slots are id==0 (empty).
        let ids = [
            100000003, 100000010, 100000005, 100000008, 100000007, 100000006, 100000004, 100000009,
            100000001, 100000002, 100000025,
        ];
        MapResolver(ids.iter().map(|&w| (w, format!("oc-{w}"))).collect())
    }

    #[test]
    fn parses_the_captured_full_squad_wire() {
        let put = parse_squad_put(PUT_F442.as_bytes()).unwrap();
        assert_eq!(put.id, 0, "path/body squad id 0 = active");
        assert_eq!(put.formation.as_deref(), Some("f442"));
        assert_eq!(put.squad_type.as_deref(), Some("REGULAR_SQUAD"));
        assert_eq!(put.chemistry, Some(52));
        assert_eq!(put.rating, Some(90));
        assert_eq!(put.star_rating, Some(90));
        assert_eq!(put.captain, Some(100000001));
        assert_eq!(put.players.len(), 23, "fixed 23-slot array");
        assert_eq!(put.kicktakers.len(), 5);
        // custom is carried opaquely, never parsed.
        assert!(put.custom.as_deref().unwrap().starts_with("[0,0,0"));
    }

    #[test]
    fn resolves_occupied_slots_drops_empties_and_flags_captain() {
        let put = parse_squad_put(PUT_F442.as_bytes()).unwrap();
        let sq = to_proposed(&put, &full_resolver());

        assert_eq!(sq.slots.len(), 11, "11 occupied; 12 empty (id==0) dropped");
        assert!(
            sq.unresolved_wire_ids.is_empty(),
            "all occupied ids resolved"
        );
        assert_eq!(
            sq.formation.as_deref(),
            Some("f442"),
            "formation token carried verbatim, never mapped"
        );
        // Every occupied f442 slot is a starter (indices 0..=10).
        assert!(sq.slots.iter().all(|s| !s.is_on_bench));
        // The captain flag lands on exactly the captain's slot (id 100000001 @ index 8).
        let caps: Vec<_> = sq.slots.iter().filter(|s| s.is_captain).collect();
        assert_eq!(caps.len(), 1);
        assert_eq!(caps[0].owned_card_id, "oc-100000001");
        assert_eq!(caps[0].index, 8);
        assert_eq!(caps[0].kit_number, 8);
    }

    #[test]
    fn unresolved_occupied_id_is_reported_never_dropped_silently() {
        let put = parse_squad_put(PUT_F442.as_bytes()).unwrap();
        // Resolver missing one occupied id (100000025).
        let mut ids = full_resolver().0;
        ids.remove(&100000025);
        let sq = to_proposed(&put, &MapResolver(ids));
        assert_eq!(
            sq.slots.len(),
            10,
            "the unresolved slot is not emitted as a player"
        );
        assert_eq!(
            sq.unresolved_wire_ids,
            vec![100000025],
            "reported for the caller to refuse"
        );
    }

    #[test]
    fn empty_slot_is_dropped_not_resolved() {
        // A minimal body: one occupied + one empty slot.
        let body = br#"{"id":0,"formation":"f442","captain":0,"players":[
            {"index":0,"itemData":{"id":100000003},"kitNumber":1},
            {"index":11,"itemData":{"id":0},"kitNumber":0}]}"#;
        let put = parse_squad_put(body).unwrap();
        let sq = to_proposed(&put, &full_resolver());
        assert_eq!(sq.slots.len(), 1);
        assert_eq!(sq.slots[0].index, 0);
        assert!(sq.unresolved_wire_ids.is_empty());
    }

    #[test]
    fn formation_token_round_trips_verbatim() {
        // Canonical formation is the FIFA token as-sent; f433 is preserved as
        // readily as f442 (the old lossy f442->"4-4-2" map is gone).
        for tok in ["f442", "f433"] {
            let body = format!(
                r#"{{"id":0,"formation":"{tok}","captain":0,"players":[{{"index":0,"itemData":{{"id":100000003}},"kitNumber":1}}]}}"#
            );
            let sq = to_proposed(&parse_squad_put(body.as_bytes()).unwrap(), &full_resolver());
            assert_eq!(sq.formation.as_deref(), Some(tok));
        }
    }

    #[test]
    fn save_ack_is_the_bare_id() {
        assert_eq!(save_ack(0), serde_json::json!({ "id": 0 }));
        assert_eq!(save_ack(7), serde_json::json!({ "id": 7 }));
    }

    #[test]
    fn malformed_body_errors_never_empty_squad() {
        assert!(matches!(
            parse_squad_put(b"not json"),
            Err(SquadError::Parse(_))
        ));
    }
}
