//! 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};
use relicario_core::org::{OrgCollections, OrgMember, OrgMembers, OrgRole};
use relicario_server::{classify_path, extract_schema_version, PathClass};

#[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,
    /// Verify a commit to an org vault: signature + role/path authorization.
    VerifyOrgCommit {
        /// The commit SHA to verify.
        commit: String,
    },
    /// Generate an org pre-receive hook script.
    GenerateOrgHook,
}

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

    match cli.command {
        Commands::VerifyCommit { commit } => verify_commit(&commit),
        Commands::GenerateHook => generate_hook(),
        Commands::VerifyOrgCommit { commit } => verify_org_commit(&commit),
        Commands::GenerateOrgHook => generate_org_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)?)
}

/// Verify the SSH signature on `commit` against the given org members and return
/// the matching member. On any failure (unsigned, malformed, or unknown signer)
/// this prints REJECT and calls `std::process::exit(1)`; it only returns on success.
fn verify_org_signer(commit: &str, members: &OrgMembers) -> OrgMember {
    // Build a temp allowed-signers file from every current member's pubkey.
    let tmp = match tempfile::tempdir() {
        Ok(t) => t,
        Err(e) => {
            eprintln!("REJECT: org commit {commit} — cannot create tempdir: {e}");
            std::process::exit(1);
        }
    };
    let allowed_path = tmp.path().join("allowed_signers");
    let mut allowed_body = String::new();
    for m in &members.members {
        allowed_body.push_str("relicario ");
        allowed_body.push_str(m.ed25519_pubkey.trim());
        allowed_body.push('\n');
    }
    if let Err(e) = fs::write(&allowed_path, &allowed_body) {
        eprintln!("REJECT: org commit {commit} — cannot write allowed_signers: {e}");
        std::process::exit(1);
    }

    // Run git verify-commit --raw with the allowed-signers file injected.
    let output = match 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()
    {
        Ok(o) => o,
        Err(e) => {
            eprintln!("REJECT: org commit {commit} — git verify-commit failed to run: {e}");
            std::process::exit(1);
        }
    };
    let stderr = String::from_utf8_lossy(&output.stderr);

    // The org hook builds allowed_signers from EVERY current member, so a clean
    // `git verify-commit` exit IS the security gate: a non-zero exit means the
    // commit was unsigned, tampered, or signed by a non-member. Make that
    // property explicit rather than relying on the stderr regex alone (regex
    // output is fragile across git versions). The fingerprint parse + member
    // mapping below then identifies WHICH member signed.
    if !output.status.success() {
        eprintln!(
            "REJECT: org commit {commit} — signature did not verify against current members \
             (git verify-commit exit {}): {}",
            output.status.code().unwrap_or(-1),
            stderr.trim()
        );
        std::process::exit(1);
    }

    // Parse the SHA-256 fingerprint from stderr (same regex as verify_commit).
    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 => {
            eprintln!(
                "REJECT: org commit {commit} — no valid signature found (stderr: {})",
                stderr.trim()
            );
            std::process::exit(1);
        }
    };

    // Map fingerprint → member via relicario_core::fingerprint over each pubkey.
    for m in &members.members {
        if let Ok(fp) = relicario_core::fingerprint(&m.ed25519_pubkey) {
            if fp == signing_fp {
                return m.clone();
            }
        }
    }

    eprintln!(
        "REJECT: org commit {commit} — signer (fingerprint {signing_fp}) is not a current org member"
    );
    std::process::exit(1);
}

