//! Every file the `wai` command line writes, makes or removes goes through here, so that
//! `--dry-run` writes nothing: each call is recorded in the plan instead, and the plan is
//! what the command reports it would have done. A request that would change a service the
//! command line names (a fleet service) is named in the plan the same way, and not sent.

// A secret's file on Windows: an access list for this account alone. Here because every binary
// that includes this file makes its private files through it.
#[path = "win_acl.rs"]
pub mod win_acl;

use serde_json::{Value, json};
use std::io::{self, Write};
use std::path::Path;
use std::sync::Mutex;
use std::sync::atomic::{AtomicBool, Ordering};

static DRY_RUN: AtomicBool = AtomicBool::new(false);
static PLAN: Mutex<Vec<Value>> = Mutex::new(Vec::new());

/// Turn dry running on or off for the rest of the process.
pub fn set_dry_run(on: bool) {
    DRY_RUN.store(on, Ordering::SeqCst);
}

/// Whether this run writes nothing.
pub fn dry_run() -> bool {
    DRY_RUN.load(Ordering::SeqCst)
}

fn note(v: Value) {
    PLAN.lock().unwrap_or_else(|e| e.into_inner()).push(v);
}

/// What a dry run would have done, in order: `{"write": path, "bytes": n}`,
/// `{"create-dir": path}`, `{"remove": path}`, `{"request": "METHOD /path", "target": url}`,
/// `{"keychain": "add" | "delete", "service", "id"}`.
pub fn plan() -> Vec<Value> {
    PLAN.lock().unwrap_or_else(|e| e.into_inner()).clone()
}

fn shown(p: &Path) -> String {
    p.display().to_string()
}

/// A file a dry run would write whose bytes it does not make (a record signed only after a
/// run): named in the plan, in a dry run alone.
#[cfg_attr(not(feature = "quickstart"), allow(dead_code))]
pub fn would_write(path: impl AsRef<Path>) {
    let p = path.as_ref();
    if dry_run() {
        note(json!({"write": shown(p), "replaces": p.exists()}));
    }
}

/// A request a dry run would send to the service at `target` (and does not): named in the
/// plan, in a dry run alone.
pub fn would_send(target: &str, method: &str, path: &str) {
    if dry_run() {
        note(json!({"request": format!("{method} {path}"), "target": target}));
    }
}

/// `std::fs::write`.
pub fn write(path: impl AsRef<Path>, bytes: impl AsRef<[u8]>) -> io::Result<()> {
    let (p, b) = (path.as_ref(), bytes.as_ref());
    if dry_run() {
        note(json!({"write": shown(p), "bytes": b.len(), "replaces": p.exists()}));
        return Ok(());
    }
    std::fs::write(p, b)
}

/// A new file that must not exist yet (`create_new`), with its bytes.
pub fn write_new(path: impl AsRef<Path>, bytes: impl AsRef<[u8]>) -> io::Result<()> {
    let (p, b) = (path.as_ref(), bytes.as_ref());
    if dry_run() {
        if p.exists() {
            return Err(io::Error::new(io::ErrorKind::AlreadyExists, "exists"));
        }
        note(json!({"write": shown(p), "bytes": b.len(), "replaces": false}));
        return Ok(());
    }
    let mut f = std::fs::OpenOptions::new().write(true).create_new(true).open(p)?;
    f.write_all(b)
}

/// A new file only its owner may read or write, which must not exist yet, open for writing: mode
/// 0600 on Unix, and on Windows an access list for this account alone (`win_acl.rs`). The one call
/// that makes the file sets its protection, so the file is never under anything wider, not even
/// before a byte is in it, and nothing can hold it open from a moment when it was.
fn create_private(p: &Path) -> io::Result<std::fs::File> {
    #[cfg(windows)]
    {
        win_acl::create_new_file(p)
    }
    #[cfg(not(windows))]
    {
        let mut o = std::fs::OpenOptions::new();
        o.write(true).create_new(true);
        #[cfg(unix)]
        {
            use std::os::unix::fs::OpenOptionsExt;
            o.mode(0o600);
        }
        o.open(p)
    }
}

/// A new file only its owner may read or write (a key's secret, a key's record), which must not
/// exist yet.
#[cfg_attr(not(feature = "quickstart"), allow(dead_code))]
pub fn write_new_private(path: impl AsRef<Path>, bytes: impl AsRef<[u8]>) -> io::Result<()> {
    let (p, b) = (path.as_ref(), bytes.as_ref());
    if dry_run() {
        if p.exists() {
            return Err(io::Error::new(io::ErrorKind::AlreadyExists, "exists"));
        }
        note(json!({"write": shown(p), "bytes": b.len(), "replaces": false}));
        return Ok(());
    }
    let mut f = create_private(p)?;
    f.write_all(b)?;
    f.sync_all()
}

