//! `wai breaking --against <git-ref>` (operation `compat.check`): what the working tree
//! breaks, by category, for a reader, a verifier or a program written against the ref.
//!
//! - **wire**: the bytes a conforming reader must accept. The registry export (capability
//!   names and their classes, deterministic identifiers, wire identifiers: magics,
//!   document kinds, headers, schemes, tags) and the conformance corpora of media and
//!   delivery (`registry`, `corpora`).
//! - **record**: signed record formats. The domain-separation prefixes, magics and
//!   versions records are built from, the corpora of signed records and their verdicts,
//!   and the published JSON Schemas (`domains`, `corpora`, `schemas`).
//! - **api**: the Rust public API of the WAI library crates, the C header and the HTTP
//!   services' OpenAPI documents (`rust`, `c`, `openapi`).
//!
//! The categories are cumulative, strictest by default: `--category api` (the default)
//! reports all three, `record` reports wire and record, `wire` reports wire alone. The
//! base is the merge base of the ref and `HEAD`, so a ref that moved on since the branch
//! left it does not read as the branch removing what the ref gained.
//!
//! Each finding names its item, its category, what changed and why that breaks; additions
//! are listed as compatible. A break is intended only when the changelog says so: a line
//! this change adds to `wai/CHANGELOG.md` or to a fragment of it under `wai/changes/` (one
//! entry a file, which the release renders into the changelog), in an entry that has the
//! word "breaking", naming the finding's id in backticks. Then it is reported as
//! acknowledged and passes; every other break fails the run (exit 1).

pub mod c_header;
pub mod corpora;
pub mod domains;
pub mod git;
pub mod jsondiff;
pub mod openapi;
pub mod registry;
pub mod rust_api;
pub mod schema;

use super::envelope::{self, EXIT_FAILED, EXIT_NOT_FOUND, OpError};
use serde_json::{Map, Value, json};
use std::collections::BTreeSet;
use std::path::PathBuf;

pub const OP: &str = "compat.check";

pub const USAGE: &str = "wai breaking --against <git-ref> [--category wire|record|api] [--only <surface>,...] [--skip <surface>,...]\n\
            [--root <checkout>] [--target-dir <dir>] [--json|--text]   what the working tree breaks against <git-ref>:\n\
            wire, record and API; a break passes only where wai/CHANGELOG.md or a wai/changes/ entry names it (operation compat.check)";

/// The changelog whose added lines acknowledge a break.
pub const CHANGELOG: &str = "wai/CHANGELOG.md";

/// The changelog's fragments, one entry a Markdown file, whose added lines acknowledge a break
/// as the changelog's do (`scripts/changelog-assemble.py` renders them into it).
pub const FRAGMENTS: &str = "wai/changes";

/// The lines this change adds to the changelog and to each of its fragments, a blank line
/// between files so that no two files' entries run together.
fn changelog_added(repo: &git::Repo, base: &str) -> Result<Vec<String>, OpError> {
    let mut added = repo.added_lines(base, CHANGELOG)?;
    let mut fragments: Vec<String> = std::fs::read_dir(repo.root.join(FRAGMENTS))
        .map(|rd| rd.filter_map(Result::ok).map(|e| e.file_name().to_string_lossy().into_owned()).filter(|n| n.ends_with(".md") && n != "README.md").collect())
        .unwrap_or_default();
    fragments.sort();
    for f in fragments {
        added.push(String::new());
        added.extend(repo.added_lines(base, &format!("{FRAGMENTS}/{f}"))?);
    }
    Ok(added)
}

#[derive(Clone, Copy, Debug, PartialEq, Eq, PartialOrd, Ord)]
pub enum Category {
    Wire,
    Record,
    Api,
}

impl Category {
    pub fn name(self) -> &'static str {
        match self {
            Category::Wire => "wire",
            Category::Record => "record",
            Category::Api => "api",
        }
    }
    fn parse(s: &str) -> Option<Category> {
        match s {
            "wire" => Some(Category::Wire),
            "record" => Some(Category::Record),
            "api" => Some(Category::Api),
            _ => None,
        }
    }
}

