//! `wai_telemetry` — delivery telemetry (`extensions/delivery-telemetry.md`): an
//! observation's CMCD report, reading a report back, and the corpus
//! `telemetry-conformance/`.
//!
//!   wai_telemetry encode <observation.json>
//!   wai_telemetry decode <dictionary>
//!   wai_telemetry verify <corpus>
//!   wai_telemetry gen <corpus>
//!
//! `verify` reads every observation, CMSD description and session log of the corpus,
//! requires each observation to read back to its own bytes, recomputes every expected
//! file and requires it byte for byte, and requires every observation under `invalid/`
//! to be refused. `gen` writes the corpus; it never runs in CI.

use std::collections::BTreeMap;
use std::path::Path;
use std::process::ExitCode;

use serde_json::{json, Value};
use wai::telemetry::cmcd::{cmcd, cmcd_headers, from_cmcd, Report};
use wai::telemetry::cmsd::{encode_dynamic, read_dynamic, read_static, CmsdDynamic, CmsdStatic};
use wai::telemetry::observation::Observation;
use wai::telemetry::qoe::session_metrics;
use wai::telemetry::sf::{SfBare, SfMember};

const SID: &str = "6f1c2a9e0b7d4c3a8e5f1d2c3b4a5968";
const SID2: &str = "0d9c8b7a6f5e4d3c2b1a09f8e7d6c5b4";
const CH: &str = "4fc1bd94bd48b2c1ded99c0053eed742f5399cec09dbd2f8694cca6a30e44875";
const BASE: &str = "aaf90792d6ff5318e5f306fbb000dfcbe73637e244aad1a2eff6dae537cd6b5f";
const RCPT: &str = "7bf0f413db62aa185969b9ccf925d40f0281601268899bb7f2e97344487f4189";
const CLAIM: &str = "e6e42e268f003a39dd887e6bdaf1501a270bcc63c29d5c40bbeedcb0b29d4277";
const T0: u64 = 1_790_000_000_000;

fn pretty(v: &Value) -> Vec<u8> {
    let mut s = serde_json::to_string_pretty(v).expect("JSON");
    s.push('\n');
    s.into_bytes()
}

fn obs(sid: &str, sn: u64, ts: Option<u64>, event: &str) -> Value {
    json!({
        "v": 1, "sid": sid, "sn": sn, "ts_ms": ts, "event": event, "cid": BASE, "st": "v", "sf": "o",
        "object": null, "present": null, "switch": null, "layer_change": null, "capable": null, "cohort": null,
        "metrics": null, "claim": null,
    })
}

fn object(members: Value) -> Value {
    let mut o = json!({
        "ot": null, "url": null, "content_hash": null, "capability": null, "rendition": null, "layer": null,
        "layers_total": null, "layer_cap": null, "counted": null, "transferred": null, "d_ms": null, "dl_ms": null,
        "ttfb_ms": null, "ttlb_ms": null, "rc": null, "path": null, "receipt": null,
    });
    for (k, x) in members.as_object().expect("members") {
        o[k] = x.clone();
    }
    o
}

fn with(mut v: Value, k: &str, x: Value) -> Value {
    v[k] = x;
    v
}

fn present(unit: u64, layers: u64, first: bool, late: Option<u64>, dec: Option<u64>) -> Value {
    json!({"unit": unit, "layers": layers, "first": first, "late_ms": late, "decode_us": dec})
}

