//! `openfut-lsx` -- the LSX responder process the launcher spawns.
//!
//! USAGE: bind BEFORE launching FIFA 17 so the Steampunks stub's `bind()` fails.
//! This process does NOT auto-start anything; the launcher owns processes.

use std::io::{self, Read};
use std::net::{SocketAddr, TcpListener, TcpStream};
use std::process::ExitCode;
use std::sync::Arc;

use openfut_lsx::crypto;
use openfut_lsx::events::{fmt_secs, login_event_frames, Conn, EventConfig};
use openfut_lsx::identity::Identity;
use openfut_lsx::protocol::{
    parse_request, push_after, py_repr, resp, safe_xml_for_log, Attrs, ProtocolConfig, Responder,
};
use openfut_lsx::{log, os_env, BUILD, CHALLENGE_KEY, LSX_PORT, VERSION};

fn main() -> ExitCode {
    if std::env::args().skip(1).any(|a| a == "--selftest") {
        return match selftest() {
            Ok(()) => ExitCode::SUCCESS,
            Err(e) => {
                log!("SELFTEST FAILED: {e}");
                ExitCode::FAILURE
            }
        };
    }
    match serve_forever() {
        Ok(()) => ExitCode::SUCCESS,
        Err(e) => {
            log!("fatal: {e}");
            ExitCode::FAILURE
        }
    }
}

fn serve_forever() -> Result<(), Box<dyn std::error::Error>> {
    let identity = Identity::from_env()?;
    let events = EventConfig::from_env()?;
    let responder = Arc::new(Responder::new(identity, ProtocolConfig::from_env()));

    // Readiness IS the bind: nothing is logged until 4216 is ours, so the launcher
    // never reads a ready line for a listener that does not exist. `TcpListener::bind`
    // already sets SO_REUSEADDR on every non-Windows target, which is the Python's
    // explicit `setsockopt(SO_REUSEADDR, 1)` -- do not reach for socket2 to re-add it.
    let bind_host = os_env("OPENFUT_BIND").unwrap_or_else(|| "127.0.0.1".to_string());
    let listener = TcpListener::bind((bind_host.as_str(), LSX_PORT))?;

    // Literal "127.0.0.1:4216" even under OPENFUT_BIND, as the Python logs it: this
    // line is what the launcher watches for.
    log!("v2 listening on 127.0.0.1:{LSX_PORT} (start FIFA 17 now)");
    log!(
        "login-state event push: {}  (period={}s count={})",
        if events.enabled { "ENABLED" } else { "DISABLED" },
        fmt_secs(events.period_secs),
        events.count
    );

    for stream in listener.incoming() {
        let stream = match stream {
            Ok(s) => s,
            Err(e) => {
                log!("accept failed: {e}");
                continue;
            }
        };
        let peer = stream
            .peer_addr()
            .unwrap_or_else(|_| SocketAddr::from(([0, 0, 0, 0], 0)));
        log!("connection from {}", py_addr(peer));
        let responder = Arc::clone(&responder);
        let events = events.clone();
        std::thread::spawn(move || serve(stream, peer, &responder, events));
    }
    Ok(())
}

/// Python prints the accept tuple, e.g. `('127.0.0.1', 54321)`.
fn py_addr(peer: SocketAddr) -> String {
    format!("('{}', {})", peer.ip(), peer.port())
}

fn serve(mut sock: TcpStream, peer: SocketAddr, responder: &Responder, events: EventConfig) {
    let conn = match Conn::new(&sock, peer, events) {
        Ok(c) => c,
        Err(e) => {
            log!("connection error: {e}");
            return;
        }
    };
    if let Err(e) = session(&mut sock, &conn, responder) {
        log!("connection error: {e}");
    }
    conn.mark_dead();
    // Dropping our halves closes the socket; the heartbeat thread notices on its
    // next tick (and its own send would fail regardless).
    drop(sock);
    log!("connection closed {}", py_addr(peer));
}

