//! Blaze RPC dispatch: inbound frame → outbound frames.
//!
//! Three behaviours here are load-bearing and none of them are obvious from the
//! individual response shapes:
//!
//! * **Login answers with four frames, in order**: the reply first, then
//!   `UserAuthenticated`, `UserSessionExtendedDataUpdate`, `UserAdded`.
//! * **An unimplemented RPC still gets an empty reply.** Silence makes the
//!   client wait for a timeout; an empty reply lets every field fall back to a
//!   client-side default and the boot continues.
//! * **Non-request message types get nothing at all** — answering a reply or a
//!   notification would desynchronise the client's own correlation.
//!
//! No error replies are emitted. `msgType` 3 exists, but the error-code
//! placement is UNRESOLVED — three clean-room sources disagree between
//! `header[14:16]`, a metadata `ERRC`, and a payload `CNTX`/`ERRC` — so
//! emitting one would be a guess on the wire. Do not add one without a capture.

use openfut_protocol_blaze::fire2::{Frame, Header, MsgType};
use openfut_protocol_blaze::heat2::{self, Struct, Value};

use super::config::AdapterConfig;
use super::ids::{association_lists, auth, census_data, component, user_sessions, util};
use super::responses as r;
use super::session::Session;

/// Everything needed to answer one RPC.
pub struct Adapter {
    pub config: AdapterConfig,
}

impl Adapter {
    pub fn new(config: AdapterConfig) -> Adapter {
        Adapter { config }
    }

    /// Answer one inbound frame.
    ///
    /// `now` is passed in rather than read from the clock so responses are
    /// reproducible: several bodies stamp a timestamp, and a hidden clock read
    /// would make every fixture unrepeatable.
    pub fn dispatch(
        &self,
        header: &Header,
        body: &Struct,
        session: &mut Session,
        now: i64,
    ) -> Vec<Frame> {
        // Transport-level ping, whatever the component/command.
        if header.msg_type == MsgType::Ping {
            return vec![reply(header, Vec::new(), MsgType::PingReply)];
        }
        // Only requests are answered.
        if header.msg_type != MsgType::Message {
            return Vec::new();
        }

        let cfg = &self.config;
        match (header.component, header.command) {
            // ------------------------------------------------------- Util
            (component::UTIL, util::PRE_AUTH) => {
                // preAuth is where the session learns who it is talking to:
                // the service name is echoed back, and the locale is captured
                // for every later ALOC field.
                session.service_name = service_name_of(body);
                if let Some(loc) =
                    find_nested_int(body, "LANG").or_else(|| find_nested_int(body, "LOC"))
                {
                    session.account_locale = loc;
                }
                let svc = session.service_name.clone();
                reply_tdf(header, &r::preauth_response(&svc, cfg))
            }

            (component::UTIL, util::PING) => reply_tdf(header, &r::ping_response(now)),

            (component::UTIL, util::FETCH_CLIENT_CONFIG) => {
                let cfid = get_str(body, "CFID");
                reply_tdf(header, &r::fetch_config_response(&cfid, cfg))
            }

            (component::UTIL, util::POST_AUTH) => {
                reply_tdf(header, &r::post_auth_response(session, cfg))
            }

            (component::UTIL, util::FETCH_QOS_CONFIG) => reply_tdf(header, &r::qos_config(cfg)),

            (component::UTIL, util::USER_SETTINGS_LOAD) => {
                reply_tdf(header, &r::user_settings_response())
            }

            // Accepted and discarded; the client only needs the ack.
            (component::UTIL, util::USER_SETTINGS_SAVE)
            | (component::UTIL, util::SET_CLIENT_STATE)
            | (component::UTIL, util::SET_CLIENT_METRICS) => empty_reply(header),

            // --------------------------------------------- Authentication
            (component::AUTHENTICATION, auth::LOGIN) => {
                session.auth_code = get_str(body, "AUTH");
                session.logged_in = true;
                session.login_time = now;
                self.login_burst(header, session, now)
            }

            // Same forged session; the request fields differ and are ignored.
            (component::AUTHENTICATION, auth::TRUSTED_LOGIN)
            | (component::AUTHENTICATION, auth::EXPRESS_LOGIN) => {
                session.logged_in = true;
                session.login_time = now;
                self.login_burst(header, session, now)
            }

            // Receiving logout is NORMAL, not a failure: the OSDK state table
            // orders Connect -> Logout -> VersionCheck -> PCLogin, so this is
            // the routine "drop any stale session" step before login. It is
            // only a symptom if login never follows.
            (component::AUTHENTICATION, auth::LOGOUT) => empty_reply(header),

            (component::AUTHENTICATION, auth::LIST_USER_ENTITLEMENTS2)
            | (component::AUTHENTICATION, auth::LIST_ENTITLEMENTS)
            | (component::AUTHENTICATION, auth::LIST_PERSONA_ENTITLEMENTS2)
            | (component::AUTHENTICATION, auth::GRANT_ENTITLEMENT2) => {
                reply_tdf(header, &r::entitlements_response(cfg))
            }

            (component::AUTHENTICATION, auth::GET_AUTH_TOKEN) => {
                reply_tdf(header, &r::get_auth_token_response(session))
            }
            (component::AUTHENTICATION, auth::GET_ACCOUNT) => {
                reply_tdf(header, &r::account_info(now, cfg))
            }
            (component::AUTHENTICATION, auth::GET_PERSONA) => {
                reply_tdf(header, &r::get_persona_response(now, cfg))
            }
            (component::AUTHENTICATION, auth::LIST_PERSONAS) => {
                reply_tdf(header, &r::list_personas_response(now, cfg))
            }

            // ---------------------------------------------- UserSessions
            (component::USER_SESSIONS, user_sessions::UPDATE_NETWORK_INFO) => {
                // Ack, then re-push the extended data so the client's cached
                // copy reflects the network info it just reported.
                vec![
                    reply(header, Vec::new(), MsgType::Reply),
                    notify(
                        component::USER_SESSIONS,
                        user_sessions::notify::EXTENDED_DATA_UPDATE,
                        &r::user_session_extended_data_update(cfg),
                    ),
                ]
            }

            // ------------------------------------------ AssociationLists
            (component::ASSOCIATION_LISTS, association_lists::GET_LISTS) => {
                reply_tdf(header, &r::get_lists_response())
            }

            // ----------------------------------------------- CensusData
            (component::CENSUS_DATA, census_data::SUBSCRIBE_TO_CENSUS_DATA_UPDATES) => {
                reply_tdf(header, &r::census_subscribe_response())
            }

            // An empty reply, never silence: see the module docs.
            _ => empty_reply(header),
        }
    }

