//! The suites `wai conform` runs: each a kind of implementation, a corpus of WAI's, and the
//! cases read from it with what a conforming implementation answers. Every expectation is
//! the corpus's own (its declared verdicts, digests and reasons), never a verifier's output.
//!
//! | kind | suite | corpus | a case is | it passes when |
//! |---|---|---|---|---|
//! | verifier | `claim` | `claim-conformance/vectors.json` | one vector | the verdict is the one declared (jwp-receipts §3.1) |
//! | verifier | `jwp` | `jwp-conformance/vectors.json` | one vector | the verdict is the one declared (jwp-receipts §2–§6) |
//! | verifier | `signer` | `signer-conformance/vectors.json` | one vector | the verdict is the one declared (jwp-receipts §7) |
//! | verifier | `conform-result` | `conform-result-conformance/vectors.json` | one record | the outcome is the one declared (conformance-results §4) |
//! | decoder | `score` | `score-conformance/` | a `WSCR` score | the rendered `i16le` PCM has the declared SHA-256 |
//! | packager | `policy` | `pack-conformance/` | a policy file | it is admitted with the declared digest, or refused with the declared reason (packaging §3, §12) |
//! | origin | `http` | `http-conformance/objects/` | an object | it is served as http-delivery §5.3 and §6.2 require: bytes, headers, ranges, head proof, preflight |
//! | sink | `native-sink` | `live-conformance/native-sink/vectors.json` | one session, its answers withheld, or one configuration | the answers the sink writes, one a line in RFC 8785 form, have the SHA-256 of the answers the corpus states (native-sink §10) |

use serde_json::Value;
use std::path::{Path, PathBuf};

/// What a case expects.
#[derive(Clone, Debug)]
pub enum Expect {
    /// A verdict line: exactly this, or, with `prefix`, any line that starts with it.
    Verdict { line: String, prefix: bool },
    /// An output whose SHA-256 is this (hexadecimal).
    Output { sha256: String },
    /// A packager's verdict: admitted with this digest, or refused with this reason.
    Admit { digest: String },
    Refuse { reason: String },
    /// An origin serving this object.
    Object { cid: String, bytes_file: PathBuf, head_proof: Option<PathBuf> },
}

/// What a case gives the implementation.
#[derive(Clone, Debug)]
pub enum Input {
    /// A vector, as the corpus writes it.
    Json(Value),
    /// A file.
    File(PathBuf),
    /// Nothing: the implementation already serves what is asked of it.
    Served,
}

#[derive(Clone, Debug)]
pub struct Case {
    pub name: String,
    pub input: Input,
    pub expect: Expect,
}

/// A suite read from a corpus: its cases, the files read (for the corpus digest), and the
/// corpus file a whole-corpus run is given (`{corpus}`), where the suite has one.
pub struct Loaded {
    pub cases: Vec<Case>,
    pub files: Vec<PathBuf>,
    pub corpus_file: Option<PathBuf>,
}

pub struct Suite {
    pub kind: &'static str,
    pub name: &'static str,
    /// The corpus directory, under `wai/`.
    pub dir: &'static str,
    pub about: &'static str,
    pub http: bool,
    pub command: bool,
    pub load: fn(&Path) -> Result<Loaded, String>,
}

pub const KINDS: &[&str] = &["verifier", "decoder", "packager", "origin", "sink"];

pub const SUITES: &[Suite] = &[
    Suite { kind: "verifier", name: "claim", dir: "claim-conformance", about: "typed claim receipts (jwp-receipts §3.1): accept with the link hash, or reject at a step with a reason", http: true, command: true, load: claim },
    Suite { kind: "verifier", name: "jwp", dir: "jwp-conformance", about: "labelled leaves, group receipts, totals, chains and the binary form (jwp-receipts §2–§6)", http: true, command: true, load: jwp },
    Suite { kind: "verifier", name: "signer", dir: "signer-conformance", about: "key discovery, signing authority and module authorisation (jwp-receipts §7)", http: true, command: true, load: stated_lines },
    Suite { kind: "verifier", name: "conform-result", dir: "conform-result-conformance", about: "conformance results, the signed record of a run (conformance-results §4)", http: true, command: true, load: stated_lines },
    Suite { kind: "decoder", name: "score", dir: "score-conformance", about: "a WSCR score rendered to its canonical i16le PCM (the score extension's mixdown-equivalence)", http: true, command: true, load: score },
    Suite { kind: "packager", name: "policy", dir: "pack-conformance", about: "packaging policies admitted with their digest, or refused with their reason (packaging §3, §12)", http: true, command: true, load: policy },
    Suite { kind: "origin", name: "http", dir: "http-conformance", about: "objects, head proofs, ranges and headers as an origin serves them (http-delivery §5.3, §6.2)", http: true, command: false, load: http_origin },
    Suite { kind: "sink", name: "native-sink", dir: "live-conformance/native-sink", about: "a native sink's answer to every call of a session, byte for byte, and its refusal of a configuration (native-sink §10)", http: true, command: true, load: native_sink },
];

