//! `wai check` (catalogue `project.check`): a project validated without building it. Its project
//! file against its reader, which `wai/schema/wai-json-1.schema.json` is held to; each file it
//! states; the capabilities it requires against the registry; the app it names, by `wai app
//! check`; what `wai build` would make of its site; and `wai.lock` against what it pins
//! (`lock.rs`).
//!
//! Every finding is reported, not only the first. A check that could not run here (no `wai-pack`,
//! no checkout of the standard, a build without the feature) says `not checked`, never that it
//! held, and the exit status says so:
//!
//! | Exit | Meaning |
//! |---|---|
//! | 0 | Every check ran, and every one held |
//! | 1 | A check failed |
//! | 5 | None failed, and one or more could not run: what they need was not found |
//! | 5 | There is no project file here |
//!
//! `wai build` runs the same checks first and refuses on any that failed.

use super::envelope::{self as agent, OpError, EXIT_FAILED, EXIT_NOT_FOUND};
use super::lock::{self, Examined, Options};
use super::project::{self, Reading};
use serde_json::{Map, Value, json};
use std::path::{Component, Path};

/// How a check came out.
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
pub enum Status {
    Held,
    Failed,
    NotChecked,
}

impl Status {
    pub fn label(self) -> &'static str {
        match self {
            Status::Held => "held",
            Status::Failed => "failed",
            Status::NotChecked => "not checked",
        }
    }
}

/// One check's outcome.
#[derive(Clone, Debug)]
pub struct Finding {
    /// What was checked: `wai.json`, `lock.registry`, `priors.q4`.
    pub check: String,
    pub status: Status,
    /// A stable string, for what did not hold or did not run.
    pub code: &'static str,
    pub message: String,
    pub hint: String,
    /// What to run to make it hold, where one command does: `run wai lock` for a lock that is
    /// stale or was edited.
    pub fix: Option<String>,
}

impl Finding {
    pub fn held(check: impl Into<String>, message: impl Into<String>) -> Finding {
        Finding { check: check.into(), status: Status::Held, code: "", message: message.into(), hint: String::new(), fix: None }
    }

    pub fn failed(check: impl Into<String>, code: &'static str, message: impl Into<String>, hint: impl Into<String>) -> Finding {
        Finding { check: check.into(), status: Status::Failed, code, message: message.into(), hint: hint.into(), fix: None }
    }

    pub fn not_checked(check: impl Into<String>, code: &'static str, message: impl Into<String>, hint: impl Into<String>) -> Finding {
        Finding { check: check.into(), status: Status::NotChecked, code, message: message.into(), hint: hint.into(), fix: None }
    }

    /// The finding, with the command that fixes it.
    pub fn with_fix(mut self, fix: impl Into<String>) -> Finding {
        self.fix = Some(fix.into());
        self
    }

    /// `fix` is in every finding: the command, or null where no one command fixes it.
    pub fn to_json(&self) -> Value {
        let mut v = json!({"check": self.check, "status": self.status.label(), "message": self.message, "fix": self.fix});
        if !self.code.is_empty() {
            v["code"] = self.code.into();
        }
        if !self.hint.is_empty() {
            v["hint"] = self.hint.clone().into();
        }
        v
    }

    /// What the report's error says to do about it: its fix, or else its hint.
    fn what_to_do(&self) -> String {
        self.fix.clone().unwrap_or_else(|| self.hint.clone())
    }
}

fn count(findings: &[Finding], s: Status) -> usize {
    findings.iter().filter(|f| f.status == s).count()
}

/// The findings' part of a report: each, and how many came out each way.
pub fn summary(findings: &[Finding], body: &mut Map<String, Value>) {
    body.insert("checks".into(), findings.iter().map(Finding::to_json).collect::<Vec<_>>().into());
    body.insert("held".into(), count(findings, Status::Held).into());
    body.insert("failed".into(), count(findings, Status::Failed).into());
    body.insert("not_checked".into(), count(findings, Status::NotChecked).into());
}

/// The findings as lines, for a person.
pub fn print_lines(findings: &[Finding]) {
    for f in findings {
        let tag = match f.status {
            Status::Held => "ok          ",
            Status::Failed => "FAIL        ",
            Status::NotChecked => "not checked ",
        };
        println!("  {tag}{}: {}", f.check, f.message);
        if f.status != Status::Held && !f.hint.is_empty() {
            println!("              {}", f.hint);
        }
        if let Some(fix) = &f.fix {
            println!("              fix: {fix}");
        }
    }
}