    /// Login reply followed by the three UserSessions pushes, in order.
    ///
    /// The order is the oracle's ("pamplona" order: reply first). The
    /// alternative ("grid-blaze": notifications first) is also reported to
    /// work, but only this one is proven against our client, so it is the one
    /// reproduced.
    fn login_burst(&self, header: &Header, session: &Session, now: i64) -> Vec<Frame> {
        let cfg = &self.config;
        vec![
            reply(
                header,
                heat2::encode(&r::login_response(session, now, cfg)),
                MsgType::Reply,
            ),
            notify(
                component::USER_SESSIONS,
                user_sessions::notify::USER_AUTHENTICATED,
                &r::user_session_login_info(session, now, cfg),
            ),
            notify(
                component::USER_SESSIONS,
                user_sessions::notify::EXTENDED_DATA_UPDATE,
                &r::user_session_extended_data_update(cfg),
            ),
            notify(
                component::USER_SESSIONS,
                user_sessions::notify::USER_ADDED,
                &r::user_data(session, cfg),
            ),
        ]
    }
}

// ------------------------------------------------------------------ helpers

fn reply(request: &Header, payload: Vec<u8>, msg_type: MsgType) -> Frame {
    let mut frame = Frame::new(
        request.component,
        request.command,
        request.msg_num,
        msg_type,
        payload,
    );
    // A reply echoes routing verbatim and changes only the msgType bits.
    frame.header.user_index = request.user_index;
    frame
}

