//! FIFA 17 Blaze response bodies.
//!
//! Every builder here mirrors a `Blaze::*` TDF class reversed from FIFA17.exe's
//! own reflection metadata. Member counts and tags are not guesses, and the
//! comments carry the class addresses so a future reader can re-derive them.
//!
//! Two recurring rules, both learned the hard way:
//!
//! * **An absent member is safe; a wrongly-typed one is fatal.** A member the
//!   client does not receive keeps its client-side default. A member encoded
//!   with the wrong wire type desynchronises the whole TDF parse. That is why
//!   `CGID`, `ADDR`, `CVAR` and `ULST` are omitted rather than guessed — their
//!   union/objid encodings are UNVERIFIED.
//! * **Identity must be byte-identical across responses.** `MAIL`/`UID`/`ASRC`
//!   in `AccountInfo` must match `LoginResponse.SESS` and `PreAuthResponse.NASP`,
//!   and the session key must be the same string in three places.
//!
//! Member order in the source below is the oracle's for readability; the
//! encoder sorts by packed tag, so source order never reaches the wire.

use openfut_protocol_blaze::heat2::{Struct, TypeId, Value};

use super::client_config;
use super::config::AdapterConfig;
use super::ids::ADVERTISED_COMPONENT_IDS;
use super::session::Session;

fn s(v: impl Into<String>) -> Value {
    Value::String(v.into())
}

fn i(v: i64) -> Value {
    Value::Int(v)
}

/// `Blaze::Util::FetchConfigResponse` @0x1448752e0 — a single `CONF`
/// map<string,string>.
///
/// NOT double-nested. The extra nesting exists only inside `PreAuthResponse`,
/// where `CONF` is itself a `FetchConfigResponse` whose own single member is
/// also called `CONF`. Easy to get wrong.
pub fn fetch_config_response(cfid: &str, cfg: &AdapterConfig) -> Struct {
    let entries = client_config::rows_for(cfid, cfg)
        .into_iter()
        .map(|(k, v)| (Value::String(k), Value::String(v)))
        .collect();
    Struct::new().with(
        "CONF",
        Value::Map {
            key: TypeId::String,
            val: TypeId::String,
            entries,
        },
    )
}

/// `Blaze::QosConfigInfo` — 4 members.
///
/// FIFA 17's descriptor has no `SVID`, unlike Mirror's Edge Catalyst; do not
/// add one back from another title's emulator.
pub fn qos_config(cfg: &AdapterConfig) -> Struct {
    Struct::new()
        .with(
            "BWPS",
            Value::Struct(
                Struct::new()
                    .with("PSA", s(&cfg.endpoints.advertise))
                    .with("PSP", i(cfg.endpoints.qos_port)),
            ),
        )
        .with("LNP", i(10))
        .with(
            "LTPS",
            Value::Map {
                key: TypeId::String,
                val: TypeId::Struct,
                entries: vec![],
            },
        )
        .with("TIME", i(5_000_000))
}

/// `Blaze::Util::PreAuthResponse`.
pub fn preauth_response(service_name: &str, cfg: &AdapterConfig) -> Struct {
    let id = &cfg.identity;
    Struct::new()
        .with("ASRC", s(&id.title_id))
        .with(
            "CIDS",
            Value::List {
                elem: TypeId::Int,
                items: ADVERTISED_COMPONENT_IDS.iter().copied().map(i).collect(),
            },
        )
        .with("CLID", s(&id.client_id))
        .with(
            "CONF",
            Value::Struct(fetch_config_response("BlazeSDK", cfg)),
        )
        .with("ESRC", s(&id.title_id))
        .with("INST", s(service_name)) // echo of CDAT.SVCN
        .with("MAID", i(0))
        .with("MINR", i(0))
        .with("NASP", s(&id.namespace))
        .with("PILD", s(""))
        .with("PLAT", s(&id.platform))
        .with("QOSS", Value::Struct(qos_config(cfg)))
        .with("RSRC", s(&id.title_id))
        .with("SVER", s(&cfg.server_version))
}

/// `Blaze::Util::PingResponse` @0x144875560 — exactly one member.
///
/// v2 also sent `TIME`; that is MEC's field, not FIFA 17's.
pub fn ping_response(now: i64) -> Struct {
    Struct::new().with("STIM", i(now))
}