fn session(
    sock: &mut TcpStream,
    conn: &Arc<Conn>,
    responder: &Responder,
) -> Result<(), Box<dyn std::error::Error>> {
    // 1. plaintext Challenge
    conn.send_plain(&format!(
        r#"<LSX><Event sender="EALS"><Challenge key="{CHALLENGE_KEY}" build="{BUILD}" version="{VERSION}"/></Event></LSX>"#
    ))?;

    // 2. plaintext ChallengeResponse from the client. The emu parses `response="`
    //    BEFORE `key="` (0x180001f10); extract both so challenge_response can echo
    //    the client's own 3rd block (REPACK_INTEL.md C1/C2).
    let mut first = [0u8; 4096];
    let n = sock.read(&mut first)?;
    let txt = String::from_utf8_lossy(&first[..n]);
    let client_key = first_attr(&txt, "key").unwrap_or(CHALLENGE_KEY);
    let client_resp = first_attr(&txt, "response").unwrap_or("");
    let h = crypto::challenge_response(client_key, client_resp)?;
    conn.set_key(crypto::derive_session_key(&h));
    log!("handshake accepted; session crypto initialized");

    // 3. plaintext ChallengeAccepted
    conn.send_plain(&resp(
        "1",
        &format!(r#"ChallengeAccepted response="{h}""#),
        "EALS",
    ))?;

    // 3b. EXPERIMENT (OPENFUT_LSX_LOGIN_PLAINTEXT=1): the shipped emu's ONLY
    //     unsolicited Event is the plaintext, pre-session-key Challenge; there is
    //     zero evidence an *encrypted mid-session* Event routes to the same parser
    //     (REPACK_INTEL.md sec.4 step 2). So push the Login Event here, in
    //     PLAINTEXT, right after ChallengeAccepted -- before the stream goes
    //     encrypted -- and suppress the encrypted heartbeat to keep the A/B clean.
    if conn.events().login_plaintext && conn.events().enabled {
        conn.stop_events();
        for frame in login_event_frames() {
            conn.send_plain(&frame)?;
            log!("PUSH (plaintext post-accept) >> {frame}");
        }
    }

    // 4. encrypted request/response loop
    let mut heartbeat_started = false;
    let mut buf = Vec::new();
    let mut chunk = [0u8; 65536];
    loop {
        let n = sock.read(&mut chunk)?;
        if n == 0 {
            break;
        }
        // Buffer partial frames: a 64 KiB read can straddle a NUL boundary, and
        // splitting without keeping the remainder would silently drop the trailing
        // partial (C3).
        buf.extend_from_slice(&chunk[..n]);
        let complete = match buf.iter().rposition(|b| *b == 0) {
            Some(i) => i + 1,
            None => continue,
        };
        let frames: Vec<Vec<u8>> = buf[..complete]
            .split(|b| *b == 0)
            .filter(|f| !f.is_empty())
            .map(<[u8]>::to_vec)
            .collect();
        buf.drain(..complete);

        for frame in frames {
            let key = conn.key().expect("session key set during the handshake");
            let xml = match crypto::lsx_decrypt(&frame, &key) {
                Ok(x) => x,
                Err(e) => {
                    log!("decrypt fail: {e}");
                    continue;
                }
            };
            let Some(req) = parse_request(&xml) else {
                log!("<< {}", safe_xml_for_log(&xml));
                continue;
            };
            let reply = responder.build_reply(req.id, req.name, &req.attrs, Some(conn), req.recipient);
            log!(
                "<< id={} {} recipient={} {}",
                req.id,
                req.name,
                py_repr(req.recipient),
                req.attrs.py_repr()
            );
            log!(">> {}", safe_xml_for_log(&reply));
            conn.send_enc(&reply)?;

            if let Some(why) = push_after(req.name) {
                if conn.events().enabled
                    // For GetGameInfo only fire on UPTODATE, otherwise we would push
                    // three times per boot for FREETRIAL/LANGUAGES too.
                    && (req.name != "GetGameInfo"
                        || req.attrs.get("GameInfoId") == Some("UPTODATE"))
                {
                    conn.push_login_state(why);
                    if !heartbeat_started {
                        heartbeat_started = true;
                        let conn = Arc::clone(conn);
                        std::thread::spawn(move || conn.heartbeat());
                    }
                }
            }
        }
    }
    Ok(())
}

/// `re.search(r'name="([^"]*)"', txt)` -- the first occurrence anywhere in the
/// plaintext handshake frame, with no word-boundary requirement (which is why the
/// needle keeps its `="`).
fn first_attr<'a>(txt: &'a str, name: &str) -> Option<&'a str> {
    let needle = format!("{name}=\"");
    let start = txt.find(&needle)? + needle.len();
    let len = txt[start..].find('"')?;
    Some(&txt[start..start + len])
}

/// No live game needed. Proves the crypto is untouched and the event frames
/// encrypt/decrypt cleanly through our own codec.
fn selftest() -> Result<(), Box<dyn std::error::Error>> {
    let h = crypto::challenge_response("18a70055a3541fb27ab8e0f47afad18c", "")?;
    check(h.starts_with("e4f5166209929e15"), &h)?;
    let k = crypto::derive_session_key(&h);
    let k_hex = crypto::hex_lower(&k);
    check(k_hex == "6a9da3e78615153cc2f10eec25ae6382", &k_hex)?;
    println!("[ok] crypto matches the captured 2026-07-30 session verbatim");

    let frames = login_event_frames();
    check(
        frames.len()
            == openfut_lsx::events::LOGIN_EVENT_SENDERS.len()
                + openfut_lsx::events::ONLINE_EVENT_SENDERS.len(),
        &format!("{} frames", frames.len()),
    )?;
    for f in &frames {
        let round = crypto::lsx_decrypt(&crypto::lsx_encrypt(f, &k), &k)?;
        check(round == *f, &round)?;
        println!("[ok] round-trip: {f}");
    }
    // "" sender first (correct for the current empty service-name table)
    check(
        frames[0].contains(r#"<Event sender=""><Login IsLoggedIn="true"/></Event>"#),
        &frames[0],
    )?;

    // conn=None: the selftest must not write the run's success-signal files.
    let responder = Responder::new(Identity::from_env()?, ProtocolConfig::from_env());
    let attrs: Attrs = [("ClientId", "X"), ("Scope", "Y")].into_iter().collect();
    let r = responder.build_reply("42", "GetAuthCode", &attrs, None, "");
    // `value` is the only attribute lsx::AuthCodeT's deserializer (0x1471312a0)
    // actually reads; Code=/Return= are legacy padding.
    check(r.contains("<AuthCode value="), &r)?;

    let redacted = safe_xml_for_log(r#"<AuthCode value="secret" Code="secret" Return="secret"/>"#);
    check(
        !redacted.contains("secret") && redacted.matches("[REDACTED]").count() == 3,
        &redacted,
    )?;
    let status = safe_xml_for_log(r#"<ErrorSuccess Code="0" Description=""/>"#);
    check(status.contains(r#"Code="0""#), &status)?;
    println!("[ok] GetAuthCode response shape and log redaction");
    println!("[ok] selftest passed");
    Ok(())
}

/// The Python's `assert cond, value`.
fn check(cond: bool, value: &str) -> Result<(), io::Error> {
    if cond {
        Ok(())
    } else {
        Err(io::Error::other(format!("assertion failed: {value}")))
    }
}
