//! relicario-server -- pre-receive hook for signature verification.

use std::fs;
use std::process::Command;

use anyhow::{Context, Result};
use clap::{Parser, Subcommand};
use relicario_core::device::{DeviceEntry, RevokedEntry};

#[derive(Parser)]
#[command(name = "relicario-server")]
struct Cli {
    #[command(subcommand)]
    command: Commands,
}

#[derive(Subcommand)]
enum Commands {
    /// Verify a commit's signature against devices.json.
    VerifyCommit {
        /// The commit SHA to verify.
        commit: String,
    },
    /// Generate a pre-receive hook script.
    GenerateHook,
}

fn main() -> Result<()> {
    let cli = Cli::parse();

    match cli.command {
        Commands::VerifyCommit { commit } => verify_commit(&commit),
        Commands::GenerateHook => generate_hook(),
    }
}

fn verify_commit(commit: &str) -> Result<()> {
    let devices_json = match git_show(commit, ".relicario/devices.json") {
        Ok(json) => json,
        Err(_) => {
            eprintln!("OK: commit {commit} (bootstrap - no devices.json)");
            return Ok(());
        }
    };
    let devices: Vec<DeviceEntry> = serde_json::from_str(&devices_json)
        .context("parse devices.json")?;

    let revoked: Vec<RevokedEntry> = git_show(commit, ".relicario/revoked.json")
        .ok()
        .and_then(|s| serde_json::from_str(&s).ok())
        .unwrap_or_default();

    // True bootstrap: no devices ever registered and none revoked.
    if devices.is_empty() && revoked.is_empty() {
        eprintln!("OK: commit {commit} (bootstrap - no devices registered)");
        return Ok(());
    }

    // Build temp allowed-signers file from registered devices.
    let tmp = tempfile::tempdir().context("create tempdir")?;
    let allowed_path = tmp.path().join("allowed_signers");
    let mut allowed_body = String::new();
    for d in &devices {
        allowed_body.push_str("relicario ");
        allowed_body.push_str(d.public_key.trim());
        allowed_body.push('\n');
    }
    fs::write(&allowed_path, &allowed_body).context("write allowed_signers")?;

    // Run git verify-commit --raw. Capture both exit code and stderr.
    // NOTE: we do NOT short-circuit on non-zero exit here because even for
    // unregistered keys git still outputs "Good ... key SHA256:..." on stderr.
    let output = Command::new("git")
        .args(["verify-commit", "--raw", commit])
        .env("GIT_CONFIG_COUNT", "1")
        .env("GIT_CONFIG_KEY_0", "gpg.ssh.allowedSignersFile")
        .env("GIT_CONFIG_VALUE_0", allowed_path.as_os_str())
        .output()
        .context("git verify-commit")?;

    let stderr = String::from_utf8_lossy(&output.stderr);

    // Parse the SHA-256 fingerprint from stderr.
    // SSH signature output: "Good "git" signature ... with ED25519 key SHA256:<base64>"
    let re = regex::Regex::new(r"key (SHA256:[A-Za-z0-9+/]+)").expect("static regex");
    let signing_fp = match re.captures(&stderr).and_then(|c| c.get(1)) {
        Some(m) => m.as_str().to_string(),
        None => {
            // No fingerprint in stderr = unsigned or completely malformed signature.
            eprintln!(
                "REJECT: commit {commit} — no valid signature found (stderr: {})",
                stderr.trim()
            );
            std::process::exit(1);
        }
    };

    // Build fingerprint → entry maps.
    let mut device_by_fp: std::collections::HashMap<String, &DeviceEntry> =
        std::collections::HashMap::new();
    for d in &devices {
        if let Ok(fp) = relicario_core::device::fingerprint(&d.public_key) {
            device_by_fp.insert(fp, d);
        }
    }

    let mut revoked_by_fp: std::collections::HashMap<String, &RevokedEntry> =
        std::collections::HashMap::new();
    for r in &revoked {
        if let Ok(fp) = relicario_core::device::fingerprint(&r.public_key) {
            revoked_by_fp.insert(fp, r);
        }
    }

    // Get committer date (NOT author date).
    let ct_out = Command::new("git")
        .args(["show", "-s", "--format=%ct", commit])
        .output()
        .context("git show committer date")?;
    let committer_ts: i64 = String::from_utf8_lossy(&ct_out.stdout)
        .trim()
        .parse()
        .context("parse committer timestamp")?;

    // Check revocation FIRST (revoked entries may not be in devices anymore).
    if let Some(r) = revoked_by_fp.get(&signing_fp) {
        if committer_ts >= r.revoked_at {
            eprintln!(
                "REJECT: commit {commit} — signed by revoked device '{}' \
                 (committer ts {committer_ts} >= revoked_at {})",
                r.name, r.revoked_at
            );
            std::process::exit(1);
        }
        // Historical commit: committer_ts < revoked_at → was valid when signed.
        eprintln!(
            "OK: commit {commit} — historical commit signed by '{}' before revocation",
            r.name
        );
        return Ok(());
    }

    // Not revoked — must be in active devices.
    if !device_by_fp.contains_key(&signing_fp) {
        eprintln!(
            "REJECT: commit {commit} — signed by unregistered device (fingerprint {signing_fp})"
        );
        std::process::exit(1);
    }

    eprintln!("OK: commit {commit} verified (signed by '{}')", device_by_fp[&signing_fp].name);
    Ok(())
}

fn generate_hook() -> Result<()> {
    print!(
        r#"#!/bin/bash
# Relicario pre-receive hook -- verify all commits are signed by registered devices

while read oldrev newrev refname; do
  [ "$newrev" = "0000000000000000000000000000000000000000" ] && continue

  if [ "$oldrev" = "0000000000000000000000000000000000000000" ]; then
    commits=$(git rev-list "$newrev")
  else
    commits=$(git rev-list "$oldrev..$newrev")
  fi

  for commit in $commits; do
    relicario-server verify-commit "$commit" || exit 1
  done
done
"#
    );
    Ok(())
}

fn git_show(commit: &str, path: &str) -> Result<String> {
    let output = Command::new("git")
        .args(["show", &format!("{}:{}", commit, path)])
        .output()
        .context("git show")?;

    if !output.status.success() {
        anyhow::bail!("git show {}:{} failed", commit, path);
    }

    Ok(String::from_utf8(output.stdout)?)
}
