//! `release-tool` — offline publisher CLI for signed release channels.
//!
//! Generates a release-channel Ed25519 keypair and signs release manifests
//! for a channel's `manifest.json`. This binary is intentionally its own
//! crate, depending only on [`release_manifest`] and `ed25519-dalek` — it
//! never links against the `huskies` server crate, so the running gateway
//! has no code path that can read a channel's private signing key. Run this
//! tool offline (or in a separate publish pipeline) and copy only the
//! resulting public key hex into the gateway's `projects.toml`.
//!
//! Usage:
//! ```text
//! release-tool keygen <key-out-path>
//! release-tool sign --key <path> --artifact <path> --version <str> --channel <str> --out <path> [--timestamp <unix-secs>]
//! ```

use ed25519_dalek::{Signer, SigningKey};
use rand::Rng;
use release_manifest::{ReleaseManifest, SignedManifest};
use sha2::{Digest, Sha256};
use std::path::{Path, PathBuf};

fn main() {
    let args: Vec<String> = std::env::args().collect();
    let result = match args.get(1).map(String::as_str) {
        Some("keygen") => run_keygen(&args[2..]),
        Some("sign") => run_sign(&args[2..]),
        _ => Err(
            "usage: release-tool keygen <key-out-path> | release-tool sign --key <path> \
             --artifact <path> --version <str> --channel <str> --out <path> [--timestamp <unix-secs>]"
                .to_string(),
        ),
    };

    if let Err(e) = result {
        eprintln!("error: {e}");
        std::process::exit(1);
    }
}

// ── keygen ───────────────────────────────────────────────────────────────────

fn run_keygen(args: &[String]) -> Result<(), String> {
    let key_path = args.first().ok_or("keygen requires a key-out-path")?;
    let signing_key = generate_signing_key();
    write_seed_file(Path::new(key_path), &signing_key)?;

    let pubkey_hex = hex_encode(signing_key.verifying_key().as_bytes());
    println!("Wrote private key seed to {key_path}");
    println!("Pinned release public key (paste into projects.toml as `pubkey`):");
    println!("{pubkey_hex}");
    Ok(())
}

fn generate_signing_key() -> SigningKey {
    let mut seed = [0u8; 32];
    rand::rng().fill_bytes(&mut seed);
    SigningKey::from_bytes(&seed)
}

fn write_seed_file(path: &Path, signing_key: &SigningKey) -> Result<(), String> {
    if let Some(parent) = path.parent()
        && !parent.as_os_str().is_empty()
    {
        std::fs::create_dir_all(parent)
            .map_err(|e| format!("cannot create {}: {e}", parent.display()))?;
    }

    #[cfg(unix)]
    {
        use std::io::Write;
        use std::os::unix::fs::OpenOptionsExt;
        let mut file = std::fs::OpenOptions::new()
            .write(true)
            .create(true)
            .truncate(true)
            .mode(0o600)
            .open(path)
            .map_err(|e| format!("cannot create {}: {e}", path.display()))?;
        file.write_all(&signing_key.to_bytes())
            .map_err(|e| format!("cannot write {}: {e}", path.display()))
    }
    #[cfg(not(unix))]
    {
        std::fs::write(path, signing_key.to_bytes())
            .map_err(|e| format!("cannot write {}: {e}", path.display()))
    }
}

fn load_seed_file(path: &Path) -> Result<SigningKey, String> {
    let bytes = std::fs::read(path).map_err(|e| format!("cannot read {}: {e}", path.display()))?;
    let seed: [u8; 32] = bytes
        .try_into()
        .map_err(|_| format!("{} must contain exactly 32 bytes", path.display()))?;
    Ok(SigningKey::from_bytes(&seed))
}

// ── sign ─────────────────────────────────────────────────────────────────────

/// Parsed `sign` subcommand arguments.
struct SignArgs {
    key: PathBuf,
    artifact: PathBuf,
    version: String,
    channel: String,
    out: PathBuf,
    timestamp: Option<i64>,
}

fn parse_sign_args(args: &[String]) -> Result<SignArgs, String> {
    let mut key = None;
    let mut artifact = None;
    let mut version = None;
    let mut channel = None;
    let mut out = None;
    let mut timestamp = None;

    let mut i = 0;
    while i < args.len() {
        let flag = args[i].as_str();
        let value = args
            .get(i + 1)
            .ok_or_else(|| format!("missing value for {flag}"))?;
        match flag {
            "--key" => key = Some(PathBuf::from(value)),
            "--artifact" => artifact = Some(PathBuf::from(value)),
            "--version" => version = Some(value.clone()),
            "--channel" => channel = Some(value.clone()),
            "--out" => out = Some(PathBuf::from(value)),
            "--timestamp" => {
                timestamp = Some(
                    value
                        .parse::<i64>()
                        .map_err(|_| format!("--timestamp must be an integer, got `{value}`"))?,
                )
            }
            other => return Err(format!("unknown flag `{other}`")),
        }
        i += 2;
    }

    Ok(SignArgs {
        key: key.ok_or("--key is required")?,
        artifact: artifact.ok_or("--artifact is required")?,
        version: version.ok_or("--version is required")?,
        channel: channel.ok_or("--channel is required")?,
        out: out.ok_or("--out is required")?,
        timestamp,
    })
}

