//! The fleet's operations (extensions/fleet.md §9): `wai enrolment create` (`enrolment.create`),
//! `wai enrolment list` (`enrolment.list`), `wai report create` (`report.create`) and `wai report
//! export` (`report.export`), against a fleet service. A bundle is checked by `wai verify`.
//!
//! They keep the agent rules of [`super::envelope`], as every operation of the catalogue does:
//! the exit codes, the JSON envelope with `--json` (data to standard output, logs to standard
//! error), `--dry-run` (every file through `out.rs`, every request the service would be sent named
//! in the plan, nothing written and nothing sent), and `--yes` before a fleet service is changed
//! or a file replaced (asked at a terminal; without one, exit 4 with the proposed changes and the
//! confirming command). A refusal by the fleet's text has that text's reason as its code
//! (`enrolment_duplicate`, `signer_not_enrolled`, ...); the service unavailable is exit 6.
//!
//! A target is a fleet service's `http://` address (`wai-fleet serve`, which listens on a
//! loopback address unless told otherwise).

use super::envelope::{self as agent, EXIT_FAILED, EXIT_INTERNAL, EXIT_NOT_FOUND, EXIT_PERMISSION, EXIT_UNAVAILABLE, OpError};
use crate::out;
use serde_json::{Value, json};
use std::io::{Read, Write};

type R = Result<bool, OpError>;

/// Print what an operation did: its envelope with `--json`, its text otherwise; in a dry run of
/// an operation that changes something, with the plan of what it would have done.
fn done(op: &str, writes: bool, data: Value, text: &str) -> R {
    let plan = (writes && out::dry_run()).then(out::plan);
    if agent::ctx().json {
        agent::print(&agent::envelope_with(op, true, data, plan));
    } else if plan.is_some() {
        println!("{text}");
        println!("dry run: nothing was written or sent. Without --dry-run, wai would:");
        for l in out::plan_lines() {
            println!("  {l}");
        }
    } else {
        println!("{text}");
    }
    Ok(true)
}

/// A file that could not be read or written.
#[cfg_attr(not(feature = "fleet"), allow(dead_code))]
fn io_fail(path: &str, e: &std::io::Error, hint: &str) -> OpError {
    match e.kind() {
        std::io::ErrorKind::NotFound => OpError::new(EXIT_NOT_FOUND, "not-found", format!("{path}: {e}"), hint),
        std::io::ErrorKind::PermissionDenied => OpError::new(EXIT_PERMISSION, "permission-denied", format!("{path}: {e}"), "the file's owner runs this"),
        _ => OpError::new(EXIT_INTERNAL, "io-error", format!("{path}: {e}"), hint),
    }
}

// ---------------------------------------------------------------------------------------
// A fleet service, over HTTP/1.0
// ---------------------------------------------------------------------------------------

