//! An observation (`extensions/delivery-telemetry.md` §2): one unsigned, advisory
//! event about WAI delivery that a sink, edge or origin emits.
//!
//! An observation is a JSON object with exactly the members of §2.1, each `null`
//! where it does not apply. [`Observation::from_json`] refuses anything else, so two
//! implementations read one observation alike; [`Observation::to_json`] writes the
//! members in §2.1's order.

use serde_json::{json, Map, Value};

use super::TelemetryError;

/// The largest integer an observation carries: `2⁵³ − 1`.
///
/// cbindgen:ignore
pub const MAX_INT: u64 = (1 << 53) - 1;

/// The event kinds of §2.1.
pub const EVENTS: &[&str] = &[
    "request", "response", "start", "present", "switch", "layer-change", "stall-start", "stall-end", "frame-drop", "capable", "abandon", "end",
];

/// The object types a request may name (CTA-5004-B `ot`).
pub const OBJECT_TYPES: &[&str] = &["m", "a", "v", "av", "i", "c", "tt", "k", "o"];

/// The switch reasons of §5: one list, kept where a claim verifier reads it too.
pub use crate::delivery_terms::SWITCH_REASONS;

/// The dispatch outcomes a `capable` observation reports (§2.3).
pub const CAPABLE_OUTCOMES: &[&str] = &["not-reached", "missing-capability", "pin-refused", "module-refused", "below-rate", "capable"];

/// The SPEC §3.1 pin refusals a `pin-refused` outcome names, in the order of its checks,
/// check 5 (`prior-revoked`, `prior-not-authorised`) included.
pub const PIN_REFUSALS: &[&str] = &[
    "pin-malformed", "form-not-registered", "form-unreadable", "form-required", "prior-revoked", "prior-not-authorised",
    "prior-not-held", "digest-superseded", "set-malformed", "part-mismatch", "set-incomplete",
];

/// The object a `request` or `response` observation is about.
#[derive(Debug, Clone, PartialEq, Eq, Default)]
pub struct ObjectObs {
    pub ot: Option<String>,
    pub url: Option<String>,
    pub content_hash: Option<String>,
    pub capability: Option<String>,
    pub rendition: Option<u64>,
    pub layer: Option<u64>,
    pub layers_total: Option<u64>,
    pub layer_cap: Option<u64>,
    pub counted: Option<u64>,
    pub transferred: Option<u64>,
    pub d_ms: Option<u64>,
    pub dl_ms: Option<u64>,
    pub ttfb_ms: Option<u64>,
    pub ttlb_ms: Option<u64>,
    pub rc: Option<u64>,
    /// `primary` or `fallback`.
    pub path: Option<String>,
    /// A receipt's link hash, 64 lowercase hexadecimal digits.
    pub receipt: Option<String>,
}

/// A presentation.
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct PresentObs {
    pub unit: u64,
    pub layers: u64,
    /// The session's first presentation.
    pub first: bool,
    pub late_ms: Option<u64>,
    pub decode_us: Option<u64>,
}

/// A switch between paths.
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct SwitchObs {
    pub unit: u64,
    pub from: String,
    pub to: String,
    pub reason: String,
}

/// A change of `layer_cap`; `None` is no cap.
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct LayerChangeObs {
    pub from: Option<u64>,
    pub to: Option<u64>,
}

/// A dispatch outcome.
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct CapableObs {
    pub outcome: String,
    /// For `pin-refused`, the refusal; otherwise `None`.
    pub reason: Option<String>,
    /// The answer for the classical rendition's codec string: `supported` or
    /// `unsupported`.
    pub classical_floor: Option<String>,
}

/// Session metrics a report may carry.
#[derive(Debug, Clone, PartialEq, Eq, Default)]
pub struct MetricsObs {
    pub msd_ms: Option<u64>,
    pub bsa: Option<u64>,
    pub bsda_ms: Option<u64>,
    pub dfa: Option<u64>,
}

