//! `wai-keys` — build, check and publish an origin's WAI key log
//! (extensions/jwp-receipts.md §7).
//!
//!   wai-keys init   --origin URL --root-seed-file F --next-root-key HEX --issued-at T
//!                   --key id=ID,key=HEX,may_sign=A|B [--key …]
//!                   [--grant-max-s N] [--status-max-age-s N] [--published-test-seed]
//!       a one-revision key log, on stdout
//!   wai-keys append LOG --root-seed-file F --issued-at T
//!                   [--rotate-with-seed-file F2 --next-root-key HEX]
//!                   [--add id=ID,key=HEX,may_sign=A|B …] [--retire ID@T …] [--revoke ID@T …]
//!                   [--revoke-module SHA256 …] [--grant-max-s N] [--status-max-age-s N]
//!                   [--published-test-seed]
//!       LOG with one more revision, on stdout. A rotation is signed by the committed
//!       key (`--rotate-with-seed-file`) and must name the key it commits to next.
//!   wai-keys status LOG --root-seed-file F --issued-at T
//!       a signed key status (§7.9) for LOG's latest revision, on stdout
//!   wai-keys grant  --origin URL --seed-file F --key-id ID (--module-file M | --module SHA256)
//!                   --form decoder|reconstruct --capability C [--capability …]
//!                   --not-before T --not-after T [--published-test-seed]
//!       a signed module grant (§7.9), on stdout
//!   wai-keys verify LOG --origin URL [--pinned]
//!                   [--anchor HASH --held HELD_LOG [--held-pinned [--pinned-revision HASH]]]
//!       the §7.3 anchoring checks: `anchored revision=…`, or `not_anchored reason=…` /
//!       `replaced reason=…` with exit status 1. With `--anchor` and `--held`, LOG is
//!       checked as a later retrieval against an anchor already held: fetched, or with
//!       `--held-pinned` pinned at `--pinned-revision` (default: the anchor). `--pinned`
//!       instead pins LOG anew, whatever is held.
//!   wai-keys did-json LOG
//!       a DID document listing exactly the active keys of LOG's latest revision
//!   wai-keys check-did LOG DID_JSON
//!       every difference between DID_JSON and LOG's active keys; exit status 1 if any
//!
//! It never creates key material: there is no key-generation command and no random
//! number generator. Seeds are read only from files the operator names — 64 hexadecimal
//! digits or 32 raw bytes — and never printed. A key that is one of
//! `key_log::published_test_keys()` (every one-byte seed, the form every seed published in
//! this repository has) is refused unless `--published-test-seed` says the log is a test
//! log; `verify` reports one it finds as `note=published_test_key`.
//!
//! Date-times are UTC as the key log writes them: `2026-09-01T00:00:00Z`.

use ed25519_dalek::SigningKey;
use serde_json::{json, Value};
use std::collections::BTreeMap;
use wai::key_log::{
    self, anchor_log, commitment, is_published_test_key, lists_published_test_key, log_json, module_digest,
    read_log, Anchor, AnchorPath, Anchoring, EntryStatus, KeyEntry, KeyStatus, ModuleForm, ModuleGrant, Revision,
};
use wai::signer_id;

fn main() {
    let args: Vec<String> = std::env::args().skip(1).collect();
    let code = match run(&args) {
        Ok(code) => code,
        Err(e) => {
            eprintln!("wai-keys: {e}");
            2
        }
    };
    std::process::exit(code);
}

/// Flags: `--name value` pairs (repeatable), bare `--flag` switches, and positionals.
struct Args {
    pos: Vec<String>,
    opts: BTreeMap<String, Vec<String>>,
    switches: Vec<String>,
}

const SWITCHES: &[&str] = &["--published-test-seed", "--pinned", "--held-pinned"];

fn parse(args: &[String]) -> Result<Args, String> {
    let mut a = Args { pos: vec![], opts: BTreeMap::new(), switches: vec![] };
    let mut i = 0;
    while i < args.len() {
        let x = &args[i];
        if SWITCHES.contains(&x.as_str()) {
            a.switches.push(x.clone());
        } else if x.starts_with("--") {
            let v = args.get(i + 1).ok_or(format!("{x} needs a value"))?;
            a.opts.entry(x.clone()).or_default().push(v.clone());
            i += 1;
        } else {
            a.pos.push(x.clone());
        }
        i += 1;
    }
    Ok(a)
}

