//! FIFA 17 Squad Building Challenge wire shapes.
//!
//! Numeric category/set/challenge ids and container types mirror the reversed FIFA 17
//! `/sbs/*` family. Core ids remain opaque strings and are mapped here, never in Core.

use serde_json::{json, Value};

pub const CATEGORY_ID: i64 = 1;
pub const CATEGORY_NAME: &str = "Foundations";

#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub struct ChallengeIdentity {
    pub core_id: &'static str,
    pub set_id: i64,
    pub challenge_id: i64,
    pub priority: i64,
}

pub const CHALLENGES: [ChallengeIdentity; 2] = [
    ChallengeIdentity {
        core_id: "sbc_bronze_upgrade",
        set_id: 1,
        challenge_id: 101,
        priority: 1,
    },
    ChallengeIdentity {
        core_id: "sbc_hybrid_nations",
        set_id: 2,
        challenge_id: 201,
        priority: 2,
    },
];

#[derive(Debug, Clone, PartialEq, Eq)]
pub struct ChallengeView {
    pub identity: ChallengeIdentity,
    pub name: String,
    pub description: String,
    pub repeatable: bool,
    pub times_completed: i64,
}

pub fn identity_for_core(core_id: &str) -> Option<ChallengeIdentity> {
    CHALLENGES
        .iter()
        .copied()
        .find(|entry| entry.core_id == core_id)
}

pub fn identity_for_challenge(challenge_id: i64) -> Option<ChallengeIdentity> {
    CHALLENGES
        .iter()
        .copied()
        .find(|entry| entry.challenge_id == challenge_id)
}

/// `GET sbs/sets`: object root; categories, sets and awards are always arrays.
pub fn sets_body(challenges: &[ChallengeView]) -> Value {
    let sets: Vec<Value> = challenges
        .iter()
        .map(|challenge| {
            json!({
                "setId": challenge.identity.set_id,
                "categoryId": CATEGORY_ID,
                "name": challenge.name,
                "description": challenge.description,
                "priority": challenge.identity.priority,
                "challengesCount": 1,
                "challengesCompletedCount": i64::from(!challenge.repeatable && challenge.times_completed > 0),
                "awards": [],
                "hidden": false,
                "endTime": 4_102_444_800_i64
            })
        })
        .collect();
    json!({
        "categories": [{
            "categoryId": CATEGORY_ID,
            "name": CATEGORY_NAME,
            "priority": 1,
            "sets": sets
        }]
    })
}

/// `GET sbs/setId/{id}/challenges`: object root with a challenge array.
pub fn challenges_body(set_id: i64, challenges: &[ChallengeView]) -> Value {
    let records: Vec<Value> = challenges
        .iter()
        .filter(|challenge| challenge.identity.set_id == set_id)
        .map(|challenge| {
            // A repeatable challenge is always available to enter again. The FIFA 17
            // client gates challenge re-entry on `timesCompleted` (not on `repeatable`):
            // a nonzero count renders the tile COMPLETED and refuses re-entry. So a
            // repeatable challenge never reports itself as terminally completed here.
            // Core keeps the true completion record (economy authority); this is
            // presentation only. Live-proven on the retail client 2026-08-18.
            let times_completed = if challenge.repeatable {
                0
            } else {
                challenge.times_completed
            };
            json!({
                "challengeId": challenge.identity.challenge_id,
                "setId": challenge.identity.set_id,
                "categoryId": CATEGORY_ID,
                "index": 0,
                "type": "OPEN_CHALLENGE",
                "name": challenge.name,
                "description": challenge.description,
                "challengeImageId": "",
                "formation": "f442",
                "endTime": 0,
                "repeatable": challenge.repeatable,
                "trophyId": 0,
                "status": "OPEN",
                "timesCompleted": times_completed,
                "awards": [],
                // `elgReq` stays empty deliberately. Reversed from the pinned CardsDLL
                // (2026-08-19, see ENDPOINT_MAP.md "Shared record shapes"): the client
                // consumes `eligibilityKey`/`eligibilityOperation` only as ordinals that
                // index the packed locale (`LOC_SBC_ELG_KEY_%d`) to render requirement
                // text — it does NOT validate on them. The ordinal->string map is not
                // recoverable from any asset we have, so any value we emit would show the
                // WRONG requirement to the player. Submission is validated server-side by
                // Core regardless; leaving this empty is display-only, never a correctness
                // gap. Populate ONLY once the locale ordinal map is captured.
                "elgReq": []
            })
        })
        .collect();
    json!({ "challenges": records })
}

/// Empty-body POST starts a challenge. `squad` is object-root on this response class.
pub fn start_body(challenge_id: i64) -> Value {
    json!({
        "challengeId": challenge_id,
        "squad": {},
        "playerRequirements": []
    })
}

