//! `wai build` (catalogue `project.build`): `dist/`, what a project is once it is built, made from
//! the project and its lock by the operations that already exist, the same bytes each time.
//!
//! It runs `wai check` first and refuses on any check that failed, so a stale or edited lock stops
//! it and nothing is written; a check that could not run is named, and does not stop a build it
//! has no bearing on. Then:
//!
//! - **The site** (`dev.site`), what `wai dev` serves as the project's own page, copied to the root
//!   of `dist/`, byte for byte. Left out: every path `wai dev` never serves (a segment that starts
//!   with a dot); the project's own working files at its root ([`WORKING_FILES`]); what `package`
//!   reads (the policy, the sources, the supplied floor); and any file that holds a signing key's
//!   secret in the form `wai-pack --key` reads, 64 hexadecimal digits. Each but a dot path is
//!   named in the command's report. A link is refused: the tree is files, and a file's bytes are
//!   what is recorded.
//! - **The app** (`app`), at its path, where the site does not already hold it.
//! - **The package** (`package`), packed into `dist/<out>` by `wai-pack pack` and verified by
//!   `wai-pack verify`. `--key` names the seed that signs its receipt; the receipt is the same
//!   for the same key.
//! - A project that states none of these gets a `dist/` that holds its build record alone, which
//!   says which project configuration and lock it is.
//! - **The build record**, `dist/wai-build.json`, last: the project file's canonical digest, the
//!   lock's digest, the toolchain it pins, and each file with its SHA-256 and size. It is RFC 8785
//!   on one line, as `wai.lock` is, and holds no time and no path outside `dist/`: two builds of
//!   one project and lock, in two directories at two times, give the same tree, and the record's
//!   SHA-256 is the tree's digest. `wai/schema/wai-build-1.schema.json` states it.
//!
//! The tree is made beside `dist/` and renamed into place, so a build that fails leaves the one
//! before it. A `dist/` with no build record is not this command's to replace without `--yes`.

use super::check::{self, Finding, Report, project_relative};
use super::envelope::{self as agent, OpError, EXIT_FAILED, EXIT_NOT_FOUND, EXIT_USAGE};
use super::lock::{self, Now, Options};
use super::project::{self, Project, Reading};
use crate::out;
use serde_json::{Value, json};
use std::path::{Path, PathBuf};

/// What a build writes into.
pub const DIST: &str = "dist";
/// The build record's name in it.
pub const RECORD: &str = "wai-build.json";
/// The URL of the record's JSON Schema, its `$schema` member and the schema's `$id`.
pub const SCHEMA: &str = "https://wai.transaction.science/schema/wai-build.json/1";
/// The record's version, its `wai` member.
pub const VERSION: u64 = 1;
/// The record's members.
#[cfg(test)]
pub const MEMBERS: &[&str] = &["$schema", "wai", "project", "lock", "toolchain", "files"];
/// A project's working files at its root, which a site that is the whole project does not ship.
pub const WORKING_FILES: &[&str] = &[project::FILE, lock::FILE, "AGENTS.md", "CLAUDE.md", "README.md", "test", DIST];

/// The package's directory under `dist/` when `package.out` does not name one.
const DEFAULT_OUT: &str = "package";

