//! PUSHED EVENTS -- the whole reason v2 exists, plus the per-connection state
//! they need.
//!
//! Frame shape is identical to the server-initiated `<Challenge>` that already
//! works, i.e. `<LSX><Event sender="..."><Element .../></Event></LSX>`. No `id`
//! attribute (the Challenge has none; the matcher never reads one).

use std::io::{self, Write};
use std::net::{SocketAddr, TcpStream};
use std::sync::atomic::{AtomicBool, Ordering};
use std::sync::Arc;
use std::time::Duration;

use parking_lot::{Mutex, RwLock};

use crate::crypto::{self, Key};
use crate::{env_flag, log, ConfigError, Env};

/// `sender` is strcmp'd against the handler's registered service name. A mismatch
/// is SILENTLY DROPPED -- it costs us nothing -- so we emit every candidate.
///
/// Event handlers are keyed on `serviceNames[facility]` (registrar 0x14710df80);
/// with our empty GetConfigResponse those names are "", so the handlers expect
/// `sender=""`. "" first; the named variants are harmless no-ops (dropped
/// silently) and become correct once GetConfigResponse populates the table.
/// "LOGIN_EVENT" is table index 14 (the one the event-handler factory uses) and
/// "LOGIN" is index 8 (the plain service name); exactly one of the three will
/// match.
pub const LOGIN_EVENT_SENDERS: [&str; 3] = ["", "LOGIN_EVENT", "LOGIN"];
pub const ONLINE_EVENT_SENDERS: [&str; 2] = ["", "ONLINE_STATUS_EVENT"];

