//! `Util::fetchClientConfig` tables.
//!
//! Between 227 and 243 key/value rows per CFID, overwhelmingly the same RS4
//! base URL repeated across 212 endpoint keys. The client resolves a per-call
//! key (`FUT_RS4_URL_<CALL>`) before a per-module one
//! (`FUT_RS4_APIURL_<MODULE>`), and any call left unresolved falls back to a
//! real (dead) EA host — which is what produced "there has been an error
//! connecting to FIFA 17 Ultimate Team" mid-session when only the boot subset
//! was served. The table has to be complete, not representative.
//!
//! # Why this is data and not code
//!
//! The rows are reverse-engineered *configuration*, not logic. They live in
//! `fixtures/client_config.json`, derived mechanically from the Python oracle
//! and templated on `{advertise}`, `{bind}`, `{pow_content_host}` and
//! `{pow_host}` so the adapter stays deployable anywhere. Hand-transcribing 400
//! string literals would add a class of silent typo no reviewer can catch, and
//! `openfut-core` already loads its content from `data/` for the same reason.
//!
//! The generator does not take its own templating on trust: it substitutes real
//! addresses back in and diffs against the oracle for every section before
//! writing the file.

use std::collections::BTreeMap;
use std::sync::OnceLock;

use super::config::AdapterConfig;

/// Rows for every known CFID, plus `__default__` for unknown ones.
const TABLE_JSON: &str = include_str!("../../fixtures/client_config.json");

type Table = BTreeMap<String, Vec<(String, String)>>;

fn table() -> &'static Table {
    static TABLE: OnceLock<Table> = OnceLock::new();
    TABLE.get_or_init(|| {
        serde_json::from_str(TABLE_JSON).expect("bundled client_config.json is valid")
    })
}

/// Resolve the rows for a CFID, with addresses substituted in.
///
/// Unknown CFIDs deliberately still receive the shared FUT/RS4/POW rows: those
/// consumers read a merged `_all` store and which section contributes is
/// unproven, so a present-but-shared table is safer than an empty one.
pub fn rows_for(cfid: &str, cfg: &AdapterConfig) -> Vec<(String, String)> {
    let t = table();
    let rows = t
        .get(cfid)
        .or_else(|| t.get("__default__"))
        .expect("client_config.json always carries a __default__ section");

    // URL-level tokens resolve through AdapterConfig so those helpers are the
    // single place a URL shape is defined. Host-level tokens cover the values
    // that are not one of the three standard URLs (roster, POW API).
    let utas_base = cfg.utas_base();
    let nucleus_base = cfg.nucleus_base();
    let pow_content_url = cfg.pow_content_url();

    rows.iter()
        .map(|(k, v)| {
            let v = if v.contains('{') {
                v.replace("{utas_base}", &utas_base)
                    .replace("{nucleus_base}", &nucleus_base)
                    .replace("{pow_content_url}", &pow_content_url)
                    .replace("{advertise}", &cfg.endpoints.advertise)
                    .replace("{bind}", &cfg.endpoints.bind)
                    .replace("{pow_content_host}", &cfg.endpoints.pow_content_host)
                    .replace("{pow_host}", &cfg.endpoints.pow_host)
            } else {
                v.clone()
            };
            debug_assert!(!v.contains('{'), "unsubstituted token left in {k}: {v}");
            (k.clone(), v)
        })
        .collect()
}

/// Fingerprint of the bundled config table.
///
/// The table is generated data, so "which binary is this?" is only half the
/// question — "which data does it carry?" is the other half. A running host
/// logs this at startup so a live FIFA trace can be tied to an exact table, and
/// a rebuild that silently picked up regenerated fixtures is visible.
///
/// FNV-1a, not a security hash and never used as one.
pub fn table_fingerprint() -> u64 {
    let mut hash: u64 = 0xcbf2_9ce4_8422_2325;
    for byte in TABLE_JSON.as_bytes() {
        hash ^= *byte as u64;
        hash = hash.wrapping_mul(0x1000_0000_01b3);
    }
    hash
}

/// Every CFID with its own section. Unknown CFIDs are still valid requests.
pub fn known_sections() -> Vec<&'static str> {
    table()
        .keys()
        .filter(|k| k.as_str() != "__default__")
        .map(String::as_str)
        .collect()
}

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

    fn cfg() -> AdapterConfig {
        let mut c = AdapterConfig::loopback();
        c.endpoints.advertise = "198.51.100.7".into();
        c.endpoints.bind = "0.0.0.0".into();
        c.endpoints.pow_content_host = "198.51.100.7:8085".into();
        c.endpoints.pow_host = "198.51.100.7:8094".into();
        c
    }

    #[test]
    fn bundled_table_parses() {
        assert!(table().contains_key("__default__"));
        assert!(table().contains_key("BlazeSDK"));
        assert!(known_sections().len() >= 10);
    }

    #[test]
    fn default_section_is_the_shared_fut_base() {
        let rows = rows_for("literally-anything", &cfg());
        assert_eq!(rows.len(), 227);
        assert!(rows.iter().any(|(k, _)| k == "FUT_RS4_BASE_URL"));
    }

    #[test]
    fn addresses_are_substituted_not_baked() {
        let rows = rows_for("BlazeSDK", &cfg());
        let base = rows
            .iter()
            .find(|(k, _)| k == "FUT_RS4_BASE_URL")
            .expect("base url present");
        assert_eq!(base.1, "http://198.51.100.7:8099/");
        assert!(
            !rows.iter().any(|(_, v)| v.contains('{')),
            "a template token survived substitution"
        );
    }

    #[test]
    fn nucleus_follows_bind_reproducing_the_oracle() {
        let rows = rows_for("BlazeSDK", &cfg());
        let n = rows.iter().find(|(k, _)| k == "nucleusConnect").unwrap();
        assert_eq!(n.1, "http://0.0.0.0:42131");
    }

    #[test]
    fn roster_section_carries_the_roster_urls() {
        let rows = rows_for("OSDK_ROSTER", &cfg());
        let r = rows.iter().find(|(k, _)| k == "ROSTER_URL").unwrap();
        assert_eq!(r.1, "https://198.51.100.7:8081/fifa17/roster/");
    }

    #[test]
    fn rows_are_sorted_as_the_wire_requires() {
        // The oracle sorts; the TDF map encoder does not, so order is ours to keep.
        let rows = rows_for("BlazeSDK", &cfg());
        let mut sorted = rows.clone();
        sorted.sort();
        assert_eq!(rows, sorted);
    }

    #[test]
    fn every_known_section_substitutes_cleanly() {
        for cfid in known_sections() {
            for (k, v) in rows_for(cfid, &cfg()) {
                assert!(!v.contains('{'), "{cfid}/{k} kept a token: {v}");
            }
        }
    }
}
