//! `wai create` (catalogue `project.create`): a new project, in one of two ways.
//!
//! - `wai create <dir> --template <name>`: from one of the examples in `wai/examples/`, whose
//!   files this binary holds ([`templates`]), so it needs neither the repository nor a network.
//!   Each example's `test/` directory is the repository's check that the example works, and is
//!   not part of the project. Where a template names a generated file (a watch room's sample
//!   sound), it is made here.
//! - `wai create <dir> --from app.oui` (also `wai app export`, catalogue `app.export`; a build
//!   with `app`): from a program made with Create. The app's files are `wai_app::export`'s, its
//!   runtime is copied in from a Create runtime (`--runtime DIR`, `$WAI_APP_RUNTIME`, or
//!   `share/wai/app` beside this binary) and checked file by file, and its project file names
//!   the program and the room `wai dev` runs for it.
//!
//! Either way the project file is written by [`project::render`] and read before anything is
//! written, and every project carries the licence its files are under, Apache-2.0, as
//! `LICENSE` (each example holds it too), and its lock, `wai.lock` (`lock.rs`), so that a new
//! project passes `wai check` and `wai build` as it is. It writes into a directory that does not exist or is
//! empty, and never over a file; every write goes through `out`, so `--dry-run` writes none.

#[path = "templates.rs"]
mod templates;

use super::{flag, opt, positional, project};
use super::envelope::OpError;
use crate::out;
use std::path::Path;

/// The licence every project `wai create` writes is under, and the file that says so: one copy
/// in this binary, which each example's `LICENSE` equals byte for byte.
const LICENSE: (&str, &[u8]) = ("LICENSE", include_bytes!("../../../../../LICENSE"));

