//! The agent rules every command of the catalogue keeps (WAI toolchain v1, rules A1 to A6),
//! for a person and for an agent alike, in this one place:
//!
//! - **exit codes**, one meaning each (`wai::catalogue::EXIT_CODES`):
//!
//!   | exit | meaning |
//!   |---|---|
//!   | 0 | done; every check held |
//!   | 1 | ran, and a check failed |
//!   | 2 | usage error or invalid input |
//!   | 3 | authentication or permission |
//!   | 4 | confirmation required; nothing changed |
//!   | 5 | not found (file, ref, suite, program, operation, or a build feature) |
//!   | 6 | target unavailable; safe to retry |
//!   | 7 | internal error |
//!
//! - **`--json`**: stdout carries one JSON envelope, `{"wai": "envelope/1", "op", "ok", "data"
//!   | "error": {"code", "message", "hint", "retryable"}, ...}`, and progress and logs stay on
//!   stderr. A check that failed (exit 1) carries `error` and the report in `data`; a failure
//!   carries its `exit`; a confirmation adds `proposed` and `confirm`; a dry run adds
//!   `"dry_run": true` and the `plan` of what it would write. Text otherwise (`--text` asks for
//!   it too). Off a terminal without `--json` the output is still text: every command's piped
//!   output stays what it was.
//! - **`--dry-run`** on every operation whose effect is not `reads`: nothing is written, and
//!   the command says what it would write (`out.rs`).
//! - **`--yes`**: an operation that would replace a file the command line did not name, or
//!   change a target (as its catalogue entry's `confirm` says), asks on a terminal, and without
//!   one (or with `--no-input`) exits 4 with the proposed change and the command that confirms
//!   it, which names `--yes`. `--yes` is never implied. `--confirm` is its deprecated spelling
//!   and does the same (`wai::catalogue::AGENT_FLAGS`).

use serde_json::{Map, Value, json};
use std::io::{BufRead, IsTerminal, Write};
use std::sync::OnceLock;

pub const EXIT_FAILED: u8 = 1;
pub const EXIT_USAGE: u8 = 2;
pub const EXIT_PERMISSION: u8 = 3;
pub const EXIT_CONFIRM: u8 = 4;
pub const EXIT_NOT_FOUND: u8 = 5;
pub const EXIT_UNAVAILABLE: u8 = 6;
pub const EXIT_INTERNAL: u8 = 7;

/// The environment variable a parent `wai --json` sets on the `wai` it runs for an envelope.
pub const CHILD_ENV: &str = "WAI_ENVELOPE_CHILD";
/// The prefix of the line a child writes last on stderr: its failure, as JSON.
pub const TRAILER_ERROR: &str = "wai-envelope-error: ";
/// The prefix of the line a child writes on stderr after a dry run: its plan, as JSON.
pub const TRAILER_PLAN: &str = "wai-envelope-plan: ";

/// This run's agent flags, read wherever they are on the command line.
#[derive(Clone, Debug, Default)]
pub struct Ctx {
    /// `--json`: one envelope on stdout.
    pub json: bool,
    /// `--dry-run`.
    pub dry_run: bool,
    /// `--yes`, or `--confirm`, its deprecated spelling.
    pub yes: bool,
    /// `--no-input`: never ask.
    pub no_input: bool,
    /// Run by a parent `wai --json` for its envelope.
    pub child: bool,
    /// The command line after `wai`, without `--json`, for the command that confirms.
    pub argv: Vec<String>,
}

static CTX: OnceLock<Ctx> = OnceLock::new();

/// Set this run's flags, once.
pub fn set(c: Ctx) {
    let _ = CTX.set(c);
}

/// This run's flags.
pub fn ctx() -> &'static Ctx {
    CTX.get_or_init(Ctx::default)
}

