//! Record: the constants a signed or framed record is built from, in the WAI crates' library
//! sources (`wai/*/src/`, binaries and tests left out). A constant whose name has the word
//! `DOMAIN`, `MAGIC` or `VERSION` and whose value is a literal is one of:
//!
//! - a domain-separation prefix of a signing preimage or a hash (`SIGN_DOMAIN`,
//!   `LINK_DOMAIN`): another value changes every signature and link over the record, and a
//!   verifier of the old records refuses the new ones;
//! - a record's magic (`MAGIC`, `GRANT_MAGIC`): a reader that matches the old magic refuses
//!   the new records;
//! - a record's version (`RECORD_VERSION`): a reader that knows the old version refuses
//!   the new one.
//!
//! So such a constant breaks when its value changes or it is gone (unless the same name
//! and value now sit in another file: a move). A new one is compatible. A record's other
//! fields and its preimage's shape reach the corpora (see `corpora`), which hold the bytes
//! the code writes and signs.

use super::git::Repo;
use super::super::envelope::OpError;
use super::{Category, Finding};
use std::collections::BTreeMap;

pub const PATHSPECS: &[&str] = &[":(glob)wai/*/src/**/*.rs", ":(exclude,glob)wai/*/src/bin/**"];

/// The prefilter `git grep` runs (POSIX extended syntax).
const GREP: &str = "const[[:space:]]+[A-Z0-9_]*(DOMAIN|MAGIC|VERSION)";

/// One constant: its name, its type and its value as written (spaces dropped).
#[derive(Clone, Debug, PartialEq, Eq)]
pub struct Const {
    pub name: String,
    pub ty: String,
    pub value: String,
}

fn named(name: &str) -> bool {
    let words: Vec<&str> = name.split('_').collect();
    words.iter().any(|w| matches!(*w, "DOMAIN" | "MAGIC" | "VERSION"))
        && !words.iter().any(|w| matches!(*w, "MAX" | "MIN" | "LEN" | "UNKNOWN" | "API" | "HEADER" | "VERSIONS"))
}

fn literal(v: &str) -> bool {
    v.starts_with("b\"") || v.starts_with("*b\"") || v.starts_with('"') || v.starts_with("b'") || v.starts_with('[') || v.starts_with("&[") || v.starts_with(|c: char| c.is_ascii_digit())
}

/// The constants of one source file, keyed by name (`NAME`, then `NAME#2`… for a name
/// declared again in the same file). Scanning stops at the file's test module.
pub fn scan(src: &str) -> BTreeMap<String, Const> {
    let mut out = BTreeMap::new();
    let lines: Vec<&str> = src.lines().collect();
    let mut i = 0;
    while i < lines.len() {
        let t = lines[i].trim();
        if t == "#[cfg(test)]" && lines.get(i + 1).is_some_and(|n| n.trim_start().starts_with("mod ")) {
            break;
        }
        let rest = t.strip_prefix("pub ").or_else(|| t.strip_prefix("pub(crate) ")).or_else(|| t.strip_prefix("pub(super) ")).unwrap_or(t);
        if let Some(decl) = rest.strip_prefix("const ") {
            // The declaration, joined across lines until its `;`.
            let mut text = decl.to_string();
            let mut j = i;
            while !text.contains(';') && j + 1 < lines.len() && j < i + 6 {
                j += 1;
                text.push(' ');
                text.push_str(lines[j].trim());
            }
            if let Some((name, after)) = text.split_once(':') {
                let name = name.trim();
                if name.chars().all(|c| c.is_ascii_uppercase() || c.is_ascii_digit() || c == '_')
                    && named(name)
                    && let Some((ty, value)) = after.split_once('=')
                {
                    let value: String = value.split(';').next().unwrap_or("").split_whitespace().collect::<Vec<_>>().join("");
                    if literal(&value) {
                        let ty: String = ty.split_whitespace().collect();
                        let mut key = name.to_string();
                        let mut n = 1;
                        while out.contains_key(&key) {
                            n += 1;
                            key = format!("{name}#{n}");
                        }
                        out.insert(key, Const { name: name.to_string(), ty, value });
                    }
                }
            }
            i = j;
        }
        i += 1;
    }
    out
}