fn run_sign(args: &[String]) -> Result<(), String> {
    let parsed = parse_sign_args(args)?;
    let signing_key = load_seed_file(&parsed.key)?;
    let artifact_bytes = std::fs::read(&parsed.artifact)
        .map_err(|e| format!("cannot read {}: {e}", parsed.artifact.display()))?;
    let artifact_name = parsed
        .artifact
        .file_name()
        .and_then(|n| n.to_str())
        .ok_or("--artifact path has no filename")?
        .to_string();

    let timestamp = match parsed.timestamp {
        Some(t) => t,
        None => std::time::SystemTime::now()
            .duration_since(std::time::UNIX_EPOCH)
            .map_err(|e| format!("system clock before epoch: {e}"))?
            .as_secs() as i64,
    };

    let signed = sign_manifest(
        &signing_key,
        artifact_name,
        &artifact_bytes,
        parsed.version,
        parsed.channel,
        timestamp,
    );

    let json =
        serde_json::to_string_pretty(&signed).map_err(|e| format!("serialise manifest: {e}"))?;
    std::fs::write(&parsed.out, json)
        .map_err(|e| format!("cannot write {}: {e}", parsed.out.display()))?;
    println!("Signed manifest written to {}", parsed.out.display());
    Ok(())
}

/// Build and sign a [`SignedManifest`] for the given artifact bytes.
///
/// Pure aside from the signature computation — split out from `run_sign` so
/// tests can exercise it without touching the filesystem.
fn sign_manifest(
    signing_key: &SigningKey,
    artifact: String,
    artifact_bytes: &[u8],
    version: String,
    channel: String,
    timestamp: i64,
) -> SignedManifest {
    let mut hasher = Sha256::new();
    hasher.update(artifact_bytes);
    let sha256 = hex_encode(&hasher.finalize());

    let manifest = ReleaseManifest {
        artifact,
        sha256,
        version,
        channel,
        timestamp,
    };
    let signature = hex_encode(&signing_key.sign(&manifest.canonical_bytes()).to_bytes());
    SignedManifest {
        manifest,
        signature,
    }
}

// ── helpers ──────────────────────────────────────────────────────────────────

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

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

    #[test]
    fn keygen_then_sign_produces_verifiable_signature() {
        let tmp = tempfile::tempdir().unwrap();
        let key_path = tmp.path().join("channel.key");
        let signing_key = generate_signing_key();
        write_seed_file(&key_path, &signing_key).unwrap();

        let loaded = load_seed_file(&key_path).unwrap();
        assert_eq!(loaded.verifying_key(), signing_key.verifying_key());

        let signed = sign_manifest(
            &loaded,
            "huskies-linux-arm64".to_string(),
            b"fake binary contents",
            "abc1234".to_string(),
            "stable".to_string(),
            1_700_000_000,
        );

        // Verify with ed25519-dalek directly, mirroring how the gateway verifies.
        use ed25519_dalek::Verifier;
        let sig_bytes: [u8; 64] = hex_bytes(&signed.signature).try_into().unwrap();
        let sig = ed25519_dalek::Signature::from_bytes(&sig_bytes);
        assert!(
            signing_key
                .verifying_key()
                .verify(&signed.manifest.canonical_bytes(), &sig)
                .is_ok(),
            "signature produced by sign_manifest must verify against the signing key's pubkey"
        );
    }

    #[test]
    fn sign_manifest_hashes_artifact_bytes() {
        let signing_key = generate_signing_key();
        let signed = sign_manifest(
            &signing_key,
            "art".to_string(),
            b"hello world",
            "v1".to_string(),
            "stable".to_string(),
            1,
        );
        let mut hasher = Sha256::new();
        hasher.update(b"hello world");
        let expected = hex_encode(&hasher.finalize());
        assert_eq!(signed.manifest.sha256, expected);
    }

    #[test]
    fn parse_sign_args_rejects_missing_required_flag() {
        let args: Vec<String> = vec!["--key".into(), "k".into()];
        assert!(parse_sign_args(&args).is_err());
    }

    #[test]
    fn parse_sign_args_accepts_all_flags() {
        let args: Vec<String> = vec![
            "--key".into(),
            "k".into(),
            "--artifact".into(),
            "a".into(),
            "--version".into(),
            "v1".into(),
            "--channel".into(),
            "stable".into(),
            "--out".into(),
            "o".into(),
            "--timestamp".into(),
            "42".into(),
        ];
        let parsed = parse_sign_args(&args).unwrap();
        assert_eq!(parsed.timestamp, Some(42));
        assert_eq!(parsed.channel, "stable");
    }

    fn hex_bytes(s: &str) -> Vec<u8> {
        (0..s.len())
            .step_by(2)
            .map(|i| u8::from_str_radix(&s[i..i + 2], 16).unwrap())
            .collect()
    }
}