/// Say what the findings came to, as the output mode asks, and end with its exit status: 0 when
/// every check ran and held, 1 when one failed (`failed_code`), 5 when none failed and one or more
/// could not run. `body` is the report's other members.
pub fn conclude(op: &str, title: &str, failed_code: &'static str, findings: &[Finding], mut body: Map<String, Value>) -> Result<bool, OpError> {
    let (failed, not_checked) = (count(findings, Status::Failed), count(findings, Status::NotChecked));
    summary(findings, &mut body);
    let json = agent::ctx().json;
    if !json {
        println!("{title}:");
        print_lines(findings);
    }
    if failed > 0 {
        let first = findings.iter().find(|f| f.status == Status::Failed).expect("one failed");
        let message = format!("{failed} failed, {not_checked} not checked; the first: {}: {}", first.check, first.message);
        if json {
            agent::print(&agent::error_envelope_with(Some(op), &OpError::of(EXIT_FAILED, failed_code, message).with_hint(first.what_to_do()), Some(Value::Object(body))));
        } else {
            eprintln!("{title}: {message}");
            if let Some(fix) = &first.fix {
                eprintln!("{title}: fix: {fix}");
            }
        }
        return Ok(false);
    }
    if not_checked > 0 {
        let first = findings.iter().find(|f| f.status == Status::NotChecked).expect("one not checked");
        let message = format!("none failed, and {not_checked} not checked: {}: {}", first.check, first.message);
        if json {
            agent::print(&agent::error_envelope_with(Some(op), &OpError::of(EXIT_NOT_FOUND, "not-checked", message).with_hint(first.what_to_do()), Some(Value::Object(body))));
        } else {
            eprintln!("{title}: {message}");
        }
        return Err(OpError::reported(EXIT_NOT_FOUND));
    }
    if json {
        agent::print(&agent::envelope_with(op, true, Value::Object(body), None));
    } else {
        println!("{title}: every check held");
    }
    Ok(true)
}

/// A path the project file states, relative to the project with `/` between its segments, when it
/// stays inside the project: `None` when it leaves, or is absolute. The project itself is `""`.
pub fn project_relative(root: &Path, path: &Path) -> Option<String> {
    let rel = path.strip_prefix(root).ok()?;
    let mut segments = Vec::new();
    for c in rel.components() {
        match c {
            Component::Normal(s) => segments.push(s.to_str()?.to_string()),
            Component::CurDir => {}
            _ => return None,
        }
    }
    Some(segments.join("/"))
}

#[derive(Clone, Copy, PartialEq, Eq)]
enum Kind {
    File,
    Dir,
    Either,
}

/// A file the project file states: it stays inside the project and is there.
fn stated(root: &Path, check: &str, path: &Path, kind: Kind, out: &mut Vec<Finding>) {
    if project_relative(root, path).is_none() {
        out.push(Finding::failed(check, "path-leaves-project", format!("{} leaves the project: paths are relative to {} and stay under it", path.display(), project::FILE), ""));
        return;
    }
    let holds = match kind {
        Kind::File => path.is_file(),
        Kind::Dir => path.is_dir(),
        Kind::Either => path.exists(),
    };
    if holds {
        out.push(Finding::held(check, format!("{} is there", path.display())));
    } else {
        let want = match kind {
            Kind::File => "a file",
            Kind::Dir => "a directory",
            Kind::Either => "a file or a directory",
        };
        out.push(Finding::failed(check, "file-missing", format!("{} is not there: {check} names {want}", path.display()), ""));
    }
}

/// `wai app check`'s own report on the project's app.
#[cfg(feature = "app")]
fn app_check(root: &Path, out: &mut Vec<Finding>) {
    match super::app::check(root) {
        Err(e) => out.push(Finding::failed("app", "app-check-failed", e, "")),
        Ok(r) => {
            out.extend(r.checked.iter().map(|c| Finding::held("app", c.clone())));
            out.extend(r.failures.iter().map(|f| Finding::failed("app", "app-check-failed", f.clone(), "wai app check says the same")));
        }
    }
}