/// What a build would copy.
#[derive(Clone, Debug, Default)]
pub struct Plan {
    /// Each file: where it is, and its path under `dist/`, `/`-joined.
    pub files: Vec<(PathBuf, String)>,
    /// What the site holds that the build leaves out: its path in the project, and why.
    pub left_out: Vec<(String, &'static str)>,
    /// What stops the build.
    pub findings: Vec<Finding>,
    /// Whether the project states anything to build.
    pub something: bool,
}

/// Whether a file holds a signing key's secret in the form `wai-pack --key` reads.
fn holds_a_seed(path: &Path) -> bool {
    if std::fs::metadata(path).map_or(true, |m| m.len() > 80) {
        return false;
    }
    std::fs::read(path).is_ok_and(|b| {
        let t = b.trim_ascii();
        t.len() == 64 && t.iter().all(u8::is_ascii_hexdigit)
    })
}

/// A directory's files below `rel`, in order, with the links and names a tree cannot carry named.
fn walk(base: &Path, rel: &str, files: &mut Vec<String>, refused: &mut Vec<(String, &'static str)>) -> std::io::Result<()> {
    let dir = if rel.is_empty() { base.to_path_buf() } else { base.join(rel) };
    let mut entries: Vec<std::fs::DirEntry> = std::fs::read_dir(&dir)?.collect::<Result<_, _>>()?;
    entries.sort_by_key(std::fs::DirEntry::file_name);
    for e in entries {
        let Some(name) = e.file_name().to_str().map(String::from) else {
            refused.push((format!("{rel}/{}", e.file_name().to_string_lossy()), "its name is not UTF-8"));
            continue;
        };
        let path = if rel.is_empty() { name.clone() } else { format!("{rel}/{name}") };
        let t = e.file_type()?;
        if name.starts_with('.') {
            continue;
        }
        if t.is_symlink() {
            refused.push((path, "a link"));
        } else if t.is_dir() {
            walk(base, &path, files, refused)?;
        } else if t.is_file() {
            files.push(path);
        }
    }
    Ok(())
}

fn out_dir(p: &Project) -> String {
    p.package.as_ref().and_then(|k| k.out.clone()).unwrap_or_else(|| DEFAULT_OUT.to_string())
}

/// What a build of this project would copy, and what would stop it.
pub fn plan(dir: &Path, p: &Project, now: &Now) -> Plan {
    let mut plan = Plan { something: p.dev.site.is_some() || p.app.is_some() || p.package.is_some(), ..Plan::default() };
    let out = out_dir(p);
    let out_ok = !out.is_empty() && out != "." && !Path::new(&out).is_absolute() && out.split(['/', '\\']).all(|s| !s.is_empty() && s != "." && s != ".." && !s.starts_with('.'));
    if p.package.is_some() && !out_ok {
        plan.findings.push(Finding::failed("package.out", "path-leaves-project", format!("{out:?} is not a directory under {DIST}/: relative, with no dot segment"), ""));
    }
    // What `package` reads, by its path in the project.
    let mut read_by_package: Vec<String> = Vec::new();
    if let Some(k) = &p.package {
        let beside = k.policy.parent().unwrap_or(dir);
        read_by_package.extend(project_relative(dir, &k.policy));
        read_by_package.extend(k.sources.values().filter_map(|s| project_relative(dir, s)));
        read_by_package.extend(project_relative(dir, &beside.join("floor")));
        if let Some(policy) = &now.policy {
            read_by_package.extend(policy.sources.iter().filter_map(|(_, s)| project_relative(dir, &beside.join(s))));
        }
    }
    let under = |path: &str, top: &str| path == top || path.strip_prefix(top).is_some_and(|r| r.starts_with('/'));
    if let Some(site) = &p.dev.site
        && site.is_dir()
        && let Some(site_rel) = project_relative(dir, site)
    {
        if under(&site_rel, DIST) {
            plan.findings.push(Finding::failed("dev.site", "site-is-output", format!("dev.site is {site_rel}, which {} writes", "wai build"), ""));
            return plan;
        }
        let (mut files, mut refused) = (Vec::new(), Vec::new());
        if let Err(e) = walk(site, "", &mut files, &mut refused) {
            plan.findings.push(Finding::failed("dev.site", "site-unreadable", format!("{}: {e}", site.display()), ""));
            return plan;
        }
        for (path, why) in refused {
            plan.findings.push(Finding::failed("dev.site", "site-not-files", format!("{path}: {why}; {DIST}/ holds files"), ""));
        }
        for rel in files {
            let in_project = if site_rel.is_empty() { rel.clone() } else { format!("{site_rel}/{rel}") };
            let src = site.join(&rel);
            let why = if WORKING_FILES.iter().any(|w| under(&in_project, w)) {
                Some("a working file of the project")
            } else if read_by_package.iter().any(|r| under(&in_project, r)) {
                Some("what package reads")
            } else if p.package.is_some() && under(&rel, &out) {
                Some("package writes this directory")
            } else if holds_a_seed(&src) {
                Some("it holds a signing key's secret")
            } else {
                None
            };
            match why {
                Some(w) => plan.left_out.push((in_project, w)),
                None if rel == RECORD => plan.findings.push(Finding::failed("dev.site", "site-holds-record", format!("the site holds {RECORD}, the build record's name"), "rename it")),
                None => plan.files.push((src, rel)),
            }
        }
    }
    if let Some(a) = &p.app
        && let Some(rel) = project_relative(dir, a)
        && a.is_file()
        && !plan.files.iter().any(|(_, r)| *r == rel)
    {
        plan.files.push((a.clone(), rel));
    }
    plan.files.sort_by(|a, b| a.1.as_bytes().cmp(b.1.as_bytes()));
    plan
}

/// A file's SHA-256 and size, read as it goes.
fn sha256_file(path: &Path) -> std::io::Result<(String, u64)> {
    use sha2::{Digest, Sha256};
    let mut h = Sha256::new();
    let n = std::io::copy(&mut std::fs::File::open(path)?, &mut h)?;
    Ok((h.finalize().iter().map(|x| format!("{x:02x}")).collect(), n))
}

/// The record's bytes.
fn record(project_digest: &str, name: Option<&str>, lock_sha256: &str, toolchain: &Value, files: Vec<Value>) -> Vec<u8> {
    let mut p = json!({"sha256": project_digest});
    if let Some(n) = name {
        p["name"] = n.into();
    }
    let v = json!({"$schema": SCHEMA, "wai": VERSION, "project": p, "lock": {"sha256": lock_sha256}, "toolchain": toolchain, "files": files});
    wai::jcs::jcs(&v).expect("a record holds strings and whole numbers").into_bytes()
}

/// What a build makes it from, past the checks.
pub struct Inputs<'a> {
    pub o: Options<'a>,
    /// The seed that signs a package's receipt.
    pub key: Option<&'a str>,
}

/// Removes the tree a build was making, unless it was put in place.
struct Stage(PathBuf);

impl Drop for Stage {
    fn drop(&mut self) {
        let _ = std::fs::remove_dir_all(&self.0);
    }
}

/// Everything after the checks: `dist/` written, and what was built as `data`.
fn write(dir: &Path, r: &Reading, report: &Report, i: &Inputs) -> Result<Value, OpError> {
    let p = &r.project;
    let plan = plan(dir, p, &report.lock.now);
    let key = match (&p.package, i.key) {
        (Some(_), None) => return Err(OpError::usage("wai build: this project has a package, whose receipt needs --key <seed file>", "")),
        (_, k) => k,
    };
    let dist = dir.join(DIST);
    if dist.exists() {
        if !dist.is_dir() {
            return Err(OpError::of(EXIT_USAGE, "dist-not-a-directory", format!("{} is not a directory", dist.display())));
        }
        if !dist.join(RECORD).is_file() {
            agent::confirm(&format!("{} exists, and holds no build record", dist.display()), vec![json!({"replace": dist.display().to_string()})])?;
        }
    }
    let project_digest = report.digest.clone().ok_or_else(|| OpError::internal("a project file that reads has a canonical digest"))?;
    let lock_sha256 = report.lock.sha256.clone().ok_or_else(|| OpError::internal("a lock that holds has a digest"))?;
    let toolchain = report.lock.now.lock.get("toolchain").cloned().unwrap_or(Value::Null);
    let dry = out::dry_run();
    let stage_dir = if dry { dist.clone() } else { dir.join(format!(".{DIST}.new-{}", std::process::id())) };
    let stage = (!dry).then(|| Stage(stage_dir.clone()));
    if !dry {
        // A process of this id that stopped part way leaves nothing in this tree.
        let _ = std::fs::remove_dir_all(&stage_dir);
    }
    let io = |what: &Path, e: std::io::Error| OpError::from(format!("{}: {e}", what.display()));
    out::create_dir_all(&stage_dir).map_err(|e| io(&stage_dir, e))?;
    for (src, rel) in &plan.files {
        let to = stage_dir.join(rel);
        if let Some(parent) = to.parent() {
            out::create_dir_all(parent).map_err(|e| io(parent, e))?;
        }
        out::copy(src, &to).map_err(|e| io(&to, e))?;
    }
    if let Some(k) = &p.package {
        let pack_dir = stage_dir.join(out_dir(p));
        if dry {
            out::would_write(&pack_dir);
        } else {
            let bin = crate::package::find(i.o.packer)?;
            let mut args: Vec<String> = vec!["--policy".into(), k.policy.display().to_string(), "--out".into(), pack_dir.display().to_string(), "--key".into(), key.unwrap_or_default().to_string()];
            for (id, path) in &k.sources {
                args.extend(["--source".to_string(), format!("{id}={}", path.display())]);
            }
            if !crate::package::quiet(&bin, "pack", &args)? {
                return Err(OpError::of(EXIT_FAILED, "package-refused", format!("wai-pack refused the package of {} (above)", k.policy.display())));
            }
            if !crate::package::quiet(&bin, "verify", &[pack_dir.display().to_string()])? {
                return Err(OpError::of(EXIT_FAILED, "package-refused", "wai-pack verify refused the package it packed (above)"));
            }
        }
    }
    // Every file of the tree, as it stands.
    let mut names: Vec<String> = Vec::new();
    if dry {
        names.extend(plan.files.iter().map(|(_, r)| r.clone()));
    } else {
        let mut refused = Vec::new();
        walk(&stage_dir, "", &mut names, &mut refused).map_err(|e| io(&stage_dir, e))?;
        if let Some((path, why)) = refused.first() {
            return Err(OpError::of(EXIT_FAILED, "package-refused", format!("{path}: {why}; {DIST}/ holds files")));
        }
    }
    names.sort_unstable_by(|a, b| a.as_bytes().cmp(b.as_bytes()));
    let mut files = Vec::with_capacity(names.len());
    for rel in &names {
        let at = if dry { plan.files.iter().find(|(_, r)| r == rel).map(|(s, _)| s.clone()).unwrap_or_default() } else { stage_dir.join(rel) };
        let (sha256, bytes) = sha256_file(&at).map_err(|e| io(&at, e))?;
        files.push(json!({"path": rel, "sha256": sha256, "bytes": bytes}));
    }
    let count = files.len();
    let bytes = record(&project_digest, p.name.as_deref(), &lock_sha256, &toolchain, files);
    let record_path = stage_dir.join(RECORD);
    out::write(&record_path, &bytes).map_err(|e| io(&record_path, e))?;
    out::replace_dir(&stage_dir, &dist).map_err(|e| io(&dist, e))?;
    drop(stage);
    let not_checked: Vec<Value> = report.findings.iter().filter(|f| f.status == check::Status::NotChecked).map(Finding::to_json).collect();
    let left_out: Vec<Value> = plan.left_out.iter().map(|(path, why)| json!({"path": path, "why": why})).collect();
    Ok(json!({
        "dir": dist.display().to_string(),
        // No dev.site, no app and no package: the tree is the record alone.
        "record_only": !plan.something,
        "record": dist.join(RECORD).display().to_string(),
        // In a dry run a package's files are not made, so the record is not the one a build writes.
        "record_sha256": (!dry || p.package.is_none()).then(|| wai::pin::sha256_hex(&bytes)),
        "project_sha256": project_digest,
        "lock_sha256": lock_sha256,
        "files": count,
        "left_out": left_out,
        "not_checked": not_checked,
    }))
}

/// A project built, as a person's command and a coding agent's tool both run it, printing nothing:
/// `Err` with the report as `Ok(Err(..))` when a check stopped it.
pub fn build(dir: &Path, i: &Inputs) -> Result<Result<Value, (Report, &'static str)>, OpError> {
    let (text, r) = lock::open(dir)?;
    if !dir.join(lock::FILE).exists() {
        return Err(OpError::new(EXIT_NOT_FOUND, "lock-missing", format!("{}: not there; a build names the lock it came from", dir.join(lock::FILE).display()), lock::fix(dir, i.o)));
    }
    let report = check::run(dir, &text, &r, i.o);
    if report.findings.iter().any(|f| f.status == check::Status::Failed) {
        let stale = report.findings.iter().any(|f| f.status == check::Status::Failed && f.check.starts_with("lock"));
        return Ok(Err((report, if stale { "lock-stale" } else { "check-failed" })));
    }
    write(dir, &r, &report, i).map(Ok)
}

/// `wai build [<dir>] [--key <seed file>] [--packer BIN] [--root <wai-dir>]`.
pub fn main(args: &[String]) -> Result<bool, OpError> {
    let usage = super::usage("project.build");
    let pos = agent::check_args(args, &[], &["--key", "--packer", "--root"], 1, &usage)?;
    let i = Inputs { o: Options { root: agent::value(args, "--root", &usage)?, packer: agent::value(args, "--packer", &usage)? }, key: agent::value(args, "--key", &usage)? };
    let dir = Path::new(pos.first().copied().unwrap_or("."));
    let data = match build(dir, &i)? {
        Err((report, code)) => {
            let body = check::body(dir, &report);
            check::conclude("project.build", "wai build", code, &report.findings, body)?;
            eprintln!("wai build: nothing was written");
            return Ok(false);
        }
        Ok(d) => d,
    };
    if agent::ctx().json {
        let Value::Object(body) = data else { unreachable!("an object") };
        agent::print(&agent::envelope("project.build", true, body));
        return Ok(true);
    }
    print_text(&data);
    Ok(true)
}

fn print_text(d: &Value) {
    let s = |k: &str| d[k].as_str().unwrap_or_default();
    if out::dry_run() {
        println!("dry run: nothing was written. Without --dry-run, wai would:");
        for l in out::plan_lines() {
            println!("  {l}");
        }
    } else if d["record_only"] == true {
        println!("wai build: {}, the build record alone: the project states no dev.site, no app and no package", s("dir"));
    } else {
        println!("wai build: {} ({} files and the record)", s("dir"), d["files"]);
    }
    println!("  project  sha256 {}", s("project_sha256"));
    println!("  lock     sha256 {}", s("lock_sha256"));
    if let Some(r) = d["record_sha256"].as_str() {
        println!("  record   {}, sha256 {r}", s("record"));
    }
    for l in d["left_out"].as_array().into_iter().flatten() {
        println!("  left out {}: {}", l["path"].as_str().unwrap_or_default(), l["why"].as_str().unwrap_or_default());
    }
    for n in d["not_checked"].as_array().into_iter().flatten() {
        println!("  not checked {}: {}", n["check"].as_str().unwrap_or_default(), n["message"].as_str().unwrap_or_default());
    }
}

#[cfg(test)]
mod tests {
    use super::super::project::tests::valid;
    use super::*;