/// An operation that did not run to a verdict: its exit status, a stable code, what happened,
/// what to do about it, whether running it again may help, and, waiting for confirmation, the
/// proposed changes and the command that confirms them.
#[derive(Clone, Debug, PartialEq)]
pub struct OpError {
    pub exit: u8,
    /// A stable string: `usage`, `invalid-input`, `check-failed`, `permission-denied`,
    /// `confirmation-required`, `not-found`, `ref-not-found`, `operation-not-found`,
    /// `operation-not-in-build`, `target-unavailable`, `internal`, ...
    pub code: &'static str,
    pub message: String,
    pub hint: String,
    pub retryable: bool,
    /// With exit 4: the changes that wait for confirmation.
    pub proposed: Vec<Value>,
    /// With exit 4: the command that confirms them.
    pub confirm: Option<String>,
    /// The command reported it itself, in text or in its envelope: only its exit is left.
    pub reported: bool,
}

impl OpError {
    pub fn new(exit: u8, code: &'static str, message: impl Into<String>, hint: impl Into<String>) -> OpError {
        OpError { exit, code, message: message.into(), hint: hint.into(), retryable: exit == EXIT_UNAVAILABLE, proposed: Vec::new(), confirm: None, reported: false }
    }

    /// A failure with no hint yet.
    pub fn of(exit: u8, code: &'static str, message: impl Into<String>) -> OpError {
        OpError::new(exit, code, message, "")
    }

    pub fn with_hint(mut self, h: impl Into<String>) -> OpError {
        self.hint = h.into();
        self
    }

    pub fn usage(message: impl Into<String>, usage: &str) -> OpError {
        OpError::new(EXIT_USAGE, "usage", message, usage)
    }

    pub fn unavailable(message: impl Into<String>) -> OpError {
        OpError::new(EXIT_UNAVAILABLE, "target-unavailable", message, "start it again; nothing was changed")
    }

    pub fn internal(message: impl Into<String>) -> OpError {
        OpError::of(EXIT_INTERNAL, "internal", message)
    }

    /// A failure the command has reported itself; it ends with `exit`.
    #[allow(dead_code)]
    pub fn reported(exit: u8) -> OpError {
        OpError { reported: true, ..OpError::of(exit, "reported", "") }
    }

    /// The envelope's `error` member.
    pub fn to_json(&self) -> Value {
        json!({"code": self.code, "message": self.message, "hint": (!self.hint.is_empty()).then_some(&self.hint), "retryable": self.retryable})
    }
}

/// An error a command reports as text, read for its exit code: a refused permission (3), a
/// file or a binary that is not there, or an operation this build does not hold (5), a usage
/// line (2), and any other refusal of the input (2).
impl From<String> for OpError {
    fn from(message: String) -> OpError {
        let m = message.to_ascii_lowercase();
        let has = |s: &[&str]| s.iter().any(|x| m.contains(x));
        if has(&["(os error 13)", "permission denied", "access is denied"]) {
            OpError::of(EXIT_PERMISSION, "permission-denied", message)
        } else if has(&["(os error 2)", "(os error 3)", "no such file or directory", "cannot find the file", "cannot find the path", "nor on path", "is not a file", "are not found"]) {
            OpError::of(EXIT_NOT_FOUND, "not-found", message)
        } else if has(&["needs a build with", "built without"]) {
            OpError::of(EXIT_NOT_FOUND, "operation-not-in-build", message)
        } else if m.starts_with("usage") || m.starts_with("wai ") || has(&["not an option", "takes a value", "is required"]) {
            OpError::of(EXIT_USAGE, "usage", message)
        } else {
            OpError::of(EXIT_USAGE, "invalid-input", message)
        }
    }
}

impl From<&str> for OpError {
    fn from(m: &str) -> OpError {
        OpError::from(m.to_string())
    }
}

/// Whether the output is the JSON envelope: `--json` asks for it (the agent flag `wai` read,
/// or the operation's own when a parent `wai --json` runs it), `--text` refuses it.
pub fn json_wanted(args: &[String]) -> bool {
    if args.iter().any(|a| a == "--text") {
        return false;
    }
    ctx().json || args.iter().any(|a| a == "--json")
}

/// A word as a POSIX shell reads it back.
pub(crate) fn shell_word(w: &str) -> String {
    if !w.is_empty() && w.bytes().all(|c| c.is_ascii_alphanumeric() || b"-_./=:,@%+".contains(&c)) {
        w.to_string()
    } else {
        format!("'{}'", w.replace('\'', "'\\''"))
    }
}