/// A file of a template: its path in the project, and its bytes.
pub type File = (&'static str, &'static [u8]);
/// A file made when the project is created, by a function of this binary.
pub type Generated = (&'static str, fn() -> Vec<u8>);

/// A template: its name, the line `--list` shows, and its files.
pub struct Template {
    pub name: &'static str,
    pub title: &'static str,
    pub files: &'static [File],
    pub generated: &'static [Generated],
}

pub fn main(args: &[String]) -> Result<bool, OpError> {
    if flag(args, "--list") {
        if flag(args, "--json") {
            let v: Vec<serde_json::Value> = templates::TEMPLATES
                .iter()
                .map(|t| serde_json::json!({ "template": t.name, "title": t.title, "files": t.files.iter().map(|f| f.0).chain(t.generated.iter().map(|g| g.0)).chain([super::lock::FILE]).collect::<Vec<_>>() }))
                .collect();
            println!("{}", serde_json::to_string_pretty(&v).expect("json"));
        } else {
            for t in templates::TEMPLATES {
                println!("{:<20} {}", t.name, t.title);
            }
        }
        return Ok(true);
    }
    let usage = super::usage("project.create");
    if let Some(a) = args.iter().find(|a| a.starts_with("--") && !["--template", "--from", "--runtime"].contains(&a.as_str())) {
        return Err(OpError::usage(format!("wai create: {a}: not an option ({usage})"), ""));
    }
    if let Some(program) = opt(args, "--from") {
        let pos = positional(args, &["--from", "--runtime"]);
        let (Some(dir), 1, None) = (pos.first(), pos.len(), opt(args, "--template")) else {
            return Err(OpError::usage(format!("wai create <dir> --from app.oui [--runtime DIR]\n{usage}"), ""));
        };
        return from_app(Path::new(program), Path::new(dir), opt(args, "--runtime").map(Path::new)).map_err(OpError::from).map(|written| {
            println!("wai create: an app made with Create, in {dir}");
            for f in written.iter().filter(|f| !f.starts_with("vendor/")) {
                println!("  {f}");
            }
            println!("  vendor/   ({} runtime files)", written.iter().filter(|f| f.starts_with("vendor/")).count());
            println!("Next:\n  cd {dir}\n  wai app check   the app, its room and its runtime\n  wai dev         then open the link it prints");
            true
        });
    }
    let pos = positional(args, &["--template"]);
    let (Some(dir), Some(name), 1) = (pos.first(), opt(args, "--template"), pos.len()) else {
        return Err(OpError::usage(format!("{usage}\ntemplates: {}", names()), ""));
    };
    let t = templates::TEMPLATES.iter().find(|t| t.name == name).ok_or_else(|| format!("no template {name}; the templates: {}", names()))?;
    let dir = Path::new(dir);
    write(t, dir)?;
    println!("wai create: {} in {}", t.title, dir.display());
    for (f, _) in t.files.iter() {
        println!("  {f}");
    }
    for (f, _) in t.generated.iter() {
        println!("  {f}   (made now)");
    }
    println!("  {}   (what the project was made with, pinned)", super::lock::FILE);
    println!("Next:\n  cd {}\n  wai dev        then open the link it prints; the README says what you will see", dir.display());
    Ok(true)
}

fn names() -> String {
    templates::TEMPLATES.iter().map(|t| t.name).collect::<Vec<_>>().join(", ")
}

/// The digest of the template `name` as this binary holds it, or `None` for one it does not hold:
/// the SHA-256 of its listing, one line a file, sorted by path as bytes: the file's SHA-256, two
/// spaces, its path, a line feed. The files made when a project is created are in it, the sample
/// sound of a watch room among them: `wai create` writes those bytes.
pub(super) fn template_digest(name: &str) -> Option<String> {
    let t = templates::TEMPLATES.iter().find(|t| t.name == name)?;
    let mut lines: Vec<(&str, String)> = t.files.iter().map(|(p, b)| (*p, wai::pin::sha256_hex(b))).collect();
    lines.extend(t.generated.iter().map(|(p, f)| (*p, wai::pin::sha256_hex(&f()))));
    lines.sort_unstable_by_key(|(p, _)| p.as_bytes());
    let listing: String = lines.iter().map(|(p, h)| format!("{h}  {p}\n")).collect();
    Some(wai::pin::sha256_hex(listing.as_bytes()))
}

/// The template's files in `dir`, which must not exist or must be empty. The project is named
/// for its directory when that is a project name, else for the template ([`project::name_for`]):
/// its project file is the template's with that `name`, or one written where the template
/// carries none.
fn write(t: &Template, dir: &Path) -> Result<(), String> {
    let name = project::name_for(dir, t.name);
    let mut files: Vec<(String, Vec<u8>)> = t.files.iter().map(|(p, b)| (p.to_string(), b.to_vec())).collect();
    files.extend(t.generated.iter().map(|(p, f)| (p.to_string(), f())));
    match files.iter_mut().find(|(p, _)| p == project::FILE) {
        Some((_, b)) => *b = project::renamed(&String::from_utf8_lossy(b), &name).map_err(|e| format!("{}'s {}: {e}", t.name, project::FILE))?.into_bytes(),
        None => files.push((project::FILE.into(), project::minimal(&name, t.name).into_bytes())),
    }
    write_project(dir, files)
}

/// A project's files in `dir`, which must not exist or must be empty, with the licence every
/// project carries; then its project file, read back. The one place `wai create` writes.
fn write_project(dir: &Path, mut files: Vec<(String, Vec<u8>)>) -> Result<(), String> {
    if dir.exists() && std::fs::read_dir(dir).map_err(|e| format!("{}: {e}", dir.display()))?.next().is_some() {
        return Err(format!("{}: not empty; name a new directory", dir.display()));
    }
    if !files.iter().any(|(p, _)| p == LICENSE.0) {
        files.push((LICENSE.0.into(), LICENSE.1.to_vec()));
    }
    // The project file must read before anything is written: a template is checked as it is
    // made.
    let pf = files.iter().find(|(p, _)| *p == project::FILE).map(|(_, b)| String::from_utf8_lossy(b).into_owned()).unwrap_or_default();
    project::parse(dir, &pf).map_err(|e| format!("{}: {e}", dir.join(project::FILE).display()))?;
    // The project's lock, from the project file it is given: a template states nothing a lock
    // needs a packer or a checkout for, so it pins what `wai lock` would.
    let now = super::lock::resolve(&project::read(dir, &pf), super::lock::Options::default());
    if let Some(u) = now.unresolved.first() {
        return Err(format!("{}: {}", super::lock::FILE, u.message));
    }
    files.push((super::lock::FILE.into(), super::lock::render(&now.lock)));
    for (rel, bytes) in &files {
        if rel.split('/').any(|s| s.is_empty() || s == "." || s == "..") || rel.starts_with('/') {
            return Err(format!("{rel}: not a path a template may write"));
        }
        let p = dir.join(rel);
        if let Some(parent) = p.parent() {
            out::create_dir_all(parent).map_err(|e| format!("{}: {e}", parent.display()))?;
        }
        out::write_new(&p, bytes).map_err(|e| format!("{}: {e}", p.display()))?;
    }
    Ok(())
}

/// The Create runtime: the flag, `$WAI_APP_RUNTIME`, or `share/wai/app` beside this binary's
/// directory (an installed archive's layout), checked for its listing.
#[cfg(feature = "app")]
pub(super) fn find_runtime(flag_value: Option<&Path>) -> Result<std::path::PathBuf, String> {
    use std::path::PathBuf;
    let exe_dir = std::env::current_exe().ok().and_then(|e| std::fs::canonicalize(e).ok()).and_then(|e| e.parent().map(Path::to_path_buf));
    let candidates: Vec<PathBuf> = match flag_value.map(Path::to_path_buf).or_else(|| std::env::var_os("WAI_APP_RUNTIME").map(PathBuf::from)) {
        Some(p) => vec![p],
        None => exe_dir.map(|d| vec![d.join("../share/wai/app"), d.join("share/wai/app")]).unwrap_or_default(),
    };
    candidates.iter().find(|c| c.join(wai_app::runtime::LISTING).is_file()).cloned().ok_or_else(|| {
        format!(
            "the Create runtime is not found (looked for {} in {}): give --runtime DIR, a runtime laid out by wai/wai-app/web/runtime.mjs",
            wai_app::runtime::LISTING,
            if candidates.is_empty() { "nowhere".to_string() } else { candidates.iter().map(|c| c.display().to_string()).collect::<Vec<_>>().join(", ") }
        )
    })
}

/// The project for a program made with Create (`app.export`) in `dir`: the app's files and its
/// runtime, its project file, its licence. The files written, by path.
#[cfg(feature = "app")]
pub(super) fn export_app(program: &str, dir: &Path, runtime: Option<&Path>) -> Result<Vec<String>, String> {
    use wai_app::export::Contents;
    let rt_dir = find_runtime(runtime)?;
    let rt = wai_app::runtime::read(&rt_dir)?;
    let listed: Vec<wai_app::export::RuntimeFile> = rt.iter().map(|(f, _)| f.clone()).collect();
    let e = wai_app::export::export(program, &listed).map_err(|e| e.to_string())?;
    let mut files: Vec<(String, Vec<u8>)> = Vec::new();
    for f in e.files {
        let bytes = match f.contents {
            Contents::Text(t) => t.into_bytes(),
            Contents::Runtime(from) => rt.iter().find(|(r, _)| r.path == from).map(|(_, b)| b.clone()).ok_or(format!("{from}: not in the runtime"))?,
        };
        files.push((f.path, bytes));
    }
    let p = &e.project;
    let mut dev: Vec<(&str, serde_json::Value)> = vec![("site", ".".into()), ("mode", p.mode.clone().into())];
    if let Some(w) = &p.watch {
        dev.push(("watch", w.clone().into()));
    }
    if let Some(a) = p.audience {
        dev.push(("audience", a.into()));
    }
    let mut top = project::head(&project::name_for(dir, "app"));
    top.extend([("title", p.title.clone().into()), ("summary", p.summary.clone().into()), ("app", wai_app::export::APP.into())]);
    files.push((project::FILE.into(), project::render(&top, &dev).into_bytes()));
    let names: Vec<String> = files.iter().map(|(p, _)| p.clone()).chain([LICENSE.0.to_string(), super::lock::FILE.to_string()]).collect();
    write_project(dir, files)?;
    Ok(names)
}

#[cfg(feature = "app")]
fn from_app(program: &Path, dir: &Path, runtime: Option<&Path>) -> Result<Vec<String>, String> {
    let text = std::fs::read_to_string(program).map_err(|e| format!("{}: {e}", program.display()))?;
    export_app(&text, dir, runtime)
}

#[cfg(not(feature = "app"))]
fn from_app(_: &Path, _: &Path, _: Option<&Path>) -> Result<Vec<String>, String> {
    Err("`wai create --from` needs a build with the feature `app` (cargo install --path wai/wai-rs --no-default-features --features app)".into())
}

/// A sample sound for a watch room: twenty seconds of a tone that steps in pitch each
/// second, 8 kHz, 16-bit, mono, so any stretch of it is told from another by ear.
///
/// Every sample is integer arithmetic ([`tone_sample`]), so the bytes are the same on every host by
/// construction: a template's digest covers them, and a lock pins that digest
/// (`wai/project-conformance/`, whose second reader makes them by the same rule).
pub fn tone_wav() -> Vec<u8> {
    let (rate, seconds) = (8000u32, 20u32);
    let n = rate * seconds;
    let mut b = Vec::with_capacity(44 + 2 * n as usize);
    b.extend_from_slice(b"RIFF");
    b.extend_from_slice(&(36 + 2 * n).to_le_bytes());
    b.extend_from_slice(b"WAVEfmt ");
    b.extend_from_slice(&16u32.to_le_bytes());
    b.extend_from_slice(&1u16.to_le_bytes());
    b.extend_from_slice(&1u16.to_le_bytes());
    b.extend_from_slice(&rate.to_le_bytes());
    b.extend_from_slice(&(2 * rate).to_le_bytes());
    b.extend_from_slice(&2u16.to_le_bytes());
    b.extend_from_slice(&16u16.to_le_bytes());
    b.extend_from_slice(b"data");
    b.extend_from_slice(&(2 * n).to_le_bytes());
    for i in 0..n {
        // 220 Hz, rising 20 Hz each second for twelve seconds, then again.
        let f = 220 + 20 * ((i / rate) % 12);
        // The phase, exactly: f * i cycles of `rate` samples each, in samples of one cycle.
        let p = (u64::from(f) * u64::from(i) % u64::from(rate)) as u32;
        b.extend_from_slice(&tone_sample(p, rate).to_le_bytes());
    }
    b
}

/// The tone's peak.
const TONE_LEVEL: u64 = 6000;

/// The sample at phase `p` of a cycle of `rate` samples: a sine of peak [`TONE_LEVEL`], each half
/// cycle by the rational form of Bhaskara I, `sin(pi x) ~ 16 x (1 - x) / (5 - 4 x (1 - x))` for x
/// in 0..=1, rounded half up. Integers alone: it is at most 10 of 6000 from the sine, under every
/// harmonic at -59 dB, and the same value on every host.
fn tone_sample(p: u32, rate: u32) -> i16 {
    let half = u64::from(rate / 2);
    let q = u64::from(p) % half;
    let u = q * (half - q);
    let (num, den) = (16 * u, 5 * half * half - 4 * u);
    let v = (2 * TONE_LEVEL * num + den) / (2 * den);
    let v = i16::try_from(v).expect("at most the peak");
    if u64::from(p) < half { v } else { -v }
}

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

    /// [`tone_wav`]'s digest, as `wai/project-conformance/vectors.json` pins it and
    /// `project_verify.py` makes it.
    fn tone_sha256() -> String {
        let p = std::path::Path::new(env!("CARGO_MANIFEST_DIR")).join("../project-conformance/vectors.json");
        let v: serde_json::Value = serde_json::from_slice(&std::fs::read(p).unwrap()).unwrap();
        v["tone"]["sha256"].as_str().unwrap().to_string()
    }

    fn tmp(name: &str) -> std::path::PathBuf {
        let d = std::env::temp_dir().join(format!("wai-create-{name}-{}", std::process::id()));
        let _ = std::fs::remove_dir_all(&d);
        d
    }

    #[test]
    fn every_template_writes_a_project_that_reads() {
        assert!(!templates::TEMPLATES.is_empty());
        for t in templates::TEMPLATES {
            let d = tmp(t.name);
            write(t, &d).unwrap_or_else(|e| panic!("{}: {e}", t.name));
            let p = project::load(&d).unwrap().unwrap();
            assert_eq!(p.template.as_deref(), Some(t.name), "{}: its project file names it", t.name);
            // Its lock, as `wai lock` writes it, and pinning the template.
            let lock = super::super::lock::read(&std::fs::read(d.join(super::super::lock::FILE)).unwrap()).unwrap_or_else(|e| panic!("{}: {e:?}", t.name));
            assert_eq!(lock["template"]["sha256"].as_str(), template_digest(t.name).as_deref(), "{}", t.name);
            // Named for its directory, which is a project name here.
            assert_eq!(p.name, Some(d.file_name().unwrap().to_str().unwrap().to_string()), "{}", t.name);
            assert!(d.join("README.md").is_file(), "{}: a README", t.name);
            // Every project states its licence: the file, and a line of its README.
            assert_eq!(std::fs::read(d.join("LICENSE")).unwrap(), LICENSE.1, "{}: LICENSE", t.name);
            assert!(std::str::from_utf8(LICENSE.1).unwrap().contains("Apache License"));
            assert!(std::fs::read_to_string(d.join("README.md")).unwrap().contains("Apache-2.0"), "{}: its README names its licence", t.name);
            let _ = std::fs::remove_dir_all(&d);
        }
    }

    /// Every template carries the instructions a coding agent reads, AGENTS.md: what the project
    /// is, and how to build it, run it, test it and conform it, and WAI's tools for an agent.
    #[test]
    fn every_template_carries_an_agents_md_with_its_commands() {
        for t in templates::TEMPLATES {
            let (_, a) = t.files.iter().find(|(p, _)| *p == "AGENTS.md").unwrap_or_else(|| panic!("{}: no AGENTS.md", t.name));
            let a = std::str::from_utf8(a).unwrap();
            assert!(a.starts_with("# Working on this project\n"), "{}", t.name);
            assert!(a.contains(&format!("the `{}` template", t.name)), "{}: AGENTS.md names its template", t.name);
            for section in ["**Build.**", "**Run.**", "**Test.**", "**Conform.**"] {
                assert!(a.contains(section), "{}: AGENTS.md says {section}", t.name);
            }
            assert!(a.contains("`wai mcp`") && a.contains("secret"), "{}: WAI's tools, and no secret in the repository", t.name);
        }
    }

    #[test]
    fn a_directory_that_holds_anything_is_never_written_to() {
        let d = tmp("full");
        std::fs::create_dir_all(&d).unwrap();
        std::fs::write(d.join("mine.txt"), "mine").unwrap();
        assert!(write(&templates::TEMPLATES[0], &d).unwrap_err().contains("not empty"));
        assert_eq!(std::fs::read_to_string(d.join("mine.txt")).unwrap(), "mine");
        let _ = std::fs::remove_dir_all(&d);
    }

    /// The tone is a file a template's digest covers, so its bytes are one on every host: they go
    /// through the platform's sine, and this says so if a platform's differs.
    #[test]
    fn the_tone_is_a_wav_of_twenty_seconds() {
        let b = tone_wav();
        assert_eq!(&b[0..4], b"RIFF");
        assert_eq!(b.len(), 44 + 2 * 8000 * 20);
        assert_eq!(wai::pin::sha256_hex(&b), tone_sha256());
        // The level and the shape: a peak of 6000 a quarter cycle in, zero at the half cycle.
        assert_eq!((tone_sample(2000, 8000), tone_sample(4000, 8000), tone_sample(6000, 8000), tone_sample(0, 8000)), (6000, 0, -6000, 0));
    }

    /// The tone is the same on every host by construction only if no floating-point value is in
    /// its path: a platform's floating point, its sine above all, need not round as another's.
    #[test]
    fn the_tone_is_made_of_integers_alone() {
        // As written, whatever line ends a checkout gave this file.
        let src = include_str!("create.rs").replace("\r\n", "\n");
        let body = |name: &str| -> String {
            let start = src.find(&format!("fn {name}(")).unwrap_or_else(|| panic!("{name}"));
            let end = start + src[start..].find("\n}\n").unwrap();
            src[start..end].to_string()
        };
        for f in ["tone_wav", "tone_sample"] {
            let b = body(f);
            for float in ["f32", "f64", ".sin(", ".cos(", "PI", ".round(", "as f"] {
                assert!(!b.contains(float), "{f} holds {float}: the tone's bytes would rest on the host's floating point");
            }
            assert!(!b.chars().zip(b.chars().skip(1)).zip(b.chars().skip(2)).any(|((a, d), c)| a.is_ascii_digit() && d == '.' && c.is_ascii_digit()), "{f} holds a decimal literal");
        }
    }

    #[test]
    fn a_templates_digest_is_of_the_files_it_holds() {
        for t in templates::TEMPLATES {
            let d = template_digest(t.name).unwrap();
            assert_eq!(d.len(), 64, "{}", t.name);
        }
        assert_eq!(template_digest("no-such-template"), None);
        assert_ne!(template_digest("video-room"), template_digest("messaging"));
    }
}