/// `Blaze::CensusData::SubscribeToCensusDataUpdatesResponse` — 3 TimeValues,
/// encoded as INT microseconds.
///
/// These must be non-zero. The client computes `delay_ms = (CNP + NTMT) / 1000`
/// and re-arms a resend timer; an empty reply gives delay 0, which lands the job
/// on the scheduler's ready list and produces a ~30/s re-subscribe storm that
/// hangs the FUT loading screen.
pub fn census_subscribe_response() -> Struct {
    Struct::new()
        .with("CNP", i(30 * 1_000_000))
        .with("NTMT", i(90 * 1_000_000))
        .with("RTMT", i(300 * 1_000_000))
}

/// `Blaze::Authentication::PersonaDetails` @0x14487cab0 — 6 members.
pub fn persona_details(now: i64, cfg: &AdapterConfig) -> Struct {
    let id = &cfg.identity;
    Struct::new()
        .with("DSNM", s(&id.persona_name))
        .with("LAST", i(now))
        .with("PID", i(id.persona_id))
        .with("PLAT", i(id.client_platform))
        .with("STAS", i(id.persona_status))
        .with("XREF", i(id.ext_id))
}

/// `Blaze::Authentication::UserLoginInfo` @0x14487cb00 — 8 members.
///
/// `'1CON'` packs to 0x11, which sorts below `'A'` = 0x21, so it leads.
pub fn user_login_info(sess: &Session, now: i64, cfg: &AdapterConfig) -> Struct {
    let id = &cfg.identity;
    Struct::new()
        .with("1CON", i(0))
        .with("BUID", i(id.user_id)) // must be non-zero
        .with("FRST", i(0))
        .with("KEY", s(&sess.session_key)) // must be non-empty
        .with("LLOG", i(now))
        .with("MAIL", s(&id.email))
        .with("PDTL", Value::Struct(persona_details(now, cfg)))
        .with("UID", i(id.user_id)) // must be non-zero
}

/// `Blaze::Authentication::LoginResponse` @0x14487d170 — exactly 5 members.
///
/// Diverges from both public MEC emulators, which emit `CNTX`, `ERRC` and a
/// top-level `SKEY`. FIFA 17 has none of those: `CNTX`/`ERRC` are the Blaze
/// error-metadata block, and the session key lives at `SESS.KEY`.
pub fn login_response(sess: &Session, now: i64, cfg: &AdapterConfig) -> Struct {
    Struct::new()
        .with("ANON", i(0))
        .with("NTOS", i(0)) // 1 would divert to the legal-doc flow
        .with("SESS", Value::Struct(user_login_info(sess, now, cfg)))
        .with("SPAM", i(1))
        .with("UNDR", i(0))
}

/// ISO-8601 UTC, matching the oracle's `%Y-%m-%dT%H:%M:%SZ`.
///
/// Hand-rolled from a Unix timestamp to keep this crate free of a date
/// dependency for one format string. Proleptic Gregorian, no leap seconds —
/// the same calendar `time.gmtime` uses.
fn iso8601_utc(unix: i64) -> String {
    let days = unix.div_euclid(86_400);
    let secs = unix.rem_euclid(86_400);
    let (h, mi, sec) = (secs / 3600, (secs % 3600) / 60, secs % 60);

    // Civil-from-days (Howard Hinnant's algorithm), shifted to a March-based year.
    let z = days + 719_468;
    let era = z.div_euclid(146_097);
    let doe = z.rem_euclid(146_097);
    let yoe = (doe - doe / 1460 + doe / 36_524 - doe / 146_096) / 365;
    let y = yoe + era * 400;
    let doy = doe - (365 * yoe + yoe / 4 - yoe / 100);
    let mp = (5 * doy + 2) / 153;
    let d = doy - (153 * mp + 2) / 5 + 1;
    let m = if mp < 10 { mp + 3 } else { mp - 9 };
    let y = if m <= 2 { y + 1 } else { y };

    format!("{y:04}-{m:02}-{d:02}T{h:02}:{mi:02}:{sec:02}Z")
}

