//! Adapter-level squad read-after-write / round-trip fidelity, driven entirely by
//! committed sanitized capture evidence.
//!
//! Pipeline exercised end to end, with NO database, socket, or Core:
//!
//! ```text
//!   captured PUT  ─parse─▶ Fifa17SquadPut
//!                 ─build─▶ ProposedSquad (canonical)  +  Fifa17SquadExtensionV1
//!   (simulate committed canonical state: the ProposedSquad IS what Core stored)
//!                 ─project─▶ FIFA 17 squad wire object
//! ```
//!
//! Fidelity is asserted by ownership class:
//!   CANONICAL  player instance per index, formation, captain, bench split, and
//!              the ownership-backed manager assignment (migration 0023)
//!   EXTENSION  custom, kicktakers, kit numbers (by player), squadType
//!   SHADOW     chemistry/rating/starRating (client-reported, round-tripped as-is)
//!   DERIVED    correct FIFA 17 item identity (wire id + resourceId)
//!
//! We assert *semantic wire fidelity*, not byte equality: the read oracle was
//! produced by the pre-migration Python backend, whose display-only shadow fields
//! (`untradeable`, `discardValue`) and server-*recomputed* chemistry are not the
//! adapter's to reproduce.

use std::collections::HashMap;

use openfut_adapter_fifa17::fut::item::{CoreOwnedItem, Fifa17Identity, ItemIdentityResolver};
use openfut_adapter_fifa17::fut::squad::{parse_squad_put, Fifa17SquadPut, SquadWireResolver};
use openfut_adapter_fifa17::fut::squad_ext::{build_squad_write, SquadWriteBuild};
use openfut_adapter_fifa17::fut::squad_projection::{
    project_squad, squad_list, user_mass_info_squad, ProjectionSlot, SquadExtInput,
    SquadProjection, SquadProjectionInput,
};
use serde_json::Value;

const PUT_BASELINE: &str = include_str!("../fixtures/utas/squad_put_f442.json");
const PUT_SWAP: &str = include_str!("../fixtures/utas/squad_put_swap_f442.json");
const PUT_F433: &str = include_str!("../fixtures/utas/squad_put_f433.json");
const READ_ORACLE: &str = include_str!("../fixtures/utas/squad_read_usermassinfo.json");

// ---- host-role stand-ins (identity resolution, entity resolver) -------------

/// Wire→owned reverse map. In production the host builds this from the identity
/// store; here every occupied wire id maps to a stable `oc-<wire>`.
struct OcResolver;
impl SquadWireResolver for OcResolver {
    fn owned_id_for_wire(&self, wire: i64) -> Option<String> {
        Some(format!("oc-{wire}"))
    }
}

/// owned_card_id → FIFA identity, so two copies of one definition stay distinct.
struct TableIdentity(HashMap<String, Fifa17Identity>);
impl ItemIdentityResolver for TableIdentity {
    fn resolve(&self, it: &CoreOwnedItem) -> Option<Fifa17Identity> {
        self.0.get(&it.owned_card_id).copied()
    }
}

/// Neutral entity resolver — badge/flag ids are covered by `fut::item` tests; the
/// projector round-trip asserts item *identity* (wire id + asset), not entity ids.
struct NoEntities;
impl openfut_adapter_fifa17::fut::entities::ReverseEntityResolver for NoEntities {
    fn league_id(&self, _: &str) -> Option<u32> {
        None
    }
    fn team_id(&self, _: &str) -> Option<u32> {
        None
    }
    fn nation_id(&self, _: &str) -> Option<u32> {
        None
    }
}

/// Build the owned-item map + identity table from the persisted read oracle, so
/// every wire id used by the captures resolves to its real FIFA asset id/rating.
/// Keyed by `oc-<wire>` to match `OcResolver`.
fn oracle_tables() -> (HashMap<String, CoreOwnedItem>, TableIdentity) {
    let oracle: Value = serde_json::from_str(READ_ORACLE).unwrap();
    let mut owned = HashMap::new();
    let mut ident = HashMap::new();
    for p in oracle["players"].as_array().unwrap() {
        let it = &p["itemData"];
        let wire = it["id"].as_i64().unwrap();
        if wire == 0 {
            continue; // empty slot
        }
        let oc = format!("oc-{wire}");
        let asset = it["resourceId"].as_u64().unwrap() as u32;
        let attrs: Vec<u8> = it["attributeList"]
            .as_array()
            .unwrap()
            .iter()
            .map(|a| a["value"].as_u64().unwrap() as u8)
            .collect();
        owned.insert(
            oc.clone(),
            CoreOwnedItem {
                owned_card_id: oc.clone(),
                card_id: format!("def-{asset}"),
                rating: it["rating"].as_u64().unwrap() as u8,
                position: it["preferredPosition"].as_str().unwrap().to_string(),
                nation: String::new(),
                league: String::new(),
                club: String::new(),
                attributes: [attrs[0], attrs[1], attrs[2], attrs[3], attrs[4], attrs[5]],
            },
        );
        ident.insert(
            oc,
            Fifa17Identity {
                item_id: wire as u32,
                asset_id: asset,
                resource_id: asset,
                rareflag: 1,
            },
        );
    }
    (owned, TableIdentity(ident))
}

