//! FIFA17 non-economy presentation routes, migrated from the Python oracle.
//!
//! Pure, IO-free shapers that reproduce the **observed production** oracle
//! contract (`fifa17-recon/tools/utas_server.py`) for the non-economy UTAS
//! routes a Rust host can own without any Core or account state:
//!
//! * `GET  …/user/accountinfo`        → `{}`               (FUT_ACCOUNTINFO off)
//! * `GET  …/settings`                → `{"configs":[]}`   (FUT_SETTINGS off)
//! * `GET  …/leaderboards/options`    → `{}`               (FUT_MODES off)
//! * `PUT  …/match/reset`             → `{}`               (Tier-B no-op ack)
//! * `GET/POST/PUT …/phishing/{trusteddevice,question,validate}` — the retired
//!   FUT security-question service, a stateless acknowledgement.
//!
//! These match the bodies the live prod oracle actually returns under the
//! production environment (`FUT_TRADING=1 FUT_PILESIZES=1 FUT_TRADEABLE=1`, none
//! of the feature flags set). They carry no persisted state: the security
//! record the oracle seeds (`{version:1,verified:true}`) is log-only — the
//! response is invariant — so a faithful Rust owner needs no persistence.

use serde_json::{json, Value};

/// `GET …/user/accountinfo` — production oracle returns an empty object.
pub fn accountinfo_body() -> Value {
    json!({})
}

/// `GET …/settings` — production oracle returns an empty config list.
pub fn settings_body() -> Value {
    json!({ "configs": [] })
}

/// `GET …/leaderboards/options` — production oracle (FUT_MODES off) returns an
/// empty object; the retail client ignores the body.
pub fn leaderboard_options_body() -> Value {
    json!({})
}

/// `PUT …/match/reset` — Tier-B no-op acknowledgement.
pub fn match_reset_body() -> Value {
    json!({})
}

/// `GET …/club/stats/staff` — production oracle returns an empty object (FIFA's
/// staff-bonus stat set is deliberately empty; the client tolerates `{}`).
pub fn club_stats_staff_body() -> Value {
    json!({})
}

/// The FUT modes (Seasons / Tournaments / FUT Champions) and the club-identity
/// service are disabled in this emulator, so their read routes (`season`,
/// `tournament`, `champion`, `clubUser`, `user/list`) return an empty object —
/// byte-identical to the Python oracle with `FUT_MODES` / `FUT_CLUB_IDENTITY`
/// off. Enabling a mode later requires a real Rust implementation here, never a
/// Python fallback (which would reintroduce split authority).
pub fn feature_off_body() -> Value {
    json!({})
}

/// One FUT item-definition for a requested `resource_id`, replicating the Python
/// oracle's `item_def`: `assetId = resource_id & 0xffffff`; a single hardcoded
/// card (Ronaldo, asset 20801) and a generic placeholder (`"Player"`, 75, CM,
/// attrs 70) for every other asset. The FIFA client renders the real card from
/// its LOCAL DB from the `(rareflag, resourceId)` pair, so this route only needs
/// a valid-shaped record — the placeholder is exactly what the oracle itself
/// returns for all but the one hardcoded asset. Key order is irrelevant (the
/// client's deserializer is key-addressed and skip-safe).
pub fn item_def(resource_id: i64) -> Value {
    let asset = resource_id & 0xff_ffff;
    // (name, rating, position, nation, leagueId, teamid, [6 attrs])
    let (name, rating, pos, nation, league, team, attrs): (
        &str,
        i64,
        &str,
        i64,
        i64,
        i64,
        [i64; 6],
    ) = if asset == 20801 {
        ("Ronaldo", 94, "ST", 38, 53, 243, [90, 93, 82, 91, 33, 80])
    } else {
        ("Player", 75, "CM", 0, 0, 0, [70, 70, 70, 70, 70, 70])
    };
    let attribute_list: Vec<Value> = attrs
        .iter()
        .enumerate()
        .map(|(i, v)| json!({ "index": i, "value": v }))
        .collect();
    json!({
        "id": resource_id,
        "resourceId": resource_id,
        "definitionId": resource_id,
        "assetId": asset,
        "cardassetid": asset,
        "commodityId": asset,
        "cardsubtypeid": 0,
        "cardType": 0,
        "itemType": "player",
        "rareflag": 1,
        "rating": rating,
        "preferredPosition": pos,
        "nation": nation,
        "leagueId": league,
        "teamid": team,
        "playStyle": 250,
        "attributeList": attribute_list,
        "name": name,
        "commonName": name,
        "lastName": name,
        "itemState": "free",
        "untradeable": true,
    })
}