/// The command that runs this one again, confirmed.
pub fn confirm_command() -> String {
    let mut words = vec!["wai".to_string()];
    words.extend(ctx().argv.iter().filter(|a| *a != "--yes" && *a != "--confirm").map(|a| shell_word(a)));
    words.push("--yes".into());
    words.join(" ")
}

/// Ask before `proposed` changes are made. Go ahead with `--yes`, in a dry run (which makes
/// none), or when a person at a terminal answers yes; otherwise nothing changes and the
/// command exits 4 with the proposal and the command that confirms it.
pub fn confirm(what: &str, proposed: Vec<Value>) -> Result<(), OpError> {
    let c = ctx();
    if c.yes || c.dry_run {
        return Ok(());
    }
    let interactive = !c.json && !c.child && !c.no_input && std::io::stdin().is_terminal() && std::io::stdout().is_terminal();
    if interactive {
        eprint!("wai: {what}. Go ahead? [y/N] ");
        let _ = std::io::stderr().flush();
        let mut line = String::new();
        if std::io::stdin().lock().read_line(&mut line).is_ok() && matches!(line.trim(), "y" | "Y" | "yes") {
            return Ok(());
        }
    }
    Err(OpError {
        proposed,
        confirm: Some(confirm_command()),
        ..OpError::new(EXIT_CONFIRM, "confirmation-required", format!("{what}; nothing was changed"), "read the proposed changes, then run the confirming command")
    })
}

/// The envelope of an operation that reached a verdict, its body as `data`; in a dry run,
/// with the plan of what it would have written.
pub fn envelope(op: &str, ok: bool, body: Map<String, Value>) -> Value {
    envelope_with(op, ok, Value::Object(body), crate::out::dry_run().then(crate::out::plan))
}

/// The envelope of an operation that reached a verdict: `data`, and a dry run's plan.
pub fn envelope_with(op: &str, ok: bool, data: Value, plan: Option<Vec<Value>>) -> Value {
    let mut v = json!({"wai": wai::catalogue::ENVELOPE, "op": op, "ok": ok, "data": data});
    if let Some(p) = plan {
        v["dry_run"] = Value::Bool(true);
        v["plan"] = Value::Array(p);
    }
    v
}

/// The envelope of an operation that did not.
pub fn error_envelope(op: &str, e: &OpError) -> Value {
    error_envelope_with(Some(op), e, None)
}

/// The envelope of a failure, for an operation or for a command line that named none; with
/// exit 1 (a check failed) it carries the command's `data` as well, the report of what did not
/// hold.
pub fn error_envelope_with(op: Option<&str>, e: &OpError, data: Option<Value>) -> Value {
    let mut v = json!({"wai": wai::catalogue::ENVELOPE, "op": op, "ok": false, "error": e.to_json(), "exit": e.exit});
    if let Some(d) = data {
        v["data"] = d;
    }
    if e.exit == EXIT_CONFIRM {
        v["proposed"] = Value::Array(e.proposed.clone());
        v["confirm"] = json!(e.confirm);
    }
    v
}

/// Print an envelope on stdout, one JSON document.
pub fn print(v: &Value) {
    println!("{}", serde_json::to_string_pretty(v).expect("an envelope is JSON"));
}

/// Report a failure: as text on stderr, with what waits for confirmation; in a child, also as
/// the trailer line its parent reads.
pub fn report(op: Option<&str>, e: &OpError) {
    eprintln!("error: {}", e.message);
    if e.exit == EXIT_CONFIRM {
        for p in &e.proposed {
            eprintln!("  would {}", p);
        }
        if let Some(c) = &e.confirm {
            eprintln!("to go ahead: {c}");
        }
    } else if !e.hint.is_empty() {
        eprintln!("hint: {}", e.hint);
    }
    if ctx().child {
        let t = json!({"error": e.to_json(), "exit": e.exit, "proposed": e.proposed, "confirm": e.confirm, "op": op});
        eprintln!("{TRAILER_ERROR}{t}");
    }
}

/// Print an error the way the output mode asks, then exit with its status.
pub fn fail(op: &str, json_mode: bool, e: &OpError) -> ! {
    if json_mode {
        println!("{}", error_envelope(op, e));
    } else {
        report(Some(op), e);
    }
    exit(e.exit)
}

/// Exit with `code` once standard output is flushed.
pub fn exit(code: u8) -> ! {
    let _ = std::io::stdout().flush();
    std::process::exit(code as i32)
}