/// The observations: every event kind, and the members each maps.
fn observations() -> Vec<(&'static str, Value)> {
    let req = |sn, m: Value| with(obs(SID, sn, None, "request"), "object", object(m));
    vec![
        ("01-request-base", with(req(0, json!({"ot": "v", "content_hash": BASE, "capability": "wai.neural.int_hyper", "layer": 0, "layers_total": 4, "counted": 460, "d_ms": 40, "dl_ms": 200, "path": "primary"})), "cohort", json!("wai"))),
        ("02-request-layer", req(1, json!({"ot": "v", "content_hash": CH, "capability": "wai.image.int_refine", "layer": 1, "layers_total": 4, "layer_cap": 2, "counted": 951, "transferred": 951, "dl_ms": 400, "path": "primary", "receipt": RCPT}))),
        ("03-request-rendition", with(req(2, json!({"ot": "o", "rendition": 1, "counted": 2519, "path": "fallback"})), "cid", Value::Null)),
        ("04-response", with(obs(SID, 3, Some(T0 + 120), "response"), "object", object(json!({"url": "https://media.example/t/base.wai", "content_hash": BASE, "counted": 460, "transferred": 460, "ttfb_ms": 38, "ttlb_ms": 41, "rc": 200, "path": "primary"})))),
        ("05-start", obs(SID, 4, Some(T0), "start")),
        ("06-present-first", with(with(obs(SID, 5, Some(T0 + 160), "present"), "present", present(0, 1, true, None, Some(2900))), "metrics", json!({"msd_ms": 160, "bsa": null, "bsda_ms": null, "dfa": null}))),
        ("07-present-later", with(obs(SID, 6, Some(T0 + 210), "present"), "present", present(0, 2, false, Some(10), Some(1400)))),
        ("08-stall-start", with(obs(SID, 7, Some(T0 + 4000), "stall-start"), "metrics", json!({"msd_ms": null, "bsa": 1, "bsda_ms": 0, "dfa": null}))),
        ("09-stall-end", with(obs(SID, 8, Some(T0 + 4350), "stall-end"), "metrics", json!({"msd_ms": null, "bsa": 1, "bsda_ms": 350, "dfa": null}))),
        ("10-switch", with(obs(SID, 9, Some(T0 + 5000), "switch"), "switch", json!({"unit": 125, "from": "primary", "to": "fallback", "reason": "prior-not-held"}))),
        ("11-layer-change", with(obs(SID, 10, Some(T0 + 6000), "layer-change"), "layer_change", json!({"from": null, "to": 1}))),
        ("12-frame-drop", with(obs(SID, 11, Some(T0 + 6500), "frame-drop"), "metrics", json!({"msd_ms": null, "bsa": null, "bsda_ms": null, "dfa": 3}))),
        ("13-capable-pin-refused", with(obs(SID2, 0, Some(T0 + 10), "capable"), "capable", json!({"outcome": "pin-refused", "reason": "part-mismatch", "classical_floor": "supported"}))),
        ("14-capable", with(with(obs(SID2, 1, Some(T0 + 11), "capable"), "capable", json!({"outcome": "capable", "reason": null, "classical_floor": null})), "cohort", json!("control"))),
        ("15-abandon", obs(SID2, 2, Some(T0 + 900), "abandon")),
        ("16-end", with(obs(SID, 12, Some(T0 + 9000), "end"), "claim", json!(CLAIM))),
        ("17-live-low-latency", with(with(obs(SID, 13, Some(T0 + 9100), "start"), "st", json!("ll")), "sf", json!("h"))),
        ("18-switch-prior-revoked", with(obs(SID, 14, Some(T0 + 9200), "switch"), "switch", json!({"unit": 126, "from": "primary", "to": "fallback", "reason": "prior-revoked"}))),
        ("19-capable-not-authorised", with(obs(SID2, 3, Some(T0 + 12), "capable"), "capable", json!({"outcome": "pin-refused", "reason": "prior-not-authorised", "classical_floor": null}))),
        // Strings an RFC 9651 String escapes: a quote and a backslash in a Request-mode
        // custom key, and a quote in an Event-mode report's url (which holds no `\`).
        ("20-request-escaped", req(15, json!({"ot": "v", "capability": "x.test.\"quoted\"\\cap", "counted": 12, "path": "primary"}))),
        ("21-response-escaped", with(obs(SID, 16, Some(T0 + 9300), "response"), "object", object(json!({"url": "https://media.example/t/a\"b.wai", "counted": 12, "rc": 200, "path": "primary"})))),
    ]
}