fn read_json(p: &Path) -> Result<Value, String> {
    let b = std::fs::read(p).map_err(|e| format!("{}: {e}", p.display()))?;
    serde_json::from_slice(&b).map_err(|e| format!("{}: {e}", p.display()))
}

fn vectors(v: &Value, p: &Path) -> Result<Vec<Value>, String> {
    v.get("vectors").and_then(Value::as_array).cloned().ok_or_else(|| format!("{}: no `vectors` list", p.display()))
}

fn name_of(v: &Value) -> String {
    v.get("name").and_then(Value::as_str).unwrap_or("?").to_string()
}

fn verdict_cases(dir: &Path, expected_of: fn(&Value) -> (String, bool)) -> Result<Loaded, String> {
    let p = dir.join("vectors.json");
    let doc = read_json(&p)?;
    let cases = vectors(&doc, &p)?
        .into_iter()
        .map(|v| {
            let (line, prefix) = expected_of(v.get("expected").unwrap_or(&Value::Null));
            Case { name: name_of(&v), input: Input::Json(v), expect: Expect::Verdict { line, prefix } }
        })
        .collect();
    Ok(Loaded { cases, files: vec![p.clone()], corpus_file: Some(p) })
}

/// The claim corpus's verdicts (its README): `accept link_hash=<hex>` for an accepted
/// claim (any link hash where none is pinned), `accept ancestor=<hex>` for a recovery,
/// `reject step=<n> reason=<label>`.
fn claim_expected(e: &Value) -> (String, bool) {
    let s = |k: &str| e.get(k).map(|x| x.as_str().map(String::from).unwrap_or_else(|| x.to_string())).unwrap_or_default();
    match e.get("outcome").and_then(Value::as_str) {
        Some("accept") if e.get("ancestor").is_some() => (format!("accept ancestor={}", s("ancestor")), false),
        Some("accept") if e.get("link_hash").is_some() => (format!("accept link_hash={}", s("link_hash")), false),
        Some("accept") => ("accept link_hash=".into(), true),
        _ => (format!("reject step={} reason={}", s("step"), s("reason")), false),
    }
}

fn claim(dir: &Path) -> Result<Loaded, String> {
    verdict_cases(dir, claim_expected)
}

/// A value in the form the jwp corpus writes a reading in: keys sorted, no spaces.
fn canonical(v: &Value) -> String {
    fn sorted(v: &Value) -> Value {
        match v {
            Value::Object(m) => {
                let mut keys: Vec<&String> = m.keys().collect();
                keys.sort();
                let mut out = serde_json::Map::new();
                for k in keys {
                    out.insert(k.clone(), sorted(&m[k]));
                }
                Value::Object(out)
            }
            Value::Array(a) => Value::Array(a.iter().map(sorted).collect()),
            x => x.clone(),
        }
    }
    sorted(v).to_string()
}