/// One observation.
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct Observation {
    pub sid: String,
    pub sn: u64,
    pub ts_ms: Option<u64>,
    pub event: String,
    pub cid: Option<String>,
    pub st: String,
    pub sf: String,
    pub object: Option<ObjectObs>,
    pub present: Option<PresentObs>,
    pub switch: Option<SwitchObs>,
    pub layer_change: Option<LayerChangeObs>,
    pub capable: Option<CapableObs>,
    pub cohort: Option<String>,
    pub metrics: Option<MetricsObs>,
    pub claim: Option<String>,
}

const MEMBERS: &[&str] = &[
    "v", "sid", "sn", "ts_ms", "event", "cid", "st", "sf", "object", "present", "switch", "layer_change", "capable", "cohort", "metrics", "claim",
];
const OBJECT_MEMBERS: &[&str] = &[
    "ot", "url", "content_hash", "capability", "rendition", "layer", "layers_total", "layer_cap", "counted", "transferred", "d_ms", "dl_ms",
    "ttfb_ms", "ttlb_ms", "rc", "path", "receipt",
];

fn bad(code: &str) -> TelemetryError {
    TelemetryError::Invalid(code.to_string())
}

fn exact<'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)
}

fn int(v: &Value, at: &str) -> Result<u64, TelemetryError> {
    v.as_u64().filter(|&x| x <= MAX_INT).ok_or_else(|| bad(&format!("{at}: not an integer in 0…2⁵³ − 1")))
}

fn opt_int(v: &Value, at: &str) -> Result<Option<u64>, TelemetryError> {
    if v.is_null() { Ok(None) } else { int(v, at).map(Some) }
}

/// The largest integer a report carries as an RFC 9651 Integer: `10¹⁵ − 1`.
///
/// cbindgen:ignore
pub const MAX_SF_INT: u64 = 999_999_999_999_999;

/// A member a report carries as an Integer (§3.3): at most [`MAX_SF_INT`], so every
/// observation read is one a report can carry.
fn sf_int(v: &Value, at: &str) -> Result<u64, TelemetryError> {
    v.as_u64().filter(|&x| x <= MAX_SF_INT).ok_or_else(|| bad(&format!("{at}: not an integer in 0…10¹⁵ − 1, which an RFC 9651 Integer holds")))
}

fn opt_sf_int(v: &Value, at: &str) -> Result<Option<u64>, TelemetryError> {
    if v.is_null() { Ok(None) } else { sf_int(v, at).map(Some) }
}

/// A string of printable ASCII (what a Structured Field String carries) of at most `max`
/// characters.
fn text(v: &Value, max: usize, at: &str) -> Result<String, TelemetryError> {
    let s = v.as_str().ok_or_else(|| bad(&format!("{at}: not a string")))?;
    if s.is_empty() || s.len() > max || !s.bytes().all(|c| (0x20..=0x7e).contains(&c)) {
        return Err(bad(&format!("{at}: not 1–{max} printable ASCII characters")));
    }
    Ok(s.to_string())
}

fn opt_text(v: &Value, max: usize, at: &str) -> Result<Option<String>, TelemetryError> {
    if v.is_null() { Ok(None) } else { text(v, max, at).map(Some) }
}

/// `cid` (§2.1, §8): the entry object's content hash, 64 lowercase hexadecimal digits,
/// or a deployer's content id of 1–64 letters, digits, `.`, `_` and `-`, which can hold
/// no address, query or token.
fn opt_cid(v: &Value) -> Result<Option<String>, TelemetryError> {
    match v {
        Value::Null => Ok(None),
        Value::String(s) if !s.is_empty() && s.len() <= 64 && s.bytes().all(|c| c.is_ascii_alphanumeric() || matches!(c, b'.' | b'_' | b'-')) => Ok(Some(s.clone())),
        _ => Err(bad("cid: not a content hash or 1–64 letters, digits, `.`, `_` and `-`")),
    }
}

