//! Server-side approval nonces.
//!
//! Nonces are generated by aoe (the ACP client). They are sent to UI clients
//! as part of the approval card, and clients must echo them back when the
//! user taps allow/deny. This prevents:
//!
//! - Stale-button replay attacks (a backgrounded mobile client tapping an
//!   approval that has already been superseded).
//! - Malicious agents synthesizing approvals (the agent never sees the
//!   nonce; it travels client → server only on resolution).
//!
//! A nonce is single-use and expires with the approval timeout.

use chrono::{DateTime, Utc};
use rand::TryRng;
use serde::{Deserialize, Serialize};

use super::state::ToolCall;

const NONCE_BYTES: usize = 16;

/// Server-generated single-use token for approval round-trip.
#[derive(Debug, Clone, PartialEq, Eq, Hash, Serialize, Deserialize)]
pub struct Nonce(pub String);

impl Nonce {
    pub fn new() -> Self {
        let mut buf = [0u8; NONCE_BYTES];
        rand::rng()
            .try_fill_bytes(&mut buf)
            .expect("OS rng should not fail for 16 bytes");
        Self(hex::encode_lower(&buf))
    }
}

impl Default for Nonce {
    fn default() -> Self {
        Self::new()
    }
}

/// Tiny hex helper local to this module to avoid pulling in a crate just for
/// nonce display. Lowercase, no separators, no prefix.
mod hex {
    pub fn encode_lower(bytes: &[u8]) -> String {
        const HEX: &[u8; 16] = b"0123456789abcdef";
        let mut out = String::with_capacity(bytes.len() * 2);
        for &b in bytes {
            out.push(HEX[(b >> 4) as usize] as char);
            out.push(HEX[(b & 0xF) as usize] as char);
        }
        out
    }
}

#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
pub enum ApprovalDecision {
    /// One-time allow, equivalent to ACP `allow_once`.
    Allow,
    /// Allow now and add a permission rule, equivalent to ACP `allow_always`.
    AllowAlways,
    /// Deny, equivalent to ACP `reject`.
    Deny,
    /// Approval was not resolved by the user, e.g. the daemon was
    /// restarted while the approval card was on screen. Emitted only as
    /// the `decision` on a synthetic `Event::ApprovalResolved` from
    /// `Supervisor::attach`'s rehydration sweep so the UI can clear the
    /// dead card. Never sent to the agent (the runner-side cancellation
    /// path produces the JSON-RPC response).
    Cancelled,
}

/// A pending or resolved approval for a tool call. Held in
/// `CockpitState::pending_approvals` until it is resolved through
/// `apply_event(Event::ApprovalResolved { ... })`.
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct Approval {
    pub nonce: Nonce,
    pub tool_call: ToolCall,
    /// True for tools the cockpit considers destructive (rm -rf,
    /// `git push --force`, etc.). Mobile UI requires long-press for these.
    pub destructive: bool,
    pub requested_at: DateTime<Utc>,
    pub resolved: Option<ResolvedApproval>,
}

#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct ResolvedApproval {
    pub decision: ApprovalDecision,
    pub message: Option<String>,
    pub resolved_at: DateTime<Utc>,
}

/// Heuristic for "this tool call is destructive enough that mobile UI
/// should require long-press confirmation."
pub fn is_destructive(tool_name: &str, args_preview: &str) -> bool {
    let lower_args = args_preview.to_lowercase();
    match tool_name {
        "Bash" | "Terminal" => {
            lower_args.contains("rm -rf")
                || lower_args.contains("rm -fr")
                || lower_args.contains("git push --force")
                || lower_args.contains("git push -f ")
                || lower_args.contains("drop table")
                || lower_args.contains("truncate ")
                || lower_args.contains("> /dev/sda")
        }
        "Write" | "Edit" => {
            // Writes outside the worktree get treated as destructive at the
            // approval layer; the actual sandbox check is in fs_handler.
            lower_args.contains("/etc/")
                || lower_args.contains("/usr/")
                || lower_args.contains("/system/")
                || lower_args.contains("$home/.ssh")
        }
        _ => false,
    }
}

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

    #[test]
    fn nonce_is_hex_and_unique() {
        let a = Nonce::new();
        let b = Nonce::new();
        assert_ne!(a, b);
        assert_eq!(a.0.len(), NONCE_BYTES * 2);
        assert!(a.0.chars().all(|c| c.is_ascii_hexdigit()));
    }

    #[test]
    fn destructive_heuristic_catches_classics() {
        assert!(is_destructive("Bash", r#"{"command":"rm -rf /tmp/foo"}"#));
        assert!(is_destructive(
            "Bash",
            r#"{"command":"git push --force origin main"}"#
        ));
        assert!(!is_destructive("Bash", r#"{"command":"ls -la"}"#));
        assert!(is_destructive("Write", r#"{"path":"/etc/hosts"}"#));
        assert!(!is_destructive("Read", r#"{"path":"/etc/hosts"}"#));
    }
}