    const H: &str = "0123456789abcdef0123456789abcdef0123456789abcdef0123456789abcdef";

    #[test]
    fn the_schema_states_the_record_this_writes() {
        let path = Path::new(env!("CARGO_MANIFEST_DIR")).join("../schema/wai-build-1.schema.json");
        let schema: Value = serde_json::from_str(&std::fs::read_to_string(&path).unwrap()).unwrap();
        assert_eq!(schema["$id"], SCHEMA);
        assert_eq!(schema["properties"]["$schema"]["const"], SCHEMA);
        assert_eq!(schema["properties"]["wai"]["const"], VERSION);
        assert_eq!(schema["properties"].as_object().unwrap().keys().cloned().collect::<Vec<_>>(), MEMBERS);
        let toolchain = json!({"version": "0.4.0", "catalogue": {"sha256": H}});
        let files = vec![json!({"path": "index.html", "sha256": H, "bytes": 3})];
        let named: Value = serde_json::from_slice(&record(H, Some("my-room"), H, &toolchain, files.clone())).unwrap();
        let bare: Value = serde_json::from_slice(&record(H, None, H, &toolchain, files)).unwrap();
        assert!(valid(&schema, &named), "{named}");
        assert!(valid(&schema, &bare), "{bare}");
        for (k, v) in [("extra", json!(1)), ("wai", json!(2)), ("lock", json!({"sha256": "x"})), ("project", json!({"name": "x"})), ("files", json!([{"path": "a"}])), ("toolchain", json!({"version": "1"}))] {
            let mut d = named.clone();
            d[k] = v;
            assert!(!valid(&schema, &d), "{d}");
        }
    }
}