/// Observations every reader refuses, with what is wrong.
fn invalid() -> Vec<(&'static str, Value)> {
    let base = obs(SID, 0, Some(T0), "start");
    let mut missing = base.clone();
    missing.as_object_mut().expect("object").remove("cohort");
    vec![
        ("unknown-member", with(base.clone(), "extra", json!(1))),
        ("missing-member", missing),
        ("version-2", with(base.clone(), "v", json!(2))),
        ("sid-not-hex", with(base.clone(), "sid", json!("not-a-session-id-not-a-session-i"))),
        ("event-mode-without-time", with(base.clone(), "ts_ms", Value::Null)),
        ("sn-past-2-53", with(base.clone(), "sn", json!(9_007_199_254_740_992u64))),
        ("ts-past-sf-integer", with(base.clone(), "ts_ms", json!(1_000_000_000_000_000u64))),
        ("claim-before-end", with(base.clone(), "claim", json!(CLAIM))),
        ("unknown-event", with(base.clone(), "event", json!("pause"))),
        ("response-key-on-request", with(obs(SID, 0, None, "request"), "object", object(json!({"rc": 200})))),
        ("request-key-on-response", with(obs(SID, 0, Some(T0), "response"), "object", object(json!({"dl_ms": 200})))),
        ("switch-to-same-path", with(obs(SID, 0, Some(T0), "switch"), "switch", json!({"unit": 1, "from": "primary", "to": "primary", "reason": "trap"}))),
        ("switch-unknown-reason", with(obs(SID, 0, Some(T0), "switch"), "switch", json!({"unit": 1, "from": "primary", "to": "fallback", "reason": "slow"}))),
        ("pin-refused-without-reason", with(obs(SID, 0, Some(T0), "capable"), "capable", json!({"outcome": "pin-refused", "reason": null, "classical_floor": null}))),
        ("object-type-unknown", with(obs(SID, 0, None, "request"), "object", object(json!({"ot": "x"})))),
        ("cid-past-64", with(base.clone(), "cid", json!("c".repeat(65)))),
        ("cid-an-address", with(base.clone(), "cid", json!("viewer@example.org"))),
        ("cid-a-url", with(base.clone(), "cid", json!("https://media.example/t"))),
        ("url-with-query", with(obs(SID, 0, Some(T0), "response"), "object", object(json!({"url": "https://media.example/t/base.wai?token=abc", "rc": 200})))),
        ("url-with-fragment", with(obs(SID, 0, Some(T0), "response"), "object", object(json!({"url": "https://media.example/t/base.wai#t=1", "rc": 200})))),
        ("url-with-userinfo", with(obs(SID, 0, Some(T0), "response"), "object", object(json!({"url": "https://viewer:secret@media.example/t/base.wai", "rc": 200})))),
        ("url-userinfo-behind-backslashes", with(obs(SID, 0, Some(T0), "response"), "object", object(json!({"url": "https:\\\\viewer:secret@media.example/t/base.wai", "rc": 200})))),
        ("url-userinfo-without-slashes", with(obs(SID, 0, Some(T0), "response"), "object", object(json!({"url": "https:viewer:secret@media.example/t/base.wai", "rc": 200})))),
        ("url-userinfo-behind-a-mixed-separator", with(obs(SID, 0, Some(T0), "response"), "object", object(json!({"url": "https:/\\viewer:secret@media.example/t/base.wai", "rc": 200})))),
        ("url-with-a-backslash", with(obs(SID, 0, Some(T0), "response"), "object", object(json!({"url": "https://media.example\\t/base.wai", "rc": 200})))),
        ("url-with-an-at-in-its-path", with(obs(SID, 0, Some(T0), "response"), "object", object(json!({"url": "https://media.example/u/viewer@example.org/base.wai", "rc": 200})))),
        ("present-zero-layers", with(obs(SID, 0, Some(T0), "present"), "present", present(0, 0, true, None, None))),
        ("cohort-unknown", with(base, "cohort", json!("treatment"))),
    ]
}

