//! CMSD (CTA-5006) for a WAI object (`extensions/delivery-telemetry.md` §6): the
//! static description an origin or edge gives a response, and the dynamic state each
//! node adds. Keys and parameters are written in bytewise order; a key is written only
//! when its value is not `null`, and a Boolean only when true. CMSD version 1 is not
//! written (CTA-5006: a sender omits `v` for version 1).

use serde_json::{Map, Value};

use super::cmcd::PREFIX;
use super::observation::OBJECT_TYPES;
use super::sf::{serialize_dict, serialize_list, SfBare, SfItem, SfMember};
use super::TelemetryError;

/// CMSD-Static for a WAI object (§6.1, §6.2).
#[derive(Debug, Clone, PartialEq, Eq, Default)]
pub struct CmsdStatic {
    pub at: Option<u64>,
    pub d: Option<u64>,
    pub ht: Option<u64>,
    pub nor: Option<String>,
    pub nrr: Option<String>,
    pub ot: Option<String>,
    pub sf: Option<String>,
    pub st: Option<String>,
    pub su: bool,
    /// The object's content hash (`wai-ch`).
    pub content_hash: Option<String>,
    /// Its layer and its stage set's stage count (`wai-l`, `wai-lt`).
    pub layer: Option<u64>,
    pub layers_total: Option<u64>,
    /// A delivery receipt's link hash (`wai-rcpt`).
    pub receipt: Option<String>,
    /// A deriving node's operation token (`wai-op`).
    pub op: Option<String>,
}

/// One node's CMSD-Dynamic entry (§6.3).
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct CmsdDynamic {
    pub server: String,
    pub du: bool,
    pub etp: Option<u64>,
    pub mb: Option<u64>,
    pub rd: Option<u64>,
    pub rtt: Option<u64>,
    /// Objects discarded on delivery timeout since the node's previous response to the
    /// same session (`wai-drop`).
    pub drop: Option<u64>,
}

fn bad(s: impl Into<String>) -> TelemetryError {
    TelemetryError::Invalid(s.into())
}

fn only<'a>(v: &'a Value, members: &[&str], at: &str) -> Result<&'a Map<String, Value>, TelemetryError> {
    let o = v.as_object().ok_or_else(|| bad(format!("{at}: not an object")))?;
    for k in o.keys() {
        if !members.contains(&k.as_str()) {
            return Err(bad(format!("{at}: unknown member `{k}`")));
        }
    }
    for k in members {
        if !o.contains_key(*k) {
            return Err(bad(format!("{at}: missing member `{k}`")));
        }
    }
    Ok(o)
}

/// An integer member: at most `10¹⁵ − 1`, which an RFC 9651 Integer holds (the custom
/// keys, which carry Strings, take the same bound).
fn oint(v: &Value, at: &str) -> Result<Option<u64>, TelemetryError> {
    match v {
        Value::Null => Ok(None),
        x => x.as_u64().filter(|&n| n <= super::observation::MAX_SF_INT).map(Some).ok_or_else(|| bad(format!("{at}: not an integer in 0…10¹⁵ − 1"))),
    }
}

fn ostr(v: &Value, at: &str) -> Result<Option<String>, TelemetryError> {
    match v {
        Value::Null => Ok(None),
        Value::String(s) if !s.is_empty() && s.bytes().all(|c| (0x20..=0x7e).contains(&c)) => Ok(Some(s.clone())),
        _ => Err(bad(format!("{at}: not a string of printable ASCII"))),
    }
}

fn otok(v: &Value, set: &[&str], at: &str) -> Result<Option<String>, TelemetryError> {
    match v {
        Value::Null => Ok(None),
        Value::String(s) if set.contains(&s.as_str()) => Ok(Some(s.clone())),
        _ => Err(bad(format!("{at}: not one of {set:?}"))),
    }
}

fn ohex(v: &Value, at: &str) -> Result<Option<String>, TelemetryError> {
    match v {
        Value::Null => Ok(None),
        Value::String(s) if s.len() == 64 && s.bytes().all(|c| c.is_ascii_digit() || (b'a'..=b'f').contains(&c)) => Ok(Some(s.clone())),
        _ => Err(bad(format!("{at}: not 64 lowercase hexadecimal digits"))),
    }
}