/// A build without `wai app check` cannot say whether the app holds.
#[cfg(not(feature = "app"))]
fn app_check(_: &Path, out: &mut Vec<Finding>) {
    out.push(Finding::not_checked("app", "operation-not-in-build", "the app is not checked: `wai app check` needs a build with the feature app", "cargo install --path wai/wai-rs --no-default-features --features quickstart,app"));
}

/// What `wai check` found.
#[derive(Clone, Debug, Default)]
pub struct Report {
    /// The project file's canonical digest, when it has one.
    pub digest: Option<String>,
    pub lock: Examined,
    pub findings: Vec<Finding>,
}

/// Every check, on the project in `dir`.
pub fn run(dir: &Path, text: &str, r: &Reading, o: Options) -> Report {
    let mut out = Vec::new();
    let p = &r.project;
    for problem in &r.problems {
        out.push(Finding::failed(project::FILE, "project-file-invalid", problem.clone(), ""));
    }
    let digest = project::digest(text).ok();
    if r.problems.is_empty() {
        out.push(Finding::held(project::FILE, format!("reads as version {}, canonical digest {}", project::VERSION, digest.as_deref().unwrap_or("none"))));
    }
    if let Some(a) = &p.app {
        stated(dir, "app", a, Kind::File, &mut out);
    }
    if let Some(s) = &p.dev.site {
        stated(dir, "dev.site", s, Kind::Dir, &mut out);
    }
    if let Some(w) = &p.dev.watch {
        stated(dir, "dev.watch", w, Kind::File, &mut out);
    }
    for (name, path) in &p.priors {
        stated(dir, &format!("priors.{name}"), path, Kind::Either, &mut out);
    }
    if let Some(pk) = &p.package {
        stated(dir, "package.policy", &pk.policy, Kind::File, &mut out);
        for (id, path) in &pk.sources {
            stated(dir, &format!("package.sources.{id}"), path, Kind::File, &mut out);
        }
    }
    for c in &p.capabilities {
        if wai::codecs::registry::capability_kind(c).is_none() {
            out.push(Finding::failed("requires.capabilities", "capability-not-registered", format!("{c} is not a registered capability"), "wai capabilities lists them"));
        } else if wai::codecs::parameter_set_shape(c).is_some() && p.priors.is_empty() {
            out.push(Finding::failed("requires.capabilities", "prior-not-named", format!("{c} decodes against a parameter set, and priors names none, so the lock pins none"), "name the set under priors"));
        } else {
            out.push(Finding::held("requires.capabilities", format!("{c} is registered")));
        }
    }
    if p.app.is_some() {
        app_check(dir, &mut out);
    }
    let lock = lock::examine(dir, r, o, &mut out);
    // The sources the policy itself names, beside it, where the project file names no other.
    if let (Some(pk), Some(read)) = (&p.package, &lock.now.policy) {
        let beside = pk.policy.parent().unwrap_or(dir);
        for (id, s) in &read.sources {
            if !pk.sources.contains_key(id) && !beside.join(s).is_file() {
                out.push(Finding::failed("package.policy", "file-missing", format!("{}: component {id} names the source {s}, which is not there", pk.policy.display()), ""));
            }
        }
    }
    if r.read("package") && r.read("dev") && r.read("app") {
        for f in super::build::plan(dir, p, &lock.now).findings {
            out.push(f);
        }
    }
    Report { digest, lock, findings: out }
}

/// The report's members other than its checks.
pub fn body(dir: &Path, report: &Report) -> Map<String, Value> {
    let mut body = Map::new();
    body.insert("project".into(), dir.join(project::FILE).display().to_string().into());
    body.insert("digest".into(), report.digest.clone().into());
    body.insert("lock".into(), json!({"file": dir.join(lock::FILE).display().to_string(), "present": report.lock.present, "sha256": report.lock.sha256}));
    body
}

/// `wai check [<dir>] [--root <wai-dir>] [--packer BIN]`.
pub fn main(args: &[String]) -> Result<bool, OpError> {
    let usage = super::usage("project.check");
    let pos = agent::check_args(args, &[], &["--root", "--packer"], 1, &usage)?;
    let o = Options { root: agent::value(args, "--root", &usage)?, packer: agent::value(args, "--packer", &usage)? };
    let dir = Path::new(pos.first().copied().unwrap_or("."));
    let (text, r) = lock::open(dir)?;
    let report = run(dir, &text, &r, o);
    conclude("project.check", "wai check", "check-failed", &report.findings, body(dir, &report))
}