fn cmsd_cases() -> Vec<(&'static str, Value)> {
    vec![
        ("static-base", json!({"at": T0, "d": 40, "ht": null, "nor": "L01.wai", "nrr": null, "ot": "v", "sf": "o", "st": "v", "su": true, "content_hash": BASE, "layer": 0, "layers_total": 4, "receipt": RCPT, "op": null})),
        ("static-derived", json!({"at": T0 + 5, "d": null, "ht": 20, "nor": null, "nrr": "0-1023", "ot": "o", "sf": "o", "st": "l", "su": false, "content_hash": CH, "layer": 1, "layers_total": 4, "receipt": null, "op": "crop"})),
        ("dynamic-two-nodes", json!([
            {"server": "edge-7", "du": false, "etp": 25000, "mb": 8000, "rd": 3, "rtt": 40, "drop": 2},
            {"server": "origin-1", "du": true, "etp": null, "mb": null, "rd": 12, "rtt": null, "drop": null},
        ])),
    ]
}

/// CMSD fields to read (§6.6): what §6.1 to §6.3 write, the same with its keys in another
/// order, `v=1` and a key the extension does not name, and fields each refusal refuses.
fn cmsd_read_cases() -> Vec<(&'static str, &'static str, String)> {
    let base = format!("at={T0},d=40,nor=\"L01.wai\",ot=v,science.transaction.wai-ch=\"{BASE}\",science.transaction.wai-l=\"0\",science.transaction.wai-lt=\"4\",science.transaction.wai-rcpt=\"{RCPT}\",sf=o,st=v,su");
    vec![
        ("static-as-written", "static", base.clone()),
        ("static-another-order", "static", format!("v=1,su,st=v,sf=o,science.transaction.wai-rcpt=\"{RCPT}\",science.transaction.wai-lt=\"4\",science.transaction.wai-l=\"0\",science.transaction.wai-ch=\"{BASE}\",ot=v,nor=\"L01.wai\",d=40,at={T0},org.example-x=\"y\"")),
        ("static-su-false", "static", format!("at={T0},ot=o,su=?0")),
        ("static-derived-op", "static", format!("at={},ht=20,nrr=\"0-1023\",ot=o,science.transaction.wai-ch=\"{CH}\",science.transaction.wai-l=\"1\",science.transaction.wai-lt=\"4\",science.transaction.wai-op=crop,sf=o,st=l", T0 + 5)),
        ("dynamic-two-nodes", "dynamic", "\"edge-7\";etp=25000;mb=8000;rd=3;rtt=40;science.transaction.wai-drop=\"2\",\"origin-1\";du;rd=12".into()),
        ("dynamic-unknown-parameter", "dynamic", "\"edge-7\";rtt=40;xyz=1".into()),
        ("static-not-a-dictionary", "static", "at=,d=40".into()),
        ("static-duration-a-string", "static", "d=\"40\"".into()),
        ("static-layer-not-decimal", "static", "science.transaction.wai-l=\"one\"".into()),
        ("static-layer-leading-zero", "static", "science.transaction.wai-l=\"01\"".into()),
        ("static-hash-short", "static", "science.transaction.wai-ch=\"abc\"".into()),
        ("static-op-a-string", "static", "science.transaction.wai-op=\"crop\"".into()),
        ("static-version-2", "static", "v=2,ot=v".into()),
        ("dynamic-server-a-token", "dynamic", "edge-7;rtt=40".into()),
        ("dynamic-throughput-a-string", "dynamic", "\"edge-7\";etp=\"1\"".into()),
        ("dynamic-drop-not-decimal", "dynamic", "\"edge-7\";science.transaction.wai-drop=\"two\"".into()),
        ("dynamic-not-a-list", "dynamic", "\"edge-7\",".into()),
    ]
}