/// `GET …/item/resource`, `…/defid` — `{itemData:[…]}` with one [`item_def`] per
/// requested id (mirrors the oracle's `defs_route`). No ids → an empty list.
pub fn item_defs_body(ids: &[i64]) -> Value {
    json!({ "itemData": ids.iter().map(|&id| item_def(id)).collect::<Vec<_>>() })
}

/// `GET …/marketdata/pricelimits?defId=a,b,c` — FutGetSuggestedPricing. The root
/// MUST be a BARE ARRAY (one element per defId): returning an object here froze a
/// live client (object-where-array busy loop at the listing screen). Constant
/// band 150..15000 (placeholder pricing; not a freeze concern).
pub fn marketdata_pricelimits_body(def_ids: &[i64]) -> Value {
    json!(def_ids
        .iter()
        .map(|&d| json!({ "defId": d, "minPrice": 150, "maxPrice": 15000 }))
        .collect::<Vec<_>>())
}

/// `GET …/marketdata` (NOT `/pricelimits`) — the price-comparison endpoint, which
/// takes an OBJECT `{minPrice,maxPrice}`. Array-where-object would be the same
/// freeze in reverse, so the container type is load-bearing. Constant band.
pub fn marketdata_object_body() -> Value {
    json!({ "minPrice": 150, "maxPrice": 15000 })
}

/// The phishing/security-question action, parsed from the URL tail.
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum SecurityAction {
    TrustedDevice,
    Question,
    Validate,
    Unknown,
}

/// The `phishing/<action>` selector from a `…/phishing/<action>` path tail.
pub fn parse_security_action(tail: &str) -> SecurityAction {
    let last = tail.trim_end_matches('/').rsplit('/').next().unwrap_or("");
    match last {
        "trusteddevice" => SecurityAction::TrustedDevice,
        "question" => SecurityAction::Question,
        "validate" => SecurityAction::Validate,
        _ => SecurityAction::Unknown,
    }
}

/// A well-formed FUT phishing token is an opaque 32-char lowercase/uppercase hex
/// value (`_PHISHING_HEX32.fullmatch` in the oracle).
fn is_hex32(s: &str) -> bool {
    s.len() == 32 && s.bytes().all(|b| b.is_ascii_hexdigit())
}

/// Reproduce `utas_server.py::security_question_route` verbatim.
///
/// The retired FUT security-question service is a stateless acknowledgement: it
/// validates request shape (session, 32-hex device id, 32-hex answer, numeric
/// question) and returns fixed bodies. The answer is a client-transformed opaque
/// value that is **never stored or compared**; the trusted-device response is an
/// invariant `verified/trusted` constant.
///
/// * `session_known` — whether `X-UT-SID` maps to an open Rust session.
/// * `device_id` / `question` / `answer` — decoded query parameters (`""`/`None`
///   when absent).
///
/// Returns `(http_status, body)`.
pub fn security_question_response(
    method: &str,
    action: SecurityAction,
    session_known: bool,
    device_id: &str,
    question: Option<&str>,
    answer: Option<&str>,
) -> (u16, Value) {
    let is = |m: &str| method.eq_ignore_ascii_case(m);
    if !session_known {
        return (400, json!({ "reason": "invalid_session" }));
    }
    if !is_hex32(device_id) {
        return (400, json!({ "reason": "malformed_request" }));
    }
    match action {
        SecurityAction::TrustedDevice => {
            if !is("GET") {
                return (405, json!({ "reason": "method_not_allowed" }));
            }
            (
                200,
                json!({ "changed": false, "exists": true, "locked": false, "trusted": true }),
            )
        }
        SecurityAction::Question if is("GET") => (
            200,
            json!({ "question": 0, "attempts": 5, "recoverAttempts": 0 }),
        ),
        SecurityAction::Question if is("POST") || is("PUT") => {
            let q = question.unwrap_or("");
            let a = answer.unwrap_or("");
            if q.is_empty() || !q.bytes().all(|b| b.is_ascii_digit()) || !is_hex32(a) {
                return (400, json!({ "reason": "malformed_request" }));
            }
            (200, json!({}))
        }
        SecurityAction::Validate if is("POST") => {
            let a = answer.unwrap_or("");
            if !is_hex32(a) {
                return (400, json!({ "reason": "malformed_request" }));
            }
            (200, json!({}))
        }
        _ => (405, json!({ "reason": "method_not_allowed" })),
    }
}