/// `object.url` (§2.2, §8.2): printable ASCII, at most 2048 characters, holding no `?`,
/// `#`, `@` or `\`, so no query, fragment or user information enters an observation,
/// however a URL parser would split it (some read `\` as `/`, which moves user
/// information past a check of the authority alone). A URL that holds one is refused,
/// not stripped, and a path that holds a `@` with it.
fn opt_url(v: &Value) -> Result<Option<String>, TelemetryError> {
    let u = opt_text(v, 2048, "object.url")?;
    if let Some(c) = u.as_deref().and_then(|u| u.chars().find(|c| matches!(c, '?' | '#' | '@' | '\\'))) {
        return Err(bad(&format!("object.url: holds `{c}` (a query, a fragment, user information or a backslash)")));
    }
    Ok(u)
}

fn one_of(v: &Value, set: &[&str], at: &str) -> Result<String, TelemetryError> {
    let s = v.as_str().filter(|s| set.contains(s)).ok_or_else(|| bad(&format!("{at}: not one of {set:?}")))?;
    Ok(s.to_string())
}

fn opt_one_of(v: &Value, set: &[&str], at: &str) -> Result<Option<String>, TelemetryError> {
    if v.is_null() { Ok(None) } else { one_of(v, set, at).map(Some) }
}

fn is_hex(s: &str, n: usize) -> bool {
    s.len() == n && s.bytes().all(|c| c.is_ascii_digit() || (b'a'..=b'f').contains(&c))
}

fn opt_hex64(v: &Value, at: &str) -> Result<Option<String>, TelemetryError> {
    match v {
        Value::Null => Ok(None),
        Value::String(s) if is_hex(s, 64) => Ok(Some(s.clone())),
        _ => Err(bad(&format!("{at}: not 64 lowercase hexadecimal digits"))),
    }
}

impl Observation {
    /// Whether this observation is reported in CMCD Event mode (§3.1): every event but
    /// `request`, and a `present` only when it is the session's first.
    pub fn is_event_mode(&self) -> bool {
        match self.event.as_str() {
            "request" => false,
            "present" => self.present.as_ref().is_some_and(|p| p.first),
            _ => true,
        }
    }