fn cmsd_read(field: &str, value: &str) -> Value {
    let r = if field == "static" {
        read_static(value).map(|s| s.to_json())
    } else {
        read_dynamic(value).map(|d| Value::Array(d.iter().map(CmsdDynamic::to_json).collect()))
    };
    match r {
        Ok(v) => json!({ "read": v }),
        Err(e) => json!({ "refuse": e.code() }),
    }
}

/// Session logs: one session each.
fn logs() -> Vec<(&'static str, Vec<Value>)> {
    let o = |sid, sn, t, e| obs(sid, sn, Some(T0 + t), e);
    let resp = |sid, sn, t, counted: u64| with(o(sid, sn, t, "response"), "object", object(json!({"counted": counted, "rc": 200})));
    vec![
        ("session-stalls", vec![
            o(SID, 0, 0, "start"),
            resp(SID, 1, 100, 460),
            with(o(SID, 2, 160, "present"), "present", present(0, 1, true, None, None)),
            resp(SID, 3, 180, 951),
            with(o(SID, 4, 210, "present"), "present", present(0, 2, false, None, None)),
            o(SID, 5, 4000, "stall-start"),
            o(SID, 6, 4350, "stall-end"),
            with(o(SID, 7, 4400, "present"), "present", present(1, 1, false, Some(30), None)),
            o(SID, 8, 8000, "stall-start"),
            with(o(SID, 9, 9000, "end"), "claim", json!(CLAIM)),
        ]),
        ("session-exit-before-start", vec![o(SID2, 0, 0, "start"), resp(SID2, 1, 300, 2519), o(SID2, 2, 900, "abandon")]),
        ("session-start-failure", vec![
            o(SID2, 0, 0, "start"),
            with(o(SID2, 1, 50, "capable"), "capable", json!({"outcome": "missing-capability", "reason": null, "classical_floor": "unsupported"})),
            o(SID2, 2, 3000, "end"),
        ]),
    ]
}

fn expected_cmcd(o: &Observation) -> Result<Value, String> {
    Ok(match cmcd(o).map_err(|e| e.to_string())? {
        Report::None => json!({"mode": "none", "report": null, "headers": null}),
        Report::Event(r) => json!({"mode": "event", "report": r, "headers": null}),
        Report::Request(r) => json!({"mode": "request", "report": r, "headers": cmcd_headers(o).map_err(|e| e.to_string())?}),
    })
}

/// Every file of the corpus but its README and the differential's own files.
fn corpus() -> Result<BTreeMap<String, Vec<u8>>, String> {
    let mut files = BTreeMap::new();
    let mut cm = serde_json::Map::new();
    for (n, v) in observations() {
        let o = Observation::from_json(&v).map_err(|e| format!("{n}: {e}"))?;
        if o.to_json() != v {
            return Err(format!("{n}: does not write back as given"));
        }
        files.insert(format!("observations/{n}.json"), pretty(&v));
        cm.insert(n.to_string(), expected_cmcd(&o)?);
    }
    files.insert("expected/cmcd.json".into(), pretty(&Value::Object(cm)));
    let mut refused = Vec::new();
    for (n, v) in invalid() {
        if Observation::from_json(&v).is_ok() {
            return Err(format!("invalid/{n}: accepted"));
        }
        files.insert(format!("invalid/{n}.json"), pretty(&v));
        refused.push(n);
    }
    files.insert("expected/refused.json".into(), pretty(&json!(refused)));
    let mut cs = serde_json::Map::new();
    for (n, v) in cmsd_cases() {
        let s = if n.starts_with("static") {
            CmsdStatic::from_json(&v).and_then(|s| s.encode())
        } else {
            v.as_array()
                .ok_or_else(|| wai::telemetry::TelemetryError::Invalid("a list".into()))
                .and_then(|a| a.iter().map(CmsdDynamic::from_json).collect::<Result<Vec<_>, _>>())
                .and_then(|d| encode_dynamic(&d))
        }
        .map_err(|e| format!("cmsd/{n}: {e}"))?;
        files.insert(format!("cmsd/{n}.json"), pretty(&v));
        cs.insert(n.to_string(), json!(s));
    }
    files.insert("expected/cmsd.json".into(), pretty(&Value::Object(cs)));
    let mut cases = Vec::new();
    let mut read = serde_json::Map::new();
    for (n, field, value) in cmsd_read_cases() {
        cases.push(json!({ "name": n, "field": field, "value": value }));
        read.insert(n.to_string(), cmsd_read(field, &value));
    }
    for code in wai::telemetry::cmsd::CmsdRefusal::all().iter().map(|r| r.code()) {
        if !read.values().any(|v| v["refuse"] == code) {
            return Err(format!("no CMSD field is refused with {code}"));
        }
    }
    files.insert("cmsd-read/cases.json".into(), pretty(&json!(cases)));
    files.insert("expected/cmsd-read.json".into(), pretty(&Value::Object(read)));
    let mut q = serde_json::Map::new();
    for (n, log) in logs() {
        let mut text = String::new();
        let mut os = Vec::new();
        for v in &log {
            os.push(Observation::from_json(v).map_err(|e| format!("logs/{n}: {e}"))?);
            text.push_str(&serde_json::to_string(v).expect("JSON"));
            text.push('\n');
        }
        files.insert(format!("logs/{n}.jsonl"), text.into_bytes());
        q.insert(n.to_string(), session_metrics(&os).map_err(|e| format!("logs/{n}: {e}"))?.to_json());
    }
    files.insert("expected/qoe.json".into(), pretty(&Value::Object(q)));
    Ok(files)
}