/// `Blaze::Authentication::AccountInfo` @0x14487c810 — exactly 16 members.
///
/// The RPC behind the "Unable to retrieve account information" popup: before it
/// was implemented, the empty-reply fallback produced an AccountInfo with
/// `UID=0`/`CO=""` and the popup appeared one layer later.
///
/// Member tags come from the reflection tag table @0x1448775a0; wire types from
/// each member's subtype descriptor (string subtype 0x144867628 covers ASRC CO
/// DOB DTCR LATH LN MAIL PML; the other eight are int/enum). Enum values:
/// `STAS` = AccountStatus ACTIVE = 1, `STAT` = EmailStatus VERIFIED = 2,
/// `RC` = StatusReason none = 0.
pub fn account_info(now: i64, cfg: &AdapterConfig) -> Struct {
    let id = &cfg.identity;
    Struct::new()
        .with("AMU", i(0))
        .with("ASRC", s(&id.namespace)) // == PreAuthResponse.NASP
        .with("CO", s("US"))
        .with("DOB", s("1990-01-01T00:00:00Z"))
        .with("DTCR", s("2016-09-01T00:00:00Z"))
        .with("GOPT", i(0))
        .with("LATH", s(iso8601_utc(now)))
        .with("LN", s(&id.locale))
        .with("MAIL", s(&id.email)) // == LoginResponse.SESS.MAIL
        .with("PML", s(""))
        .with("RC", i(0))
        .with("STAS", i(1))
        .with("STAT", i(2))
        .with("TPOT", i(0))
        .with("UDU", i(0))
        .with("UID", i(id.user_id)) // == LoginResponse.SESS.UID
}

/// `Blaze::Authentication::PersonaInfo` @0x14487c7c0 — 7 members.
///
/// `STAS` here is PersonaStatus ACTIVE = 2 (table 0x14487ad20) — a different
/// enum from AccountInfo's `STAS`, which is AccountStatus ACTIVE = 1. `LADT`'s
/// wire type is a best guess (INT timestamp); it is only reachable via
/// getPersona/listPersonas, off the critical getAccount path.
pub fn persona_info(now: i64, cfg: &AdapterConfig) -> Struct {
    let id = &cfg.identity;
    Struct::new()
        .with("DSNM", s(&id.persona_name))
        .with("DTCR", s("2016-09-01T00:00:00Z"))
        .with("LADT", i(now))
        .with("NSNM", s(&id.namespace))
        .with("PID", i(id.persona_id))
        .with("STAS", i(2))
        .with("STRC", i(0))
}

/// `Blaze::Authentication::GetPersonaResponse` @0x14487d1c0 — PINF + UID.
pub fn get_persona_response(now: i64, cfg: &AdapterConfig) -> Struct {
    Struct::new()
        .with("PINF", Value::Struct(persona_info(now, cfg)))
        .with("UID", i(cfg.identity.user_id))
}

/// `Blaze::Authentication::ListPersonasResponse` @0x14487d210 — one member.
pub fn list_personas_response(now: i64, cfg: &AdapterConfig) -> Struct {
    Struct::new().with(
        "PINF",
        Value::List {
            elem: TypeId::Struct,
            items: vec![Value::Struct(persona_info(now, cfg))],
        },
    )
}

/// `Blaze::UserSessionLoginInfo` @0x14486f920 — 16 members.
///
/// A superset of `UserLoginInfo` with the persona fields flattened in rather
/// than nested. `KEY` must be byte-identical to `LoginResponse.SESS.KEY`.
///
/// `CGID` (a connectionGroup ObjectId) is omitted: the OBJID encoding is
/// UNVERIFIED and a wrong one desynchronises the parse, while an absent member
/// simply keeps its default.
pub fn user_session_login_info(sess: &Session, now: i64, cfg: &AdapterConfig) -> Struct {
    let id = &cfg.identity;
    Struct::new()
        .with("1CON", i(0))
        .with("ALOC", i(sess.account_locale)) // echo the client's own locale
        .with("BUID", i(id.user_id))
        .with("DSNM", s(&id.persona_name))
        .with("FRST", i(0))
        .with("KEY", s(&sess.session_key)) // same string as LoginResponse
        .with("LAST", i(now))
        .with("LLOG", i(now))
        .with("MAIL", s(&id.email))
        .with("NASP", s(&id.namespace))
        .with("PID", i(id.persona_id))
        .with("PLAT", i(id.client_platform))
        .with("UID", i(id.user_id))
        .with("USTP", i(id.user_session_type))
        .with("XREF", i(id.ext_id))
}

