//! Wire and record: the conformance corpora (`wai/*-conformance/`) and the pinned vectors
//! of the reference (`wai/wai-rs/tests/vectors/`). Each vector states bytes a conforming
//! reader must accept or refuse, and what it must make of them: a decoded output's digest,
//! a verdict, a reason. The conformance workflow holds every corpus to the code that
//! generates it, byte for byte, so a change in what the code writes or signs (a field, a
//! signing preimage, a magic) reaches these files, or that workflow fails.
//!
//! So a vector file that is gone, or whose bytes changed, breaks; a JSON file breaks where
//! an entry or member is gone or a value changed, and is compatible where it only gained
//! entries; a JSON Lines file breaks where a line is gone. A new file is compatible.
//! Verifier code, documentation and scripts beside a corpus are not vectors and are not
//! compared, and neither are the files [`MEASUREMENTS`] lists: records of what one run
//! measured, kept beside a corpus, which a second measurement changes without changing any
//! rule an implementation is held to. The record corpora (signed records and their verdicts)
//! report under `record`; the others, under `wire`.

use super::git::{Repo, Status};
use super::jsondiff;
use super::super::envelope::OpError;
use super::{Category, Finding};

pub const PATHSPECS: &[&str] = &[":(glob)wai/*-conformance/**", ":(glob)wai/wai-rs/tests/vectors/**"];

/// The corpora of signed records and their verdicts.
const RECORD_CORPORA: &[&str] = &[
    "claim-conformance",
    "signer-conformance",
    "jwp-conformance",
    "pq-conformance",
    "oer-conformance",
    "pin-conformance",
    "edge-conformance",
    "live-conformance",
    "conform-result-conformance",
];

/// Corpus files that hold measurements, not vectors an implementation must match: each with
/// why. The list is explicit, one entry a file; a file is never left out by its name or its
/// contents. What a corpus derives from a measurement (vectors built around a recorded run)
/// stays compared.
pub const MEASUREMENTS: &[(&str, &str)] = &[
    (
        "wai/format-probe-conformance/provenance.json",
        "the decodes of the external vectors as measured on one build host by tools/make_external.sh; no verifier reads it as an expectation",
    ),
    (
        "wai/live-conformance/metering/relay-record.wmh",
        "a relay's metering record from one live run on loopback: its counts, span and subscriber time are what that run measured, and a second run gives other bytes; the vectors built around it (vectors.json) stay compared",
    ),
    ("wai/live-conformance/metering/relay-ledger.wml", "the ledger of that run, recorded with it"),
];

/// Why `path` is a measurement, when [`MEASUREMENTS`] lists it.
pub fn measurement(path: &str) -> Option<&'static str> {
    MEASUREMENTS.iter().find(|m| m.0 == path).map(|m| m.1)
}

/// Files beside a corpus that are not vectors: verifiers, generators, scripts, notes.
const NOT_VECTORS: &[&str] = &[
    ".py", ".pyc", ".mjs", ".js", ".ts", ".md", ".sh", ".rs", ".toml", ".lock", ".patch", ".txt", ".gitattributes", ".gitignore",
];

fn is_vector(path: &str) -> bool {
    !path.split('/').any(|c| c == "__pycache__" || (c == "src" && path.starts_with("wai/byte-exact-conformance/")))
        && !NOT_VECTORS.iter().any(|x| path.ends_with(x))
}

fn category(path: &str) -> Category {
    let dir = path.split('/').nth(1).unwrap_or("");
    if RECORD_CORPORA.contains(&dir) { Category::Record } else { Category::Wire }
}

fn list(xs: &[String]) -> String {
    let n = xs.len();
    let shown: Vec<&str> = xs.iter().take(4).map(String::as_str).collect();
    if n > 4 { format!("{} (and {} more)", shown.join("; "), n - 4) } else { shown.join("; ") }
}

/// The corpora's findings between `base` and the working tree; a changed measurement is a
/// note, not a finding.
pub fn compare(repo: &Repo, base: &str, notes: &mut Vec<String>) -> Result<Vec<Finding>, OpError> {
    let mut changed: Vec<(Status, String)> = repo.changed(base, PATHSPECS)?.into_iter().filter(|(_, p)| is_vector(p)).collect();
    changed.retain(|(_, p)| match measurement(p) {
        Some(why) => {
            notes.push(format!("corpora: {p} changed, a measurement and not compared ({why})"));
            false
        }
        None => true,
    });
    let modified: Vec<String> = changed.iter().filter(|(s, p)| *s == Status::Modified && (p.ends_with(".json") || p.ends_with(".jsonl"))).map(|x| x.1.clone()).collect();
    let olds = repo.base_files(base, &modified)?;
    let mut out = Vec::new();
    for (status, path) in changed {
        let cat = category(&path);
        let why_bytes = if cat == Category::Record {
            "a verifier conforms by reaching each vector's stated verdict on these bytes; records signed or read as before no longer match it"
        } else {
            "a reader conforms by decoding these bytes to the stated output; readers built against the old vector disagree with the new one"
        };
        match status {
            Status::Added => out.push(Finding::compatible(cat, "corpus", path, "a new vector file".into())),
            Status::Removed => out.push(Finding::broke(cat, "corpus", path, "removed".into(), "the vector file is gone".into(), why_bytes.into())),
            Status::Modified => {
                let json = path.ends_with(".json");
                let lines = path.ends_with(".jsonl");
                let parsed = if json || lines {
                    let old = olds.get(&path).cloned().unwrap_or_default();
                    let new = repo.head_file(&path).unwrap_or_default();
                    if lines {
                        Some(jsondiff::diff_lines(&String::from_utf8_lossy(&old), &String::from_utf8_lossy(&new)))
                    } else {
                        match (serde_json::from_slice(&old), serde_json::from_slice(&new)) {
                            (Ok(a), Ok(b)) => Some(jsondiff::diff(&a, &b)),
                            _ => None,
                        }
                    }
                } else {
                    None
                };
                match parsed {
                    None => out.push(Finding::broke(cat, "corpus", path, "changed".into(), "the vector's bytes changed".into(), why_bytes.into())),
                    Some(d) if d.is_empty() => {}
                    Some(d) if !d.breaks() => out.push(Finding::compatible(cat, "corpus", path, format!("added {}", list(&d.added)))),
                    Some(d) => {
                        let mut what = Vec::new();
                        if !d.removed.is_empty() {
                            what.push(format!("removed {}", list(&d.removed)));
                        }
                        if !d.changed.is_empty() {
                            what.push(format!("changed {}", list(&d.changed)));
                        }
                        if !d.added.is_empty() {
                            what.push(format!("added {}", d.added.len()));
                        }
                        out.push(Finding::broke(cat, "corpus", path, "changed".into(), what.join("; "), why_bytes.into()));
                    }
                }
            }
        }
    }
    Ok(out)
}

#[cfg(test)]
mod tests {
    use super::MEASUREMENTS;

    /// Every listed measurement is a file of this tree, named once: a list that names what is
    /// gone excuses nothing and hides its own rot.
    #[test]
    fn every_listed_measurement_exists() {
        let top = std::path::Path::new(env!("CARGO_MANIFEST_DIR")).join("../..");
        let mut seen = std::collections::BTreeSet::new();
        for (p, why) in MEASUREMENTS {
            assert!(top.join(p).is_file(), "{p} is listed as a measurement and is not in the tree");
            assert!(seen.insert(*p), "{p} is listed twice");
            assert!(!why.is_empty(), "{p} has no reason");
        }
    }
}