/// One change between the base and the working tree.
#[derive(Clone, Debug)]
pub struct Finding {
    /// `<category>:<surface>:<item>`, the name a changelog entry acknowledges it by.
    pub id: String,
    pub category: Category,
    pub surface: &'static str,
    pub item: String,
    /// `removed`, `changed` or `added`.
    pub change: String,
    pub what: String,
    /// Why it breaks; empty for a compatible change.
    pub why: String,
    pub breaking: bool,
    pub acknowledged: bool,
}

impl Finding {
    pub fn broke(category: Category, surface: &'static str, item: String, change: String, what: String, why: String) -> Finding {
        Finding { id: format!("{}:{surface}:{item}", category.name()), category, surface, item, change, what, why, breaking: true, acknowledged: false }
    }
    pub fn compatible(category: Category, surface: &'static str, item: String, what: String) -> Finding {
        Finding { id: format!("{}:{surface}:{item}", category.name()), category, surface, item, change: "added".into(), what, why: String::new(), breaking: false, acknowledged: false }
    }
    fn to_json(&self) -> Value {
        json!({
            "id": self.id, "category": self.category.name(), "surface": self.surface, "item": self.item,
            "change": self.change, "what": self.what, "why": self.why, "breaking": self.breaking, "acknowledged": self.acknowledged,
        })
    }
}

/// The surfaces, each with the most permissive category it reports under.
const SURFACES: &[(&str, Category)] = &[
    ("registry", Category::Wire),
    ("corpora", Category::Wire),
    ("domains", Category::Record),
    ("schemas", Category::Record),
    ("c", Category::Api),
    ("openapi", Category::Api),
    ("rust", Category::Api),
];

/// What to check.
pub struct Options {
    pub against: String,
    pub category: Category,
    pub surfaces: BTreeSet<&'static str>,
    pub root: PathBuf,
    pub target_dir: Option<PathBuf>,
}

/// The report of one run.
pub struct Report {
    pub against: String,
    pub base: String,
    pub category: Category,
    pub surfaces: Vec<&'static str>,
    pub findings: Vec<Finding>,
    pub notes: Vec<String>,
}

impl Report {
    pub fn unacknowledged(&self) -> usize {
        self.findings.iter().filter(|f| f.breaking && !f.acknowledged).count()
    }
    pub fn to_json(&self) -> Map<String, Value> {
        let mut m = Map::new();
        m.insert("against".into(), json!(self.against));
        m.insert("base".into(), json!(self.base));
        m.insert("category".into(), json!(self.category.name()));
        m.insert("surfaces".into(), json!(self.surfaces));
        m.insert("breaking".into(), json!(self.unacknowledged()));
        m.insert("acknowledged".into(), json!(self.findings.iter().filter(|f| f.breaking && f.acknowledged).count()));
        m.insert("compatible".into(), json!(self.findings.iter().filter(|f| !f.breaking).count()));
        m.insert("findings".into(), Value::Array(self.findings.iter().map(Finding::to_json).collect()));
        m.insert("notes".into(), json!(self.notes));
        m
    }
}

/// The ids a changelog's added lines acknowledge: every backticked span of an entry (a
/// bullet and its continuation lines, or a paragraph) that has the word "breaking".
pub fn acknowledgements(added: &[String]) -> BTreeSet<String> {
    let mut entries: Vec<String> = Vec::new();
    for l in added {
        let t = l.trim_start();
        let starts = t.starts_with("- ") || t.starts_with("* ") || t.starts_with('#') || (!l.starts_with(' ') && !t.is_empty());
        if starts || entries.is_empty() {
            entries.push(String::new());
        }
        if t.is_empty() {
            entries.push(String::new());
            continue;
        }
        if let Some(e) = entries.last_mut() {
            e.push_str(t);
            e.push(' ');
        }
    }
    let mut ids = BTreeSet::new();
    for e in entries {
        if !e.to_lowercase().contains("breaking") {
            continue;
        }
        let mut parts = e.split('`');
        parts.next();
        while let Some(span) = parts.next() {
            ids.insert(span.trim().to_string());
            parts.next();
        }
    }
    ids
}