/// The full pipeline: parse a captured PUT, build the canonical + extension, then
/// project — treating the just-built canonical squad as Core's committed state.
fn project_put(
    put: &Fifa17SquadPut,
    owned: &HashMap<String, CoreOwnedItem>,
    ident: &TableIdentity,
) -> Value {
    let SquadWriteBuild {
        canonical,
        extension,
    } = build_squad_write(put, &OcResolver).expect("build must succeed for a full valid squad");
    let slots: Vec<ProjectionSlot> = canonical
        .slots
        .iter()
        .map(|s| ProjectionSlot {
            owned_card_id: s.owned_card_id.clone(),
            index: s.index,
            is_captain: s.is_captain,
            is_on_bench: s.is_on_bench,
        })
        .collect();
    let input = SquadProjectionInput {
        fifa_squad_id: canonical.squad_id,
        name: canonical.name.clone().unwrap_or_default(),
        formation: canonical.formation.clone().unwrap(),
        slots,
        ext: SquadExtInput::Fresh(extension),
        owned,
        // This stand-in supplies no owned manager; the manager is projected from
        // the ownership-backed assignment, exercised in persisted_read below.
        manager: None,
    };
    match project_squad(&input, ident, &NoEntities).unwrap() {
        SquadProjection::Projected(v) => v,
        other => panic!("expected Projected, got {other:?}"),
    }
}

/// Map FIFA-array index → (wire item id, kit number) for the occupied slots of a
/// projected or captured squad object.
fn occupied(v: &Value) -> HashMap<i64, (i64, i64)> {
    v["players"]
        .as_array()
        .unwrap()
        .iter()
        .filter(|p| p["itemData"]["id"].as_i64().unwrap() != 0)
        .map(|p| {
            (
                p["index"].as_i64().unwrap(),
                (
                    p["itemData"]["id"].as_i64().unwrap(),
                    p["kitNumber"].as_i64().unwrap(),
                ),
            )
        })
        .collect()
}

#[test]
fn baseline_projects_the_known_squad_round_trip() {
    let (owned, ident) = oracle_tables();
    let put = parse_squad_put(PUT_BASELINE.as_bytes()).unwrap();
    let projected = project_put(&put, &owned, &ident);

    // Fixed 23-slot array; 11 occupied at 0..=10.
    assert_eq!(projected["players"].as_array().unwrap().len(), 23);
    let put_v: Value = serde_json::from_str(PUT_BASELINE).unwrap();
    assert_eq!(
        occupied(&projected),
        occupied(&put_v),
        "wire id + kit per index round-trip"
    );

    // CANONICAL: formation verbatim, captain follows the semantic player.
    assert_eq!(projected["formation"], "f442");
    assert_eq!(
        projected["captain"], 100000001,
        "captain is the player's WIRE id"
    );
    // EXTENSION: custom byte-identical, squadType preserved. The manager is now
    // an ownership-backed assignment (not projected from the PUT/ext); with none
    // supplied to this stand-in it projects empty.
    assert_eq!(projected["custom"], put_v["custom"]);
    assert!(projected["manager"].as_array().unwrap().is_empty());
    assert_eq!(projected["squadType"], "REGULAR_SQUAD");
    // SHADOW: client-reported values carried as-is (baseline chemistry 52).
    assert_eq!(projected["chemistry"], 52);
    assert_eq!(projected["rating"], 90);
    assert_eq!(projected["starRating"], 90);
}