    /// An observation from its JSON (§2.1).
    pub fn from_json(v: &Value) -> Result<Observation, TelemetryError> {
        let o = exact(v, MEMBERS, "observation")?;
        if o["v"] != json!(1) {
            return Err(bad("v: not 1"));
        }
        let sid = o["sid"].as_str().filter(|s| is_hex(s, 32)).ok_or_else(|| bad("sid: not 32 lowercase hexadecimal digits"))?.to_string();
        let event = one_of(&o["event"], EVENTS, "event")?;
        let cid = opt_cid(&o["cid"])?;
        let object = match &o["object"] {
            Value::Null => None,
            x => {
                let m = exact(x, OBJECT_MEMBERS, "object")?;
                let ob = ObjectObs {
                    ot: opt_one_of(&m["ot"], OBJECT_TYPES, "object.ot")?,
                    url: opt_url(&m["url"])?,
                    content_hash: opt_hex64(&m["content_hash"], "object.content_hash")?,
                    capability: opt_text(&m["capability"], 64, "object.capability")?,
                    rendition: opt_int(&m["rendition"], "object.rendition")?,
                    layer: opt_int(&m["layer"], "object.layer")?,
                    layers_total: opt_int(&m["layers_total"], "object.layers_total")?,
                    layer_cap: opt_int(&m["layer_cap"], "object.layer_cap")?,
                    counted: opt_int(&m["counted"], "object.counted")?,
                    transferred: opt_int(&m["transferred"], "object.transferred")?,
                    d_ms: opt_sf_int(&m["d_ms"], "object.d_ms")?,
                    dl_ms: opt_sf_int(&m["dl_ms"], "object.dl_ms")?,
                    ttfb_ms: opt_sf_int(&m["ttfb_ms"], "object.ttfb_ms")?,
                    ttlb_ms: opt_sf_int(&m["ttlb_ms"], "object.ttlb_ms")?,
                    rc: opt_sf_int(&m["rc"], "object.rc")?,
                    path: opt_one_of(&m["path"], &["primary", "fallback"], "object.path")?,
                    receipt: opt_hex64(&m["receipt"], "object.receipt")?,
                };
                Some(ob)
            }
        };
        let present = match &o["present"] {
            Value::Null => None,
            x => {
                let m = exact(x, &["unit", "layers", "first", "late_ms", "decode_us"], "present")?;
                Some(PresentObs {
                    unit: int(&m["unit"], "present.unit")?,
                    layers: int(&m["layers"], "present.layers")?,
                    first: m["first"].as_bool().ok_or_else(|| bad("present.first: not a boolean"))?,
                    late_ms: opt_int(&m["late_ms"], "present.late_ms")?,
                    decode_us: opt_int(&m["decode_us"], "present.decode_us")?,
                })
            }
        };
        let switch = match &o["switch"] {
            Value::Null => None,
            x => {
                let m = exact(x, &["unit", "from", "to", "reason"], "switch")?;
                Some(SwitchObs {
                    unit: int(&m["unit"], "switch.unit")?,
                    from: one_of(&m["from"], &["primary", "fallback"], "switch.from")?,
                    to: one_of(&m["to"], &["primary", "fallback"], "switch.to")?,
                    reason: one_of(&m["reason"], SWITCH_REASONS, "switch.reason")?,
                })
            }
        };
        let layer_change = match &o["layer_change"] {
            Value::Null => None,
            x => {
                let m = exact(x, &["from", "to"], "layer_change")?;
                Some(LayerChangeObs { from: opt_int(&m["from"], "layer_change.from")?, to: opt_int(&m["to"], "layer_change.to")? })
            }
        };
        let capable = match &o["capable"] {
            Value::Null => None,
            x => {
                let m = exact(x, &["outcome", "reason", "classical_floor"], "capable")?;
                Some(CapableObs {
                    outcome: one_of(&m["outcome"], CAPABLE_OUTCOMES, "capable.outcome")?,
                    reason: opt_one_of(&m["reason"], PIN_REFUSALS, "capable.reason")?,
                    classical_floor: opt_one_of(&m["classical_floor"], &["supported", "unsupported"], "capable.classical_floor")?,
                })
            }
        };
        let metrics = match &o["metrics"] {
            Value::Null => None,
            x => {
                let m = exact(x, &["msd_ms", "bsa", "bsda_ms", "dfa"], "metrics")?;
                Some(MetricsObs {
                    msd_ms: opt_sf_int(&m["msd_ms"], "metrics.msd_ms")?,
                    bsa: opt_sf_int(&m["bsa"], "metrics.bsa")?,
                    bsda_ms: opt_sf_int(&m["bsda_ms"], "metrics.bsda_ms")?,
                    dfa: opt_sf_int(&m["dfa"], "metrics.dfa")?,
                })
            }
        };
        let ob = Observation {
            sid,
            sn: sf_int(&o["sn"], "sn")?,
            ts_ms: opt_sf_int(&o["ts_ms"], "ts_ms")?,
            cid,
            st: one_of(&o["st"], &["v", "l", "ll"], "st")?,
            sf: one_of(&o["sf"], &["o", "h", "d"], "sf")?,
            object,
            present,
            switch,
            layer_change,
            capable,
            cohort: opt_one_of(&o["cohort"], &["control", "wai"], "cohort")?,
            metrics,
            claim: opt_hex64(&o["claim"], "claim")?,
            event,
        };
        ob.check()?;
        Ok(ob)
    }