impl Args {
    fn one(&self, k: &str) -> Result<&str, String> {
        match self.opts.get(k).map(Vec::as_slice) {
            Some([v]) => Ok(v),
            Some(_) => Err(format!("{k} given more than once")),
            None => Err(format!("{k} is required")),
        }
    }
    fn opt(&self, k: &str) -> Result<Option<&str>, String> {
        if self.opts.contains_key(k) { self.one(k).map(Some) } else { Ok(None) }
    }
    fn all(&self, k: &str) -> &[String] {
        self.opts.get(k).map_or(&[], Vec::as_slice)
    }
    fn has(&self, s: &str) -> bool {
        self.switches.iter().any(|x| x == s)
    }
    fn int(&self, k: &str) -> Result<Option<u64>, String> {
        self.opt(k)?.map(|v| v.parse::<u64>().map_err(|_| format!("{k}: not an integer"))).transpose()
    }
}

fn run(args: &[String]) -> Result<i32, String> {
    let Some(cmd) = args.first() else {
        return Err("usage: wai-keys init|append|status|grant|verify|did-json|check-did …  (see the source's doc)".into());
    };
    let a = parse(&args[1..])?;
    match cmd.as_str() {
        "init" => init(&a),
        "append" => append(&a),
        "status" => status(&a),
        "grant" => grant(&a),
        "verify" => verify(&a),
        "did-json" => did_json(&a),
        "check-did" => check_did(&a),
        c => Err(format!("unknown command {c}")),
    }
}

// ───────────────────────────── inputs ─────────────────────────────

fn read(path: &str) -> Result<Vec<u8>, String> {
    std::fs::read(path).map_err(|e| format!("read {path}: {e}"))
}

fn read_text(path: &str) -> Result<String, String> {
    String::from_utf8(read(path)?).map_err(|_| format!("{path}: not UTF-8"))
}

fn hex32(s: &str) -> Option<[u8; 32]> {
    if s.len() != 64 || !s.bytes().all(|b| b.is_ascii_hexdigit()) {
        return None;
    }
    let v: Vec<u8> = (0..64).step_by(2).map(|i| u8::from_str_radix(&s[i..i + 2], 16).ok()).collect::<Option<_>>()?;
    v.try_into().ok()
}

fn hex(b: &[u8]) -> String {
    b.iter().map(|x| format!("{x:02x}")).collect()
}

/// A seed file: 64 hexadecimal digits (surrounding whitespace ignored) or 32 raw bytes.
/// Its contents are never echoed, even in an error.
fn seed(path: &str) -> Result<SigningKey, String> {
    let bytes = read(path)?;
    let seed = match std::str::from_utf8(&bytes).ok().map(str::trim).and_then(hex32) {
        Some(s) => s,
        None if bytes.len() == 32 => bytes.try_into().expect("32 bytes"),
        None => return Err(format!("{path}: a seed file holds 64 hexadecimal digits or 32 raw bytes")),
    };
    Ok(SigningKey::from_bytes(&seed))
}

fn public_key(k: &SigningKey) -> [u8; 32] {
    k.verifying_key().to_bytes()
}

/// Refuse a published test key unless the operator said this is a test log.
fn refuse_published(a: &Args, keys: &[[u8; 32]]) -> Result<(), String> {
    if !a.has("--published-test-seed") && keys.iter().any(is_published_test_key) {
        return Err("refusing a published test key (a one-byte seed, as the repository's corpora use): \
                    it attests nothing. Pass --published-test-seed only to build a test log."
            .into());
    }
    Ok(())
}

/// `id=ID,key=HEX,may_sign=A|B`.
fn entry_spec(s: &str) -> Result<KeyEntry, String> {
    let mut m = BTreeMap::new();
    for part in s.split(',') {
        let (k, v) = part.split_once('=').ok_or(format!("--key/--add: `{part}` is not name=value"))?;
        m.insert(k, v);
    }
    let id = m.get("id").ok_or("--key/--add needs id=")?;
    let key = m.get("key").and_then(|k| hex32(&k.to_ascii_lowercase())).ok_or("--key/--add needs key=<64 hex digits>")?;
    let may: Vec<&str> = m.get("may_sign").ok_or("--key/--add needs may_sign=")?.split('|').collect();
    Ok(KeyEntry::active(id, key, &may))
}

fn time_at(s: &str) -> Result<(&str, &str), String> {
    let (id, t) = s.split_once('@').ok_or(format!("`{s}`: expected ID@TIME"))?;
    if !key_log::is_log_time(t) {
        return Err(format!("`{t}`: not a key-log date-time (YYYY-MM-DDTHH:MM:SSZ)"));
    }
    Ok((id, t))
}