/// `Blaze::Util::NetworkQosData` @0x14486e680 — 5 members. `NATT` 0 = OPEN.
pub fn network_qos_data() -> Struct {
    Struct::new()
        .with("BWHR", i(0))
        .with("DBPS", i(100_000))
        .with("NAHR", i(0))
        .with("NATT", i(0))
        .with("UBPS", i(100_000))
}

/// `Blaze::UserSessionExtendedData` @0x144870390.
///
/// Two FIFA-17-specific deltas from the MEC emulators: FIFA HAS `PSLM`
/// (latencyList), which they lack, and FIFA carries `BPS` as a top-level string
/// whereas they bury it inside the `ADDR` union. Follow FIFA's layout.
///
/// `ADDR`, `CVAR` and `ULST` are omitted — unverified union/objid encodings.
pub fn user_session_extended_data() -> Struct {
    Struct::new()
        .with("BPS", s("openfut"))
        .with("CTY", s("US"))
        .with(
            "DMAP",
            Value::Map {
                key: TypeId::Int,
                val: TypeId::Int,
                entries: vec![],
            },
        )
        .with("HWFG", i(0))
        .with("ISP", s("OpenFUT"))
        .with(
            "PSLM",
            Value::List {
                elem: TypeId::Int,
                items: vec![i(0)],
            },
        )
        .with("QDAT", Value::Struct(network_qos_data()))
        .with("TZ", s(""))
        .with("UATT", i(0))
}

/// `Blaze::UserSessionExtendedDataUpdate` @0x1448703e0 — 3 members.
pub fn user_session_extended_data_update(cfg: &AdapterConfig) -> Struct {
    Struct::new()
        .with("DATA", Value::Struct(user_session_extended_data()))
        .with("SUBS", i(1))
        .with("USID", i(cfg.identity.user_id))
}

/// `Blaze::UserIdentification` @0x14486ebc0 — 9 members.
pub fn user_identification(sess: &Session, cfg: &AdapterConfig) -> Struct {
    let id = &cfg.identity;
    Struct::new()
        .with("AID", i(id.user_id))
        .with("ALOC", i(sess.account_locale))
        .with("EXBB", Value::Blob(vec![]))
        .with("EXID", i(id.ext_id))
        .with("ID", i(id.user_id))
        .with("NAME", s(&id.persona_name))
        .with("NASP", s(&id.namespace))
        .with("ORIG", i(id.persona_id))
        .with("PIDI", i(id.persona_id))
}

/// `Blaze::UserData` @0x1448706b0 — payload of the `UserAdded` push.
/// `FLGS` is a UserDataFlags bitfield; bit 0 = online/authenticated.
pub fn user_data(sess: &Session, cfg: &AdapterConfig) -> Struct {
    Struct::new()
        .with("EDAT", Value::Struct(user_session_extended_data()))
        .with("FLGS", i(3))
        .with("USER", Value::Struct(user_identification(sess, cfg)))
}

/// `Blaze::Util::PostAuthResponse` @0x144875810 — TELE, TICK, UROP.
///
/// Telemetry and ticker point at dead local ports on purpose: the client gets a
/// well-formed config and then fails to connect quietly, rather than resolving
/// a real EA hostname.
pub fn post_auth_response(sess: &Session, cfg: &AdapterConfig) -> Struct {
    let tele = Struct::new()
        .with("ADRS", s(&cfg.endpoints.advertise))
        .with("ANON", i(0))
        .with("DISA", s(""))
        .with("EDCT", i(0))
        .with("FILT", s(""))
        .with("LOC", i(sess.account_locale))
        .with("MINR", i(0))
        .with("NOOK", s(""))
        .with("PORT", i(cfg.endpoints.telemetry_port))
        .with("SDLY", i(15_000))
        .with("SESS", s(&sess.session_key)) // same key as login
        .with("SKEY", s(""))
        .with("SPCT", i(75))
        .with("STIM", s(""))
        .with("SVNM", s("telemetry-openfut"));

    let tick = Struct::new()
        .with("ADRS", s(&cfg.endpoints.advertise))
        .with("PORT", i(cfg.endpoints.ticker_port))
        .with("SKEY", s(""));

    let urop = Struct::new()
        .with("TMOP", i(0))
        .with("UID", i(cfg.identity.user_id));

    Struct::new()
        .with("TELE", Value::Struct(tele))
        .with("TICK", Value::Struct(tick))
        .with("UROP", Value::Struct(urop))
}