impl CmsdStatic {
    pub fn from_json(v: &Value) -> Result<CmsdStatic, TelemetryError> {
        let o = only(v, &["at", "d", "ht", "nor", "nrr", "ot", "sf", "st", "su", "content_hash", "layer", "layers_total", "receipt", "op"], "cmsd-static")?;
        let op = ostr(&o["op"], "op")?;
        if op.as_deref().is_some_and(|t| !super::sf::is_token(t)) {
            return Err(bad("op: not a token"));
        }
        Ok(CmsdStatic {
            at: oint(&o["at"], "at")?,
            d: oint(&o["d"], "d")?,
            ht: oint(&o["ht"], "ht")?,
            nor: ostr(&o["nor"], "nor")?,
            nrr: ostr(&o["nrr"], "nrr")?,
            ot: otok(&o["ot"], OBJECT_TYPES, "ot")?,
            sf: otok(&o["sf"], &["o", "h", "d"], "sf")?,
            st: otok(&o["st"], &["v", "l", "ll"], "st")?,
            su: o["su"].as_bool().ok_or_else(|| bad("su: not a boolean"))?,
            content_hash: ohex(&o["content_hash"], "content_hash")?,
            layer: oint(&o["layer"], "layer")?,
            layers_total: oint(&o["layers_total"], "layers_total")?,
            receipt: ohex(&o["receipt"], "receipt")?,
            op,
        })
    }

    /// The `CMSD-Static` field value.
    pub fn encode(&self) -> Result<String, TelemetryError> {
        let mut m: Vec<(String, SfMember)> = Vec::new();
        let mut put = |k: String, v: Option<SfBare>| {
            if let Some(v) = v {
                m.push((k, SfMember::Item(v)));
            }
        };
        let i = |x: Option<u64>| x.map(|n| SfBare::Int(n as i64));
        let st = |x: &Option<String>| x.as_ref().map(|s| SfBare::Str(s.clone()));
        let tk = |x: &Option<String>| x.as_ref().map(|s| SfBare::Token(s.clone()));
        let c = |x: &str| format!("{PREFIX}{x}");
        put("at".into(), i(self.at));
        put("d".into(), i(self.d));
        put("ht".into(), i(self.ht));
        put("nor".into(), st(&self.nor));
        put("nrr".into(), st(&self.nrr));
        put("ot".into(), tk(&self.ot));
        put(c("ch"), st(&self.content_hash));
        put(c("l"), self.layer.map(|n| SfBare::Str(n.to_string())));
        put(c("lt"), self.layers_total.map(|n| SfBare::Str(n.to_string())));
        put(c("op"), tk(&self.op));
        put(c("rcpt"), st(&self.receipt));
        put("sf".into(), tk(&self.sf));
        put("st".into(), tk(&self.st));
        put("su".into(), self.su.then_some(SfBare::Bool(true)));
        m.sort_by(|a, b| a.0.cmp(&b.0));
        serialize_dict(&m).map_err(|e| bad(e.to_string()))
    }
}

impl CmsdDynamic {
    pub fn from_json(v: &Value) -> Result<CmsdDynamic, TelemetryError> {
        let o = only(v, &["server", "du", "etp", "mb", "rd", "rtt", "drop"], "cmsd-dynamic")?;
        Ok(CmsdDynamic {
            server: ostr(&o["server"], "server")?.ok_or_else(|| bad("server: required"))?,
            du: o["du"].as_bool().ok_or_else(|| bad("du: not a boolean"))?,
            etp: oint(&o["etp"], "etp")?,
            mb: oint(&o["mb"], "mb")?,
            rd: oint(&o["rd"], "rd")?,
            rtt: oint(&o["rtt"], "rtt")?,
            drop: oint(&o["drop"], "drop")?,
        })
    }
}