fn issued_at(a: &Args) -> Result<String, String> {
    let t = a.one("--issued-at")?;
    if !key_log::is_log_time(t) {
        return Err(format!("--issued-at {t}: not a key-log date-time (YYYY-MM-DDTHH:MM:SSZ)"));
    }
    Ok(t.to_owned())
}

/// A key log file: its revisions, checked as §7.3 checks a log, under the origin its
/// first revision names.
fn load_log(path: &str) -> Result<(String, Vec<Revision>), String> {
    let text = read_text(path)?;
    let v: Value = serde_json::from_str(&text).map_err(|e| format!("{path}: {e}"))?;
    let origin = v["revisions"][0]["origin"].as_str().ok_or(format!("{path}: no origin"))?.to_owned();
    let revs = read_log(&origin, &text).map_err(|r| format!("{path}: {}", r.label()))?;
    Ok((origin, revs))
}

fn emit(revs: &[Revision]) -> i32 {
    println!("{}", log_json(revs));
    0
}

// ───────────────────────────── commands ─────────────────────────────

fn init(a: &Args) -> Result<i32, String> {
    let origin = a.one("--origin")?;
    if !signer_id::origin_ok(origin) {
        return Err(format!("--origin {origin}: not an origin of jwp-receipts §7.1 (https://host[:port])"));
    }
    let root = seed(a.one("--root-seed-file")?)?;
    let next = hex32(a.one("--next-root-key")?).ok_or("--next-root-key: 64 hex digits")?;
    let keys: Vec<KeyEntry> = a.all("--key").iter().map(|s| entry_spec(s)).collect::<Result<_, _>>()?;
    let mut check: Vec<[u8; 32]> = vec![public_key(&root), next];
    check.extend(keys.iter().map(|e| e.key));
    refuse_published(a, &check)?;
    let unsigned = Revision {
        origin: origin.into(),
        seq: 0,
        prev: None,
        issued_at: issued_at(a)?,
        root: [0; 32],
        next_root: commitment(&next),
        keys,
        revoked_modules: vec![],
        grant_max_s: a.int("--grant-max-s")?.unwrap_or(key_log::module_grant_ceiling_s()),
        status_max_age_s: a.int("--status-max-age-s")?.unwrap_or(key_log::status_ceiling_s()),
        sig: [0; 64],
        hybrid: None,
    };
    let r = Revision::seal(&root, unsigned).map_err(|e| format!("the revision breaks §7.2 rule 1: {}", e.label()))?;
    Ok(emit(&[r]))
}

fn append(a: &Args) -> Result<i32, String> {
    let path = a.pos.first().ok_or("append needs LOG")?;
    let (origin, mut revs) = load_log(path)?;
    let parent = revs.last().expect("a log is non-empty").clone();
    let (signer, next_root) = match a.opt("--rotate-with-seed-file")? {
        Some(f) => {
            let k = seed(f)?;
            if commitment(&public_key(&k)) != parent.next_root {
                return Err("--rotate-with-seed-file: not the key the latest revision committed to".into());
            }
            let next = hex32(a.one("--next-root-key")?).ok_or("--next-root-key: 64 hex digits")?;
            (k, commitment(&next))
        }
        None => {
            let k = seed(a.one("--root-seed-file")?)?;
            if public_key(&k) != parent.root {
                return Err("--root-seed-file: not the root of the latest revision (rotate with --rotate-with-seed-file)".into());
            }
            if a.opts.contains_key("--next-root-key") {
                return Err("--next-root-key changes the commitment, which only a rotation may do (§7.2 rule 5)".into());
            }
            (k, parent.next_root)
        }
    };
    let mut keys = parent.keys.clone();
    for s in a.all("--add") {
        keys.push(entry_spec(s)?);
    }
    for s in a.all("--retire") {
        let (id, t) = time_at(s)?;
        let e = keys.iter_mut().find(|e| e.id == id).ok_or(format!("--retire: no key {id}"))?;
        if e.status != EntryStatus::Active {
            return Err(format!("--retire: {id} is not active"));
        }
        e.status = EntryStatus::Retired;
        e.until = Some(t.into());
    }
    for s in a.all("--revoke") {
        let (id, t) = time_at(s)?;
        let e = keys.iter_mut().find(|e| e.id == id).ok_or(format!("--revoke: no key {id}"))?;
        if e.status == EntryStatus::Revoked {
            return Err(format!("--revoke: {id} is already revoked"));
        }
        e.status = EntryStatus::Revoked;
        e.compromised_since = Some(t.into());
    }
    let mut revoked = parent.revoked_modules.clone();
    for d in a.all("--revoke-module") {
        let d = hex32(d).filter(|_| d.bytes().all(|b| !b.is_ascii_uppercase())).ok_or("--revoke-module: 64 lowercase hex digits")?;
        if !revoked.contains(&d) {
            revoked.push(d);
        }
    }
    let mut check: Vec<[u8; 32]> = vec![public_key(&signer)];
    check.extend(keys.iter().skip(parent.keys.len()).map(|e| e.key));
    if let Some(n) = a.opt("--next-root-key")? {
        check.extend(hex32(n));
    }
    refuse_published(a, &check)?;
    let unsigned = Revision {
        origin: origin.clone(),
        seq: parent.seq + 1,
        prev: Some(parent.revision_hash()),
        issued_at: issued_at(a)?,
        root: [0; 32],
        next_root,
        keys,
        revoked_modules: revoked,
        grant_max_s: a.int("--grant-max-s")?.unwrap_or(parent.grant_max_s),
        status_max_age_s: a.int("--status-max-age-s")?.unwrap_or(parent.status_max_age_s),
        sig: [0; 64],
        hybrid: None,
    };
    let r = Revision::seal(&signer, unsigned).map_err(|e| format!("the revision breaks §7.2 rule 1: {}", e.label()))?;
    key_log::check_link(&parent, &r).map_err(|e| format!("the revision breaks §7.2: {}", e.label()))?;
    revs.push(r);
    Ok(emit(&revs))
}

