//! Session metrics from one session's observations (`extensions/delivery-telemetry.md`
//! §7): start, first presentation, exit before start, start failure, stalls,
//! engagement, units presented and counted bytes, in integer milliseconds and bytes,
//! with no clock but the observations' own timestamps.

use std::collections::BTreeSet;

use serde_json::{json, Value};

use super::observation::Observation;
use super::TelemetryError;

/// What one session's log gives (§7.2).
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct SessionMetrics {
    /// The log has a `start`.
    pub started: bool,
    /// The video start time: the first presentation's time less the start's.
    pub vst_ms: Option<u64>,
    /// Exit before video start: an `abandon` before any first presentation.
    pub exit_before_start: bool,
    /// Start failure: a session that started, never presented, and ended without an
    /// `abandon`.
    pub start_failed: bool,
    /// Stalls begun after the first presentation, and their total duration; a stall
    /// still open at `end` or `abandon` closes there.
    pub stalls: u64,
    pub stall_ms: u64,
    /// From the first presentation to `end` or `abandon`.
    pub engaged_ms: u64,
    /// Distinct units presented.
    pub units: u64,
    /// The counted bytes of every response.
    pub counted: u64,
    /// The `end` observation's claim pointer.
    pub claim: Option<String>,
}

impl SessionMetrics {
    pub fn to_json(&self) -> Value {
        json!({
            "started": self.started, "vst_ms": self.vst_ms, "exit_before_start": self.exit_before_start,
            "start_failed": self.start_failed, "stalls": self.stalls, "stall_ms": self.stall_ms,
            "engaged_ms": self.engaged_ms, "units": self.units, "counted": self.counted, "claim": self.claim,
        })
    }
}

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

/// The metrics of one session's log, in `sn` order. Every observation has the log's
/// `sid`, `sn` rises strictly, timestamps never decrease, and nothing follows `end` or
/// `abandon`.
pub fn session_metrics(log: &[Observation]) -> Result<SessionMetrics, TelemetryError> {
    let first = log.first().ok_or_else(|| bad("an empty log"))?;
    let mut m = SessionMetrics {
        started: false,
        vst_ms: None,
        exit_before_start: false,
        start_failed: false,
        stalls: 0,
        stall_ms: 0,
        engaged_ms: 0,
        units: 0,
        counted: 0,
        claim: None,
    };
    let mut start_ms: Option<u64> = None;
    let mut present_ms: Option<u64> = None;
    let mut stall_open: Option<u64> = None;
    let mut units: BTreeSet<u64> = BTreeSet::new();
    let mut last_ts = 0u64;
    let mut ended = false;
    for (i, o) in log.iter().enumerate() {
        if o.sid != first.sid {
            return Err(bad(format!("observation {i}: another session")));
        }
        if i > 0 && o.sn <= log[i - 1].sn {
            return Err(bad(format!("observation {i}: sn does not rise")));
        }
        if ended {
            return Err(bad(format!("observation {i}: after the session's end")));
        }
        if let Some(t) = o.ts_ms {
            if t < last_ts {
                return Err(bad(format!("observation {i}: time runs backwards")));
            }
            last_ts = t;
        }
        let t = o.ts_ms;
        match o.event.as_str() {
            "start" if start_ms.is_none() => {
                m.started = true;
                start_ms = t;
            }
            "present" => {
                let p = o.present.as_ref().expect("checked");
                units.insert(p.unit);
                if p.first {
                    if present_ms.is_some() {
                        return Err(bad(format!("observation {i}: a second first presentation")));
                    }
                    present_ms = t;
                    m.vst_ms = match (start_ms, t) {
                        (Some(s), Some(t)) => Some(t - s.min(t)),
                        _ => None,
                    };
                }
            }
            "stall-start" if present_ms.is_some() && stall_open.is_none() => stall_open = t,
            "stall-end" => {
                if let (Some(s), Some(e)) = (stall_open.take(), t) {
                    m.stalls += 1;
                    m.stall_ms = m.stall_ms.checked_add(e - s).ok_or_else(|| bad("stall time passes 64 bits"))?;
                }
            }
            "response" => {
                let c = o.object.as_ref().and_then(|x| x.counted).unwrap_or(0);
                m.counted = m.counted.checked_add(c).ok_or_else(|| bad(format!("observation {i}: counted bytes pass 64 bits")))?;
            }
            "abandon" | "end" => {
                ended = true;
                let e = t.expect("an Event-mode report carries a timestamp");
                if let Some(s) = stall_open.take() {
                    m.stalls += 1;
                    m.stall_ms = m.stall_ms.checked_add(e - s).ok_or_else(|| bad("stall time passes 64 bits"))?;
                }
                match present_ms {
                    Some(p) => m.engaged_ms = e - p,
                    None if o.event == "abandon" => m.exit_before_start = true,
                    None => m.start_failed = m.started,
                }
                m.claim = o.claim.clone();
            }
            _ => {}
        }
    }
    m.units = units.len() as u64;
    Ok(m)
}
