//! Request parsing, verb dispatch and log redaction.
//!
//! The dispatch is request-DRIVEN (the Steampunks stub was a blind fixed script),
//! and the frame grammar is the Python's three regexes, transcribed by hand so the
//! crate needs no regex engine. Where the regexes are tolerant, this is tolerant
//! in the same way -- see [`parse_request`].

use std::fmt::Write as _;
use std::fs;

use crate::events::Conn;
use crate::identity::{Identity, CONTENT_ID, ENTITLEMENT_TAG};
use crate::{env_flag, log, Env, AUTHCODE_FILE, CLIENTID_FILE};

/// Attributes of the request's child element, in the order they appeared.
///
/// Python builds `dict(ATTR_RE.findall(rest))`: a repeated attribute keeps its
/// FIRST position but takes its LAST value, and that dict is echoed into the
/// `<< id=...` log line, so the ordering is observable.
#[derive(Debug, Default, Clone, PartialEq, Eq)]
pub struct Attrs<'a>(Vec<(&'a str, &'a str)>);

impl<'a> Attrs<'a> {
    pub fn new() -> Self {
        Self(Vec::new())
    }

    pub fn insert(&mut self, key: &'a str, value: &'a str) {
        match self.0.iter_mut().find(|(k, _)| *k == key) {
            Some(slot) => slot.1 = value,
            None => self.0.push((key, value)),
        }
    }

    pub fn get(&self, key: &str) -> Option<&'a str> {
        self.0.iter().find(|(k, _)| *k == key).map(|(_, v)| *v)
    }

    /// `attrs.get(key, "")`.
    pub fn get_or_empty(&self, key: &str) -> &'a str {
        self.get(key).unwrap_or("")
    }

    pub fn is_empty(&self) -> bool {
        self.0.is_empty()
    }

    pub fn len(&self) -> usize {
        self.0.len()
    }

    pub fn iter(&self) -> impl Iterator<Item = (&'a str, &'a str)> + '_ {
        self.0.iter().copied()
    }

    /// `repr(dict)`, because the request log line embeds it verbatim.
    pub fn py_repr(&self) -> String {
        let mut s = String::from("{");
        for (i, (k, v)) in self.0.iter().enumerate() {
            if i > 0 {
                s.push_str(", ");
            }
            let _ = write!(s, "{}: {}", py_repr(k), py_repr(v));
        }
        s.push('}');
        s
    }
}

impl<'a> FromIterator<(&'a str, &'a str)> for Attrs<'a> {
    fn from_iter<T: IntoIterator<Item = (&'a str, &'a str)>>(iter: T) -> Self {
        let mut attrs = Attrs::new();
        for (k, v) in iter {
            attrs.insert(k, v);
        }
        attrs
    }
}

/// One parsed `<Request>` frame.
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct Request<'a> {
    /// The digits of `id="N"`, kept as text: it is echoed, never arithmetic.
    pub id: &'a str,
    pub name: &'a str,
    pub attrs: Attrs<'a>,
    /// The response `sender` must byte-equal this (matcher 0x1471189b0). Captured
    /// separately from the rest of the frame, and defaulting to "", so a frame
    /// that ever lacks `recipient` still gets answered fast instead of a 15s
    /// stall.
    pub recipient: &'a str,
}

/// `REQ_RE = r'<Request[^>]*\bid="(\d+)"[^>]*>\s*<([A-Za-z]+)([^>]*)/?>'` plus
/// `RECIP_RE = r'<Request[^>]*\brecipient="([^"]*)"'`, both as `re.search`.
///
/// Transcription notes, all of them observable behaviour rather than style:
///   * `[^>]*` cannot cross a `>`, so `id` must live inside the `<Request ...>`
///     tag itself; the tag must be closed for the pattern to match at all.
///   * that leading `[^>]*` is greedy, so with more than one `id="N"` in the tag
///     the RIGHTMOST one wins.
///   * the trailing `([^>]*)/?>` is greedy too, so the captured attribute region
///     keeps a self-closing `/` -- harmless, `ATTR_RE` skips it.
///   * `recipient` is searched over the whole frame independently of the element
///     match, and its value may legally contain `>`.
pub fn parse_request(xml: &str) -> Option<Request<'_>> {
    const TAG: &str = "<Request";
    let mut from = 0;
    while let Some(off) = xml[from..].find(TAG) {
        let start = from + off;
        from = start + TAG.len();
        let tag_end = match xml[from..].find('>') {
            Some(i) => from + i,
            None => continue, // `[^>]*>` needs the tag to close
        };
        let Some(id) = rightmost_id(xml, from, tag_end) else {
            continue;
        };
        // `>\s*<`
        let child = xml[tag_end + 1..].trim_start_matches(char::is_whitespace);
        let Some(child) = child.strip_prefix('<') else {
            continue;
        };
        let name_len = child
            .as_bytes()
            .iter()
            .take_while(|c| c.is_ascii_alphabetic())
            .count();
        if name_len == 0 {
            continue;
        }
        let (name, tail) = child.split_at(name_len);
        let Some(gt) = tail.find('>') else {
            continue;
        };
        return Some(Request {
            id,
            name,
            attrs: parse_attrs(&tail[..gt]),
            recipient: find_recipient(xml).unwrap_or(""),
        });
    }
    None
}