/// Run the check.
pub fn run(o: &Options) -> Result<Report, OpError> {
    let repo = git::Repo::open(&o.root)?;
    let target = repo.commit(&o.against).ok_or_else(|| {
        OpError::new(EXIT_NOT_FOUND, "ref-not-found", format!("{}: no such commit", o.against), "fetch it first (git fetch origin main), or name a commit, tag or branch this checkout holds")
    })?;
    let base = repo.commit("HEAD").and_then(|h| repo.merge_base(&target, &h)).unwrap_or(target);
    let mut findings = Vec::new();
    let mut notes = Vec::new();
    let checked: Vec<&'static str> = SURFACES.iter().filter(|(s, c)| *c <= o.category && o.surfaces.contains(s)).map(|x| x.0).collect();
    for s in &checked {
        eprintln!("wai breaking: {s}");
        match *s {
            "registry" => {
                let old = repo.base_file(&base, registry::PATH)?.and_then(|b| serde_json::from_slice::<Value>(&b).ok());
                let new = repo.head_file(registry::PATH).and_then(|b| serde_json::from_slice::<Value>(&b).ok());
                findings.extend(registry::compare(old.as_ref(), new.as_ref()));
            }
            "corpora" => findings.extend(corpora::compare(&repo, &base, &mut notes)?),
            "domains" => findings.extend(domains::compare(&repo, &base)?),
            "schemas" => findings.extend(schema::compare(&repo, &base)?),
            "c" => {
                let old = repo.base_file(&base, c_header::PATH)?.map(|b| String::from_utf8_lossy(&b).into_owned());
                let new = repo.head_file(c_header::PATH).map(|b| String::from_utf8_lossy(&b).into_owned());
                findings.extend(c_header::compare(old.as_deref(), new.as_deref()));
            }
            "openapi" => findings.extend(openapi::compare(&repo, &base)?),
            "rust" => {
                let scratch = std::env::temp_dir().join(format!("wai-breaking-{}-{}", std::process::id(), std::time::SystemTime::now().duration_since(std::time::UNIX_EPOCH).map(|d| d.as_nanos()).unwrap_or(0)));
                let toolchain = std::fs::read_to_string(repo.root.join("rust-toolchain.toml")).ok().and_then(|t| {
                    t.lines().find_map(|l| l.trim().strip_prefix("channel").and_then(|r| r.trim_start().strip_prefix('=')).map(|v| v.trim().trim_matches('"').to_string()))
                });
                let builder = rust_api::Builder { target_dir: o.target_dir.clone().unwrap_or_else(|| scratch.join("target")), toolchain };
                let r = rust_api::compare(&repo, &base, &builder, &scratch, &mut notes);
                let _ = std::fs::remove_dir_all(&scratch);
                findings.extend(r?);
            }
            _ => {}
        }
    }
    findings.retain(|f| f.category <= o.category);
    let acks = acknowledgements(&changelog_added(&repo, &base)?);
    for f in findings.iter_mut().filter(|f| f.breaking) {
        f.acknowledged = acks.contains(&f.id);
    }
    findings.sort_by(|a, b| (!a.breaking, a.acknowledged, a.category, &a.id).cmp(&(!b.breaking, b.acknowledged, b.category, &b.id)));
    Ok(Report { against: o.against.clone(), base, category: o.category, surfaces: checked, findings, notes })
}

fn options(args: &[String]) -> Result<Options, OpError> {
    let flags = ["--against", "--category", "--only", "--skip", "--root", "--target-dir", "--json", "--text", "--all"];
    let with_value = ["--against", "--category", "--only", "--skip", "--root", "--target-dir"];
    envelope::check_args(args, &flags, &with_value, 0, USAGE)?;
    let against = envelope::value(args, "--against", USAGE)?.ok_or_else(|| OpError::usage("--against <git-ref> is required", USAGE))?.to_string();
    let category = match envelope::value(args, "--category", USAGE)? {
        None => Category::Api,
        Some(c) => Category::parse(c).ok_or_else(|| OpError::usage(format!("--category {c}: wire, record or api"), USAGE))?,
    };
    let names = |v: Option<&str>| -> Result<Option<BTreeSet<&'static str>>, OpError> {
        let Some(v) = v else { return Ok(None) };
        v.split(',')
            .map(|n| SURFACES.iter().find(|s| s.0 == n.trim()).map(|s| s.0).ok_or_else(|| OpError::usage(format!("{n}: not a surface ({})", SURFACES.iter().map(|s| s.0).collect::<Vec<_>>().join(", ")), USAGE)))
            .collect::<Result<BTreeSet<_>, _>>()
            .map(Some)
    };
    let mut surfaces: BTreeSet<&'static str> = names(envelope::value(args, "--only", USAGE)?)?.unwrap_or_else(|| SURFACES.iter().map(|s| s.0).collect());
    if let Some(skip) = names(envelope::value(args, "--skip", USAGE)?)? {
        surfaces.retain(|s| !skip.contains(s));
    }
    Ok(Options {
        against,
        category,
        surfaces,
        root: envelope::value(args, "--root", USAGE)?.map(PathBuf::from).unwrap_or_else(|| PathBuf::from(".")),
        target_dir: envelope::value(args, "--target-dir", USAGE)?.map(PathBuf::from),
    })
}