fn role(name: &str) -> (&'static str, &'static str) {
    if name.split('_').any(|w| w == "DOMAIN") {
        ("signing or hashing domain", "every signature and hash made under it changes; a verifier of the records made before refuses those made after, and the other way round")
    } else if name.split('_').any(|w| w == "MAGIC") {
        ("magic", "a reader that matches the old magic refuses the records written with the new one")
    } else {
        ("version", "a reader that knows the old version refuses records of the new one, and a writer of the old version is refused by the new reader")
    }
}

fn all(repo: &Repo, base: Option<&str>) -> Result<BTreeMap<(String, String), Const>, OpError> {
    let files = repo.grep_files(base, GREP, PATHSPECS)?;
    let texts: BTreeMap<String, Vec<u8>> = match base {
        Some(b) => repo.base_files(b, &files)?,
        None => files.iter().filter_map(|f| Some((f.clone(), repo.head_file(f)?))).collect(),
    };
    let mut out = BTreeMap::new();
    for (f, t) in texts {
        for (k, c) in scan(&String::from_utf8_lossy(&t)) {
            out.insert((f.clone(), k), c);
        }
    }
    Ok(out)
}

/// The constants' findings between `base` and the working tree.
pub fn compare(repo: &Repo, base: &str) -> Result<Vec<Finding>, OpError> {
    let old = all(repo, Some(base))?;
    let new = all(repo, None)?;
    let mut out = Vec::new();
    let moved = |c: &Const| new.values().any(|n| n.name == c.name && n.value == c.value);
    for ((file, key), c) in &old {
        let item = format!("{file}::{key}");
        let (what_it_is, why) = role(&c.name);
        match new.get(&(file.clone(), key.clone())) {
            None if moved(c) => {}
            None => out.push(Finding::broke(Category::Record, "domain", item, "removed".into(), format!("the {what_it_is} {} = {} is gone", c.name, c.value), why.into())),
            Some(n) if n.value != c.value || n.ty != c.ty => out.push(Finding::broke(
                Category::Record,
                "domain",
                item,
                "changed".into(),
                format!("the {what_it_is} {} changed: {} → {}", c.name, c.value, n.value),
                why.into(),
            )),
            Some(_) => {}
        }
    }
    for ((file, key), c) in &new {
        if !old.contains_key(&(file.clone(), key.clone())) && !old.values().any(|o| o.name == c.name && o.value == c.value) {
            out.push(Finding::compatible(Category::Record, "domain", format!("{file}::{key}"), format!("{} = {} is new", c.name, c.value)));
        }
    }
    Ok(out)
}

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

    #[test]
    fn scans_domains_magics_and_versions() {
        let src = "pub const SIGN_DOMAIN: &[u8] = b\"wai:x\\x01\";\nconst MAGIC: [u8; 4] = *b\"WEV1\";\npub const RECORD_VERSION: u8 = 1;\n\
                   pub(crate) const DOMAIN_LEN: u64 = X.len() as u64;\nconst MAX_VERSION: u8 = 3;\nconst OTHER: u8 = 1;\n\
                   pub const LONG_DOMAIN: &[u8] =\n    b\"wai:long\\x01\";\n#[cfg(test)]\nmod tests {\n const TEST_DOMAIN: &[u8] = b\"t\";\n}\n";
        let m = scan(src);
        let names: Vec<&str> = m.keys().map(String::as_str).collect();
        assert_eq!(names, vec!["LONG_DOMAIN", "MAGIC", "RECORD_VERSION", "SIGN_DOMAIN"]);
        assert_eq!(m["SIGN_DOMAIN"].value, "b\"wai:x\\x01\"");
        assert_eq!(m["LONG_DOMAIN"].value, "b\"wai:long\\x01\"");
        assert_eq!(m["MAGIC"].ty, "[u8;4]");
    }
}