/// One request to a fleet service at `target` (`http://host:port`), and its answer: the
/// status and the body read as JSON.
fn call(target: &str, method: &str, path: &str, body: Option<&Value>) -> Result<(u16, Value), OpError> {
    let Some(rest) = target.strip_prefix("http://") else {
        return Err(OpError::usage(format!("{target}: a fleet service's http:// address"), "give --target http://HOST:PORT, the address wai-fleet serve listens on"));
    };
    let authority = rest.split('/').next().unwrap_or("");
    let host = authority.rsplit_once(':').map_or(authority, |(h, _)| h);
    let addr = if authority.contains(':') { authority.to_string() } else { format!("{authority}:80") };
    let unavailable = |e: String| OpError::new(EXIT_UNAVAILABLE, "target-unavailable", format!("{target}: {e}"), "start the fleet service, or check its address");
    use std::net::ToSocketAddrs;
    let sock = addr.to_socket_addrs().map_err(|e| unavailable(e.to_string()))?.next().ok_or_else(|| unavailable("no address".into()))?;
    let mut s = std::net::TcpStream::connect_timeout(&sock, std::time::Duration::from_secs(10)).map_err(|e| unavailable(e.to_string()))?;
    let _ = s.set_read_timeout(Some(std::time::Duration::from_secs(300)));
    let payload = body.map(|b| b.to_string()).unwrap_or_default();
    let mut req = format!("{method} {path} HTTP/1.0\r\nHost: {host}\r\nUser-Agent: wai\r\nConnection: close\r\n");
    if body.is_some() {
        req.push_str(&format!("Content-Type: application/json\r\nContent-Length: {}\r\n", payload.len()));
    }
    req.push_str("\r\n");
    req.push_str(&payload);
    s.write_all(req.as_bytes()).map_err(|e| unavailable(e.to_string()))?;
    let mut all = Vec::new();
    s.take(512 << 20).read_to_end(&mut all).map_err(|e| unavailable(e.to_string()))?;
    let at = all.windows(4).position(|w| w == b"\r\n\r\n").ok_or_else(|| unavailable("no HTTP answer".into()))?;
    let head = String::from_utf8_lossy(&all[..at]);
    let status = head.split(' ').nth(1).and_then(|c| c.parse::<u16>().ok()).ok_or_else(|| unavailable("no status".into()))?;
    let v = serde_json::from_slice(&all[at + 4..]).unwrap_or_else(|_| json!({"text": String::from_utf8_lossy(&all[at + 4..])}));
    Ok((status, v))
}

/// A reason of extensions/fleet.md §10 as the stable code it is; `refused` for a reason this
/// build does not hold (the message carries the service's own).
fn reason_code(code: &str) -> &'static str {
    #[cfg(feature = "fleet")]
    {
        use wai::fleet::Reason;
        if let Some(r) = Reason::ALL.iter().chain(Reason::REUSED.iter()).find(|r| r.code() == code) {
            return r.code();
        }
    }
    let _ = code;
    "refused"
}

/// An answer that is not a success, as the toolchain's failure.
fn answer_fail(status: u16, v: &Value) -> OpError {
    match status {
        422 => {
            let r = v["reason"].as_str().unwrap_or("refused");
            OpError::new(EXIT_FAILED, reason_code(r), format!("the fleet service refused it: {r}"), "see extensions/fleet.md §10 for the reason")
        }
        404 => OpError::new(EXIT_NOT_FOUND, "not-found", v["error"].as_str().unwrap_or("not found").to_string(), "list what the service holds"),
        400 => OpError::usage(v["error"].as_str().unwrap_or("a bad request").to_string(), "see the operation's usage"),
        _ => OpError::new(EXIT_INTERNAL, "service-error", format!("HTTP {status}: {v}"), "see the fleet service's log"),
    }
}

#[cfg_attr(not(feature = "fleet"), allow(dead_code))]
fn hex(b: &[u8]) -> String {
    b.iter().map(|x| format!("{x:02x}")).collect()
}

#[cfg_attr(not(feature = "fleet"), allow(dead_code))]
fn unhex(s: &str) -> Option<Vec<u8>> {
    if !s.len().is_multiple_of(2) {
        return None;
    }
    (0..s.len()).step_by(2).map(|i| u8::from_str_radix(s.get(i..i + 2)?, 16).ok()).collect()
}

fn number(args: &[String], flag: &str, default: u64) -> Result<u64, OpError> {
    match super::opt(args, flag) {
        None => Ok(default),
        Some(v) => v.parse().map_err(|_| OpError::usage(format!("{flag} {v}: microseconds since 1970, an integer"), "give a number of microseconds")),
    }
}

/// The value of `flag`, which the operation requires.
fn required<'a>(args: &'a [String], flag: &str, what: &str, usage: &str) -> Result<&'a str, OpError> {
    super::opt(args, flag).ok_or_else(|| OpError::usage(format!("{flag}: {what}"), usage))
}