/// `Blaze::Authentication::Entitlement` @0x14487d490 — 16 members.
///
/// FUT's client-side filter (`onListEntitlements` @0x146f27440) keeps a record
/// only if `GNAM` contains `"FIFA17PCBoxContent"` or `"FIFA16PC"`, `TAG` is
/// non-empty, and `STAT == 1`. A plain `"FIFA17PC"` group matched neither
/// needle and produced an empty store.
///
/// `PRID`/`GNAM`/`TAG` must contain no `'|'` and no `'/'`: the client
/// re-serialises them as `PRID|GNAM|TAG|UCNT/`.
pub fn entitlement(group: &str, tag: &str, eid: i64, cfg: &AdapterConfig) -> Struct {
    let id = &cfg.identity;
    Struct::new()
        .with("DEVI", s(""))
        .with("GDAY", s("2016-09-01T00:00:00Z"))
        .with("GNAM", s(group))
        .with("ID", i(eid))
        .with("ISCO", i(0))
        .with("PID", i(id.persona_id))
        .with("PJID", s(&id.content_id))
        .with("PRCA", i(2))
        .with("PRID", s(&id.content_id))
        .with("STAT", i(1)) // must be 1 or FUT drops it
        .with("STRC", i(0))
        .with("TAG", s(tag)) // must be non-empty
        .with("TDAY", s(""))
        .with("TYPE", i(1))
        .with("UCNT", i(0))
        .with("VER", i(1))
}

/// `Blaze::Authentication::Entitlements` @0x14487d4e0 — single member `NLST`.
///
/// Emits BOTH accepted groups so the entitlement manager's "loaded" flag
/// (`byte[entMgr+0x88]`) flips however the client asks.
pub fn entitlements_response(cfg: &AdapterConfig) -> Struct {
    let tag = &cfg.identity.entitlement_tag;
    Struct::new().with(
        "NLST",
        Value::List {
            elem: TypeId::Struct,
            items: vec![
                Value::Struct(entitlement("FIFA17PCBoxContent", tag, 1, cfg)),
                Value::Struct(entitlement("FIFA16PC", tag, 2, cfg)),
            ],
        },
    )
}

/// `Blaze::Authentication::GetAuthTokenResponse` @0x14487d080 — one member.
pub fn get_auth_token_response(sess: &Session) -> Struct {
    Struct::new().with("AUTH", s(sess.auth_token()))
}

/// `Util::userSettingsLoad` response.
///
/// TODO(verify): the response descriptor was never reflected. Both independent
/// clean-room emulators use a single `DATA` string, and an unknown-tag payload
/// is ignored rather than fatal, so an empty `DATA` is the safe minimum — the
/// client falls back to its defaults.
pub fn user_settings_response() -> Struct {
    Struct::new().with("DATA", s(""))
}