// ─────────────────── POST /openfut/account/sync (Rust-owned) ─────────────────

/// The launcher `account/sync` request fields, with production defaults already
/// applied. Everything is optional in the wire body; missing fields fall back to
/// the fixed defaults the launcher expects. `personaId` defaults to the host's
/// configured persona (passed in), never a baked-in constant.
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct AccountSyncRequest {
    pub persona_id: i64,
    pub persona_name: String,
    pub level: i64,
    pub experience: i64,
    pub experience_max: i64,
    pub account_funds: i64,
    pub account_funds_cap: i64,
}

/// The FIFA persona display name for this emulator's single account.
///
/// The oracle sources this from the shared account (`fut_account.py`, default
/// `"CAGE"`) and documents the property as "Blaze PDTL.DSNM / LSX
/// GetProfileResponse Persona / **UTAS sellerName**". That last role is
/// load-bearing: the client decides whether a transfer-market listing is the
/// player's OWN — and therefore whether to offer Remove / Re-list at all — from
/// the seller identity on the auction record. Stamping EA's house name there
/// makes the player's own listing un-actionable (pressing it opens no dialog).
pub const PERSONA_DISPLAY_NAME: &str = "CAGE";

/// Parse the `account/sync` request body, applying every default. `default_persona`
/// is the host's configured persona id (used when `personaId` is absent).
pub fn parse_account_sync(body: &[u8], default_persona: i64) -> AccountSyncRequest {
    let v: Value = serde_json::from_slice(body).unwrap_or(Value::Null);
    let int = |key: &str, dflt: i64| v.get(key).and_then(Value::as_i64).unwrap_or(dflt);
    let persona_name = v
        .get("personaName")
        .and_then(Value::as_str)
        .unwrap_or(PERSONA_DISPLAY_NAME)
        .to_string();
    AccountSyncRequest {
        persona_id: int("personaId", default_persona),
        persona_name,
        level: int("level", 1),
        experience: int("experience", 0),
        experience_max: int("experienceMax", 1000),
        account_funds: int("accountFunds", 0),
        account_funds_cap: int("accountFundsCap", 100000),
    }
}

/// `POST /openfut/account/sync` — the launcher control-plane account summary.
/// `coins`/`unopened_packs` are the AUTHORITATIVE Core values (balance +
/// entitlement count), never Python's stale profile funds.
pub fn account_sync_body(
    req: &AccountSyncRequest,
    coins: i64,
    unopened_packs: usize,
    club_name: &str,
    club_abbr: &str,
) -> Value {
    json!({
        "account": {
            "personaId": req.persona_id,
            "personaName": req.persona_name,
            "clubName": club_name,
            "clubAbbr": club_abbr,
            "level": req.level,
            "experience": req.experience,
            "experienceMax": req.experience_max,
            "accountFunds": req.account_funds,
            "accountFundsCap": req.account_funds_cap,
            "profilePath": "accounts/33068179/fifa17_profile.json",
            "coins": coins,
            "unopenedPacks": unopened_packs,
        },
        "status": "OK",
    })
}

// ─────────────────────── GET …/userMassInfo (Rust-owned) ─────────────────────

