//! FIFA 17 **Squad Extension v1** — the adapter-owned, versioned payload that
//! carries the squad's FIFA-only wire state that OpenFUT Core does not model.
//!
//! Core stores this serialized payload **opaquely** (never interpreting it) next
//! to the canonical squad, anchored by a server fingerprint. This module owns its
//! schema and meaning; Core must never import this type.
//!
//! ## What lives here (and why it is not canonical)
//!
//! | field | ownership rationale |
//! |-------|---------------------|
//! | `custom` | opaque 33-int string; meaning UNKNOWN, round-tripped verbatim |
//! | `squad_type` | an observed FIFA wire token; no matching generic Core concept |
//! | `kit_numbers` | keyed by **`owned_card_id`** — evidence: kit follows the player |
//! | ~~manager~~ | MOVED to ownership-backed canonical Core state (migration 0023 `squad_managers`); resolved to an `owned_card_id`, no longer opaque here |
//! | `kicktakers` | role→item refs; relationship to captain UNKNOWN, kept opaque |
//! | `client_reported` | chemistry/rating/starRating — client shadow, NOT authority |
//!
//! ## Versioning
//!
//! Two independent version numbers must not be confused:
//! * [`EXT_SCHEMA_VERSION`] — the version of **this** payload schema. Stored in
//!   Core's `game_entity_ext.schema_version` and re-checked on read
//!   ([`Fifa17SquadExtensionV1::from_payload`] rejects any other version).
//! * Core's own DB storage schema version — a Core concern, unrelated to this.
//!
//! [`EXT_NAMESPACE`] is the opaque scope key Core files the row under.

use std::collections::BTreeMap;

use serde::{Deserialize, Serialize};

use crate::fut::squad::{ClientReportedSquadEval, Fifa17SquadPut, ProposedSquad};

/// Opaque scope key Core files this extension under (`game_entity_ext.namespace`).
pub const EXT_NAMESPACE: &str = "fifa17.squad";

/// The version of THIS payload schema (goes into `OpaqueExtensionWrite.schema_version`).
/// Distinct from Core's DB storage schema version.
pub const EXT_SCHEMA_VERSION: i64 = 1;

/// A FIFA item reference `{ id, dream }` preserved verbatim from the wire. `id`
/// is the FIFA wire item id (opaque to this extension — used for manager and
/// kicktaker refs whose semantics are not modelled).
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
pub struct WireItemRef {
    pub id: i64,
    #[serde(default)]
    pub dream: bool,
}

/// A kicktaker slot preserved verbatim. `index` is the role slot (0..=4 observed);
/// `item` is the referenced FIFA wire item. The role→player meaning and any
/// relationship to the captain are UNKNOWN, so this is stored opaquely and never
/// normalized to the captain or to a Core `owned_card_id`.
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
pub struct KicktakerRef {
    pub index: i64,
    #[serde(flatten)]
    pub item: WireItemRef,
}

/// FIFA 17 Squad Extension, version 1. Serialized to the opaque payload Core stores.
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
pub struct Fifa17SquadExtensionV1 {
    /// Opaque 33-int array as a JSON-encoded string, verbatim. Never decoded.
    #[serde(default)]
    pub custom: Option<String>,
    /// FIFA squad-type wire token (e.g. `"REGULAR_SQUAD"`).
    #[serde(default)]
    pub squad_type: Option<String>,
    /// kit number per player, keyed by Core `owned_card_id`. Keyed by the player
    /// instance — NEVER by slot/index or by card definition — because the wire
    /// proves the kit number follows the player across swaps and formation change.
    #[serde(default)]
    pub kit_numbers: BTreeMap<String, i64>,
    // NOTE: the squad manager is NO LONGER carried here. It is ownership-backed
    // canonical Core state (migration 0023 `squad_managers`), resolved to an
    // `owned_card_id` on the ProposedSquad — never a dangling opaque wire ref.
    /// Kicktaker role refs, opaque (see [`KicktakerRef`]).
    #[serde(default)]
    pub kicktakers: Vec<KicktakerRef>,
    /// Client-reported evaluation (shadow state). NEVER Core's authoritative
    /// evaluation and never reconciled with it.
    #[serde(default)]
    pub client_reported: ClientReportedSquadEval,
}