/// The rightmost `\bid="(\d+)"` inside `xml[from..tag_end]`.
fn rightmost_id(xml: &str, from: usize, tag_end: usize) -> Option<&str> {
    let tag = &xml[from..tag_end];
    let mut search_end = tag.len();
    while let Some(at) = tag[..search_end].rfind("id=\"") {
        search_end = at;
        // `\b`: the character before `id` must not be a word character. Look at
        // the whole frame, so `<Requestid="1">` correctly fails.
        if !word_boundary_before(xml, from + at) {
            continue;
        }
        let value = &tag[at + 4..];
        let digits = value.as_bytes().iter().take_while(|c| c.is_ascii_digit()).count();
        if digits > 0 && value.as_bytes().get(digits) == Some(&b'"') {
            return Some(&value[..digits]);
        }
    }
    None
}

fn find_recipient(xml: &str) -> Option<&str> {
    const TAG: &str = "<Request";
    let mut from = 0;
    while let Some(off) = xml[from..].find(TAG) {
        from += off + TAG.len();
        // The literal must start before the tag closes; the VALUE may run past it.
        let limit = xml[from..].find('>').map_or(xml.len(), |i| from + i);
        let mut search_end = limit;
        while let Some(at) = xml[from..search_end].rfind("recipient=\"") {
            let at = from + at;
            search_end = at;
            if !word_boundary_before(xml, at) {
                continue;
            }
            let value_start = at + "recipient=\"".len();
            if let Some(len) = xml[value_start..].find('"') {
                return Some(&xml[value_start..value_start + len]);
            }
        }
    }
    None
}

/// `ATTR_RE.findall` -- `(\w+)="([^"]*)"`, non-overlapping, left to right.
fn parse_attrs(region: &str) -> Attrs<'_> {
    let mut attrs = Attrs::new();
    let mut pos = 0;
    while let Some(off) = region[pos..].find("=\"") {
        let eq = pos + off;
        // Greedy `\w+` immediately before `="`.
        let name_len: usize = region[..eq]
            .chars()
            .rev()
            .take_while(|c| is_word(*c))
            .map(char::len_utf8)
            .sum();
        let value_start = eq + 2;
        let Some(value_len) = region[value_start..].find('"') else {
            break; // an unterminated value ends the scan, as the regex does
        };
        if name_len > 0 {
            attrs.insert(
                &region[eq - name_len..eq],
                &region[value_start..value_start + value_len],
            );
        }
        pos = value_start + value_len + 1;
    }
    attrs
}

fn is_word(c: char) -> bool {
    c.is_alphanumeric() || c == '_'
}

fn word_boundary_before(s: &str, at: usize) -> bool {
    at == 0 || !s[..at].chars().next_back().is_some_and(is_word)
}

// ------------------------------------------------------------------ redaction
const SECRET_ATTRS: [&str; 5] = ["AuthCode", "AuthToken", "SessionKey", "Token", "Sid"];
const AUTH_CODE_ATTRS: [&str; 3] = ["value", "Code", "Return"];
const CHALLENGE_ATTRS: [&str; 1] = ["response"];

/// Redact credential-bearing LSX attributes from ordinary diagnostics.
///
/// The blanket pass only catches `Name="..."` pairs; the auth code and the
/// challenge response hide behind generic attribute names (`value`, `Code`,
/// `Return`, `response`), so those two elements get a second, element-scoped pass
/// -- otherwise `<ErrorSuccess Code="0">` would be redacted too and the ordinary
/// status lines would stop being readable.
pub fn safe_xml_for_log(xml: &str) -> String {
    let safe = redact_attrs(xml, &SECRET_ATTRS);
    let safe = if safe.contains("<AuthCode ") {
        redact_attrs(&safe, &AUTH_CODE_ATTRS)
    } else {
        safe
    };
    if safe.contains("<ChallengeAccepted ") {
        redact_attrs(&safe, &CHALLENGE_ATTRS)
    } else {
        safe
    }
}