/// Build the full `GET …/userMassInfo` body entirely in Rust (no Python).
///
/// `squad` is the Core-projected active squad (`user_mass_info_squad` output), so
/// it is byte-for-byte the object `GET …/squad/active` embeds. `coins` and
/// `unopened_packs` are the authoritative Core economy values. `userInfo.actives`
/// mirrors the squad's `actives` (capped at 5), and `userInfo.squadList` is the
/// summary of the current squad.
pub fn user_mass_info_body(
    squad: Value,
    coins: i64,
    unopened_packs: usize,
    persona_id: i64,
    club_name: &str,
    club_abbr: &str,
    established: &str,
) -> Value {
    let actives: Vec<Value> = squad
        .get("actives")
        .and_then(Value::as_array)
        .map(|a| a.iter().take(5).cloned().collect())
        .unwrap_or_default();
    let squad_list = crate::fut::squad_projection::squad_list(&squad);
    let mut user_info = json!({
        "personaId": persona_id,
        "clubName": club_name,
        "clubAbbr": club_abbr,
        "established": established,
        "accountCreatedPlatformName": "pc",
        "currencies": [
            {"name": "coins", "funds": coins, "finalFunds": coins, "active": true},
            {"name": "points", "funds": 0, "finalFunds": 0, "active": true},
        ],
        "won": 0,
        "draw": 0,
        "loss": 0,
        "clubNameChangeAllowed": false,
        "divisionOffline": 10,
        "divisionOnline": 10,
        "purchased": false,
        "feature": {},
        "reliability": {"reliability": 100, "matchUnfinishedTime": 0},
        "bidTokens": {"count": 0, "updateTime": 0},
        "trophies": 0,
        "sessionCoinsBankBalance": 0,
        "actives": actives,
        "squadList": squad_list,
    });
    if unopened_packs > 0 {
        user_info.as_object_mut().unwrap().insert(
            "unopenedPacks".into(),
            json!({"preOrderPacks": 0, "recoveredPacks": unopened_packs}),
        );
    }
    json!({
        "pileSizeClientData": {"entries": [{"key": 2, "value": 100}, {"key": 4, "value": 50}]},
        "settings": {"configs": []},
        "userData": {},
        "squad": squad,
        "userInfo": user_info,
    })
}

// ───────────────────────────── POST /ut/auth (Rust) ──────────────────────────

/// Format `epoch_secs` (seconds since the Unix epoch) as UTC
/// `YYYY-MM-DD HH:MM:SS`. Pure civil-date arithmetic (Howard Hinnant's
/// `civil_from_days`), so no `time`/`chrono` dependency is needed.
pub fn format_utc_datetime(epoch_secs: i64) -> String {
    let days = epoch_secs.div_euclid(86_400);
    let secs_of_day = epoch_secs.rem_euclid(86_400);
    let (hour, min, sec) = (
        secs_of_day / 3600,
        (secs_of_day % 3600) / 60,
        secs_of_day % 60,
    );
    // civil_from_days: days is a count of days since 1970-01-01.
    let z = days + 719_468;
    let era = if z >= 0 { z } else { z - 146_096 } / 146_097;
    let doe = z - era * 146_097; // [0, 146096]
    let yoe = (doe - doe / 1460 + doe / 36_524 - doe / 146_096) / 365; // [0, 399]
    let year = yoe + era * 400;
    let doy = doe - (365 * yoe + yoe / 4 - yoe / 100); // [0, 365]
    let mp = (5 * doy + 2) / 153; // [0, 11]
    let day = doy - (153 * mp + 2) / 5 + 1; // [1, 31]
    let month = if mp < 10 { mp + 3 } else { mp - 9 }; // [1, 12]
    let year = if month <= 2 { year + 1 } else { year };
    format!("{year:04}-{month:02}-{day:02} {hour:02}:{min:02}:{sec:02}")
}

/// The persona a `/ut/auth` request adopts: `nucleusPersonaId` or `nuc` from the
/// body (numeric or numeric string), else `None` (the host substitutes its
/// configured persona). The client is never refused.
pub fn parse_auth_persona(body: &[u8]) -> Option<i64> {
    let v: Value = serde_json::from_slice(body).ok()?;
    let field = |key: &str| {
        v.get(key).and_then(|x| {
            x.as_i64()
                .or_else(|| x.as_str().and_then(|s| s.parse::<i64>().ok()))
        })
    };
    field("nucleusPersonaId").or_else(|| field("nuc"))
}