/// GET challenge squad. The reversed response requires array containers.
pub fn squad_body(challenge_id: i64, wire_item_ids: &[i64]) -> Value {
    let squad: Vec<Value> = wire_item_ids
        .iter()
        .enumerate()
        .map(|(index, id)| {
            json!({
                "index": index,
                "itemData": { "id": id },
                "kitNumber": 0
            })
        })
        .collect();
    json!({
        "id": challenge_id,
        "squad": squad,
        "playerRequirements": []
    })
}

pub fn save_body(challenge_id: i64) -> Value {
    json!({ "id": challenge_id })
}

pub fn submit_body(challenge_id: i64, set_id: i64, credits: i64, unopened_packs: i64) -> Value {
    json!({
        "challengeId": challenge_id,
        "setId": set_id,
        "credits": credits,
        "preOrderPacks": 0,
        "recoveredPacks": unopened_packs,
        "grantedChallengeAwards": [],
        "grantedSetAwards": []
    })
}

#[derive(Debug, PartialEq, Eq)]
pub enum SbcWireError {
    Json(String),
    MissingSquad,
}

impl std::fmt::Display for SbcWireError {
    fn fmt(&self, formatter: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
        match self {
            Self::Json(error) => write!(formatter, "invalid SBC squad JSON: {error}"),
            Self::MissingSquad => {
                formatter.write_str("SBC squad body contains no supported squad container")
            }
        }
    }
}

impl std::error::Error for SbcWireError {}