/// Errors reading a stored extension payload.
#[derive(Debug, PartialEq, Eq)]
pub enum ExtError {
    /// The stored `schema_version` is not one this adapter understands. Never
    /// silently coerced — the caller decides (e.g. treat as unreadable).
    UnsupportedSchemaVersion(i64),
    /// The payload bytes did not deserialize as this schema.
    Parse(String),
}

impl std::fmt::Display for ExtError {
    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
        match self {
            ExtError::UnsupportedSchemaVersion(v) => {
                write!(f, "unsupported fifa17 squad extension schema version {v} (want {EXT_SCHEMA_VERSION})")
            }
            ExtError::Parse(e) => write!(f, "fifa17 squad extension parse error: {e}"),
        }
    }
}
impl std::error::Error for ExtError {}

impl Fifa17SquadExtensionV1 {
    /// Build the extension from the parsed PUT plus the resolved canonical squad.
    /// `custom`/`squad_type`/manager/kicktakers/client eval come straight off the
    /// wire; kit numbers are re-keyed from slot index onto the resolved
    /// `owned_card_id` so they stay bound to the player, not the slot.
    pub fn from_put(put: &Fifa17SquadPut, canonical: &ProposedSquad) -> Self {
        let kit_numbers = canonical
            .slots
            .iter()
            .map(|s| (s.owned_card_id.clone(), s.kit_number))
            .collect();
        Fifa17SquadExtensionV1 {
            custom: put.custom.clone(),
            squad_type: put.squad_type.clone(),
            kit_numbers,
            kicktakers: put
                .kicktakers
                .iter()
                .map(|k| KicktakerRef {
                    index: k.index,
                    item: WireItemRef {
                        id: k.id,
                        dream: k.dream,
                    },
                })
                .collect(),
            client_reported: ClientReportedSquadEval {
                chemistry: put.chemistry,
                rating: put.rating,
                star_rating: put.star_rating,
            },
        }
    }

    /// Serialize to the opaque payload string Core stores.
    pub fn to_payload(&self) -> String {
        // Infallible for this type (no maps with non-string keys, no floats).
        serde_json::to_string(self).expect("fifa17 squad extension serializes")
    }

    /// Parse a stored payload, enforcing the schema version FIRST. A version this
    /// adapter does not understand is rejected — never coerced or ignored.
    pub fn from_payload(schema_version: i64, payload: &str) -> Result<Self, ExtError> {
        if schema_version != EXT_SCHEMA_VERSION {
            return Err(ExtError::UnsupportedSchemaVersion(schema_version));
        }
        serde_json::from_str(payload).map_err(|e| ExtError::Parse(e.to_string()))
    }
}

/// Errors building a squad write from a PUT. A save is refused, never silently
/// degraded, when it cannot be expressed faithfully as a canonical replacement.
#[derive(Debug, PartialEq, Eq)]
pub enum SquadBuildError {
    /// One or more occupied wire ids did not reverse-map to a Core owned item.
    /// Saving would silently drop an owned player — refused.
    UnresolvedWireIds(Vec<i64>),
    /// The same Core owned item appears in two slots. A full replacement cannot
    /// place one instance twice (two *copies* of a definition are distinct owned
    /// items and are fine — this is the same `owned_card_id` twice).
    DuplicateOwnedItem(String),
}

impl std::fmt::Display for SquadBuildError {
    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
        match self {
            SquadBuildError::UnresolvedWireIds(ids) => {
                write!(f, "unresolved FIFA wire item ids (save refused): {ids:?}")
            }
            SquadBuildError::DuplicateOwnedItem(id) => {
                write!(f, "owned item {id} placed in two slots (save refused)")
            }
        }
    }
}
impl std::error::Error for SquadBuildError {}