/// The jwp corpus's verdicts (its README and declared outcomes): `leaf=<hex>`, `chain: <a>
/// / <b> / …`, `accept <admission>`, `accept evidence=<e>`, `accept link_hash=<hex>
/// signing_payload=<p>`, `accept`, `read <reading>`, `reject step=<n> reason=<r>`,
/// `reject reason=<r>`.
fn jwp_expected_line(e: &Value) -> String {
    let s = |k: &str| e.get(k).map(|x| x.as_str().map(String::from).unwrap_or_else(|| x.to_string())).unwrap_or_default();
    if e.get("leaf").is_some() {
        return format!("leaf={}", s("leaf"));
    }
    match e.get("outcome").and_then(Value::as_str) {
        Some("chain") => format!("chain: {}", e.get("results").and_then(Value::as_array).map(|r| r.iter().map(jwp_expected_line).collect::<Vec<_>>().join(" / ")).unwrap_or_default()),
        Some("accept") if e.get("admission").is_some() => format!("accept {}", s("admission")),
        Some("accept") if e.get("evidence").is_some() => format!("accept evidence={}", s("evidence")),
        Some("accept") if e.get("link_hash").is_some() => format!("accept link_hash={} signing_payload={}", s("link_hash"), s("signing_payload")),
        Some("accept") => "accept".into(),
        Some("read") if e.get("reading").is_none() => {
            let mut m = e.as_object().cloned().unwrap_or_default();
            m.remove("outcome");
            format!("read {}", canonical(&Value::Object(m)))
        }
        Some("read") => format!("read {}", s("reading")),
        _ if e.get("step").is_some() => format!("reject step={} reason={}", s("step"), s("reason")),
        _ => format!("reject reason={}", s("reason")),
    }
}

fn jwp(dir: &Path) -> Result<Loaded, String> {
    verdict_cases(dir, |e| (jwp_expected_line(e), false))
}

/// The signer and conformance-result corpora declare each verdict as its line.
fn stated_lines(dir: &Path) -> Result<Loaded, String> {
    verdict_cases(dir, |e| (e.as_str().map(String::from).unwrap_or_else(|| e.to_string()), false))
}

fn score(dir: &Path) -> Result<Loaded, String> {
    let p = dir.join("expected_hashes.json");
    let doc = read_json(&p)?;
    let mut files = vec![p.clone()];
    let mut cases = Vec::new();
    for (file, e) in doc.as_object().ok_or_else(|| format!("{}: not an object", p.display()))? {
        let input = dir.join("vectors").join(file);
        let sha = e.get("pcm_sha256").and_then(Value::as_str).ok_or_else(|| format!("{}: {file} has no pcm_sha256", p.display()))?;
        files.push(input.clone());
        cases.push(Case { name: file.trim_end_matches(".wscr").to_string(), input: Input::File(input), expect: Expect::Output { sha256: sha.to_string() } });
    }
    Ok(Loaded { cases, files, corpus_file: None })
}

fn policy(dir: &Path) -> Result<Loaded, String> {
    let mut files = Vec::new();
    let mut cases = Vec::new();
    let p = dir.join("policy").join("expected.json");
    let doc = read_json(&p)?;
    files.push(p.clone());
    for c in doc.get("cases").and_then(Value::as_array).into_iter().flatten() {
        // A case read under overrides needs them given; the adapter gives a file alone.
        if c.get("overrides").is_some() {
            continue;
        }
        let (Some(f), Some(d)) = (c.get("file").and_then(Value::as_str), c.get("digest").and_then(Value::as_str)) else { continue };
        let input = dir.join("policy").join(f);
        files.push(input.clone());
        cases.push(Case { name: format!("policy/{}", f.trim_end_matches(".toml")), input: Input::File(input), expect: Expect::Admit { digest: d.to_string() } });
    }
    let p = dir.join("refusals").join("expected.json");
    let doc = read_json(&p)?;
    files.push(p.clone());
    for c in doc.get("cases").and_then(Value::as_array).into_iter().flatten() {
        let (Some(f), Some(r)) = (c.get("file").and_then(Value::as_str), c.get("reason").and_then(Value::as_str)) else { continue };
        if !f.starts_with("policy/") {
            continue;
        }
        let input = dir.join("refusals").join(f);
        files.push(input.clone());
        cases.push(Case { name: format!("refusals/{}", f.trim_end_matches(".toml")), input: Input::File(input), expect: Expect::Refuse { reason: r.to_string() } });
    }
    Ok(Loaded { cases, files, corpus_file: None })
}

fn http_origin(dir: &Path) -> Result<Loaded, String> {
    let p = dir.join("objects.json");
    let doc = read_json(&p)?;
    let mut files = vec![p.clone()];
    let mut cases = Vec::new();
    for (id, o) in doc.as_object().ok_or_else(|| format!("{}: not an object", p.display()))? {
        let (Some(cid), Some(path)) = (o.get("cid").and_then(Value::as_str), o.get("path").and_then(Value::as_str)) else { continue };
        // Objects the corpus serves from `objects/`; the others (`bad/`) are a reader's cases.
        if !path.starts_with("objects/") {
            continue;
        }
        let file = dir.join(path);
        let hp = dir.join(format!("{path}.hp"));
        files.push(file.clone());
        let head_proof = hp.is_file().then(|| {
            files.push(hp.clone());
            hp
        });
        cases.push(Case { name: id.clone(), input: Input::Served, expect: Expect::Object { cid: cid.to_string(), bytes_file: file, head_proof } });
    }
    Ok(Loaded { cases, files, corpus_file: None })
}