fn verify_org_commit(commit: &str) -> Result<()> {
    // Determine parent count from %P (space-separated parent SHAs; empty = root).
    let parents_out = Command::new("git")
        .args(["show", "-s", "--format=%P", commit])
        .output()
        .context("git show parents")?;
    let parents_line = String::from_utf8_lossy(&parents_out.stdout);
    let parents: Vec<&str> = parents_line.split_whitespace().collect();

    // Merge commits are rejected. Org repos are linear (CLI uses pull --rebase).
    if parents.len() > 1 {
        eprintln!(
            "REJECT: org commit {commit} — merge commits are not allowed in org vaults \
             ({} parents); rebase instead",
            parents.len()
        );
        std::process::exit(1);
    }
    let is_root = parents.is_empty();

    // Load members.json AS OF THIS COMMIT so the genesis commit can authorize itself.
    let members_json = match git_show(commit, "members.json") {
        Ok(s) => s,
        Err(_) => {
            if is_root {
                eprintln!("OK: org commit {commit} (root bootstrap - no members.json yet)");
                return Ok(());
            }
            eprintln!("REJECT: org commit {commit} — members.json missing from non-root commit");
            std::process::exit(1);
        }
    };
    let members: OrgMembers =
        serde_json::from_str(&members_json).context("parse members.json")?;
    if members.members.is_empty() {
        if is_root {
            eprintln!("OK: org commit {commit} (root bootstrap - empty member list)");
            return Ok(());
        }
        eprintln!("REJECT: org commit {commit} — members.json has no members");
        std::process::exit(1);
    }
    members
        .validate()
        .map_err(|e| anyhow::anyhow!("members.json invalid: {e}"))?;

    // Verify the signature and resolve the signing member (exits on failure).
    let signer = verify_org_signer(commit, &members);

    // Enumerate changed paths. Root has no parent to diff, so use ls-tree.
    let changed_paths: Vec<String> = if is_root {
        let out = Command::new("git")
            .args(["ls-tree", "-r", "--name-only", commit])
            .output()
            .context("git ls-tree")?;
        String::from_utf8_lossy(&out.stdout)
            .lines()
            .map(|l| l.trim().to_string())
            .filter(|l| !l.is_empty())
            .collect()
    } else {
        let out = Command::new("git")
            .args(["diff-tree", "--no-commit-id", "-r", "--name-only", commit])
            .output()
            .context("git diff-tree")?;
        String::from_utf8_lossy(&out.stdout)
            .lines()
            .map(|l| l.trim().to_string())
            .filter(|l| !l.is_empty())
            .collect()
    };

    // Authorize each changed path against the signing member's role/grants.
    // collections.json (as of this commit) is loaded lazily on the first item
    // path, for the L5 slug-existence check.
    let mut collection_slugs: Option<Vec<String>> = None;
    for path in &changed_paths {
        match classify_path(path) {
            PathClass::Rejected(why) => {
                eprintln!("REJECT: org commit {commit} — invalid path `{path}`: {why}");
                std::process::exit(1);
            }
            PathClass::Protected => {
                if !signer.role.can_manage_members() {
                    eprintln!(
                        "REJECT: org commit {commit} — member '{}' (role {:?}) may not write protected file `{path}`",
                        signer.display_name, signer.role
                    );
                    std::process::exit(1);
                }
                // Privilege-escalation gate: only an Owner may INTRODUCE or
                // ELEVATE an owner/admin. An Admin may write members.json but
                // must not mint owners/admins server-side (spec §148/158/271).
                if path == "members.json" {
                    enforce_owner_only_elevation(commit, is_root, &members, &signer);
                }
            }
            PathClass::Item { collection } => {
                // The signing member must hold an explicit grant for the slug.
                if !signer.collections.iter().any(|c| c == &collection) {
                    eprintln!(
                        "REJECT: org commit {commit} — member '{}' lacks a grant for collection `{collection}` (path `{path}`)",
                        signer.display_name
                    );
                    std::process::exit(1);
                }
                // Slug-existence (L5): the collection must exist in
                // collections.json AS OF THIS COMMIT. A write into a
                // granted-but-deleted (or never-created) collection is rejected.
                let known = collection_slugs.get_or_insert_with(|| {
                    git_show(commit, "collections.json")
                        .ok()
                        .and_then(|s| serde_json::from_str::<OrgCollections>(&s).ok())
                        .map(|c| c.collections.into_iter().map(|d| d.slug).collect::<Vec<_>>())
                        .unwrap_or_default()
                });
                if !known.iter().any(|s| s == &collection) {
                    eprintln!(
                        "REJECT: org commit {commit} — item write to collection `{collection}` whose slug is absent from collections.json (path `{path}`)"
                    );
                    std::process::exit(1);
                }
            }
            PathClass::Unrestricted => {
                // keys/<id>.enc, manifest.enc, etc. — signature check already passed.
            }
        }
    }

    // Schema-version monotonicity for the three JSON files (Task C2).
    enforce_schema_monotonicity(commit, is_root, &changed_paths)?;

    eprintln!(
        "OK: org commit {commit} verified — signed by '{}' ({:?}), {} path(s) authorized",
        signer.display_name,
        signer.role,
        changed_paths.len()
    );
    Ok(())
}