/// The value after `flag`, or a usage error when the flag is last.
pub fn value<'a>(args: &'a [String], flag: &str, usage: &str) -> Result<Option<&'a str>, OpError> {
    match args.iter().position(|a| a == flag) {
        None => Ok(None),
        Some(i) => match args.get(i + 1) {
            Some(v) if !v.starts_with("--") => Ok(Some(v.as_str())),
            _ => Err(OpError::usage(format!("{flag} takes a value"), usage)),
        },
    }
}

/// Every flag among `args` is one of `known` (flags that take a value are in `with_value`),
/// and there are no more positional arguments than `max_positional`; the positional ones.
pub fn check_args<'a>(args: &'a [String], known: &[&str], with_value: &[&str], max_positional: usize, usage: &str) -> Result<Vec<&'a str>, OpError> {
    let mut pos = Vec::new();
    let mut i = 0;
    while i < args.len() {
        let a = args[i].as_str();
        if with_value.contains(&a) {
            i += 2;
            continue;
        }
        if a.starts_with("--") {
            if !known.contains(&a) {
                return Err(OpError::usage(format!("{a}: not an option"), usage));
            }
        } else {
            pos.push(a);
        }
        i += 1;
    }
    if pos.len() > max_positional {
        return Err(OpError::usage(format!("unexpected argument {}", pos[max_positional]), usage));
    }
    Ok(pos)
}

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

    #[test]
    fn a_text_error_is_read_for_its_exit_code() {
        let e = |m: &str| OpError::from(m.to_string()).exit;
        assert_eq!(e("x.wai: No such file or directory (os error 2)"), EXIT_NOT_FOUND);
        assert_eq!(e("read x: Permission denied (os error 13)"), EXIT_PERMISSION);
        assert_eq!(e("`wai hello` needs a build with the feature `quickstart`"), EXIT_NOT_FOUND);
        assert_eq!(e("built without generate: `wai generate` needs ..."), EXIT_NOT_FOUND);
        assert_eq!(e("wai create: --x: not an option"), EXIT_USAGE);
        assert_eq!(e("'wai.x' is not a registered capability"), EXIT_USAGE);
        assert_eq!(e("wai-relay is not beside `wai` nor on PATH"), EXIT_NOT_FOUND);
    }

    #[test]
    fn the_codes_are_the_catalogues_table() {
        let named = [(EXIT_FAILED, 1), (EXIT_USAGE, 2), (EXIT_PERMISSION, 3), (EXIT_CONFIRM, 4), (EXIT_NOT_FOUND, 5), (EXIT_UNAVAILABLE, 6), (EXIT_INTERNAL, 7)];
        for (c, n) in named {
            assert_eq!(c, n);
            assert!(wai::catalogue::EXIT_CODES.iter().any(|(x, _)| *x == c), "{c} is in the catalogue's table");
        }
        assert_eq!(wai::catalogue::EXIT_CODES.len(), 8);
    }

    #[test]
    fn the_confirming_command_quotes_what_a_shell_would_split() {
        assert_eq!(shell_word("out.wai"), "out.wai");
        assert_eq!(shell_word("a b"), "'a b'");
        assert_eq!(shell_word("it's"), "'it'\\''s'");
    }

    #[test]
    fn a_failure_envelope_names_its_code_and_a_confirmation_its_command() {
        let e = OpError { proposed: vec![json!({"replace": "out.wai"})], confirm: Some("wai wrap x y --yes".into()), ..OpError::of(EXIT_CONFIRM, "confirmation-required", "m") };
        let v = error_envelope("envelope.wrap", &e);
        assert_eq!((v["ok"].as_bool(), v["error"]["code"].as_str(), v["confirm"].as_str(), v["exit"].as_u64()), (Some(false), Some("confirmation-required"), Some("wai wrap x y --yes"), Some(4)));
        assert_eq!(v["wai"], wai::catalogue::ENVELOPE);
        let ok = envelope_with("envelope.inspect", true, json!({"text": "t"}), None);
        assert!(ok["ok"] == true && ok.get("error").is_none() && ok.get("dry_run").is_none() && ok["data"]["text"] == "t");
    }
}