fn status(a: &Args) -> Result<i32, String> {
    let path = a.pos.first().ok_or("status needs LOG")?;
    let (_, revs) = load_log(path)?;
    let head = revs.last().expect("non-empty");
    let root = seed(a.one("--root-seed-file")?)?;
    if public_key(&root) != head.root {
        return Err("--root-seed-file: a status is signed by the latest revision's root".into());
    }
    println!("{}", KeyStatus::seal(&root, head, &issued_at(a)?).to_json());
    Ok(0)
}

fn grant(a: &Args) -> Result<i32, String> {
    let origin = a.one("--origin")?;
    let did = signer_id::did_web_of_origin(origin).ok_or(format!("--origin {origin}: not an origin of §7.1"))?;
    let key_id = a.one("--key-id")?;
    let signer = seed(a.one("--seed-file")?)?;
    refuse_published(a, &[public_key(&signer)])?;
    let module = match (a.opt("--module-file")?, a.opt("--module")?) {
        (Some(f), None) => module_digest(&read(f)?),
        (None, Some(d)) => hex32(d).filter(|_| d.bytes().all(|b| !b.is_ascii_uppercase())).ok_or("--module: 64 lowercase hex digits")?,
        _ => return Err("give exactly one of --module-file and --module".into()),
    };
    let form = match a.one("--form")? {
        "decoder" => ModuleForm::Decoder,
        "reconstruct" => ModuleForm::Reconstruct,
        f => return Err(format!("--form {f}: decoder or reconstruct")),
    };
    let caps: Vec<&str> = a.all("--capability").iter().map(String::as_str).collect();
    let g = ModuleGrant::seal(&signer, &format!("{did}#{key_id}"), origin, module, form, &caps, a.one("--not-before")?, a.one("--not-after")?)
        .map_err(|e| format!("the grant breaks §7.9: {}", e.label()))?;
    println!("{}", g.to_json());
    Ok(0)
}

fn verify(a: &Args) -> Result<i32, String> {
    let path = a.pos.first().ok_or("verify needs LOG")?;
    let origin = a.one("--origin")?;
    let text = read_text(path)?;
    let path_kind = if a.has("--pinned") { AnchorPath::Pinned } else { AnchorPath::Fetched { acquired_at: "unrecorded".into() } };
    let current = match (a.opt("--anchor")?, a.opt("--held")?) {
        (Some(h), Some(f)) => {
            let revision = hex32(h).ok_or("--anchor: 64 hex digits")?;
            let held = read_log(origin, &read_text(f)?).map_err(|r| format!("{f}: {}", r.label()))?;
            let anchor = if a.has("--held-pinned") {
                let at = match a.opt("--pinned-revision")? {
                    Some(p) => hex32(p).ok_or("--pinned-revision: 64 hex digits")?,
                    None => revision,
                };
                Anchor { pinned_revision: Some(at), ..Anchor::pinned(origin, revision) }
            } else {
                if a.opts.contains_key("--pinned-revision") {
                    return Err("--pinned-revision goes with --held-pinned".into());
                }
                Anchor::fetched(origin, revision, "unrecorded")
            };
            Some((anchor, held))
        }
        (None, None) if a.has("--held-pinned") || a.opts.contains_key("--pinned-revision") => {
            return Err("--held-pinned and --pinned-revision describe an anchor already held (--anchor, --held)".into());
        }
        (None, None) => None,
        _ => return Err("--anchor and --held go together".into()),
    };
    match anchor_log(origin, &text, path_kind, current.as_ref().map(|(a, h)| (a, h.as_slice()))) {
        Anchoring::Anchored(anchor) => {
            let latest = read_log(origin, &text).map_err(|r| r.label().to_owned())?.pop().expect("non-empty");
            let note = if lists_published_test_key(&latest) {
                eprintln!("warning: the log lists a published test key, which attests nothing (jwp-receipts §7.8)");
                " note=published_test_key"
            } else {
                ""
            };
            let pinned = match anchor.pinned_revision.filter(|p| *p != anchor.revision) {
                Some(p) => format!(" pinned_revision={}", hex(&p)),
                None => String::new(),
            };
            println!("anchored revision={} anchor={}{pinned}{note}", hex(&anchor.revision), anchor.path.label());
            Ok(0)
        }
        Anchoring::NotAnchored(r) => {
            println!("not_anchored reason={}", r.label());
            Ok(1)
        }
        Anchoring::Replaced => {
            println!("replaced reason=key_log_replaced");
            Ok(1)
        }
    }
}