/// The adapter's PUT-build output: a canonical replacement plus the FIFA-only
/// extension. The host maps `canonical` onto Core's `SquadReplacement` and
/// serializes `extension` into an `OpaqueExtensionWrite`
/// (`namespace = EXT_NAMESPACE`, `schema_version = EXT_SCHEMA_VERSION`,
/// `payload = extension.to_payload()`) so both commit in one Core transaction.
#[derive(Debug, Clone)]
pub struct SquadWriteBuild {
    pub canonical: ProposedSquad,
    pub extension: Fifa17SquadExtensionV1,
}

/// Build a full-replacement squad write from a parsed PUT and a host-supplied
/// wire→owned resolver. Refuses (never degrades) on any unresolved occupied id
/// or a duplicate owned item.
///
/// The resolver only maps identity; it is NOT authorization. The host still
/// verifies every resolved `owned_card_id` belongs to the active FIFA 17
/// profile/club before committing — a resolvable id is not proof of ownership.
pub fn build_squad_write(
    put: &Fifa17SquadPut,
    resolver: &dyn crate::fut::squad::SquadWireResolver,
) -> Result<SquadWriteBuild, SquadBuildError> {
    let canonical = crate::fut::squad::to_proposed(put, resolver);
    if !canonical.unresolved_wire_ids.is_empty() {
        return Err(SquadBuildError::UnresolvedWireIds(
            canonical.unresolved_wire_ids.clone(),
        ));
    }
    let mut seen = std::collections::HashSet::new();
    for slot in &canonical.slots {
        if !seen.insert(slot.owned_card_id.as_str()) {
            return Err(SquadBuildError::DuplicateOwnedItem(
                slot.owned_card_id.clone(),
            ));
        }
    }
    let extension = Fifa17SquadExtensionV1::from_put(put, &canonical);
    Ok(SquadWriteBuild {
        canonical,
        extension,
    })
}

#[cfg(test)]
mod tests {
    use super::*;
    use crate::fut::squad::{parse_squad_put, SquadWireResolver};
    use std::collections::HashMap;

    const PUT_F442: &str = include_str!("../../fixtures/utas/squad_put_f442.json");

    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 {
        let ids = [
            100000003, 100000010, 100000005, 100000008, 100000007, 100000006, 100000004, 100000009,
            100000001, 100000002, 100000025,
            // The f442 fixture's manager ref — the host resolves it like any other
            // owned instance, so the ownership-backed manager assignment is present.
            100000427,
        ];
        MapResolver(ids.iter().map(|&w| (w, format!("oc-{w}"))).collect())
    }

    fn built() -> SquadWriteBuild {
        let put = parse_squad_put(PUT_F442.as_bytes()).unwrap();
        build_squad_write(&put, &full_resolver()).unwrap()
    }

    #[test]
    fn serde_round_trips_the_whole_extension() {
        let ext = built().extension;
        let payload = ext.to_payload();
        let back = Fifa17SquadExtensionV1::from_payload(EXT_SCHEMA_VERSION, &payload).unwrap();
        assert_eq!(ext, back);
    }

    #[test]
    fn custom_is_preserved_byte_for_byte() {
        let put = parse_squad_put(PUT_F442.as_bytes()).unwrap();
        let ext = built().extension;
        assert_eq!(ext.custom, put.custom, "opaque custom carried verbatim");
        // survives a serialize/parse cycle unchanged.
        let back =
            Fifa17SquadExtensionV1::from_payload(EXT_SCHEMA_VERSION, &ext.to_payload()).unwrap();
        assert_eq!(back.custom, put.custom);
    }

    #[test]
    fn kit_number_is_keyed_by_owned_item_not_slot() {
        let ext = built().extension;
        // In the f442 fixture, owned oc-100000001 (captain) wears kit 8 at index 8;
        // oc-100000025 wears kit 11 at index 10. Keyed by owned id, not index.
        assert_eq!(ext.kit_numbers.get("oc-100000001"), Some(&8));
        assert_eq!(ext.kit_numbers.get("oc-100000025"), Some(&11));
        assert_eq!(ext.kit_numbers.len(), 11, "one per occupied slot");
    }