fn reply_tdf(request: &Header, body: &Struct) -> Vec<Frame> {
    vec![reply(request, heat2::encode(body), MsgType::Reply)]
}

fn empty_reply(request: &Header) -> Vec<Frame> {
    vec![reply(request, Vec::new(), MsgType::Reply)]
}

fn notify(component: u16, notify_id: u16, body: &Struct) -> Frame {
    Frame::notification(component, notify_id, heat2::encode(body))
}

/// `PreAuthRequest.CDAT.SVCN`, echoed back as `INST`.
fn service_name_of(body: &Struct) -> String {
    body.get("CDAT")
        .and_then(Value::as_struct)
        .and_then(|c| c.get("SVCN"))
        .and_then(Value::as_str)
        .filter(|s| !s.is_empty())
        .unwrap_or(super::session::DEFAULT_SERVICE_NAME)
        .to_string()
}

/// Depth-first search for an INT member anywhere in a decoded body.
///
/// The client has moved which struct carries `LANG`/`LOC` between builds, so
/// the oracle searches rather than addressing a fixed path.
fn find_nested_int(body: &Struct, tag: &str) -> Option<i64> {
    for (t, v) in body.iter() {
        if t.to_label() == tag {
            if let Value::Int(n) = v {
                return Some(*n);
            }
        }
        if let Value::Struct(inner) = v {
            if let Some(found) = find_nested_int(inner, tag) {
                return Some(found);
            }
        }
    }
    None
}

fn get_str(body: &Struct, tag: &str) -> String {
    body.get(tag)
        .and_then(Value::as_str)
        .unwrap_or("")
        .to_string()
}

#[cfg(test)]
mod tests {
    use super::*;
    use openfut_protocol_blaze::heat2::Struct as S;

    fn adapter() -> Adapter {
        Adapter::new(AdapterConfig::loopback())
    }

    fn req(component: u16, command: u16) -> Header {
        Header::new(component, command, 7, MsgType::Message)
    }

    #[test]
    fn login_answers_with_reply_then_three_pushes_in_order() {
        let a = adapter();
        let mut sess = Session::new("k", 0);
        let out = a.dispatch(
            &req(component::AUTHENTICATION, auth::LOGIN),
            &S::new(),
            &mut sess,
            1,
        );

        assert_eq!(out.len(), 4);
        assert_eq!(out[0].header.msg_type, MsgType::Reply);
        let ids: Vec<u16> = out[1..].iter().map(|f| f.header.command).collect();
        assert_eq!(
            ids,
            vec![
                user_sessions::notify::USER_AUTHENTICATED,
                user_sessions::notify::EXTENDED_DATA_UPDATE,
                user_sessions::notify::USER_ADDED,
            ]
        );
        for f in &out[1..] {
            assert_eq!(f.header.msg_type, MsgType::Notification);
            assert_eq!(f.header.msg_num, 0, "notifications are uncorrelated");
        }
    }

    #[test]
    fn unimplemented_rpcs_get_an_empty_reply_not_silence() {
        let a = adapter();
        let mut sess = Session::new("k", 0);
        let out = a.dispatch(&req(0x1234, 0x0001), &S::new(), &mut sess, 1);
        assert_eq!(out.len(), 1);
        assert_eq!(out[0].header.msg_type, MsgType::Reply);
        assert!(out[0].payload.is_empty());
    }

    #[test]
    fn non_requests_are_ignored_entirely() {
        let a = adapter();
        let mut sess = Session::new("k", 0);
        for mt in [
            MsgType::Reply,
            MsgType::Notification,
            MsgType::ErrorReply,
            MsgType::PingReply,
        ] {
            let h = Header::new(component::UTIL, util::PING, 1, mt);
            assert!(a.dispatch(&h, &S::new(), &mut sess, 1).is_empty(), "{mt:?}");
        }
    }

    #[test]
    fn transport_ping_gets_an_empty_ping_reply() {
        let a = adapter();
        let mut sess = Session::new("k", 0);
        let h = Header::new(component::UTIL, util::PING, 1, MsgType::Ping);
        let out = a.dispatch(&h, &S::new(), &mut sess, 1);
        assert_eq!(out.len(), 1);
        assert_eq!(out[0].header.msg_type, MsgType::PingReply);
        assert!(out[0].payload.is_empty());
    }