fn write_corpus(dir: &Path) -> Result<(), String> {
    for (rel, b) in corpus()? {
        let p = dir.join(&rel);
        std::fs::create_dir_all(p.parent().expect("a directory")).map_err(|e| e.to_string())?;
        std::fs::write(&p, b).map_err(|e| format!("{rel}: {e}"))?;
    }
    println!("telemetry-conformance written");
    Ok(())
}

/// Read every input from disk (so a hand edit is caught), recompute the expected
/// files, and compare; and require the written corpus to be the generator's.
fn verify(dir: &Path) -> Result<(), String> {
    let mut fails = Vec::new();
    let want = corpus()?;
    for (rel, b) in &want {
        if std::fs::read(dir.join(rel)).ok().as_ref() != Some(b) {
            fails.push(format!("{rel}: not what the generator writes"));
        }
    }
    for sub in ["observations", "invalid", "cmsd", "cmsd-read", "logs"] {
        for e in std::fs::read_dir(dir.join(sub)).map_err(|e| format!("{sub}: {e}"))?.flatten() {
            let rel = format!("{sub}/{}", e.file_name().to_string_lossy());
            if !want.contains_key(&rel) {
                fails.push(format!("{rel}: not in the corpus the generator writes"));
            }
        }
    }
    // The expected reports, recomputed from the files on disk.
    let expected: Value = serde_json::from_slice(&std::fs::read(dir.join("expected/cmcd.json")).map_err(|e| e.to_string())?).map_err(|e| e.to_string())?;
    for (n, _) in observations() {
        let v: Value = serde_json::from_slice(&std::fs::read(dir.join(format!("observations/{n}.json"))).map_err(|e| e.to_string())?).map_err(|e| e.to_string())?;
        match Observation::from_json(&v) {
            Err(e) => fails.push(format!("{n}: refused: {e}")),
            Ok(o) => {
                let got = expected_cmcd(&o)?;
                if got != expected[n] {
                    fails.push(format!("{n}: the report is not expected/cmcd.json's"));
                }
                if let Some(r) = got["report"].as_str() {
                    if let Err(e) = from_cmcd(r) {
                        fails.push(format!("{n}: the report does not read back: {e}"));
                    }
                }
            }
        }
    }
    for (n, _) in invalid() {
        let v: Value = serde_json::from_slice(&std::fs::read(dir.join(format!("invalid/{n}.json"))).map_err(|e| e.to_string())?).map_err(|e| e.to_string())?;
        if Observation::from_json(&v).is_ok() {
            fails.push(format!("invalid/{n}: accepted"));
        }
    }
    // Every CMSD field of cmsd-read/, read from disk, gets the outcome expected/cmsd-read.json
    // states; and every field expected/cmsd.json states reads back to its description.
    let cases: Value = serde_json::from_slice(&std::fs::read(dir.join("cmsd-read/cases.json")).map_err(|e| e.to_string())?).map_err(|e| e.to_string())?;
    let read: Value = serde_json::from_slice(&std::fs::read(dir.join("expected/cmsd-read.json")).map_err(|e| e.to_string())?).map_err(|e| e.to_string())?;
    for c in cases.as_array().into_iter().flatten() {
        let (n, f, v) = (c["name"].as_str().unwrap_or("?"), c["field"].as_str().unwrap_or(""), c["value"].as_str().unwrap_or(""));
        if cmsd_read(f, v) != read[n] {
            fails.push(format!("cmsd-read/{n}: not the outcome expected/cmsd-read.json states"));
        }
    }
    let written: Value = serde_json::from_slice(&std::fs::read(dir.join("expected/cmsd.json")).map_err(|e| e.to_string())?).map_err(|e| e.to_string())?;
    for (n, _) in cmsd_cases() {
        let desc: Value = serde_json::from_slice(&std::fs::read(dir.join(format!("cmsd/{n}.json"))).map_err(|e| e.to_string())?).map_err(|e| e.to_string())?;
        let field = if n.starts_with("static") { "static" } else { "dynamic" };
        if cmsd_read(field, written[n].as_str().unwrap_or("")) != json!({ "read": desc }) {
            fails.push(format!("cmsd/{n}: its field does not read back to it"));
        }
    }
    if fails.is_empty() {
        println!("telemetry: {} observations, {} refusals, {} CMSD descriptions and {} session logs verify", observations().len(), invalid().len(), cmsd_cases().len(), logs().len());
        Ok(())
    } else {
        for f in &fails {
            eprintln!("FAIL {f}");
        }
        Err(format!("{} failures", fails.len()))
    }
}