/// `AssociationLists::getLists` response.
///
/// TODO(verify): FIFA's association-list names are NOT known — do not invent
/// them. An empty list is well-formed and means "this user has no association
/// lists", which is true offline.
pub fn get_lists_response() -> Struct {
    Struct::new().with(
        "LMAP",
        Value::List {
            elem: TypeId::Struct,
            items: vec![],
        },
    )
}

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

    fn cfg() -> AdapterConfig {
        AdapterConfig::loopback()
    }

    #[test]
    fn iso8601_matches_known_instants() {
        assert_eq!(iso8601_utc(0), "1970-01-01T00:00:00Z");
        assert_eq!(iso8601_utc(1_754_870_400), "2025-08-11T00:00:00Z");
        // A leap day, to exercise the civil-from-days branch.
        assert_eq!(iso8601_utc(1_709_164_800), "2024-02-29T00:00:00Z");
        assert_eq!(iso8601_utc(951_782_400), "2000-02-29T00:00:00Z");
    }

    #[test]
    fn login_response_has_exactly_five_members() {
        let sess = Session::new("k", 0);
        assert_eq!(login_response(&sess, 0, &cfg()).len(), 5);
    }

    #[test]
    fn account_info_has_exactly_sixteen_members() {
        assert_eq!(account_info(0, &cfg()).len(), 16);
    }

    #[test]
    fn session_key_appears_identically_in_all_three_places() {
        let sess = Session::new("THE-KEY", 0);
        let c = cfg();

        let login = login_response(&sess, 1, &c);
        let in_login = login
            .get("SESS")
            .and_then(Value::as_struct)
            .and_then(|s| s.get("KEY"))
            .and_then(Value::as_str)
            .unwrap();

        let notify = user_session_login_info(&sess, 1, &c);
        let in_notify = notify.get("KEY").and_then(Value::as_str).unwrap();

        let post = post_auth_response(&sess, &c);
        let in_post = post
            .get("TELE")
            .and_then(Value::as_struct)
            .and_then(|s| s.get("SESS"))
            .and_then(Value::as_str)
            .unwrap();

        assert_eq!(in_login, "THE-KEY");
        assert_eq!(in_notify, "THE-KEY");
        assert_eq!(in_post, "THE-KEY");
    }

    #[test]
    fn identity_is_consistent_between_login_and_account_info() {
        let sess = Session::new("k", 0);
        let c = cfg();
        let acct = account_info(0, &c);
        let sess_info = user_login_info(&sess, 0, &c);

        assert_eq!(
            acct.get("MAIL").and_then(Value::as_str),
            sess_info.get("MAIL").and_then(Value::as_str)
        );
        assert_eq!(
            acct.get("UID").and_then(Value::as_int),
            sess_info.get("UID").and_then(Value::as_int)
        );
        assert_eq!(
            acct.get("ASRC").and_then(Value::as_str),
            Some(c.identity.namespace.as_str())
        );
    }

    #[test]
    fn entitlement_groups_match_the_clients_needles() {
        let c = cfg();
        let list = entitlements_response(&c);
        let items = match list.get("NLST") {
            Some(Value::List { items, .. }) => items,
            _ => panic!("NLST is a list"),
        };
        assert_eq!(items.len(), 2);
        for item in items {
            let e = item.as_struct().unwrap();
            let gnam = e.get("GNAM").and_then(Value::as_str).unwrap();
            assert!(
                gnam.contains("FIFA17PCBoxContent") || gnam.contains("FIFA16PC"),
                "group {gnam} matches neither client needle"
            );
            assert_eq!(e.get("STAT").and_then(Value::as_int), Some(1));
            assert!(!e.get("TAG").and_then(Value::as_str).unwrap().is_empty());
            // The client re-serialises these delimited; a separator would corrupt it.
            for tag in ["PRID", "GNAM", "TAG"] {
                let v = e.get(tag).and_then(Value::as_str).unwrap();
                assert!(
                    !v.contains('|') && !v.contains('/'),
                    "{tag} has a separator"
                );
            }
        }
    }

    #[test]
    fn census_periods_are_non_zero() {
        // Zero here is the ~30/s storm that hangs the FUT loading screen.
        let r = census_subscribe_response();
        assert!(r.get("CNP").and_then(Value::as_int).unwrap() > 0);
        assert!(r.get("NTMT").and_then(Value::as_int).unwrap() > 0);
    }

    #[test]
    fn preauth_echoes_the_requested_service_name() {
        let p = preauth_response("fifa-2017-pc-de", &cfg());
        assert_eq!(
            p.get("INST").and_then(Value::as_str),
            Some("fifa-2017-pc-de")
        );
    }

    #[test]
    fn extended_data_omits_the_unverified_members() {
        // Absent is safe; a wrong union/objid encoding breaks the whole parse.
        let d = user_session_extended_data();
        for absent in ["ADDR", "CVAR", "ULST"] {
            assert!(d.get(absent).is_none(), "{absent} must stay omitted");
        }
        assert!(
            d.get("PSLM").is_some(),
            "PSLM is FIFA-specific and required"
        );
    }
}