// ---------------------------------------------------------------------------------------
// enrolment.create, enrolment.list
// ---------------------------------------------------------------------------------------

#[cfg(not(feature = "fleet"))]
pub fn enrolment_create(_: &[String]) -> R {
    Err(crate::door::not_built(wai::catalogue::op("enrolment.create").expect("listed")))
}

/// `wai enrolment create <node-key> --role <role> --key <file> ...`: the node's enrolment, signed
/// with the organisation key, written to a file and, with `--target`, given to a fleet service.
#[cfg(feature = "fleet")]
pub fn enrolment_create(args: &[String]) -> R {
    use wai::fleet::{Enrolment, NO_END, role};
    let usage = super::usage("enrolment.create");
    let with_value = ["--role", "--key", "--label", "--from", "--until", "--out", "--target"];
    let pos = agent::check_args(args, &with_value, &with_value, 1, &usage)?;
    let node: [u8; 32] = pos.first().and_then(|s| unhex(s)).and_then(|b| b.try_into().ok()).ok_or_else(|| OpError::usage("the node's key: 64 hexadecimal digits", &usage))?;
    let role_arg = required(args, "--role", "relay, compositor, ingest, room, ledger or audit", &usage)?;
    let r = role::code(role_arg)
        .or_else(|| role_arg.parse::<u8>().ok().filter(|r| role::name(*r).is_some()))
        .ok_or_else(|| OpError::usage(format!("--role {role_arg}: relay, compositor, ingest, room, ledger or audit"), &usage))?;
    let key = super::key::signing_key(required(args, "--key", "the organisation key: its name in the keystore, or its file (32 bytes, or 64 hexadecimal digits)", &usage)?, false)?;
    let label = super::opt(args, "--label").unwrap_or("");
    let (from, until) = (number(args, "--from", 0)?, number(args, "--until", NO_END)?);
    let e = Enrolment::new(key.verifying_key().to_bytes(), node, r, from, until, label).sign(&key);
    let bytes = e.to_bytes();
    // Its own rules, before anything is written or sent.
    Enrolment::parse(&bytes).map_err(|x| {
        OpError::new(agent::EXIT_USAGE, x.code(), format!("the enrolment is refused: {x}"), "extensions/fleet.md §3.3: from below until, a label of at most 64 bytes, a node that is not the organisation")
    })?;
    let target = super::opt(args, "--target");
    let file = super::opt(args, "--out").map(str::to_string).or_else(|| target.is_none().then(|| format!("{}.wfe", &hex(&node)[..16])));
    let data = json!({
        "node": hex(&node), "role": role::name(r), "label": label, "from": from, "until": until,
        "organisation": hex(&key.verifying_key().to_bytes()), "digest": hex(&e.digest()), "enrolment": hex(&bytes), "file": file, "target": target,
    });
    let who = format!("{} as {} ({label:?}), digest {}", &hex(&node)[..16], role::name(r).unwrap_or("?"), &hex(&e.digest())[..16]);
    if out::dry_run() {
        if let Some(f) = &file {
            out::write(f, &bytes).map_err(|e| io_fail(f, &e, "check the directory"))?;
        }
        if let Some(t) = target {
            out::would_send(t, "POST", "/v1/fleet/enrolments");
        }
        return done("enrolment.create", true, data, &format!("would enrol {who}"));
    }
    let replaces = file.as_ref().filter(|f| std::path::Path::new(f).exists());
    if target.is_some() || replaces.is_some() {
        let mut what = Vec::new();
        let mut proposed = Vec::new();
        if let Some(f) = replaces {
            what.push(format!("{f} exists, and the enrolment would replace it"));
            proposed.push(json!({"write": f, "bytes": bytes.len(), "replaces": true}));
        } else if let Some(f) = &file {
            proposed.push(json!({"write": f, "bytes": bytes.len(), "replaces": false}));
        }
        if let Some(t) = target {
            what.push(format!("the fleet service at {t} would hold the enrolment of {}…", &hex(&node)[..16]));
            proposed.push(json!({"request": "POST /v1/fleet/enrolments", "target": t, "node": hex(&node), "role": role::name(r)}));
        }
        agent::confirm(&what.join("; "), proposed)?;
    }
    if let Some(f) = &file {
        out::write(f, &bytes).map_err(|e| io_fail(f, &e, "check the directory"))?;
    }
    if let Some(t) = target {
        let (status, v) = call(t, "POST", "/v1/fleet/enrolments", Some(&json!({"enrolment": hex(&bytes)})))?;
        if status != 201 {
            return Err(answer_fail(status, &v));
        }
    }
    let text = format!(
        "enrolled {who}{}{}",
        file.as_ref().map(|f| format!("; written to {f}")).unwrap_or_default(),
        target.map(|t| format!("; held by {t}")).unwrap_or_default()
    );
    done("enrolment.create", true, data, &text)
}