pub fn event(sender: &str, element: &str) -> String {
    format!(r#"<LSX><Event sender="{sender}"><{element}/></Event></LSX>"#)
}

/// The frames that flip `OriginMgr.m_isLoggedIn` ([OriginMgr+0x13]) to 1.
///
/// `IsLoggedIn` is parsed as `strcmp(v,"false") != 0`, so "true" -> TRUE. Keep the
/// value literally "true" anyway: it is what a real Origin client sends and it
/// keeps the log readable.
pub fn login_event_frames() -> Vec<String> {
    let mut out = Vec::with_capacity(LOGIN_EVENT_SENDERS.len() + ONLINE_EVENT_SENDERS.len());
    out.extend(LOGIN_EVENT_SENDERS.iter().map(|s| event(s, r#"Login IsLoggedIn="true""#)));
    out.extend(
        ONLINE_EVENT_SENDERS
            .iter()
            .map(|s| event(s, r#"OnlineStatusEvent isOnline="true""#)),
    );
    out
}

/// Event tuning.
///
/// Pushes are idempotent state notifications, so re-sending is harmless and is
/// cheap insurance against FIFA registering its `<Login>` handler later than our
/// first push. Set `OPENFUT_LSX_EVENTS=0` to fall back to v1 behaviour (useful as
/// an A/B control if you want to prove the events are what moved the needle).
#[derive(Debug, Clone, PartialEq)]
pub struct EventConfig {
    /// `OPENFUT_LSX_EVENTS != "0"` (default on).
    pub enabled: bool,
    /// `OPENFUT_LSX_EVENT_PERIOD` seconds (default 5).
    pub period_secs: f64,
    /// `OPENFUT_LSX_EVENT_COUNT` heartbeat repeats (default 24).
    pub count: i64,
    /// EXPERIMENT (`OPENFUT_LSX_LOGIN_PLAINTEXT`, default off): push the Login
    /// Event in PLAINTEXT right after ChallengeAccepted (before the stream goes
    /// encrypted) instead of via the encrypted heartbeat. Tests the workflow's
    /// strongest remaining hypothesis -- that FIFA drops encrypted mid-session
    /// Events (the emu's only Event, the Challenge, is plaintext and pre-key).
    /// See REPACK_INTEL.md sec.4 step 2.
    pub login_plaintext: bool,
}

impl Default for EventConfig {
    fn default() -> Self {
        Self {
            enabled: true,
            period_secs: 5.0,
            count: 24,
            login_plaintext: false,
        }
    }
}

impl EventConfig {
    pub fn from_env() -> Result<Self, ConfigError> {
        Self::from_vars(&crate::os_env)
    }

    pub fn from_vars(env: Env<'_>) -> Result<Self, ConfigError> {
        let period_secs = match env("OPENFUT_LSX_EVENT_PERIOD") {
            Some(v) => v.trim().parse::<f64>().map_err(|_| ConfigError {
                var: "OPENFUT_LSX_EVENT_PERIOD",
                value: v.clone(),
                expected: "a number of seconds",
            })?,
            None => 5.0,
        };
        let count = match env("OPENFUT_LSX_EVENT_COUNT") {
            Some(v) => v.trim().parse::<i64>().map_err(|_| ConfigError {
                var: "OPENFUT_LSX_EVENT_COUNT",
                value: v.clone(),
                expected: "an integer",
            })?,
            None => 24,
        };
        Ok(Self {
            enabled: env_flag(env, "OPENFUT_LSX_EVENTS", true),
            period_secs,
            count,
            login_plaintext: env_flag(env, "OPENFUT_LSX_LOGIN_PLAINTEXT", false),
        })
    }
}

/// Render a period the way Python's `float` repr does, so the startup line reads
/// `period=5.0s` and not `period=5s`.
pub fn fmt_secs(secs: f64) -> String {
    if secs.is_finite() && secs.fract() == 0.0 && secs.abs() < 1e16 {
        format!("{secs:.1}")
    } else {
        format!("{secs}")
    }
}

/// Socket + session key + a send lock.
///
/// The lock matters: pushes come from a heartbeat thread while the request loop
/// may be writing a Response. LSX frames are NUL-delimited, so two interleaved
/// writes would corrupt the stream and the client would drop the connection
/// (which would look exactly like a protocol bug).
pub struct Conn {
    pub peer: SocketAddr,
    /// A `try_clone` of the accepted socket: the request loop reads from the
    /// original while this half is serialised behind the send lock.
    writer: Mutex<TcpStream>,
    key: RwLock<Option<Key>>,
    alive: AtomicBool,
    pushed_login: AtomicBool,
    /// Set once GetAuthCode has been issued, so the heartbeat stops re-pushing
    /// Login/OnlineStatus events. Re-pushing after the auth code is granted
    /// re-enters FIFA's state-mutating Origin event dispatcher (case 2
    /// @0x146f1e0ab sets m_isLoggedIn + clears loginError + rebroadcasts on the FE
    /// bus) ~24 more times DURING Blaze login, which we do not want.
    stop_events: AtomicBool,
    events: EventConfig,
}

impl Conn {
    pub fn new(sock: &TcpStream, peer: SocketAddr, events: EventConfig) -> io::Result<Arc<Self>> {
        Ok(Arc::new(Self {
            peer,
            writer: Mutex::new(sock.try_clone()?),
            key: RwLock::new(None),
            alive: AtomicBool::new(true),
            pushed_login: AtomicBool::new(false),
            stop_events: AtomicBool::new(false),
            events,
        }))
    }

    pub fn events(&self) -> &EventConfig {
        &self.events
    }

    pub fn set_key(&self, key: Key) {
        *self.key.write() = Some(key);
    }

    pub fn key(&self) -> Option<Key> {
        *self.key.read()
    }

    // These flags are advisory hand-offs between the request loop and the
    // heartbeat thread; no data travels with them, so Relaxed is enough.
    pub fn is_alive(&self) -> bool {
        self.alive.load(Ordering::Relaxed)
    }

    pub fn mark_dead(&self) {
        self.alive.store(false, Ordering::Relaxed);
    }

    pub fn stop_events(&self) {
        self.stop_events.store(true, Ordering::Relaxed);
    }

    pub fn events_stopped(&self) -> bool {
        self.stop_events.load(Ordering::Relaxed)
    }

    pub fn send_plain(&self, xml: &str) -> io::Result<()> {
        let mut buf = Vec::with_capacity(xml.len() + 1);
        buf.extend_from_slice(xml.as_bytes());
        buf.push(0);
        self.writer.lock().write_all(&buf)
    }

    pub fn send_enc(&self, xml: &str) -> io::Result<()> {
        let key = self
            .key()
            .ok_or_else(|| io::Error::other("session key not initialised"))?;
        let frame = crypto::lsx_encrypt(xml, &key);
        self.writer.lock().write_all(&frame)
    }

    pub fn push_login_state(&self, why: &str) {
        if !self.events.enabled {
            return;
        }
        for frame in login_event_frames() {
            if let Err(e) = self.send_enc(&frame) {
                self.mark_dead();
                log!("push failed: {e}");
                return;
            }
            log!("PUSH ({why}) >> {frame}");
        }
        if !self.pushed_login.swap(true, Ordering::Relaxed) {
            log!(
                "*** first <Login IsLoggedIn=\"true\"> pushed. Watch for \
                 GetAuthCode next. ***"
            );
        }
    }

    /// Re-push the login state a bounded number of times.
    ///
    /// FIFA builds its Origin event handlers lazily; if our first push lands
    /// before the `<Login>` handler is registered the matcher simply finds no
    /// handler and drops it. Re-pushing removes that race without needing to
    /// guess the exact registration moment.
    pub fn heartbeat(&self) {
        let period = match Duration::try_from_secs_f64(self.events.period_secs) {
            Ok(d) => d,
            Err(e) => {
                // Python's time.sleep() would raise here and kill just this
                // thread; the connection keeps serving requests either way.
                log!(
                    "heartbeat disabled: OPENFUT_LSX_EVENT_PERIOD={} is not a usable delay ({e})",
                    self.events.period_secs
                );
                return;
            }
        };
        for _ in 0..self.events.count.max(0) {
            std::thread::sleep(period);
            if !self.is_alive() || self.events_stopped() {
                return;
            }
            self.push_login_state("heartbeat");
        }
    }
}

#[cfg(test)]
mod tests {
    use super::*;
    use crate::crypto::{derive_session_key, lsx_decrypt, lsx_encrypt};
    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()
    }

    #[test]
    fn frames_are_the_five_candidate_pushes_with_the_empty_sender_first() {
        let frames = login_event_frames();
        assert_eq!(
            frames.len(),
            LOGIN_EVENT_SENDERS.len() + ONLINE_EVENT_SENDERS.len()
        );
        assert_eq!(
            frames,
            vec![
                r#"<LSX><Event sender=""><Login IsLoggedIn="true"/></Event></LSX>"#,
                r#"<LSX><Event sender="LOGIN_EVENT"><Login IsLoggedIn="true"/></Event></LSX>"#,
                r#"<LSX><Event sender="LOGIN"><Login IsLoggedIn="true"/></Event></LSX>"#,
                r#"<LSX><Event sender=""><OnlineStatusEvent isOnline="true"/></Event></LSX>"#,
                r#"<LSX><Event sender="ONLINE_STATUS_EVENT"><OnlineStatusEvent isOnline="true"/></Event></LSX>"#,
            ]
        );
    }

    #[test]
    fn frames_round_trip_through_the_session_codec() {
        let k = derive_session_key(&crate::crypto::challenge_response(
            "18a70055a3541fb27ab8e0f47afad18c",
            "",
        )
        .unwrap());
        for f in login_event_frames() {
            assert_eq!(lsx_decrypt(&lsx_encrypt(&f, &k), &k).unwrap(), f);
        }
    }

    #[test]
    fn defaults_match_the_python() {
        let cfg = EventConfig::from_vars(&env_of(&[])).unwrap();
        assert_eq!(cfg, EventConfig::default());
        assert!(cfg.enabled);
        assert_eq!(cfg.period_secs, 5.0);
        assert_eq!(cfg.count, 24);
        assert!(!cfg.login_plaintext);
    }

    #[test]
    fn events_are_disabled_only_by_the_literal_zero() {
        for (value, enabled) in [("0", false), ("1", true), ("", true), ("false", true)] {
            let cfg = EventConfig::from_vars(&env_of(&[("OPENFUT_LSX_EVENTS", value)])).unwrap();
            assert_eq!(cfg.enabled, enabled, "OPENFUT_LSX_EVENTS={value:?}");
        }
    }

    #[test]
    fn login_plaintext_is_enabled_by_anything_but_zero() {
        for (value, on) in [("0", false), ("1", true), ("", true), ("no", true)] {
            let cfg =
                EventConfig::from_vars(&env_of(&[("OPENFUT_LSX_LOGIN_PLAINTEXT", value)])).unwrap();
            assert_eq!(cfg.login_plaintext, on, "OPENFUT_LSX_LOGIN_PLAINTEXT={value:?}");
        }
    }

    #[test]
    fn period_and_count_are_overridable() {
        let cfg = EventConfig::from_vars(&env_of(&[
            ("OPENFUT_LSX_EVENT_PERIOD", "0.25"),
            ("OPENFUT_LSX_EVENT_COUNT", "3"),
        ]))
        .unwrap();
        assert_eq!(cfg.period_secs, 0.25);
        assert_eq!(cfg.count, 3);
    }

    #[test]
    fn bad_period_is_refused() {
        let err = EventConfig::from_vars(&env_of(&[("OPENFUT_LSX_EVENT_PERIOD", "soon")]))
            .unwrap_err();
        assert_eq!(err.var, "OPENFUT_LSX_EVENT_PERIOD");
        let err =
            EventConfig::from_vars(&env_of(&[("OPENFUT_LSX_EVENT_COUNT", "many")])).unwrap_err();
        assert_eq!(err.var, "OPENFUT_LSX_EVENT_COUNT");
    }

    #[test]
    fn period_renders_like_a_python_float() {
        assert_eq!(fmt_secs(5.0), "5.0");
        assert_eq!(fmt_secs(0.25), "0.25");
        assert_eq!(fmt_secs(0.0), "0.0");
    }
}