/// `re.sub(r'(?i)\b(a|b|c)="[^"]*"', r'\1="[REDACTED]"', xml)`; the attribute name
/// keeps the case it was written in.
fn redact_attrs(input: &str, names: &[&str]) -> String {
    let mut out = String::with_capacity(input.len());
    let mut last = 0;
    let mut i = 0;
    while i < input.len() {
        if !input.is_char_boundary(i) || !word_boundary_before(input, i) {
            i += 1;
            continue;
        }
        let rest = &input[i..];
        let hit = names.iter().find_map(|name| {
            let after = rest.get(..name.len())?;
            if !after.eq_ignore_ascii_case(name) {
                return None;
            }
            let value = rest[name.len()..].strip_prefix("=\"")?;
            let len = value.find('"')?;
            Some((name.len(), name.len() + 2 + len + 1))
        });
        match hit {
            Some((name_len, match_len)) => {
                out.push_str(&input[last..i]);
                out.push_str(&rest[..name_len]);
                out.push_str("=\"[REDACTED]\"");
                i += match_len;
                last = i;
            }
            None => i += 1,
        }
    }
    out.push_str(&input[last..]);
    out
}

/// `repr()` of a Python string, for the log lines that embed one.
pub fn py_repr(s: &str) -> String {
    let quote = if s.contains('\'') && !s.contains('"') {
        '"'
    } else {
        '\''
    };
    let mut out = String::with_capacity(s.len() + 2);
    out.push(quote);
    for c in s.chars() {
        match c {
            '\\' => out.push_str("\\\\"),
            '\n' => out.push_str("\\n"),
            '\r' => out.push_str("\\r"),
            '\t' => out.push_str("\\t"),
            c if c == quote => {
                out.push('\\');
                out.push(c);
            }
            // Python renders the remaining ASCII controls as \xNN; anything
            // printable (including non-ASCII) goes through verbatim.
            c if (c as u32) < 0x20 || c as u32 == 0x7f => {
                let _ = write!(out, "\\x{:02x}", c as u32);
            }
            c => out.push(c),
        }
    }
    out.push(quote);
    out
}