fn text(r: &Report, all: bool) {
    let short = r.base.get(..12).unwrap_or(&r.base);
    println!("wai breaking: against {} (merge base {short}); {} and every category below it; checked {}", r.against, r.category.name(), r.surfaces.join(", "));
    let breaks: Vec<&Finding> = r.findings.iter().filter(|f| f.breaking).collect();
    let compat: Vec<&Finding> = r.findings.iter().filter(|f| !f.breaking).collect();
    if !breaks.is_empty() {
        println!();
        println!("breaking changes ({}, {} acknowledged in {CHANGELOG}):", breaks.len(), breaks.iter().filter(|f| f.acknowledged).count());
        for f in &breaks {
            println!("  {} {}", if f.acknowledged { "ACK  " } else { "BREAK" }, f.id);
            println!("        {} ({} · {}): {}", f.change, f.category.name(), f.surface, f.what);
            println!("        why it breaks: {}", f.why);
        }
    }
    if !compat.is_empty() {
        println!();
        println!("compatible changes ({}):", compat.len());
        let shown = if all { compat.len() } else { compat.len().min(40) };
        for f in &compat[..shown] {
            println!("  +     {}  {}", f.id, f.what);
        }
        if shown < compat.len() {
            println!("  … and {} more (--all lists them)", compat.len() - shown);
        }
    }
    for n in &r.notes {
        println!("note: {n}");
    }
    println!();
    let n = r.unacknowledged();
    if n == 0 {
        println!("wai breaking: no unacknowledged breaking change");
    } else {
        println!("wai breaking: {n} breaking change{} not acknowledged. If {} intended, say so in this change's entry, a new file of {FRAGMENTS}/ (or in {CHANGELOG}, under the current version):", if n == 1 { "" } else { "s" }, if n == 1 { "it is" } else { "they are" });
        for f in breaks.iter().filter(|f| !f.acknowledged).take(8) {
            println!("  - **Breaking:** `{}`: <why it is intended, and what a user does about it>", f.id);
        }
    }
}

pub fn main(args: &[String]) -> Result<bool, String> {
    let json_mode = envelope::json_wanted(args);
    let o = match options(args) {
        Ok(o) => o,
        Err(e) => envelope::fail(OP, json_mode, &e),
    };
    let r = match run(&o) {
        Ok(r) => r,
        Err(e) => envelope::fail(OP, json_mode, &e),
    };
    let ok = r.unacknowledged() == 0;
    if json_mode {
        println!("{}", envelope::envelope(OP, ok, r.to_json()));
    } else {
        text(&r, args.iter().any(|a| a == "--all"));
    }
    if !ok {
        envelope::exit(EXIT_FAILED);
    }
    Ok(true)
}

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

    #[test]
    fn an_entry_acknowledges_only_when_it_says_breaking() {
        let added: Vec<String> = [
            "- **Breaking:** `api:c:wai_pack` takes a",
            "  `uint64_t` now, and api:rust:x is not in backticks.",
            "- `wire:capability:wai.x` is new (no such word here).",
            "",
            "Breaking, as a paragraph: `record:domain:a.rs::SIGN_DOMAIN`.",
        ]
        .iter()
        .map(|s| s.to_string())
        .collect();
        let a = acknowledgements(&added);
        assert!(a.contains("api:c:wai_pack"));
        assert!(a.contains("uint64_t"));
        assert!(a.contains("record:domain:a.rs::SIGN_DOMAIN"));
        assert!(!a.contains("wire:capability:wai.x"));
        assert!(!a.contains("api:rust:x"));
    }
}