/// The active keys of the latest revision, as DID verification-method ids and their
/// `publicKeyMultibase` values.
fn active_methods(origin: &str, latest: &Revision) -> Result<(String, BTreeMap<String, String>), String> {
    let did = signer_id::did_web_of_origin(origin).ok_or(format!("{origin}: not an origin of §7.1"))?;
    let methods = latest
        .keys
        .iter()
        .filter(|e| e.status == EntryStatus::Active)
        .map(|e| (format!("{did}#{}", e.id), format!("z{}", signer_id::multibase_ed25519(&e.key))))
        .collect();
    Ok((did, methods))
}

fn did_json(a: &Args) -> Result<i32, String> {
    let path = a.pos.first().ok_or("did-json needs LOG")?;
    let (origin, revs) = load_log(path)?;
    let (did, methods) = active_methods(&origin, revs.last().expect("non-empty"))?;
    let vm: Vec<Value> = methods
        .iter()
        .map(|(id, mb)| json!({ "id": id, "type": "Multikey", "controller": did, "publicKeyMultibase": mb }))
        .collect();
    let ids: Vec<&String> = methods.keys().collect();
    let doc = json!({
        "@context": ["https://www.w3.org/ns/did/v1", "https://w3id.org/security/multikey/v1"],
        "id": did,
        "verificationMethod": vm,
        "assertionMethod": ids,
    });
    println!("{}", serde_json::to_string_pretty(&doc).expect("serialises"));
    Ok(0)
}

fn check_did(a: &Args) -> Result<i32, String> {
    let (Some(log), Some(doc_path)) = (a.pos.first(), a.pos.get(1)) else {
        return Err("check-did needs LOG DID_JSON".into());
    };
    let (origin, revs) = load_log(log)?;
    let (did, want) = active_methods(&origin, revs.last().expect("non-empty"))?;
    let doc: Value = serde_json::from_str(&read_text(doc_path)?).map_err(|e| format!("{doc_path}: {e}"))?;
    let mut diffs = Vec::new();
    if doc["id"].as_str() != Some(did.as_str()) {
        diffs.push(format!("id: the document is {}, the log's origin is {did}", doc["id"]));
    }
    let mut have = BTreeMap::new();
    for m in doc["verificationMethod"].as_array().map_or(&[][..], Vec::as_slice) {
        let id = m["id"].as_str().unwrap_or("").to_owned();
        let mb = m["publicKeyMultibase"].as_str().unwrap_or("").to_owned();
        if signer_id::read_multibase_ed25519(&mb).is_none() {
            diffs.push(format!("{id}: publicKeyMultibase is not an Ed25519 multikey"));
        }
        have.insert(id, mb);
    }
    for (id, mb) in &want {
        match have.get(id) {
            None => diffs.push(format!("{id}: active in the key log, missing from the DID document")),
            Some(h) if h != mb => diffs.push(format!("{id}: the DID document lists another key")),
            Some(_) => {}
        }
    }
    for id in have.keys().filter(|id| !want.contains_key(*id)) {
        diffs.push(format!("{id}: in the DID document, not an active key of the key log"));
    }
    for d in &diffs {
        println!("{d}");
    }
    if diffs.is_empty() {
        println!("consistent: the DID document lists exactly the key log's active keys");
        Ok(0)
    } else {
        Ok(1)
    }
}