/// `POST /ut/auth` response body. `sid` is the freshly minted Rust session id;
/// `server_time` is UTC `YYYY-MM-DD HH:MM:SS` (also used for `lastOnlineTime`).
pub fn auth_body(sid: &str, server_time: &str) -> Value {
    json!({
        "protocol": 1,
        "sid": sid,
        "serverTime": server_time,
        "lastOnlineTime": server_time,
    })
}

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

    const DEV: &str = "6236375476659cd0f6c780e728774b71"; // 32-hex (live deviceId)
    const ANS: &str = "0123456789abcdef0123456789abcdef";

    #[test]
    fn static_bodies_match_oracle() {
        assert_eq!(accountinfo_body(), json!({}));
        assert_eq!(settings_body(), json!({ "configs": [] }));
        assert_eq!(leaderboard_options_body(), json!({}));
        assert_eq!(match_reset_body(), json!({}));
        assert_eq!(club_stats_staff_body(), json!({}));
    }

    #[test]
    fn action_parse() {
        assert_eq!(
            parse_security_action("phishing/trusteddevice"),
            SecurityAction::TrustedDevice
        );
        assert_eq!(
            parse_security_action("phishing/question/"),
            SecurityAction::Question
        );
        assert_eq!(
            parse_security_action("phishing/validate"),
            SecurityAction::Validate
        );
        assert_eq!(
            parse_security_action("phishing/other"),
            SecurityAction::Unknown
        );
    }

    #[test]
    fn trusted_device_verified_constant() {
        let (s, b) =
            security_question_response("GET", SecurityAction::TrustedDevice, true, DEV, None, None);
        assert_eq!(s, 200);
        assert_eq!(
            b,
            json!({ "changed": false, "exists": true, "locked": false, "trusted": true })
        );
    }

    #[test]
    fn no_session_is_400_invalid_session() {
        let (s, b) = security_question_response(
            "GET",
            SecurityAction::TrustedDevice,
            false,
            DEV,
            None,
            None,
        );
        assert_eq!(s, 400);
        assert_eq!(b, json!({ "reason": "invalid_session" }));
    }

    #[test]
    fn bad_device_id_is_malformed() {
        for bad in [
            "",
            "xyz",
            "6236375476659cd0f6c780e728774b7",
            "not-hex-not-hex-not-hex-not-hexx",
        ] {
            let (s, b) = security_question_response(
                "GET",
                SecurityAction::TrustedDevice,
                true,
                bad,
                None,
                None,
            );
            assert_eq!(s, 400, "device {bad:?}");
            assert_eq!(b, json!({ "reason": "malformed_request" }));
        }
    }

    #[test]
    fn trusted_device_wrong_method_405() {
        let (s, _) = security_question_response(
            "POST",
            SecurityAction::TrustedDevice,
            true,
            DEV,
            None,
            None,
        );
        assert_eq!(s, 405);
    }

    #[test]
    fn question_get_returns_prompt() {
        let (s, b) =
            security_question_response("GET", SecurityAction::Question, true, DEV, None, None);
        assert_eq!(s, 200);
        assert_eq!(
            b,
            json!({ "question": 0, "attempts": 5, "recoverAttempts": 0 })
        );
    }

    #[test]
    fn question_setup_validates_shape() {
        // valid numeric question + 32-hex answer
        let (s, b) = security_question_response(
            "POST",
            SecurityAction::Question,
            true,
            DEV,
            Some("0"),
            Some(ANS),
        );
        assert_eq!(s, 200);
        assert_eq!(b, json!({}));
        // empty question -> malformed
        let (s, _) = security_question_response(
            "POST",
            SecurityAction::Question,
            true,
            DEV,
            Some(""),
            Some(ANS),
        );
        assert_eq!(s, 400);
        // non-digit question -> malformed
        let (s, _) = security_question_response(
            "PUT",
            SecurityAction::Question,
            true,
            DEV,
            Some("x"),
            Some(ANS),
        );
        assert_eq!(s, 400);
        // bad answer -> malformed
        let (s, _) = security_question_response(
            "POST",
            SecurityAction::Question,
            true,
            DEV,
            Some("0"),
            Some("short"),
        );
        assert_eq!(s, 400);
    }

    #[test]
    fn validate_checks_answer() {
        let (s, b) = security_question_response(
            "POST",
            SecurityAction::Validate,
            true,
            DEV,
            None,
            Some(ANS),
        );
        assert_eq!(s, 200);
        assert_eq!(b, json!({}));
        let (s, _) = security_question_response(
            "POST",
            SecurityAction::Validate,
            true,
            DEV,
            None,
            Some("nope"),
        );
        assert_eq!(s, 400);
        // wrong method for validate
        let (s, _) =
            security_question_response("GET", SecurityAction::Validate, true, DEV, None, Some(ANS));
        assert_eq!(s, 405);
    }

    #[test]
    fn account_sync_defaults_and_core_economy() {
        // Empty body -> every default applied; persona falls back to the host's.
        let req = parse_account_sync(b"", 33_068_179);
        assert_eq!(req.persona_id, 33_068_179);
        assert_eq!(req.persona_name, "CAGE");
        assert_eq!(req.level, 1);
        assert_eq!(req.experience_max, 1000);
        assert_eq!(req.account_funds_cap, 100_000);
        let body = account_sync_body(&req, 29_859_876, 2, "Real FUT", "RF");
        let acc = &body["account"];
        assert_eq!(acc["clubName"], "Real FUT");
        assert_eq!(acc["clubAbbr"], "RF");
        assert_eq!(acc["profilePath"], "accounts/33068179/fifa17_profile.json");
        assert_eq!(acc["coins"], 29_859_876);
        assert_eq!(acc["unopenedPacks"], 2);
        assert_eq!(body["status"], "OK");
    }

    #[test]
    fn account_sync_honours_request_overrides() {
        let req = parse_account_sync(
            br#"{"personaId":42,"personaName":"X","level":9,"accountFunds":500}"#,
            33_068_179,
        );
        assert_eq!(req.persona_id, 42);
        assert_eq!(req.persona_name, "X");
        assert_eq!(req.level, 9);
        assert_eq!(req.account_funds, 500);
    }

    #[test]
    fn user_mass_info_flat_shape() {
        let squad = json!({
            "id": 0,
            "squadName": "OpenFUT",
            "formation": "f433",
            "squadType": "REGULAR_SQUAD",
            "rating": 90,
            "chemistry": 49,
            "actives": [],
            "players": [],
        });
        let body = user_mass_info_body(squad, 29_859_876, 0, 33_068_179, "Real FUT", "RF", "2016");
        // Flat top-level envelope.
        assert_eq!(
            body["pileSizeClientData"]["entries"][0],
            json!({"key": 2, "value": 100})
        );
        assert_eq!(
            body["pileSizeClientData"]["entries"][1],
            json!({"key": 4, "value": 50})
        );
        assert_eq!(body["settings"], json!({"configs": []}));
        assert_eq!(body["userData"], json!({}));
        // userInfo economy + club identity.
        let ui = &body["userInfo"];
        assert_eq!(ui["personaId"], 33_068_179);
        assert_eq!(ui["clubName"], "Real FUT");
        assert_eq!(ui["clubAbbr"], "RF");
        assert_eq!(ui["established"], "2016"); // string, not number
        assert_eq!(ui["accountCreatedPlatformName"], "pc");
        assert_eq!(ui["currencies"][0]["name"], "coins");
        assert_eq!(ui["currencies"][0]["funds"], 29_859_876);
        assert_eq!(ui["currencies"][0]["finalFunds"], 29_859_876);
        assert_eq!(ui["currencies"][1]["name"], "points");
        assert_eq!(ui["reliability"]["reliability"], 100);
        assert_eq!(ui["divisionOnline"], 10);
        assert!(ui.get("unopenedPacks").is_none(), "no packs -> key omitted");
        assert_eq!(ui["squadList"]["squad"][0]["squadName"], "OpenFUT");
        // Squad object embedded flat under top-level `squad`.
        assert_eq!(body["squad"]["squadName"], "OpenFUT");
    }

    #[test]
    fn user_mass_info_includes_unopened_packs_when_present() {
        let squad = json!({"id": 0, "actives": [], "players": []});
        let body = user_mass_info_body(squad, 100, 3, 33_068_179, "OpenFUT", "OFC", "2026");
        assert_eq!(body["userInfo"]["unopenedPacks"]["recoveredPacks"], 3);
        assert_eq!(body["userInfo"]["unopenedPacks"]["preOrderPacks"], 0);
    }

    #[test]
    fn utc_datetime_formats_known_epochs() {
        // 2026-08-17 03:54:47 UTC == 1_786_938_887.
        assert_eq!(format_utc_datetime(1_786_938_887), "2026-08-17 03:54:47");
        // Unix epoch.
        assert_eq!(format_utc_datetime(0), "1970-01-01 00:00:00");
    }

    #[test]
    fn auth_persona_and_body() {
        assert_eq!(
            parse_auth_persona(br#"{"nucleusPersonaId":33068179}"#),
            Some(33_068_179)
        );
        assert_eq!(parse_auth_persona(br#"{"nuc":"42"}"#), Some(42));
        assert_eq!(parse_auth_persona(b"{}"), None);
        let b = auth_body("OPENFUT-SID-DEADBEEF", "2026-08-17 03:54:47");
        assert_eq!(b["protocol"], 1);
        assert_eq!(b["sid"], "OPENFUT-SID-DEADBEEF");
        assert_eq!(b["serverTime"], "2026-08-17 03:54:47");
        assert_eq!(b["lastOnlineTime"], "2026-08-17 03:54:47");
    }
}