fn member_json(m: &SfMember) -> Value {
    match m {
        SfMember::Item(SfBare::Int(i)) => json!(i),
        SfMember::Item(SfBare::Str(s)) => json!({"string": s}),
        SfMember::Item(SfBare::Token(t)) => json!({"token": t}),
        SfMember::Item(SfBare::Bool(b)) => json!(b),
        SfMember::IntList(l) => json!(l),
    }
}

fn main() -> ExitCode {
    let args: Vec<String> = std::env::args().skip(1).collect();
    let r = match args.iter().map(String::as_str).collect::<Vec<_>>().as_slice() {
        ["encode", path] => std::fs::read(path)
            .map_err(|e| format!("{path}: {e}"))
            .and_then(|b| serde_json::from_slice::<Value>(&b).map_err(|e| e.to_string()))
            .and_then(|v| Observation::from_json(&v).map_err(|e| e.to_string()))
            .and_then(|o| expected_cmcd(&o))
            .map(|v| print!("{}", String::from_utf8(pretty(&v)).expect("UTF-8"))),
        ["decode", dict] => from_cmcd(dict)
            .map_err(|e| e.to_string())
            .map(|m| print!("{}", String::from_utf8(pretty(&Value::Object(m.iter().map(|(k, v)| (k.clone(), member_json(v))).collect()))).expect("UTF-8"))),
        ["verify", dir] => verify(Path::new(dir)),
        ["gen", dir] => write_corpus(Path::new(dir)),
        _ => {
            eprintln!("usage: wai_telemetry encode <observation.json> | decode <dictionary> | verify <corpus> | gen <corpus>");
            return ExitCode::from(2);
        }
    };
    match r {
        Ok(()) => ExitCode::SUCCESS,
        Err(e) => {
            eprintln!("error: {e}");
            ExitCode::from(1)
        }
    }
}