    /// The rules that tie the members to the event (§2.1).
    fn check(&self) -> Result<(), TelemetryError> {
        let e = self.event.as_str();
        let want = |member: &str, present: bool, applies: bool| -> Result<(), TelemetryError> {
            if present != applies {
                Err(bad(&format!("{member}: {} for event `{e}`", if applies { "required" } else { "not allowed" })))
            } else {
                Ok(())
            }
        };
        want("object", self.object.is_some(), matches!(e, "request" | "response"))?;
        want("present", self.present.is_some(), e == "present")?;
        want("switch", self.switch.is_some(), e == "switch")?;
        want("layer_change", self.layer_change.is_some(), e == "layer-change")?;
        want("capable", self.capable.is_some(), e == "capable")?;
        if self.claim.is_some() && e != "end" {
            return Err(bad("claim: only on `end`"));
        }
        if self.is_event_mode() && self.ts_ms.is_none() {
            return Err(bad("ts_ms: required for an event reported in Event mode"));
        }
        if let Some(ob) = &self.object {
            let response_only = [ob.ttfb_ms, ob.ttlb_ms, ob.rc].iter().any(Option::is_some) || ob.url.is_some();
            let request_only = [ob.d_ms, ob.dl_ms, ob.layer_cap].iter().any(Option::is_some) || ob.ot.is_some();
            if e == "request" && response_only {
                return Err(bad("object: a response key on a request"));
            }
            if e == "response" && request_only {
                return Err(bad("object: a request key on a response"));
            }
        }
        if let Some(s) = &self.switch {
            if s.from == s.to {
                return Err(bad("switch: from and to are one path"));
            }
        }
        if let Some(c) = &self.capable {
            if (c.outcome == "pin-refused") != c.reason.is_some() {
                return Err(bad("capable.reason: exactly for pin-refused"));
            }
        }
        if let Some(p) = &self.present {
            if p.layers == 0 {
                return Err(bad("present.layers: at least 1"));
            }
        }
        Ok(())
    }

    /// The observation as JSON, members in §2.1's order.
    pub fn to_json(&self) -> Value {
        let mut m = Map::new();
        m.insert("v".into(), json!(1));
        m.insert("sid".into(), json!(self.sid));
        m.insert("sn".into(), json!(self.sn));
        m.insert("ts_ms".into(), json!(self.ts_ms));
        m.insert("event".into(), json!(self.event));
        m.insert("cid".into(), json!(self.cid));
        m.insert("st".into(), json!(self.st));
        m.insert("sf".into(), json!(self.sf));
        m.insert("object".into(), match &self.object {
            None => Value::Null,
            Some(o) => json!({
                "ot": o.ot, "url": o.url, "content_hash": o.content_hash, "capability": o.capability, "rendition": o.rendition,
                "layer": o.layer, "layers_total": o.layers_total, "layer_cap": o.layer_cap, "counted": o.counted,
                "transferred": o.transferred, "d_ms": o.d_ms, "dl_ms": o.dl_ms, "ttfb_ms": o.ttfb_ms, "ttlb_ms": o.ttlb_ms,
                "rc": o.rc, "path": o.path, "receipt": o.receipt,
            }),
        });
        m.insert("present".into(), match &self.present {
            None => Value::Null,
            Some(p) => json!({"unit": p.unit, "layers": p.layers, "first": p.first, "late_ms": p.late_ms, "decode_us": p.decode_us}),
        });
        m.insert("switch".into(), match &self.switch {
            None => Value::Null,
            Some(s) => json!({"unit": s.unit, "from": s.from, "to": s.to, "reason": s.reason}),
        });
        m.insert("layer_change".into(), match &self.layer_change {
            None => Value::Null,
            Some(l) => json!({"from": l.from, "to": l.to}),
        });
        m.insert("capable".into(), match &self.capable {
            None => Value::Null,
            Some(c) => json!({"outcome": c.outcome, "reason": c.reason, "classical_floor": c.classical_floor}),
        });
        m.insert("cohort".into(), json!(self.cohort));
        m.insert("metrics".into(), match &self.metrics {
            None => Value::Null,
            Some(x) => json!({"msd_ms": x.msd_ms, "bsa": x.bsa, "bsda_ms": x.bsda_ms, "dfa": x.dfa}),
        });
        m.insert("claim".into(), json!(self.claim));
        Value::Object(m)
    }
}