    #[test]
    fn client_reported_eval_is_carried_as_shadow() {
        let ext = built().extension;
        assert_eq!(
            ext.client_reported,
            ClientReportedSquadEval {
                chemistry: Some(52),
                rating: Some(90),
                star_rating: Some(90)
            }
        );
    }

    #[test]
    fn manager_is_canonical_and_kicktakers_stay_opaque() {
        let build = built();
        // Manager is now ownership-backed canonical state: the wire ref resolved
        // to a Core owned_card_id on the ProposedSquad, not an opaque ext blob.
        assert_eq!(
            build.canonical.manager_owned_card_id.as_deref(),
            Some("oc-100000427")
        );
        // Kicktakers remain opaque in the extension.
        let ext = build.extension;
        assert_eq!(ext.kicktakers.len(), 5);
        assert!(ext.kicktakers.iter().all(|k| k.item.id == 100000001));
        assert_eq!(ext.kicktakers[0].index, 0);
    }

    #[test]
    fn build_refuses_an_unowned_manager() {
        // Manager wire id present but NOT resolvable → refused, never assigned a
        // manager the club does not own.
        let put = parse_squad_put(PUT_F442.as_bytes()).unwrap();
        let mut ids = full_resolver().0;
        ids.remove(&100000427);
        let err = build_squad_write(&put, &MapResolver(ids)).unwrap_err();
        assert_eq!(err, SquadBuildError::UnresolvedWireIds(vec![100000427]));
    }

    #[test]
    fn unknown_schema_version_is_rejected_not_coerced() {
        let payload = built().extension.to_payload();
        assert_eq!(
            Fifa17SquadExtensionV1::from_payload(2, &payload),
            Err(ExtError::UnsupportedSchemaVersion(2))
        );
        assert_eq!(
            Fifa17SquadExtensionV1::from_payload(0, &payload),
            Err(ExtError::UnsupportedSchemaVersion(0))
        );
    }

    #[test]
    fn malformed_payload_errors() {
        assert!(matches!(
            Fifa17SquadExtensionV1::from_payload(EXT_SCHEMA_VERSION, "not json"),
            Err(ExtError::Parse(_))
        ));
    }

    #[test]
    fn build_refuses_unresolved_wire_ids() {
        let put = parse_squad_put(PUT_F442.as_bytes()).unwrap();
        let mut ids = full_resolver().0;
        ids.remove(&100000025);
        let err = build_squad_write(&put, &MapResolver(ids)).unwrap_err();
        assert_eq!(err, SquadBuildError::UnresolvedWireIds(vec![100000025]));
    }

    #[test]
    fn build_refuses_duplicate_owned_item() {
        // Two occupied slots resolving to the SAME owned id (one instance twice).
        let body = br#"{"id":0,"formation":"f442","captain":100000003,"players":[
            {"index":0,"itemData":{"id":100000003},"kitNumber":1},
            {"index":1,"itemData":{"id":100000004},"kitNumber":2}]}"#;
        let put = parse_squad_put(body).unwrap();
        let mut m = HashMap::new();
        m.insert(100000003, "oc-dup".to_string());
        m.insert(100000004, "oc-dup".to_string()); // collide onto same owned id
        let err = build_squad_write(&put, &MapResolver(m)).unwrap_err();
        assert_eq!(
            err,
            SquadBuildError::DuplicateOwnedItem("oc-dup".to_string())
        );
    }

    #[test]
    fn full_replacement_carries_every_occupied_slot_no_diff() {
        let build = built();
        assert_eq!(build.canonical.slots.len(), 11, "whole squad, not a diff");
        assert_eq!(build.canonical.formation.as_deref(), Some("f442"));
        // No FIFA wire integer survives into the canonical slots.
        assert!(build
            .canonical
            .slots
            .iter()
            .all(|s| s.owned_card_id.starts_with("oc-")));
    }
}