/// The native sink's corpus (native-sink §10). Each case is given to the sink with its answers
/// withheld: the bodies its calls name (`objects`, hexadecimal), its `config`, `seed` and
/// `start_us`, and each call's `do`. Each configuration the corpus states a session refuses is a
/// case of no call, under a seed of zeros. The sink opens the session and writes its answer to
/// each call in order, or, refusing the configuration, that refusal alone (`{"error":
/// <reason>}`): one JSON text a line, in RFC 8785 form. A case passes when what it writes has the
/// SHA-256 of the answers the corpus states, written the same way.
fn native_sink(dir: &Path) -> Result<Loaded, String> {
    let p = dir.join("vectors.json");
    let doc = read_json(&p)?;
    let objects = doc.get("objects").and_then(Value::as_object).ok_or_else(|| format!("{}: no `objects`", p.display()))?;
    let digest = |answers: &[&Value]| -> Result<String, String> {
        let mut text = String::new();
        for a in answers {
            text.push_str(&wai::jcs::jcs(a).map_err(|e| format!("{}: an answer outside RFC 8785's subset: {e}", p.display()))?);
            text.push('\n');
        }
        Ok(super::adapter::sha256_hex(text.as_bytes()))
    };
    let mut cases = Vec::new();
    for c in doc.get("cases").and_then(Value::as_array).ok_or_else(|| format!("{}: no `cases`", p.display()))? {
        let calls = c.get("calls").and_then(Value::as_array).ok_or_else(|| format!("{}: {} has no calls", p.display(), name_of(c)))?;
        let mut named = serde_json::Map::new();
        for d in calls.iter().map(|x| &x["do"]) {
            for k in ["body", "receipt"] {
                if let Some(n) = d.get(k).and_then(Value::as_str) {
                    let b = objects.get(n).ok_or_else(|| format!("{}: {} names no object {n}", p.display(), name_of(c)))?;
                    named.insert(n.to_string(), b.clone());
                }
            }
        }
        let input = serde_json::json!({
            "objects": named, "config": c["config"], "seed": c["seed"], "start_us": c["start_us"],
            "calls": calls.iter().map(|x| serde_json::json!({"do": x["do"]})).collect::<Vec<_>>(),
        });
        let answers: Vec<&Value> = calls.iter().map(|x| &x["answer"]).collect();
        cases.push(Case { name: name_of(c), input: Input::Json(input), expect: Expect::Output { sha256: digest(&answers)? } });
    }
    for (i, c) in doc.get("configs").and_then(Value::as_array).into_iter().flatten().enumerate() {
        let input = serde_json::json!({"objects": {}, "config": c["config"], "seed": "0".repeat(64), "start_us": 0, "calls": []});
        let refused = serde_json::json!({"error": c["refused"]});
        cases.push(Case { name: format!("configs/{i}"), input: Input::Json(input), expect: Expect::Output { sha256: digest(&[&refused])? } });
    }
    Ok(Loaded { cases, files: vec![p], corpus_file: None })
}

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

    #[test]
    fn verdict_lines_follow_the_corpora() {
        assert_eq!(claim_expected(&json!({"outcome": "reject", "step": 3, "reason": "signature_invalid"})).0, "reject step=3 reason=signature_invalid");
        assert_eq!(claim_expected(&json!({"outcome": "accept"})), ("accept link_hash=".to_string(), true));
        assert_eq!(jwp_expected_line(&json!({"outcome": "chain", "results": [{"outcome": "accept"}, {"outcome": "reject", "reason": "gap"}]})), "chain: accept / reject reason=gap");
        assert_eq!(jwp_expected_line(&json!({"outcome": "read", "b": 1, "a": {"y": "é", "x": [2]}})), "read {\"a\":{\"x\":[2],\"y\":\"é\"},\"b\":1}");
    }
}