/// `wai enrolment list --target URL`: the nodes a fleet service holds.
pub fn enrolment_list(args: &[String]) -> R {
    let usage = super::usage("enrolment.list");
    agent::check_args(args, &["--target"], &["--target"], 0, &usage)?;
    let target = required(args, "--target", "the fleet service's http:// address", &usage)?;
    let (status, v) = call(target, "GET", "/v1/fleet/enrolments", None)?;
    if status != 200 {
        return Err(answer_fail(status, &v));
    }
    let list = v["enrolments"].as_array().cloned().unwrap_or_default();
    let mut text = format!("{} node(s) enrolled at {target}", list.len());
    for e in &list {
        let node = e["node"].as_str().unwrap_or("");
        text.push_str(&format!("\n  {}…  {:<10} {:<16} records {}", &node[..16.min(node.len())], e["role"].as_str().unwrap_or("?"), e["label"].as_str().unwrap_or(""), e["records"]));
    }
    done("enrolment.list", false, json!({"enrolments": list}), &text)
}

// ---------------------------------------------------------------------------------------
// report.create, report.export
// ---------------------------------------------------------------------------------------

/// A report's outcome, for a person: its period, its lines and each scope's coverage.
fn summarise(o: &Value) -> String {
    let cov = |e: &Value| format!("sessions {}, hops {}", e["sessions"]["coverage"].as_str().unwrap_or("?"), e["hops"]["coverage"].as_str().unwrap_or("?"));
    let digest = o["digest"].as_str().unwrap_or("");
    let mut t = format!(
        "report {} of [{}, {}): {} node line(s), {} room line(s), {} record(s)",
        &digest[..16.min(digest.len())],
        o["start"],
        o["end"],
        o["nodes"].as_array().map_or(0, Vec::len),
        o["rooms"].as_array().map_or(0, Vec::len),
        o["records"]
    );
    for n in o["nodes"].as_array().into_iter().flatten() {
        t.push_str(&format!("\n  node {:<16} {:<10} sessions {} links {} bytes in {} out {}; energy: {}", n["label"].as_str().unwrap_or(""), n["role"].as_str().unwrap_or(""), n["sessions"], n["links"], n["bytes_in"], n["bytes_out"], cov(&n["energy"])));
    }
    for e in o["enrolments"].as_array().into_iter().flatten().filter(|e| e["silent"] == true) {
        t.push_str(&format!("\n  node {:<16} {:<10} silent: no record in the period", e["label"].as_str().unwrap_or(""), e["role"].as_str().unwrap_or("")));
    }
    t.push_str(&format!("\n  fleet energy: {} (a partial total is not the fleet's energy)", cov(&o["totals"]["energy"])));
    t
}