#[test]
fn swap_moves_two_players_with_their_kits_and_round_trips() {
    // The swap PUT is baseline with two players rotated between slots. Projecting
    // build(swap) must reproduce the swap wire exactly, and the affected players'
    // kit numbers must have travelled with them (kit follows the player).
    let (owned, ident) = oracle_tables();
    let projected = project_put(
        &parse_squad_put(PUT_SWAP.as_bytes()).unwrap(),
        &owned,
        &ident,
    );
    let swap_v: Value = serde_json::from_str(PUT_SWAP).unwrap();
    let base_v: Value = serde_json::from_str(PUT_BASELINE).unwrap();

    // CANONICAL: the projected occupancy per index matches the swap PUT exactly.
    assert_eq!(
        occupied(&projected),
        occupied(&swap_v),
        "player+kit per index round-trip"
    );

    // The swap is real: at least two indices carry a different player than baseline.
    let (proj_occ, base_occ) = (occupied(&projected), occupied(&base_v));
    let moved: Vec<i64> = proj_occ
        .iter()
        .filter(|(idx, pair)| base_occ.get(idx).map(|b| b.0) != Some(pair.0))
        .map(|(idx, _)| *idx)
        .collect();
    assert!(
        moved.len() >= 2,
        "a swap changes at least two slots, got {moved:?}"
    );

    // kit follows the PLAYER: for every player, its kit in baseline == its kit
    // in the swap projection, regardless of which slot it now occupies.
    let kit_by_player = |occ: &HashMap<i64, (i64, i64)>| -> HashMap<i64, i64> {
        occ.values().map(|(id, kit)| (*id, *kit)).collect()
    };
    assert_eq!(
        kit_by_player(&proj_occ),
        kit_by_player(&base_occ),
        "each player kept its kit number through the swap"
    );

    assert_eq!(
        projected["captain"], 100000001,
        "captain follows the semantic player"
    );
    assert_eq!(
        projected["custom"], swap_v["custom"],
        "opaque custom unchanged by the swap"
    );
    // SHADOW: the client-reported chemistry from THIS PUT (58) is round-tripped
    // as-is — never reconciled to a server recompute.
    assert_eq!(projected["chemistry"], 58);
}

#[test]
fn persisted_read_round_trips_via_reconstructed_canonical_and_extension() {
    // Simulate Core's committed state for the persisted (post-relaunch) squad by
    // reconstructing the canonical slots + FIFA extension straight from the read
    // evidence, then project and require the read back — the strongest fidelity
    // check across all four ownership classes.
    use openfut_adapter_fifa17::fut::squad::ClientReportedSquadEval;
    use openfut_adapter_fifa17::fut::squad_ext::{Fifa17SquadExtensionV1, KicktakerRef};
    use std::collections::BTreeMap;

    let oracle: Value = serde_json::from_str(READ_ORACLE).unwrap();
    let (owned, mut ident) = oracle_tables();
    let captain = oracle["captain"].as_i64().unwrap();

    let mut slots = Vec::new();
    let mut kit_numbers = BTreeMap::new();
    for p in oracle["players"].as_array().unwrap() {
        let wire = p["itemData"]["id"].as_i64().unwrap();
        if wire == 0 {
            continue;
        }
        let index = p["index"].as_i64().unwrap();
        let oc = format!("oc-{wire}");
        kit_numbers.insert(oc.clone(), p["kitNumber"].as_i64().unwrap());
        slots.push(ProjectionSlot {
            owned_card_id: oc,
            index,
            is_captain: wire == captain,
            is_on_bench: index >= 11,
        });
    }
    // The manager is now ownership-backed: register its owned instance + identity
    // and pass it as the assignment, not as an opaque extension field.
    let mgr_wire = oracle["manager"][0]["id"].as_i64().unwrap();
    let mgr_oc = format!("oc-{mgr_wire}");
    ident.0.insert(
        mgr_oc.clone(),
        Fifa17Identity {
            item_id: mgr_wire as u32,
            asset_id: 5001,
            resource_id: 5001,
            rareflag: 1,
        },
    );
    let manager_item = CoreOwnedItem {
        owned_card_id: mgr_oc,
        card_id: "def-manager".to_string(),
        rating: 0,
        position: String::new(),
        nation: String::new(),
        league: String::new(),
        club: String::new(),
        attributes: [0; 6],
    };
    let kicktakers: Vec<KicktakerRef> =
        serde_json::from_value(oracle["kicktakers"].clone()).unwrap();
    let ext = Fifa17SquadExtensionV1 {
        custom: oracle["custom"].as_str().map(str::to_string),
        squad_type: oracle["squadType"].as_str().map(str::to_string),
        kit_numbers,
        kicktakers,
        client_reported: ClientReportedSquadEval {
            chemistry: oracle["chemistry"].as_i64(),
            rating: oracle["rating"].as_i64(),
            star_rating: oracle["starRating"].as_i64(),
        },
    };
    let input = SquadProjectionInput {
        fifa_squad_id: oracle["id"].as_i64().unwrap(),
        name: oracle["squadName"].as_str().unwrap().to_string(),
        formation: oracle["formation"].as_str().unwrap().to_string(),
        slots,
        ext: SquadExtInput::Fresh(ext),
        owned: &owned,
        manager: Some(manager_item),
    };
    let SquadProjection::Projected(projected) = project_squad(&input, &ident, &NoEntities).unwrap()
    else {
        panic!("expected Projected");
    };

    // CANONICAL + DERIVED: identity and placement per slot match the read.
    assert_eq!(
        occupied(&projected),
        occupied(&oracle),
        "player+kit per index"
    );
    assert_eq!(projected["captain"], oracle["captain"]);
    assert_eq!(projected["formation"], oracle["formation"]);
    for (pp, op) in projected["players"]
        .as_array()
        .unwrap()
        .iter()
        .zip(oracle["players"].as_array().unwrap())
    {
        assert_eq!(
            pp["itemData"]["id"], op["itemData"]["id"],
            "wire id per slot"
        );
        assert_eq!(
            pp["itemData"]["resourceId"], op["itemData"]["resourceId"],
            "asset id per slot"
        );
        assert_eq!(pp["itemData"]["rating"], op["itemData"]["rating"]);
        assert_eq!(
            pp["itemData"]["preferredPosition"],
            op["itemData"]["preferredPosition"]
        );
    }
    // EXTENSION + SHADOW: sourced from the read, so they round-trip identically.
    assert_eq!(projected["custom"], oracle["custom"]);
    assert_eq!(projected["manager"], oracle["manager"]);
    assert_eq!(projected["kicktakers"], oracle["kicktakers"]);
    assert_eq!(projected["squadType"], oracle["squadType"]);
    assert_eq!(projected["chemistry"], oracle["chemistry"]);
    assert_eq!(projected["rating"], oracle["rating"]);
    assert_eq!(projected["starRating"], oracle["starRating"]);
}