/// Reject the commit unless every newly-introduced or elevated owner/admin is
/// authorized. The signer's AUTHORITY is their role in the PARENT state — the role
/// they held BEFORE this commit — NOT the role this commit may grant them. Reading
/// `signer.role` (which is parsed from the post-change members.json) would let an
/// admin self-promote to owner and then pass this very gate with the owner role
/// they are minting — the exact escalation H-C1 exists to stop. We diff the new
/// members.json against the parent's by member_id and require an owner-authority
/// signer for any member that BECOMES owner/admin (new entry, or a role elevated
/// up to owner/admin). On genesis (root) the sole bootstrap owner is allowed.
///
/// `git_show_parent` is defined alongside `enforce_schema_monotonicity` below.
fn enforce_owner_only_elevation(
    commit: &str,
    is_root: bool,
    new_members: &OrgMembers,
    signer: &OrgMember,
) {
    let is_privileged = |r: OrgRole| matches!(r, OrgRole::Owner | OrgRole::Admin);

    // Genesis: the bootstrap commit introduces the sole owner; allow it.
    if is_root {
        return;
    }

    // Parent baseline. If members.json did not exist in the parent, every
    // privileged member here is "new" and must be owner-signed.
    let parent_members: Vec<(String, OrgRole)> = match git_show_parent(commit, "members.json") {
        Ok(s) => serde_json::from_str::<OrgMembers>(&s)
            .map(|m| {
                m.members
                    .into_iter()
                    .map(|m| (m.member_id.0, m.role))
                    .collect()
            })
            .unwrap_or_default(),
        Err(_) => Vec::new(),
    };
    let parent_role = |id: &str| -> Option<OrgRole> {
        parent_members.iter().find(|(mid, _)| mid == id).map(|(_, r)| *r)
    };

    // The signer's authority = their PARENT role. A member absent from the parent
    // (brand new) has no prior authority and cannot mint owners/admins.
    let signer_parent = parent_role(signer.member_id.as_str());
    let signer_may_manage_owners = signer_parent.is_some_and(|r| r.can_manage_owners());

    for m in &new_members.members {
        if !is_privileged(m.role) {
            continue;
        }
        // Skip ONLY if the role is unchanged from the parent (a no-op same-role
        // entry). Any CHANGE into a privileged role — a new privileged member,
        // Member→Admin/Owner, or Admin→Owner — must be owner-signed.
        if parent_role(m.member_id.as_str()) == Some(m.role) {
            continue;
        }
        // A new owner/admin, or a member elevated to owner/admin → owner-only,
        // judged by the signer's PRE-commit authority.
        if !signer_may_manage_owners {
            eprintln!(
                "REJECT: org commit {commit} — member '{}' (parent role {:?}) may not introduce or \
                 elevate owner/admin '{}' to {:?}; only an owner may",
                signer.display_name, signer_parent, m.display_name, m.role
            );
            std::process::exit(1);
        }
    }
}

fn generate_org_hook() -> Result<()> {
    print!(
        r#"#!/bin/bash
# Relicario org pre-receive hook -- verify signatures + role/path authorization

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-org-commit "$commit" || exit 1
  done
done
"#
    );
    Ok(())
}

/// For each protected JSON file changed in this commit, ensure schema_version did
/// not decrease vs the parent commit, and re-validate collections.json structure.
fn enforce_schema_monotonicity(
    commit: &str,
    is_root: bool,
    changed_paths: &[String],
) -> Result<()> {
    const VERSIONED: [&str; 3] = ["members.json", "collections.json", "org.json"];

    for file in VERSIONED {
        if !changed_paths.iter().any(|p| p == file) {
            continue;
        }

        // A deletion of a protected file is not allowed.
        let new_content = match git_show(commit, file) {
            Ok(s) => s,
            Err(_) => {
                eprintln!(
                    "REJECT: org commit {commit} — protected file `{file}` was deleted; \
                     org vaults never delete {file}"
                );
                std::process::exit(1);
            }
        };
        let new_version = match extract_schema_version(&new_content) {
            Ok(v) => v,
            Err(e) => {
                eprintln!("REJECT: org commit {commit} — `{file}` invalid: {e}");
                std::process::exit(1);
            }
        };

        // collections.json structural validation.
        if file == "collections.json" {
            match serde_json::from_str::<relicario_core::org::OrgCollections>(&new_content) {
                Ok(c) => {
                    if let Err(e) = c.validate() {
                        eprintln!("REJECT: org commit {commit} — collections.json invalid: {e}");
                        std::process::exit(1);
                    }
                }
                Err(e) => {
                    eprintln!("REJECT: org commit {commit} — collections.json parse error: {e}");
                    std::process::exit(1);
                }
            }
        }

        // On the root commit there is no parent baseline; any starting version is fine.
        if is_root {
            continue;
        }

        // Parent version: if the file did not exist in the parent (newly added),
        // there is no prior version to regress against — accept.
        if let Ok(old_content) = git_show_parent(commit, file) {
            let old_version = match extract_schema_version(&old_content) {
                Ok(v) => v,
                Err(_) => {
                    continue;
                }
            };
            if new_version < old_version {
                eprintln!(
                    "REJECT: org commit {commit} — `{file}` schema_version decreased \
                     ({old_version} -> {new_version})"
                );
                std::process::exit(1);
            }
        }
    }

    Ok(())
}

/// Read a file from a commit's FIRST PARENT tree: `git show {commit}^:{path}`.
fn git_show_parent(commit: &str, path: &str) -> Result<String> {
    let output = Command::new("git")
        .args(["show", &format!("{}^:{}", commit, path)])
        .output()
        .context("git show parent")?;
    if !output.status.success() {
        anyhow::bail!("git show {}^:{} failed", commit, path);
    }
    Ok(String::from_utf8(output.stdout)?)
}