/// Extract FIFA wire item ids from a saved/submitted challenge squad.
///
/// The exact retail challenge-squad body is now captured (2026-08-18 Gate C,
/// `PUT /ut/game/fifa17/sbs/challenge/101/squad`): a fixed 23-entry `players`
/// array of `{index, itemData:{id, dream}}` (empty slots carry `id == 0`),
/// alongside sibling `chemistry`, `rating`, `formation`, and a `manager` array
/// of `{id, dream}`. Only `players[].itemData.id` selects the consumed cards;
/// the manager, chemistry, rating, formation, dream, and index fields are
/// presentation/validation hints and are never consumed. The `squad` array
/// container remains accepted for the alternate proven shape. Within either,
/// only non-zero `itemData.id` values are interpreted.
pub fn parse_wire_item_ids(body: &[u8]) -> Result<Vec<i64>, SbcWireError> {
    let root: Value =
        serde_json::from_slice(body).map_err(|error| SbcWireError::Json(error.to_string()))?;
    let entries = root
        .get("players")
        .and_then(Value::as_array)
        .or_else(|| root.get("squad").and_then(Value::as_array))
        .ok_or(SbcWireError::MissingSquad)?;

    let mut ids = Vec::new();
    for entry in entries {
        if let Some(id) = entry
            .get("itemData")
            .and_then(|item| item.get("id"))
            .and_then(Value::as_i64)
            .filter(|id| *id != 0)
        {
            ids.push(id);
        }
    }
    Ok(ids)
}

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

    fn challenge() -> ChallengeView {
        ChallengeView {
            identity: CHALLENGES[0],
            name: "Bronze Upgrade".into(),
            description: "Submit players".into(),
            repeatable: true,
            times_completed: 2,
        }
    }

    #[test]
    fn response_containers_match_reversed_fifa17_shapes() {
        let sets = sets_body(&[challenge()]);
        assert!(sets.is_object());
        assert!(sets["categories"].is_array());
        assert!(sets["categories"][0]["sets"].is_array());
        assert!(sets["categories"][0]["sets"][0]["awards"].is_array());

        let challenges = challenges_body(1, &[challenge()]);
        assert!(challenges.is_object());
        assert!(challenges["challenges"].is_array());
        assert!(challenges["challenges"][0]["awards"].is_array());
        assert!(challenges["challenges"][0]["elgReq"].is_array());

        assert!(start_body(101)["squad"].is_object());
        assert!(start_body(101)["playerRequirements"].is_array());
        assert!(squad_body(101, &[100_000_001])["squad"].is_array());
        let submit = submit_body(101, 1, 1234, 1);
        assert!(submit["grantedChallengeAwards"].is_array());
        assert!(submit["grantedSetAwards"].is_array());
    }

    #[test]
    fn repeatable_completed_challenge_stays_enterable() {
        // The FIFA 17 client refuses challenge re-entry when timesCompleted > 0, so
        // a repeatable challenge must always project as not-yet-completed while a
        // non-repeatable one keeps its true count. Core holds the real record.
        let repeatable = ChallengeView {
            identity: CHALLENGES[0],
            name: "Bronze Upgrade".into(),
            description: "Submit players".into(),
            repeatable: true,
            times_completed: 3,
        };
        let once = ChallengeView {
            identity: CHALLENGES[1],
            name: "Hybrid Nations".into(),
            description: "Submit a hybrid squad".into(),
            repeatable: false,
            times_completed: 1,
        };

        let repeatable_view =
            challenges_body(CHALLENGES[0].set_id, std::slice::from_ref(&repeatable));
        assert_eq!(repeatable_view["challenges"][0]["timesCompleted"], 0);
        assert_eq!(repeatable_view["challenges"][0]["status"], "OPEN");
        let once_view = challenges_body(CHALLENGES[1].set_id, std::slice::from_ref(&once));
        assert_eq!(once_view["challenges"][0]["timesCompleted"], 1);

        let sets = sets_body(&[repeatable, once]);
        let sets_arr = sets["categories"][0]["sets"].as_array().unwrap();
        let repeatable_set = sets_arr
            .iter()
            .find(|set| set["setId"] == CHALLENGES[0].set_id)
            .unwrap();
        let once_set = sets_arr
            .iter()
            .find(|set| set["setId"] == CHALLENGES[1].set_id)
            .unwrap();
        assert_eq!(
            repeatable_set["challengesCompletedCount"], 0,
            "a repeatable set never reports itself terminally completed"
        );
        assert_eq!(
            once_set["challengesCompletedCount"], 1,
            "a one-shot set counts its single completion"
        );
    }

    #[test]
    fn parser_accepts_only_known_squad_containers_and_item_ids() {
        let normal = br#"{"players":[{"index":0,"itemData":{"id":100000001}},{"index":1,"itemData":{"id":0}}]}"#;
        assert_eq!(parse_wire_item_ids(normal).unwrap(), [100_000_001]);
        let sbc = br#"{"squad":[{"itemData":{"id":100000002}}]}"#;
        assert_eq!(parse_wire_item_ids(sbc).unwrap(), [100_000_002]);
        assert_eq!(
            parse_wire_item_ids(br#"{"challengeId":101}"#),
            Err(SbcWireError::MissingSquad)
        );
        assert!(matches!(
            parse_wire_item_ids(br#"{"squad":"not-an-array"}"#),
            Err(SbcWireError::MissingSquad)
        ));
        assert!(matches!(
            parse_wire_item_ids(br#"{"squad":["#),
            Err(SbcWireError::Json(_))
        ));
    }

    #[test]
    fn parser_matches_captured_retail_challenge_squad_body() {
        // Verbatim shape from the 2026-08-18 Gate C retail capture of
        // PUT /ut/game/fifa17/sbs/challenge/101/squad (11 filled + 12 empty
        // slots, plus manager/chemistry/rating/formation siblings). Only the 11
        // non-zero player itemData.id values are consumed, in wire order; the
        // manager and all presentation fields are ignored.
        let retail = br#"{"chemistry":21,"rating":86,"formation":"f433",
            "manager":[{"id":100000427,"dream":false}],
            "players":[
              {"index":0,"itemData":{"id":100004227,"dream":false}},
              {"index":1,"itemData":{"id":100004233,"dream":false}},
              {"index":2,"itemData":{"id":100001317,"dream":false}},
              {"index":3,"itemData":{"id":100001531,"dream":false}},
              {"index":4,"itemData":{"id":100000966,"dream":false}},
              {"index":5,"itemData":{"id":100001947,"dream":false}},
              {"index":6,"itemData":{"id":100000169,"dream":false}},
              {"index":7,"itemData":{"id":100002017,"dream":false}},
              {"index":8,"itemData":{"id":100002765,"dream":false}},
              {"index":9,"itemData":{"id":100000147,"dream":false}},
              {"index":10,"itemData":{"id":100000311,"dream":false}},
              {"index":11,"itemData":{"id":0,"dream":false}},
              {"index":12,"itemData":{"id":0,"dream":false}},
              {"index":13,"itemData":{"id":0,"dream":false}},
              {"index":14,"itemData":{"id":0,"dream":false}},
              {"index":15,"itemData":{"id":0,"dream":false}},
              {"index":16,"itemData":{"id":0,"dream":false}},
              {"index":17,"itemData":{"id":0,"dream":false}},
              {"index":18,"itemData":{"id":0,"dream":false}},
              {"index":19,"itemData":{"id":0,"dream":false}},
              {"index":20,"itemData":{"id":0,"dream":false}},
              {"index":21,"itemData":{"id":0,"dream":false}},
              {"index":22,"itemData":{"id":0,"dream":false}}
            ]}"#;
        assert_eq!(
            parse_wire_item_ids(retail).unwrap(),
            [
                100_004_227,
                100_004_233,
                100_001_317,
                100_001_531,
                100_000_966,
                100_001_947,
                100_000_169,
                100_002_017,
                100_002_765,
                100_000_147,
                100_000_311
            ],
            "exactly the 11 non-zero players in wire order; manager and empty slots ignored"
        );
    }
}