    #[test]
    fn replies_echo_routing_including_user_index() {
        let a = adapter();
        let mut sess = Session::new("k", 0);
        let mut h = req(component::UTIL, util::PING);
        h.user_index = 7;
        h.msg_num = 0x4242;
        let out = a.dispatch(&h, &S::new(), &mut sess, 1);
        assert_eq!(out[0].header.user_index, 7);
        assert_eq!(out[0].header.msg_num, 0x4242);
        assert_eq!(out[0].header.component, component::UTIL);
        assert_eq!(out[0].header.command, util::PING);
    }

    #[test]
    fn preauth_captures_locale_and_service_name() {
        let a = adapter();
        let mut sess = Session::new("k", 0x656E5553);
        let body = S::new().with(
            "CDAT",
            Value::Struct(
                S::new()
                    .with("LANG", Value::Int(0x64654445))
                    .with("SVCN", Value::String("fifa-2017-pc-de".into())),
            ),
        );
        a.dispatch(&req(component::UTIL, util::PRE_AUTH), &body, &mut sess, 1);
        assert_eq!(sess.account_locale, 0x64654445);
        assert_eq!(sess.service_name, "fifa-2017-pc-de");
    }

    #[test]
    fn preauth_without_svcn_falls_back_to_the_default() {
        let a = adapter();
        let mut sess = Session::new("k", 0);
        a.dispatch(
            &req(component::UTIL, util::PRE_AUTH),
            &S::new(),
            &mut sess,
            1,
        );
        assert_eq!(
            sess.service_name,
            super::super::session::DEFAULT_SERVICE_NAME
        );
    }

    #[test]
    fn login_records_the_auth_code_for_later_get_auth_token() {
        let a = adapter();
        let mut sess = Session::new("k", 0);
        let body = S::new().with("AUTH", Value::String("CODE-123".into()));
        a.dispatch(
            &req(component::AUTHENTICATION, auth::LOGIN),
            &body,
            &mut sess,
            1,
        );

        let out = a.dispatch(
            &req(component::AUTHENTICATION, auth::GET_AUTH_TOKEN),
            &S::new(),
            &mut sess,
            1,
        );
        let decoded = heat2::decode(&out[0].payload).unwrap();
        assert_eq!(
            decoded.get("AUTH").and_then(Value::as_str),
            Some("CODE-123")
        );
    }

    #[test]
    fn update_network_info_acks_then_pushes() {
        let a = adapter();
        let mut sess = Session::new("k", 0);
        let out = a.dispatch(
            &req(component::USER_SESSIONS, user_sessions::UPDATE_NETWORK_INFO),
            &S::new(),
            &mut sess,
            1,
        );
        assert_eq!(out.len(), 2);
        assert!(out[0].payload.is_empty());
        assert_eq!(out[1].header.msg_type, MsgType::Notification);
    }

    #[test]
    fn all_four_entitlement_aliases_agree() {
        let a = adapter();
        let mut sess = Session::new("k", 0);
        let bodies: Vec<Vec<u8>> = [
            auth::LIST_USER_ENTITLEMENTS2,
            auth::LIST_ENTITLEMENTS,
            auth::LIST_PERSONA_ENTITLEMENTS2,
            auth::GRANT_ENTITLEMENT2,
        ]
        .iter()
        .map(|&cmd| {
            a.dispatch(
                &req(component::AUTHENTICATION, cmd),
                &S::new(),
                &mut sess,
                1,
            )[0]
            .payload
            .clone()
        })
        .collect();
        assert!(bodies.windows(2).all(|w| w[0] == w[1]));
    }

    #[test]
    fn finds_a_nested_int_at_any_depth() {
        let body = S::new().with(
            "A",
            Value::Struct(S::new().with("B", Value::Struct(S::new().with("LANG", Value::Int(42))))),
        );
        assert_eq!(find_nested_int(&body, "LANG"), Some(42));
        assert_eq!(find_nested_int(&body, "NOPE"), None);
    }
}