/// `wai report create --target URL --start µs --end µs`: the fleet report of a period, which the
/// service signs. It changes the service, so it waits for `--yes`.
pub fn report_create(args: &[String]) -> R {
    let usage = super::usage("report.create");
    let with_value = ["--target", "--start", "--end"];
    agent::check_args(args, &with_value, &with_value, 0, &usage)?;
    let target = required(args, "--target", "the fleet service's http:// address", &usage)?;
    let (start, end) = (number(args, "--start", 0)?, number(args, "--end", 0)?);
    if super::opt(args, "--start").is_none() || super::opt(args, "--end").is_none() || start >= end {
        return Err(OpError::usage("--start and --end: the period, in microseconds since 1970, start below end", &usage));
    }
    let period = json!({"target": target, "start": start, "end": end});
    if out::dry_run() {
        out::would_send(target, "POST", "/v1/fleet/reports");
        return done("report.create", true, period, &format!("would have {target} sign the fleet report of [{start}, {end})"));
    }
    agent::confirm(
        &format!("the fleet service at {target} would sign the fleet report of [{start}, {end})"),
        vec![json!({"request": "POST /v1/fleet/reports", "target": target, "start": start, "end": end})],
    )?;
    let (status, v) = call(target, "POST", "/v1/fleet/reports", Some(&json!({"start": start, "end": end})))?;
    if status != 201 {
        return Err(answer_fail(status, &v));
    }
    let o = &v["outcome"];
    done("report.create", true, json!({"report": v["report"], "outcome": o}), &summarise(o))
}

#[cfg(not(feature = "fleet"))]
pub fn report_export(_: &[String]) -> R {
    Err(crate::door::not_built(wai::catalogue::op("report.export").expect("listed")))
}

/// `wai report export <digest> --target URL [--out F]`: a report's bundle, fetched, verified here
/// (the service is not trusted) and written to a file.
#[cfg(feature = "fleet")]
pub fn report_export(args: &[String]) -> R {
    let usage = super::usage("report.export");
    let with_value = ["--target", "--out"];
    let pos = agent::check_args(args, &with_value, &with_value, 1, &usage)?;
    let target = required(args, "--target", "the fleet service's http:// address", &usage)?;
    let digest = *pos.first().filter(|d| d.len() == 64 && unhex(d).is_some()).ok_or_else(|| OpError::usage("the report's digest: 64 hexadecimal digits", &usage))?;
    let file = super::opt(args, "--out").map(str::to_string).unwrap_or_else(|| format!("{}{}", &digest[..16], wai::fleet::BUNDLE_SUFFIX));
    let replaces = std::path::Path::new(&file).exists();
    if out::dry_run() {
        out::would_send(target, "GET", &format!("/v1/fleet/reports/{digest}"));
        out::would_write(&file);
        return done("report.export", true, json!({"digest": digest, "target": target, "file": file}), &format!("would export the bundle of report {}… from {target} to {file}", &digest[..16]));
    }
    let (status, v) = call(target, "GET", &format!("/v1/fleet/reports/{digest}"), None)?;
    if status != 200 {
        return Err(answer_fail(status, &v));
    }
    // The bundle verified here, by this build, before it is written: the service is not trusted.
    let o = wai::fleet::outcome_json(&wai::fleet::verify(&v["bundle"]));
    if o["outcome"] != "accept" || o["digest"] != json!(digest) {
        let r = o["reason"].as_str().unwrap_or("report_mismatch");
        return Err(OpError::new(EXIT_FAILED, reason_code(r), format!("the service's bundle does not verify: {o}"), "report it to the fleet's operator"));
    }
    if replaces {
        agent::confirm(&format!("{file} exists, and the bundle would replace it"), vec![json!({"write": file, "replaces": true})])?;
    }
    let text_out = serde_json::to_string_pretty(&v["bundle"]).expect("JSON") + "\n";
    out::write(&file, text_out).map_err(|e| io_fail(&file, &e, "check the directory"))?;
    let text = format!("{}\nwritten to {file}; check it anywhere with `wai verify {file}`", summarise(&o));
    done("report.export", true, json!({"file": file, "outcome": o}), &text)
}