/// The `CMSD-Dynamic` field value: one entry per node, in the order given (each node
/// adds its own entry), each node's parameters in bytewise order.
pub fn encode_dynamic(entries: &[CmsdDynamic]) -> Result<String, TelemetryError> {
    let items: Vec<SfItem> = entries
        .iter()
        .map(|e| {
            let mut params: Vec<(String, SfBare)> = Vec::new();
            if e.du {
                params.push(("du".into(), SfBare::Bool(true)));
            }
            for (k, v) in [("etp", e.etp), ("mb", e.mb), ("rd", e.rd), ("rtt", e.rtt)] {
                if let Some(v) = v {
                    params.push((k.into(), SfBare::Int(v as i64)));
                }
            }
            if let Some(d) = e.drop {
                params.push((format!("{PREFIX}drop"), SfBare::Str(d.to_string())));
            }
            params.sort_by(|a, b| a.0.cmp(&b.0));
            SfItem { value: SfBare::Str(e.server.clone()), params }
        })
        .collect();
    serialize_list(&items).map_err(|e| bad(e.to_string()))
}

// ---- Reading (§6.6) -------------------------------------------------------------------

/// Why a CMSD field is not read (§6.6).
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum CmsdRefusal {
    /// The field is not an RFC 9651 Dictionary (CMSD-Static) or List (CMSD-Dynamic) of the
    /// subset §3.3 writes.
    Malformed,
    /// A key §6.1 to §6.3 names holds a value of another type or form.
    Type,
    /// The field states a CMSD version other than 1.
    Version,
}

impl CmsdRefusal {
    /// Every refusal, in §6.6's order. (A function, not an associated constant, so the C
    /// header generator does not export it.)
    pub const fn all() -> [CmsdRefusal; 3] {
        [CmsdRefusal::Malformed, CmsdRefusal::Type, CmsdRefusal::Version]
    }
    pub fn code(self) -> &'static str {
        match self {
            CmsdRefusal::Malformed => "cmsd_malformed",
            CmsdRefusal::Type => "cmsd_type",
            CmsdRefusal::Version => "cmsd_version",
        }
    }
}

fn r_int(b: &SfBare) -> Result<u64, CmsdRefusal> {
    match b {
        SfBare::Int(i) if *i >= 0 => Ok(*i as u64),
        _ => Err(CmsdRefusal::Type),
    }
}

fn r_str(b: &SfBare) -> Result<String, CmsdRefusal> {
    match b {
        SfBare::Str(s) if !s.is_empty() => Ok(s.clone()),
        _ => Err(CmsdRefusal::Type),
    }
}

fn r_token(b: &SfBare) -> Result<String, CmsdRefusal> {
    match b {
        SfBare::Token(t) => Ok(t.clone()),
        _ => Err(CmsdRefusal::Type),
    }
}

fn r_hex(b: &SfBare) -> Result<String, CmsdRefusal> {
    let s = r_str(b)?;
    if s.len() == 64 && s.bytes().all(|c| c.is_ascii_digit() || (b'a'..=b'f').contains(&c)) {
        Ok(s)
    } else {
        Err(CmsdRefusal::Type)
    }
}

/// A custom key's decimal String: digits, no sign, no leading zero but for 0 itself, at
/// most `10¹⁵ − 1`.
fn r_decimal(b: &SfBare) -> Result<u64, CmsdRefusal> {
    let s = r_str(b)?;
    let ok = s.bytes().all(|c| c.is_ascii_digit()) && (s == "0" || !s.starts_with('0')) && s.len() <= 15;
    if ok { s.parse().map_err(|_| CmsdRefusal::Type) } else { Err(CmsdRefusal::Type) }
}

/// Reads a `CMSD-Static` field value into the description §6.1 and §6.2 write. Keys the
/// extension does not name are passed over (CTA-5006 lets a sender add keys); a key it
/// names with a value of another type is `cmsd_type`; `v` other than 1 is `cmsd_version`.
pub fn read_static(field: &str) -> Result<CmsdStatic, CmsdRefusal> {
    use super::sf::parse_dict;
    let members = parse_dict(field).map_err(|_| CmsdRefusal::Malformed)?;
    let mut out = CmsdStatic::default();
    for (k, m) in &members {
        let SfMember::Item(b) = m else {
            if matches!(k.as_str(), "v" | "at" | "d" | "ht" | "nor" | "nrr" | "ot" | "sf" | "st" | "su") || k.starts_with(PREFIX) {
                return Err(CmsdRefusal::Type);
            }
            continue;
        };
        let custom = k.strip_prefix(PREFIX);
        match (k.as_str(), custom) {
            ("v", _) => {
                if r_int(b)? != 1 {
                    return Err(CmsdRefusal::Version);
                }
            }
            ("at", _) => out.at = Some(r_int(b)?),
            ("d", _) => out.d = Some(r_int(b)?),
            ("ht", _) => out.ht = Some(r_int(b)?),
            ("nor", _) => out.nor = Some(r_str(b)?),
            ("nrr", _) => out.nrr = Some(r_str(b)?),
            ("ot", _) => out.ot = Some(r_token(b)?),
            ("sf", _) => out.sf = Some(r_token(b)?),
            ("st", _) => out.st = Some(r_token(b)?),
            ("su", _) => match b {
                SfBare::Bool(v) => out.su = *v,
                _ => return Err(CmsdRefusal::Type),
            },
            (_, Some("ch")) => out.content_hash = Some(r_hex(b)?),
            (_, Some("l")) => out.layer = Some(r_decimal(b)?),
            (_, Some("lt")) => out.layers_total = Some(r_decimal(b)?),
            (_, Some("rcpt")) => out.receipt = Some(r_hex(b)?),
            (_, Some("op")) => out.op = Some(r_token(b)?),
            _ => {}
        }
    }
    Ok(out)
}