#[test]
fn formation_change_reindexes_without_deriving_slots_and_kit_follows_player() {
    let (owned, ident) = oracle_tables();
    let swap = project_put(
        &parse_squad_put(PUT_SWAP.as_bytes()).unwrap(),
        &owned,
        &ident,
    );
    let f433 = project_put(
        &parse_squad_put(PUT_F433.as_bytes()).unwrap(),
        &owned,
        &ident,
    );

    assert_eq!(f433["formation"], "f433");
    assert_eq!(swap["formation"], "f442");

    // Same 11 starters (same set of wire ids), reindexed.
    let set = |v: &Value| {
        let mut ids: Vec<i64> = occupied(v).values().map(|(id, _)| *id).collect();
        ids.sort();
        ids
    };
    assert_eq!(
        set(&swap),
        set(&f433),
        "same 11 players survive the formation change"
    );

    // The captain (wire 100000001) moved from index 8 (f442) to index 10 (f433) —
    // proof indices are round-tripped, not derived from the formation.
    let idx_of = |v: &Value, wire: i64| -> i64 {
        occupied(v)
            .into_iter()
            .find(|(_, (id, _))| *id == wire)
            .unwrap()
            .0
    };
    assert_eq!(idx_of(&swap, 100000001), 8);
    assert_eq!(idx_of(&f433, 100000001), 10);

    // kit follows the PLAYER, not the slot: captain keeps kit 8 across the reindex.
    let kit_of = |v: &Value, wire: i64| -> i64 {
        occupied(v)
            .into_iter()
            .find(|(_, (id, _))| *id == wire)
            .unwrap()
            .1
             .1
    };
    assert_eq!(kit_of(&swap, 100000001), 8);
    assert_eq!(
        kit_of(&f433, 100000001),
        8,
        "kit stayed with the player despite the reindex"
    );
    assert_eq!(f433["captain"], 100000001, "captain still the same player");
}

#[test]
fn one_projector_serves_every_endpoint_no_divergence() {
    let (owned, ident) = oracle_tables();
    let projected = project_put(
        &parse_squad_put(PUT_SWAP.as_bytes()).unwrap(),
        &owned,
        &ident,
    );

    // userMassInfo.squad = the projected object + session envelope.
    let ummi = user_mass_info_squad(projected.clone(), 33068179);
    assert_eq!(ummi["personaId"], 33068179);
    assert_eq!(ummi["changed"], 0);
    assert!(ummi["actives"].is_array());
    assert_eq!(ummi["players"], projected["players"], "same projected body");
    assert_eq!(ummi["formation"], projected["formation"]);

    // squad/list = a summary SUBSET of the SAME object, not a second projection.
    let list = squad_list(&projected);
    let entry = &list["squad"][0];
    for k in [
        "id",
        "squadName",
        "formation",
        "squadType",
        "rating",
        "chemistry",
    ] {
        assert_eq!(
            entry[k], projected[k],
            "summary field {k} derived from the one projection"
        );
    }
    // The summary carries only those six keys — no divergent squad shape.
    assert_eq!(entry.as_object().unwrap().len(), 6);
}