// ------------------------------------------------------------------ responses
pub fn resp(mid: &str, body: &str, sender: &str) -> String {
    format!(r#"<LSX><Response id="{mid}" sender="{sender}"><{body}/></Response></LSX>"#)
}

/// Knobs that shape individual replies.
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct ProtocolConfig {
    /// A/B-control integrity: v1 (`lsx_responder.py`) answered GetGameInfo
    /// FULLGAME_PURCHASED with "false" (it fell through to the default). v2 had
    /// silently changed it to "true", which meant `OPENFUT_LSX_EVENTS=0` was NOT a
    /// byte-identical control any more. Keep it OFF by default so events-off ==
    /// v1 exactly; flip `OPENFUT_LSX_FULLGAME=1` to run the FULLGAME="true"
    /// experiment on its own.
    pub fullgame_purchased: bool,
    /// `OPENFUT_AUTHCODE`.
    pub auth_code: String,
}

impl Default for ProtocolConfig {
    fn default() -> Self {
        Self {
            fullgame_purchased: false,
            auth_code: format!("OPENFUT-{}", "0".repeat(24)),
        }
    }
}

impl ProtocolConfig {
    pub fn from_env() -> Self {
        Self::from_vars(&crate::os_env)
    }

    pub fn from_vars(env: Env<'_>) -> Self {
        Self {
            fullgame_purchased: env_flag(env, "OPENFUT_LSX_FULLGAME", false),
            auth_code: env("OPENFUT_AUTHCODE").unwrap_or_else(|| Self::default().auth_code),
        }
    }
}

/// The verb dispatcher.
pub struct Responder {
    pub identity: Identity,
    pub config: ProtocolConfig,
}

impl Responder {
    pub fn new(identity: Identity, config: ProtocolConfig) -> Self {
        Self { identity, config }
    }

    /// Request-DRIVEN dispatch.
    ///
    /// CRITICAL (2026-07-31, connect-reverse workflow): FIFA's response matcher
    /// 0x1471189b0 rejects any `<Response>` whose `sender` attribute does not
    /// byte-equal the `recipient` the client put on the matching `<Request>` (it
    /// reads `serviceNames[facility]`; with our empty GetConfigResponse all 34
    /// names are "" so recipient="" for every verb after GetConfig, which itself
    /// uses the hard-coded literal "EbisuSDK"). We were answering GetProfile/
    /// GetAuthCode/QueryEntitlements with sender="EbisuSDK" -> silently discarded
    /// -> GetProfile (the SOLE writer of OriginSDK+0x3a0 default-user) never took
    /// -> the whole online-login chain stalled at OSDK_INVALID_USER. FIX = ECHO
    /// the request's recipient back as the response sender, which is what every
    /// `reply` below does.
    ///
    /// `conn` is `None` in the selftest; a `None` connection must not touch the
    /// run's success-signal files.
    pub fn build_reply(
        &self,
        mid: &str,
        req_name: &str,
        attrs: &Attrs<'_>,
        conn: Option<&Conn>,
        recipient: &str,
    ) -> String {
        let reply = |body: &str| resp(mid, body, recipient);

        match req_name {
            // FLAG (1): "internet is reachable". The stub hardcoded
            // connected="0" -> "log in to Origin". This is NOT the logged-in
            // flag; see the crate docs.
            "GetInternetConnectedState" => reply(r#"InternetConnectedState connected="1""#),

            // Request shape is built at 0x14713b8d0:
            //     <GetAuthCode ClientId="..." Scope="..."/>
            // Response is matched at 0x1470e2b60: outer "LSX", element "AuthCode".
            //
            // THE ATTRIBUTE NAME IS "value" -- verified, not guessed: the
            // "AuthCode" element match at 0x1470e2b63 tail-jumps to 0x14712fac0
            // -> 0x1471312a0 = the lsx::AuthCodeT deserializer. It builds one
            // attribute name (ns-prefix for "lsx" @0x14394def0, then "value"
            // @0x1436c7768, concat at 0x14712d130) and does exactly ONE
            // get-attribute-as-string call 0x14713fe50(node, "value", &dest).
            // dest is ctx+0x00 == LSXRequest+0xb8, whose std::string size lands
            // at +0xc8 -- which is what OriginRequestAuthCodeSync's impl
            // 0x1470e67f0 reads back at 0x1470e6924 (`mov rbx,[rdi+0xc8]`) as
            // *out_len. Code=/Return= are NEVER read; with them alone the parsed
            // string is empty -> out_len 0 -> EbisuMgr+0x948 stays NULL -> the
            // OSDK classifier 0x14717d5d0 falls into its `test rbp,rbp / je` arm
            // and reports OSDK_UNDERAGE_ERROR (a mislabelled "no auth code"
            // fallback). Code=/Return= are kept only as harmless padding.
            "GetAuthCode" => {
                let client_id = attrs.get_or_empty("ClientId");
                let scope = attrs.get_or_empty("Scope");
                let code = &self.config.auth_code;
                // Only touch the run's success-signal files on a REAL request. The
                // selftest passes conn=None; if it wrote these files it would
                // pre-satisfy watch-step "authcode.txt becomes non-empty" and make
                // a non-event read as success on the next live run.
                if let Some(conn) = conn {
                    for (path, val) in [(AUTHCODE_FILE, code.as_str()), (CLIENTID_FILE, client_id)]
                    {
                        if let Err(e) = fs::write(path, val) {
                            log!("could not write {path}: {e}");
                        }
                    }
                    // GetAuthCode has fired: stop the heartbeat so we do not keep
                    // re-pushing Login/OnlineStatus events during Blaze login.
                    conn.stop_events();
                }
                log!("*** GetAuthCode ISSUED ***");
                log!(
                    "    ClientId={}  Scope={}",
                    py_repr(client_id),
                    py_repr(scope)
                );
                log!("    code=[REDACTED] -- issued for Blaze Authentication::login (1/0x0A)");
                reply(&format!(
                    r#"AuthCode value="{code}" Code="{code}" Return="{code}""#
                ))
            }

            // The only reply with a child element, so it cannot use the
            // single-element `resp` helper.
            "QueryEntitlements" => format!(
                concat!(
                    r#"<LSX><Response id="{mid}" sender="{recipient}">"#,
                    r#"<QueryEntitlementsResponse>"#,
                    r#"<OriginItem ItemId="{tag}" EntitlementId="1" "#,
                    r#"ResourceId="{content}" OfferId="{content}" "#,
                    r#"GrantDate="2016-09-01T00:00:00Z" bIsOwned="true" Uses="0"/>"#,
                    r#"</QueryEntitlementsResponse>"#,
                    r#"</Response></LSX>"#
                ),
                mid = mid,
                recipient = recipient,
                tag = ENTITLEMENT_TAG,
                content = CONTENT_ID
            ),

            // This is the ONLY feed for OriginSDK[+0x3a0]/[+0x3a8]
            // (OriginGetDefaultUser @0x1470da6d0 / OriginGetDefaultPersona
            // @0x1470da680 are bare reads of those fields, written only by
            // OriginSDK::Initialize @0x1470e5ad5/0x1470e5ae1). Keep it complete.
            // ONLY PersonaId/UserId/Persona come from the identity; the rest of
            // this template (Country/CommerceCountry/GeoCountry/CommerceCurrency/
            // AvatarId/IsSubscriber/IsUnderAge) is byte-exact per REPACK_INTEL 1.4
            // and is latched into OriginSDK[+0x3a0]/[+0x3a8] -- leave it verbatim.
            "GetProfile" => reply(&format!(
                concat!(
                    r#"GetProfileResponse IsSubscriber="true" PersonaId="{persona}" "#,
                    r#"AvatarId="" Country="US" CommerceCountry="US" GeoCountry="US" "#,
                    r#"UserId="{user}" Persona="{name}" IsUnderAge="false" "#,
                    r#"CommerceCurrency="USD""#
                ),
                persona = self.identity.persona_id,
                user = self.identity.user_id(),
                name = self.identity.persona_name
            )),

            "GetGameInfo" => match attrs.get("GameInfoId") {
                Some("LANGUAGES") => reply(concat!(
                    r#"GetGameInfoResponse GameInfo="ar_SA,cs_CZ,da_DK,de_DE,"#,
                    r#"en_US,es_ES,es_MX,fr_FR,it_IT,nl_NL,no_NO,pl_PL,pt_BR,"#,
                    r#"pt_PT,ru_RU,sv_SE,tr_TR,zh_TW""#
                )),
                // MUST be true or the client shows "Your title version is
                // outdated" and blocks all online features.
                Some("UPTODATE") => reply(r#"GetGameInfoResponse GameInfo="true""#),
                // OFF by default: v1 answered "false" here (fell through to the
                // default), and keeping this gated makes OPENFUT_LSX_EVENTS=0
                // byte-identical to v1.
                Some("FULLGAME_PURCHASED") if self.config.fullgame_purchased => {
                    reply(r#"GetGameInfoResponse GameInfo="true""#)
                }
                // FREETRIAL / FULLGAME_PURCHASED etc. -> false (retail, not a
                // trial; matches v1 exactly)
                _ => reply(r#"GetGameInfoResponse GameInfo="false""#),
            },

            "GetSetting" => {
                // The client asks in UPPERCASE.
                match attrs.get_or_empty("SettingId").to_uppercase().as_str() {
                    "ENVIRONMENT" | "ENVIRONMENTNAME" => {
                        reply(r#"GetSettingResponse Setting="production""#)
                    }
                    "LANGUAGE" => reply(&format!(
                        r#"GetSettingResponse Setting="{}""#,
                        self.identity.locale
                    )),
                    _ => reply(r#"GetSettingResponse Setting="false""#),
                }
            }

            "GetConfig" => reply(r#"GetConfigResponse Config="false""#),

            "IsProgressiveInstallationAvailable" => reply(concat!(
                r#"IsProgressiveInstallationAvailableResponse ItemId="" "#,
                r#"Available="false""#
            )),

            _ => reply(r#"ErrorSuccess Code="0" Description="""#),
        }
    }
}

/// Trigger points: push right after answering these verbs. GetProfile is the
/// earliest safe moment -- by then the SDK has built its handler set and has a
/// default user, so a Login event has somewhere to land.
///
/// For GetGameInfo the caller only fires on UPTODATE, otherwise we would push
/// three times per boot for FREETRIAL/LANGUAGES too.
pub fn push_after(req_name: &str) -> Option<&'static str> {
    match req_name {
        "GetProfile" => Some("after GetProfile"),
        "GetInternetConnectedState" => Some("after GetInternetConnectedState"),
        "GetGameInfo" => Some("after GetGameInfo UPTODATE"),
        _ => None,
    }
}

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

    fn env_of(pairs: &[(&str, &str)]) -> impl Fn(&str) -> Option<String> {
        let map: HashMap<String, String> = pairs
            .iter()
            .map(|(k, v)| ((*k).to_string(), (*v).to_string()))
            .collect();
        move |k: &str| map.get(k).cloned()
    }

    fn responder() -> Responder {
        Responder::new(Identity::default(), ProtocolConfig::default())
    }

    fn reply(verb: &str, attrs: &[(&str, &str)]) -> String {
        responder().build_reply("7", verb, &attrs.iter().copied().collect(), None, "")
    }

    // ---------------------------------------------------------------- parsing
    #[test]
    fn parses_a_request_with_a_recipient() {
        let xml = r#"<LSX><Request id="12" recipient="EbisuSDK"><GetConfig Locale="en_US" Env="prod"/></Request></LSX>"#;
        let req = parse_request(xml).unwrap();
        assert_eq!(req.id, "12");
        assert_eq!(req.name, "GetConfig");
        assert_eq!(req.recipient, "EbisuSDK");
        assert_eq!(req.attrs.get("Locale"), Some("en_US"));
        assert_eq!(req.attrs.get("Env"), Some("prod"));
        assert_eq!(req.attrs.len(), 2);
    }

    #[test]
    fn parses_a_request_without_a_recipient() {
        // Must still be answered -- fast -- rather than stalling ~15s.
        let xml = r#"<LSX><Request id="3"><GetProfile/></Request></LSX>"#;
        let req = parse_request(xml).unwrap();
        assert_eq!((req.id, req.name, req.recipient), ("3", "GetProfile", ""));
        assert!(req.attrs.is_empty());
    }

    #[test]
    fn parses_an_empty_recipient() {
        let xml = r#"<LSX><Request id="9" recipient=""><GetSetting SettingId="LANGUAGE"/></Request></LSX>"#;
        let req = parse_request(xml).unwrap();
        assert_eq!(req.recipient, "");
        assert_eq!(req.attrs.get("SettingId"), Some("LANGUAGE"));
    }

    #[test]
    fn tolerates_whitespace_between_the_request_and_its_element() {
        let xml = "<LSX><Request id=\"4\" recipient=\"X\">\n  <GetAuthCode ClientId=\"c\" Scope=\"s\" />\n</Request></LSX>";
        let req = parse_request(xml).unwrap();
        assert_eq!((req.id, req.name, req.recipient), ("4", "GetAuthCode", "X"));
        assert_eq!(req.attrs.get("ClientId"), Some("c"));
        assert_eq!(req.attrs.get("Scope"), Some("s"));
    }

    #[test]
    fn rejects_frames_that_are_not_requests() {
        for xml in [
            "",
            r#"<LSX><Event sender=""><Login IsLoggedIn="true"/></Event></LSX>"#,
            r#"<LSX><Request recipient="X"><GetConfig/></Request></LSX>"#, // no id
            r#"<LSX><Request id="abc"><GetConfig/></Request></LSX>"#,      // id not digits
            r#"<LSX><Requestid="1"><GetConfig/></Request></LSX>"#,         // \b fails
            r#"<LSX><Request id="1">"#,                                   // no element
        ] {
            assert!(parse_request(xml).is_none(), "{xml}");
        }
    }

    #[test]
    fn a_repeated_attribute_keeps_its_place_and_takes_the_last_value() {
        let xml = r#"<LSX><Request id="1"><V a="1" b="2" a="3"/></Request></LSX>"#;
        let req = parse_request(xml).unwrap();
        assert_eq!(req.attrs.py_repr(), "{'a': '3', 'b': '2'}");
    }

    #[test]
    fn greedy_id_takes_the_rightmost_one() {
        let xml = r#"<LSX><Request id="1" other-id="2"><GetConfig/></Request></LSX>"#;
        assert_eq!(parse_request(xml).unwrap().id, "2");
    }

    #[test]
    fn the_self_closing_slash_does_not_become_an_attribute() {
        let xml = r#"<LSX><Request id="1"><GetGameInfo GameInfoId="UPTODATE"/></Request></LSX>"#;
        let req = parse_request(xml).unwrap();
        assert_eq!(req.attrs.len(), 1);
        assert_eq!(req.attrs.get("GameInfoId"), Some("UPTODATE"));
    }

    // ------------------------------------------------------------------ verbs
    #[test]
    fn internet_connected_state_is_one() {
        assert_eq!(
            reply("GetInternetConnectedState", &[]),
            r#"<LSX><Response id="7" sender=""><InternetConnectedState connected="1"/></Response></LSX>"#
        );
    }

    #[test]
    fn auth_code_carries_the_value_attribute() {
        let r = reply("GetAuthCode", &[("ClientId", "X"), ("Scope", "Y")]);
        assert_eq!(
            r,
            concat!(
                r#"<LSX><Response id="7" sender=""><AuthCode "#,
                r#"value="OPENFUT-000000000000000000000000" "#,
                r#"Code="OPENFUT-000000000000000000000000" "#,
                r#"Return="OPENFUT-000000000000000000000000"/></Response></LSX>"#
            )
        );
    }

    #[test]
    fn auth_code_honours_the_env_override() {
        let cfg = ProtocolConfig::from_vars(&env_of(&[("OPENFUT_AUTHCODE", "ABC")]));
        let r = Responder::new(Identity::default(), cfg).build_reply(
            "1",
            "GetAuthCode",
            &Attrs::new(),
            None,
            "",
        );
        assert!(r.contains(r#"<AuthCode value="ABC" Code="ABC" Return="ABC"/>"#), "{r}");
    }

    #[test]
    fn query_entitlements_owns_the_retail_offer() {
        assert_eq!(
            reply("QueryEntitlements", &[]),
            concat!(
                r#"<LSX><Response id="7" sender=""><QueryEntitlementsResponse>"#,
                r#"<OriginItem ItemId="ONLINE_ACCESS" EntitlementId="1" "#,
                r#"ResourceId="1027460" OfferId="1027460" "#,
                r#"GrantDate="2016-09-01T00:00:00Z" bIsOwned="true" Uses="0"/>"#,
                r#"</QueryEntitlementsResponse></Response></LSX>"#
            )
        );
    }

    #[test]
    fn profile_carries_the_identity_and_the_verbatim_template() {
        assert_eq!(
            reply("GetProfile", &[]),
            concat!(
                r#"<LSX><Response id="7" sender=""><GetProfileResponse IsSubscriber="true" "#,
                r#"PersonaId="33068179" AvatarId="" Country="US" CommerceCountry="US" "#,
                r#"GeoCountry="US" UserId="33068179" Persona="CAGE" IsUnderAge="false" "#,
                r#"CommerceCurrency="USD"/></Response></LSX>"#
            )
        );
    }

    #[test]
    fn profile_echoes_an_overridden_persona() {
        let ident = Identity::from_vars(&env_of(&[
            ("FUT_PERSONA_ID", "42"),
            ("FUT_PERSONA_NAME", "ZED"),
        ]))
        .unwrap();
        let r = Responder::new(ident, ProtocolConfig::default())
            .build_reply("1", "GetProfile", &Attrs::new(), None, "");
        assert!(r.contains(r#"PersonaId="42""#), "{r}");
        assert!(r.contains(r#"UserId="42""#), "{r}");
        assert!(r.contains(r#"Persona="ZED""#), "{r}");
    }

    #[test]
    fn game_info_languages_and_uptodate() {
        let r = reply("GetGameInfo", &[("GameInfoId", "LANGUAGES")]);
        assert!(r.contains(r#"GameInfo="ar_SA,cs_CZ,"#), "{r}");
        assert!(r.ends_with(r#"tr_TR,zh_TW"/></Response></LSX>"#), "{r}");
        assert!(reply("GetGameInfo", &[("GameInfoId", "UPTODATE")])
            .contains(r#"GetGameInfoResponse GameInfo="true""#));
    }

    #[test]
    fn fullgame_purchased_is_false_unless_the_knob_is_set() {
        // A/B-control integrity: events-off must stay byte-identical to v1.
        for id in ["FULLGAME_PURCHASED", "FREETRIAL", "ANYTHING_ELSE"] {
            assert!(
                reply("GetGameInfo", &[("GameInfoId", id)])
                    .contains(r#"GetGameInfoResponse GameInfo="false""#),
                "{id}"
            );
        }
        // ... and a GetGameInfo with no GameInfoId at all.
        assert!(reply("GetGameInfo", &[]).contains(r#"GameInfo="false""#));

        let cfg = ProtocolConfig::from_vars(&env_of(&[("OPENFUT_LSX_FULLGAME", "1")]));
        let on = Responder::new(Identity::default(), cfg);
        assert!(on
            .build_reply(
                "7",
                "GetGameInfo",
                &[("GameInfoId", "FULLGAME_PURCHASED")].into_iter().collect(),
                None,
                ""
            )
            .contains(r#"GameInfo="true""#));
        // Still only that one id flips.
        assert!(on
            .build_reply(
                "7",
                "GetGameInfo",
                &[("GameInfoId", "FREETRIAL")].into_iter().collect(),
                None,
                ""
            )
            .contains(r#"GameInfo="false""#));
    }

    #[test]
    fn fullgame_knob_follows_the_not_zero_rule() {
        for (value, on) in [("0", false), ("1", true), ("", true), ("false", true)] {
            let cfg = ProtocolConfig::from_vars(&env_of(&[("OPENFUT_LSX_FULLGAME", value)]));
            assert_eq!(cfg.fullgame_purchased, on, "OPENFUT_LSX_FULLGAME={value:?}");
        }
    }

    #[test]
    fn settings() {
        for id in ["ENVIRONMENT", "environment", "EnvironmentName"] {
            assert!(
                reply("GetSetting", &[("SettingId", id)])
                    .contains(r#"GetSettingResponse Setting="production""#),
                "{id}"
            );
        }
        assert!(reply("GetSetting", &[("SettingId", "LANGUAGE")])
            .contains(r#"GetSettingResponse Setting="en_US""#));
        assert!(reply("GetSetting", &[("SettingId", "WHATEVER")])
            .contains(r#"GetSettingResponse Setting="false""#));
        assert!(reply("GetSetting", &[]).contains(r#"Setting="false""#));

        let ident = Identity::from_vars(&env_of(&[("FUT_LOCALE", "fr_FR")])).unwrap();
        assert!(Responder::new(ident, ProtocolConfig::default())
            .build_reply(
                "7",
                "GetSetting",
                &[("SettingId", "LANGUAGE")].into_iter().collect(),
                None,
                ""
            )
            .contains(r#"Setting="fr_FR""#));
    }

    #[test]
    fn config_is_empty_which_is_why_every_recipient_is_the_empty_string() {
        assert!(reply("GetConfig", &[]).contains(r#"GetConfigResponse Config="false""#));
    }

    #[test]
    fn progressive_installation_is_unavailable() {
        assert!(reply("IsProgressiveInstallationAvailable", &[]).contains(
            r#"IsProgressiveInstallationAvailableResponse ItemId="" Available="false""#
        ));
    }

    #[test]
    fn unknown_verbs_fall_through_to_error_success() {
        assert_eq!(
            reply("GetSomethingWeHaveNeverSeen", &[]),
            r#"<LSX><Response id="7" sender=""><ErrorSuccess Code="0" Description=""/></Response></LSX>"#
        );
    }

    #[test]
    fn the_response_sender_byte_equals_the_request_recipient() {
        let r = responder().build_reply("5", "GetProfile", &Attrs::new(), None, "EbisuSDK");
        assert!(r.starts_with(r#"<LSX><Response id="5" sender="EbisuSDK">"#), "{r}");
        // Including for the one verb built without the `resp` helper.
        let q = responder().build_reply("5", "QueryEntitlements", &Attrs::new(), None, "EbisuSDK");
        assert!(q.starts_with(r#"<LSX><Response id="5" sender="EbisuSDK">"#), "{q}");
    }

    #[test]
    fn push_triggers() {
        assert_eq!(push_after("GetProfile"), Some("after GetProfile"));
        assert_eq!(
            push_after("GetInternetConnectedState"),
            Some("after GetInternetConnectedState")
        );
        assert_eq!(push_after("GetGameInfo"), Some("after GetGameInfo UPTODATE"));
        assert_eq!(push_after("GetConfig"), None);
        assert_eq!(push_after("GetAuthCode"), None);
    }

    // -------------------------------------------------------------- redaction
    #[test]
    fn redacts_credential_attributes() {
        let safe = safe_xml_for_log(
            r#"<Thing AuthCode="a" AuthToken="b" SessionKey="c" Token="d" Sid="e" Keep="f"/>"#,
        );
        assert_eq!(
            safe,
            concat!(
                r#"<Thing AuthCode="[REDACTED]" AuthToken="[REDACTED]" "#,
                r#"SessionKey="[REDACTED]" Token="[REDACTED]" Sid="[REDACTED]" Keep="f"/>"#
            )
        );
    }

    #[test]
    fn redacts_the_auth_code_element_generically() {
        let safe =
            safe_xml_for_log(r#"<AuthCode value="secret" Code="secret" Return="secret"/>"#);
        assert!(!safe.contains("secret"), "{safe}");
        assert_eq!(safe.matches("[REDACTED]").count(), 3, "{safe}");
    }

    #[test]
    fn redacts_the_challenge_response() {
        let safe = safe_xml_for_log(
            r#"<LSX><Response id="1" sender="EALS"><ChallengeAccepted response="e4f5"/></Response></LSX>"#,
        );
        assert!(!safe.contains("e4f5"), "{safe}");
        assert!(safe.contains(r#"response="[REDACTED]""#), "{safe}");
        assert!(safe.contains(r#"id="1""#), "{safe}");
    }

    #[test]
    fn leaves_ordinary_status_lines_readable() {
        let status = safe_xml_for_log(r#"<ErrorSuccess Code="0" Description=""/>"#);
        assert_eq!(status, r#"<ErrorSuccess Code="0" Description=""/>"#);
        // The element name alone must not trip the blanket pass.
        assert_eq!(
            safe_xml_for_log(r#"<GetConfigResponse Config="false"/>"#),
            r#"<GetConfigResponse Config="false"/>"#
        );
    }

    #[test]
    fn redaction_is_case_insensitive_and_keeps_the_written_case() {
        assert_eq!(
            safe_xml_for_log(r#"<X authtoken="q" SID="r"/>"#),
            r#"<X authtoken="[REDACTED]" SID="[REDACTED]"/>"#
        );
    }

    #[test]
    fn redaction_respects_word_boundaries() {
        // `MySid="x"` has no boundary before `Sid`, so it is left alone.
        assert_eq!(
            safe_xml_for_log(r#"<X MySid="x"/>"#),
            r#"<X MySid="x"/>"#
        );
    }

    #[test]
    fn python_repr_of_strings() {
        assert_eq!(py_repr(""), "''");
        assert_eq!(py_repr("X"), "'X'");
        assert_eq!(py_repr("it's"), "\"it's\"");
        assert_eq!(py_repr("a\"b"), "'a\"b'");
        assert_eq!(py_repr("a\nb"), r"'a\nb'");
        assert_eq!(py_repr("a\\b"), r"'a\\b'");
        assert_eq!(Attrs::new().py_repr(), "{}");
    }
}