/// Reads a `CMSD-Dynamic` field value: one entry per node, in order (§6.3).
pub fn read_dynamic(field: &str) -> Result<Vec<CmsdDynamic>, CmsdRefusal> {
    use super::sf::parse_list;
    let items = parse_list(field).map_err(|_| CmsdRefusal::Malformed)?;
    let mut out = Vec::with_capacity(items.len());
    for it in &items {
        let mut e = CmsdDynamic { server: r_str(&it.value)?, du: false, etp: None, mb: None, rd: None, rtt: None, drop: None };
        for (k, b) in &it.params {
            match k.as_str() {
                "du" => match b {
                    SfBare::Bool(v) => e.du = *v,
                    _ => return Err(CmsdRefusal::Type),
                },
                "etp" => e.etp = Some(r_int(b)?),
                "mb" => e.mb = Some(r_int(b)?),
                "rd" => e.rd = Some(r_int(b)?),
                "rtt" => e.rtt = Some(r_int(b)?),
                k if k.strip_prefix(PREFIX) == Some("drop") => e.drop = Some(r_decimal(b)?),
                _ => {}
            }
        }
        out.push(e);
    }
    Ok(out)
}

impl CmsdStatic {
    /// The description, in the form [`CmsdStatic::from_json`] reads.
    pub fn to_json(&self) -> Value {
        serde_json::json!({
            "at": self.at, "d": self.d, "ht": self.ht, "nor": self.nor, "nrr": self.nrr, "ot": self.ot,
            "sf": self.sf, "st": self.st, "su": self.su, "content_hash": self.content_hash, "layer": self.layer,
            "layers_total": self.layers_total, "receipt": self.receipt, "op": self.op,
        })
    }
}

impl CmsdDynamic {
    /// The entry, in the form [`CmsdDynamic::from_json`] reads.
    pub fn to_json(&self) -> Value {
        serde_json::json!({
            "server": self.server, "du": self.du, "etp": self.etp, "mb": self.mb, "rd": self.rd,
            "rtt": self.rtt, "drop": self.drop,
        })
    }
}

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

    #[test]
    fn what_is_written_reads_back() {
        let s = CmsdStatic { at: Some(5), d: Some(40), ot: Some("v".into()), su: true, layer: Some(0), layers_total: Some(4), content_hash: Some("ab".repeat(32)), op: Some("crop".into()), ..Default::default() };
        assert_eq!(read_static(&s.encode().unwrap()).unwrap(), s);
        let d = vec![CmsdDynamic { server: "edge-7".into(), du: true, etp: Some(1), mb: None, rd: Some(3), rtt: None, drop: Some(0) }];
        assert_eq!(read_dynamic(&encode_dynamic(&d).unwrap()).unwrap(), d);
        assert_eq!(read_static("v=2"), Err(CmsdRefusal::Version));
        assert_eq!(read_static("d=\"4\""), Err(CmsdRefusal::Type));
        assert_eq!(read_static("at=,d=4"), Err(CmsdRefusal::Malformed));
        assert_eq!(read_static("science.transaction.wai-l=\"01\""), Err(CmsdRefusal::Type));
        assert_eq!(read_dynamic("edge;rtt=4"), Err(CmsdRefusal::Type));
    }
}