/// A file only its owner may read or write, replaced whole: written beside it, then renamed over
/// it, so that a reader finds the old file or the new one and never part of either.
#[cfg_attr(not(feature = "quickstart"), allow(dead_code))]
pub fn replace_private(path: impl AsRef<Path>, bytes: impl AsRef<[u8]>) -> io::Result<()> {
    let (p, b) = (path.as_ref(), bytes.as_ref());
    if dry_run() {
        note(json!({"write": shown(p), "bytes": b.len(), "replaces": p.exists()}));
        return Ok(());
    }
    let mut name = p.file_name().ok_or_else(|| io::Error::new(io::ErrorKind::InvalidInput, "not a file name"))?.to_os_string();
    name.push(format!(".{}.new", std::process::id()));
    let tmp = p.with_file_name(name);
    let _ = std::fs::remove_file(&tmp);
    let written = create_private(&tmp).and_then(|mut f| f.write_all(b).and_then(|_| f.sync_all()));
    match written.and_then(|_| std::fs::rename(&tmp, p)) {
        Ok(()) => Ok(()),
        Err(e) => {
            let _ = std::fs::remove_file(&tmp);
            Err(e)
        }
    }
}

/// A step a dry run would take that is not a file of this machine's (a secret kept in the
/// keychain, or removed from it): named in the plan, in a dry run alone.
#[cfg_attr(not(feature = "quickstart"), allow(dead_code))]
pub fn would(step: Value) {
    if dry_run() {
        note(step);
    }
}

/// `std::fs::create_dir_all`.
pub fn create_dir_all(path: impl AsRef<Path>) -> io::Result<()> {
    let p = path.as_ref();
    if dry_run() {
        if !p.is_dir() {
            note(json!({"create-dir": shown(p)}));
        }
        return Ok(());
    }
    std::fs::create_dir_all(p)
}

/// `std::fs::copy`: a dry run names the file it would write, and reads nothing.
#[allow(dead_code)]
pub fn copy(from: impl AsRef<Path>, to: impl AsRef<Path>) -> io::Result<()> {
    let (f, t) = (from.as_ref(), to.as_ref());
    if dry_run() {
        note(json!({"write": shown(t), "replaces": t.exists()}));
        return Ok(());
    }
    std::fs::copy(f, t).map(|_| ())
}

/// A directory made whole beside `to` put in its place: what `to` held removed, then `from` renamed
/// to it. A reader never finds a directory half written; `from` does not exist in a dry run, which
/// names the directory it would replace.
#[allow(dead_code)]
pub fn replace_dir(from: impl AsRef<Path>, to: impl AsRef<Path>) -> io::Result<()> {
    let (f, t) = (from.as_ref(), to.as_ref());
    if dry_run() {
        if t.exists() {
            note(json!({"remove": shown(t)}));
        }
        return Ok(());
    }
    if t.exists() {
        std::fs::remove_dir_all(t)?;
    }
    std::fs::rename(f, t)
}

/// `std::fs::remove_file`.
#[cfg_attr(not(feature = "quickstart"), allow(dead_code))]
pub fn remove_file(path: impl AsRef<Path>) -> io::Result<()> {
    let p = path.as_ref();
    if dry_run() {
        note(json!({"remove": shown(p)}));
        return Ok(());
    }
    std::fs::remove_file(p)
}

/// `std::fs::File::create`, as a writer: one that discards in a dry run.
#[cfg_attr(not(feature = "codecs"), allow(dead_code))]
pub fn create(path: impl AsRef<Path>) -> io::Result<Box<dyn Write>> {
    let p = path.as_ref();
    if dry_run() {
        note(json!({"write": shown(p), "replaces": p.exists()}));
        return Ok(Box::new(io::sink()));
    }
    Ok(Box::new(std::fs::File::create(p)?))
}

/// The plan as lines, for a person.
pub fn plan_lines() -> Vec<String> {
    plan()
        .iter()
        .map(|v| {
            if let Some(p) = v["write"].as_str() {
                let n = v["bytes"].as_u64().map(|n| format!(" ({n} bytes)")).unwrap_or_default();
                let r = if v["replaces"] == true { ", replacing the file there" } else { "" };
                format!("write {p}{n}{r}")
            } else if let Some(p) = v["create-dir"].as_str() {
                format!("make the directory {p}")
            } else if let Some(p) = v["remove"].as_str() {
                format!("remove {p}")
            } else if let (Some(r), Some(t)) = (v["request"].as_str(), v["target"].as_str()) {
                format!("send {r} to {t}")
            } else if let (Some(k), Some(id)) = (v["keychain"].as_str(), v["id"].as_str()) {
                let into = if k == "add" { "keep the secret of" } else { "remove the secret of" };
                format!("{into} {id} {} the login keychain", if k == "add" { "in" } else { "from" })
            } else {
                v.to_string()
            }
        })
        .collect()
}

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

    /// The file is the owner's alone before a byte of the secret is in it.
    #[test]
    fn a_private_file_is_restricted_before_it_holds_anything() {
        use std::os::unix::fs::PermissionsExt;
        let d = std::env::temp_dir().join(format!("wai-out-private-{}", std::process::id()));
        let _ = std::fs::remove_dir_all(&d);
        std::fs::create_dir_all(&d).unwrap();
        let p = d.join("k.seed");
        let f = create_private(&p).unwrap();
        let m = std::fs::metadata(&p).unwrap();
        assert_eq!((m.len(), m.permissions().mode() & 0o777), (0, 0o600));
        drop(f);
        assert_eq!(create_private(&p).unwrap_err().kind(), io::ErrorKind::AlreadyExists, "never over a file that exists");
        std::fs::remove_dir_all(&d).unwrap();
    }
}
