//! Phase 82.10.b — admin RPC audit log writer.
//!
//! Records every admin call with `(microapp_id, method,
//! capability, args_hash, started_at, result, duration_ms)`.
//! Operator audit pipelines parse these rows for SaaS billing /
//! compliance.
//!
//! Sub-phase scope:
//! - **82.10.b** (this module): in-memory writer behind a trait.
//!   Production builds use [`InMemoryAuditWriter`]. Tests inject
//!   their own implementations via [`AdminAuditWriter`].
//! - **82.10.g** (deferred): SQLite-backed writer + retention
//!   sweep + `nexo microapp admin audit tail` CLI.

use std::sync::{Arc, Mutex};
use std::time::SystemTime;

use serde_json::Value;
use sha2::{Digest, Sha256};

// Phase 83.12.audit-page — `AdminAuditRow`, `AdminAuditResult`,
// `AuditTailFilter`, and `AuditTailPage` moved to `nexo-tool-meta`
// so the ts-types-codegen pipeline (Phase 83.12.ts-types-codegen)
// generates their TypeScript declarations. Re-exported here so
// existing consumers (`use nexo_core::agent::admin_rpc::audit::AdminAuditRow`)
// keep resolving without per-file edits.
pub use nexo_tool_meta::admin::audit::{
    AdminAuditResult, AdminAuditRow, AuditTailFilter, AuditTailPage,
};

/// Audit writer abstraction. Async to keep the SQLite future
/// (82.10.g) plug-compatible without changing this trait.
#[async_trait::async_trait]
pub trait AdminAuditWriter: Send + Sync + std::fmt::Debug {
    /// Append one row. Errors are logged but not propagated —
    /// audit failures must never block admin dispatch.
    async fn append(&self, row: AdminAuditRow);
}

/// Phase 83.12.audit-page — read-side trait for the
/// `nexo/admin/microapp_audit/tail` admin RPC. Separated from
/// [`AdminAuditWriter`] so the dispatcher can wire write + read
/// independently (matches the `transcript_reader` pattern).
/// `SqliteAuditWriter` implements both.
#[async_trait::async_trait]
pub trait AdminAuditReader: Send + Sync + std::fmt::Debug {
    /// Return one paginated page of audit rows matching `filter`.
    /// Newest-first order.
    async fn tail(&self, filter: &AuditTailFilter) -> anyhow::Result<AuditTailPage>;
}

/// In-memory writer used in tests + as the default production
/// writer until 82.10.g ships SQLite persistence.
#[derive(Debug, Default, Clone)]
pub struct InMemoryAuditWriter {
    rows: Arc<Mutex<Vec<AdminAuditRow>>>,
}

impl InMemoryAuditWriter {
    /// Build an empty writer.
    pub fn new() -> Self {
        Self::default()
    }

    /// Snapshot of recorded rows (test-only inspection).
    pub fn rows(&self) -> Vec<AdminAuditRow> {
        self.rows.lock().unwrap().clone()
    }

    /// Last row appended, if any.
    pub fn last(&self) -> Option<AdminAuditRow> {
        self.rows.lock().unwrap().last().cloned()
    }
}

#[async_trait::async_trait]
impl AdminAuditWriter for InMemoryAuditWriter {
    async fn append(&self, row: AdminAuditRow) {
        self.rows.lock().unwrap().push(row);
    }
}

/// Hash JSON params canonically (sorted keys) so repeated calls
/// with semantically-identical payloads produce identical
/// hashes. Operator audit pipelines use this to detect
/// duplicate-call abuse without storing the params themselves.
pub fn hash_params(params: &Value) -> String {
    let canonical = canonicalize(params);
    let serialized = serde_json::to_string(&canonical).unwrap_or_default();
    let digest = Sha256::digest(serialized.as_bytes());
    hex_encode(&digest)
}

/// Redact sensitive fields per-method BEFORE hashing for the
/// audit row. Without this, low-entropy values (e.g. operator
/// pasting `"test"`) could be brute-forced from the stored hash.
///
/// Called from the dispatcher before [`hash_params`].
///
/// Phase 82.10.k — `secrets/write` redacts `value`. Future
/// methods that carry secret-grade fields hook in here.
///
/// Phase 82.10.n — `credentials/register` redacts secret-grade
/// keys inside `payload` + `metadata` (token, password,
/// xoauth2_token, api_key, secret). The
/// [`ChannelCredentialPersister`] family routes structured
/// payloads through here, so the audit hash is invariant to the
/// secret material.
///
/// [`ChannelCredentialPersister`]: super::domains::credentials::ChannelCredentialPersister
pub fn redact_for_audit(method: &str, params: &Value) -> Value {
    if method == "nexo/admin/secrets/write" {
        if let Some(obj) = params.as_object() {
            let mut redacted = obj.clone();
            if redacted.contains_key("value") {
                redacted.insert("value".into(), Value::String("<redacted>".into()));
            }
            return Value::Object(redacted);
        }
    }
    if method == "nexo/admin/credentials/register" {
        if let Some(obj) = params.as_object() {
            let mut redacted = obj.clone();
            if let Some(payload) = redacted.get("payload").cloned() {
                redacted.insert("payload".into(), redact_secret_keys(&payload));
            }
            if let Some(metadata) = redacted.get("metadata").cloned() {
                redacted.insert("metadata".into(), redact_secret_keys(&metadata));
            }
            return Value::Object(redacted);
        }
    }
    // Phase 82.10.s.4 — `llm_providers/upsert` carries
    // `api_key_secret_value` (write-through API key plaintext)
    // when the operator rotates a key from the SPA. Redact it
    // before audit so the cleartext never lands on disk; the
    // SecretsStore is the only place it persists.
    if method == "nexo/admin/llm_providers/upsert" {
        if let Some(obj) = params.as_object() {
            let mut redacted = obj.clone();
            if redacted.contains_key("api_key_secret_value") {
                redacted.insert(
                    "api_key_secret_value".into(),
                    Value::String("<redacted>".into()),
                );
            }
            // Phase 82.10.u — schema-driven payload. Walk the
            // `fields` map with the same key-based redactor used
            // for credentials/register so secret-grade names
            // (api_key, setup_token, password, etc) get masked.
            // Non-secret fields (group_id, region, key_kind) stay
            // literal — useful for diagnostics. The dispatcher
            // already validated against the factory schema; this
            // is defense-in-depth for the audit trail.
            if let Some(fields) = redacted.get("fields").cloned() {
                redacted.insert("fields".into(), redact_secret_keys(&fields));
            }
            return Value::Object(redacted);
        }
    }
    params.clone()
}

/// Walk a JSON value and replace every occurrence of a known
/// secret-grade key (`token`, `password`, `xoauth2_token`,
/// `api_key`, `secret`) with `"<redacted>"`. Recurses into nested
/// objects and arrays so deeply-nested metadata (e.g.
/// `metadata.imap.password`) is covered defense-in-depth.
///
/// Recursion is bounded by serde_json's nesting limit (default
/// 128) — no separate guard needed.
fn redact_secret_keys(value: &Value) -> Value {
    const SECRET_KEYS: &[&str] = &[
        "token",
        "password",
        "xoauth2_token",
        "api_key",
        "secret",
        // Phase 82.10.u — schema-driven LLM credentials.
        "setup_token",
        "access_token",
        "refresh_token",
        "oauth_bundle",
    ];
    match value {
        Value::Object(map) => {
            let mut out = serde_json::Map::with_capacity(map.len());
            for (k, v) in map {
                if SECRET_KEYS.iter().any(|s| s.eq_ignore_ascii_case(k)) {
                    out.insert(k.clone(), Value::String("<redacted>".into()));
                } else {
                    out.insert(k.clone(), redact_secret_keys(v));
                }
            }
            Value::Object(out)
        }
        Value::Array(arr) => Value::Array(arr.iter().map(redact_secret_keys).collect()),
        other => other.clone(),
    }
}

fn canonicalize(value: &Value) -> Value {
    match value {
        Value::Object(map) => {
            let mut sorted: Vec<(String, Value)> = map
                .iter()
                .map(|(k, v)| (k.clone(), canonicalize(v)))
                .collect();
            sorted.sort_by(|a, b| a.0.cmp(&b.0));
            Value::Object(sorted.into_iter().collect())
        }
        Value::Array(arr) => Value::Array(arr.iter().map(canonicalize).collect()),
        other => other.clone(),
    }
}

fn hex_encode(bytes: &[u8]) -> String {
    let mut s = String::with_capacity(bytes.len() * 2);
    for byte in bytes {
        s.push_str(&format!("{byte:02x}"));
    }
    s
}

/// Phase 83.8.12.7 — sniff `tenant_id` from JSON params for the
/// audit row. Tenant-scoped admin domains (agents/llm_providers/
/// skills/audit) pass this in the params object; non tenant-
/// scoped calls (echo, pairing, credentials) lack the field, so
/// the function returns `None` instead of stamping a fake value.
/// Returns `None` for non-string values (defensive — never panic
/// on malformed params).
pub fn extract_tenant_id(params: &Value) -> Option<String> {
    params
        .as_object()
        .and_then(|m| m.get("tenant_id"))
        .and_then(|v| v.as_str())
        .map(|s| s.to_string())
}

/// Helper used by the dispatcher to stamp `started_at_ms`.
pub fn now_epoch_ms() -> u64 {
    SystemTime::now()
        .duration_since(SystemTime::UNIX_EPOCH)
        .map(|d| d.as_millis() as u64)
        .unwrap_or(0)
}

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

    #[tokio::test]
    async fn in_memory_writer_records_each_call() {
        let writer = InMemoryAuditWriter::new();
        let row = AdminAuditRow {
            microapp_id: "agent-creator".into(),
            method: "nexo/admin/agents/list".into(),
            capability: "agents_crud".into(),
            args_hash: "abc123".into(),
            started_at_ms: 1_700_000_000_000,
            result: AdminAuditResult::Ok,
            duration_ms: 5,
            tenant_id: None,
        };
        writer.append(row.clone()).await;
        assert_eq!(writer.rows().len(), 1);
        assert_eq!(writer.last().unwrap(), row);
    }

    #[tokio::test]
    async fn in_memory_writer_records_denial() {
        let writer = InMemoryAuditWriter::new();
        writer
            .append(AdminAuditRow {
                microapp_id: "agent-creator".into(),
                method: "nexo/admin/llm_providers/upsert".into(),
                capability: "llm_keys_crud".into(),
                args_hash: hash_params(&serde_json::json!({})),
                started_at_ms: now_epoch_ms(),
                result: AdminAuditResult::Denied,
                duration_ms: 0,
                tenant_id: None,
            })
            .await;
        let last = writer.last().unwrap();
        assert_eq!(last.result, AdminAuditResult::Denied);
        assert_eq!(last.capability, "llm_keys_crud");
    }

    #[test]
    fn hash_params_is_deterministic_with_key_order() {
        let a = serde_json::json!({ "z": 1, "a": 2 });
        let b = serde_json::json!({ "a": 2, "z": 1 });
        assert_eq!(hash_params(&a), hash_params(&b));
    }

    #[test]
    fn hash_params_differs_for_different_payloads() {
        let a = serde_json::json!({ "x": 1 });
        let b = serde_json::json!({ "x": 2 });
        assert_ne!(hash_params(&a), hash_params(&b));
    }

    #[test]
    fn audit_result_as_str_table() {
        assert_eq!(AdminAuditResult::Ok.as_str(), "ok");
        assert_eq!(AdminAuditResult::Error.as_str(), "error");
        assert_eq!(AdminAuditResult::Denied.as_str(), "denied");
    }

    /// Phase 82.10.k — `secrets/write` redacts the `value`
    /// field BEFORE hashing so low-entropy values can't be
    /// brute-forced from the stored hash.
    #[test]
    fn redact_for_audit_redacts_secrets_write_value() {
        let original = serde_json::json!({
            "name": "MINIMAX_API_KEY",
            "value": "sk-leak-this-not"
        });
        let redacted = redact_for_audit("nexo/admin/secrets/write", &original);
        assert_eq!(redacted["name"], "MINIMAX_API_KEY");
        assert_eq!(redacted["value"], "<redacted>");
        // Hashes diverge — proves the redaction takes effect
        // before hashing in the dispatcher.
        assert_ne!(hash_params(&original), hash_params(&redacted));
    }

    /// Phase 82.10.s.4 — `llm_providers/upsert` redacts the
    /// write-through `api_key_secret_value` field so the cleartext
    /// only persists in the SecretsStore, never on the audit log.
    #[test]
    fn redact_for_audit_redacts_llm_upsert_secret_value() {
        let original = serde_json::json!({
            "id": "minimax-cliente-a",
            "base_url": "https://api.minimax.chat/v1",
            "factory_type": "minimax",
            "api_key_secret_value": "sk-leak-this-not"
        });
        let redacted = redact_for_audit("nexo/admin/llm_providers/upsert", &original);
        assert_eq!(redacted["id"], "minimax-cliente-a");
        assert_eq!(redacted["factory_type"], "minimax");
        assert_eq!(redacted["api_key_secret_value"], "<redacted>");
        assert_ne!(hash_params(&original), hash_params(&redacted));
    }

    /// Phase 82.10.u — schema-driven `fields` payload masks
    /// secret-grade keys (api_key, setup_token) but keeps
    /// non-secret identifiers (group_id, region) literal so the
    /// audit log stays useful for diagnostics.
    #[test]
    fn redact_for_audit_redacts_llm_upsert_schema_fields() {
        let original = serde_json::json!({
            "id": "minimax-cliente-a",
            "base_url": "https://api.minimax.chat/v1",
            "factory_type": "minimax",
            "auth_mode": "api_key",
            "fields": {
                "api_key": "sk-leak-this-not",
                "group_id": "1234567890123",
                "region": "global",
                "setup_token": "sk-ant-oat01-leak-too",
            }
        });
        let redacted = redact_for_audit("nexo/admin/llm_providers/upsert", &original);
        let fields = &redacted["fields"];
        assert_eq!(fields["api_key"], "<redacted>");
        assert_eq!(fields["setup_token"], "<redacted>");
        // Non-secret identifiers stay literal.
        assert_eq!(fields["group_id"], "1234567890123");
        assert_eq!(fields["region"], "global");
        // Top-level metadata also preserved.
        assert_eq!(redacted["auth_mode"], "api_key");
        assert_eq!(redacted["factory_type"], "minimax");
    }

    /// Phase 82.10.u — OAuth bundle JSON pasted via
    /// `api_key_secret_value` would be caught by the existing
    /// `api_key_secret_value` rule, but defense-in-depth: confirm
    /// the new SECRET_KEYS additions also fire when an operator
    /// drops a bundle into the schema-driven payload.
    #[test]
    fn redact_for_audit_masks_oauth_bundle_keys_in_fields() {
        let original = serde_json::json!({
            "id": "anthropic-personal",
            "base_url": "https://api.anthropic.com/v1",
            "factory_type": "anthropic",
            "auth_mode": "oauth_bundle_import",
            "fields": {
                "oauth_bundle": "{\"access_token\":\"sk-...\",\"refresh_token\":\"r-...\"}",
            }
        });
        let redacted = redact_for_audit("nexo/admin/llm_providers/upsert", &original);
        assert_eq!(redacted["fields"]["oauth_bundle"], "<redacted>");
    }

    /// Confirm that the redactor leaves `api_key_secret_id` intact
    /// — that field references a secret BY NAME, not value, so
    /// it's safe to surface for diagnostics.
    #[test]
    fn redact_for_audit_keeps_llm_upsert_secret_id_visible() {
        let original = serde_json::json!({
            "id": "minimax-cliente-a",
            "base_url": "https://api.minimax.chat/v1",
            "api_key_secret_id": "LLM_MINIMAX_CLIENTE_A"
        });
        let redacted = redact_for_audit("nexo/admin/llm_providers/upsert", &original);
        assert_eq!(redacted["api_key_secret_id"], "LLM_MINIMAX_CLIENTE_A");
    }

    #[test]
    fn redact_for_audit_passthrough_for_other_methods() {
        let original = serde_json::json!({"agent_id": "ana"});
        let result = redact_for_audit("nexo/admin/agents/get", &original);
        assert_eq!(result, original);
    }

    /// Phase 82.10.n — `credentials/register` redacts the
    /// telegram bot token in `payload.token` so the audit hash
    /// doesn't fingerprint the secret.
    #[test]
    fn redact_for_audit_redacts_telegram_token() {
        let original = serde_json::json!({
            "channel": "telegram",
            "instance": "kate",
            "agent_ids": ["kate"],
            "payload": { "token": "bot12345:ABCDEF" }
        });
        let redacted = redact_for_audit("nexo/admin/credentials/register", &original);
        assert_eq!(redacted["channel"], "telegram");
        assert_eq!(redacted["payload"]["token"], "<redacted>");
        // Hash diverges from the unredacted form.
        assert_ne!(hash_params(&original), hash_params(&redacted));
    }

    /// Email password lives in `payload.password`; xoauth2 token
    /// lives in `payload.xoauth2_token`. Both must redact.
    #[test]
    fn redact_for_audit_redacts_email_password_and_xoauth2() {
        let with_pw = serde_json::json!({
            "channel": "email",
            "instance": "ops",
            "payload": { "address": "ops@example.com", "password": "p@ss" }
        });
        let r1 = redact_for_audit("nexo/admin/credentials/register", &with_pw);
        assert_eq!(r1["payload"]["password"], "<redacted>");
        assert_eq!(r1["payload"]["address"], "ops@example.com");

        let with_xoauth = serde_json::json!({
            "channel": "email",
            "instance": "ops",
            "payload": { "address": "ops@example.com", "xoauth2_token": "xo.AAA" }
        });
        let r2 = redact_for_audit("nexo/admin/credentials/register", &with_xoauth);
        assert_eq!(r2["payload"]["xoauth2_token"], "<redacted>");
    }

    /// Defense-in-depth: secret-grade keys nested inside
    /// `metadata.*` (operator could fat-finger a password into
    /// metadata) also redact.
    #[test]
    fn redact_for_audit_redacts_nested_metadata_secrets() {
        let original = serde_json::json!({
            "channel": "email",
            "payload": { "address": "ops@example.com" },
            "metadata": {
                "imap": { "host": "imap.example.com", "password": "leaked-here" },
                "api_key": "should-not-leak",
                "tls": "implicit_tls"
            }
        });
        let redacted = redact_for_audit("nexo/admin/credentials/register", &original);
        // Nested key inside metadata.imap.
        assert_eq!(redacted["metadata"]["imap"]["password"], "<redacted>");
        // Top-level metadata.api_key.
        assert_eq!(redacted["metadata"]["api_key"], "<redacted>");
        // Non-secret fields preserved.
        assert_eq!(redacted["metadata"]["imap"]["host"], "imap.example.com");
        assert_eq!(redacted["metadata"]["tls"], "implicit_tls");
    }

    /// Non-secret fields stay untouched — verifies the redactor
    /// is conservative (doesn't accidentally mask things like
    /// `address` or `host`).
    #[test]
    fn redact_for_audit_preserves_non_secret_fields() {
        let original = serde_json::json!({
            "channel": "email",
            "instance": "ops",
            "agent_ids": ["ana"],
            "payload": { "address": "ops@example.com" },
            "metadata": {
                "imap": { "host": "imap.example.com", "port": 993, "tls": "implicit_tls" }
            }
        });
        let redacted = redact_for_audit("nexo/admin/credentials/register", &original);
        assert_eq!(redacted["channel"], "email");
        assert_eq!(redacted["agent_ids"][0], "ana");
        assert_eq!(redacted["metadata"]["imap"]["host"], "imap.example.com");
        assert_eq!(redacted["metadata"]["imap"]["port"], 993);
    }

    #[test]
    fn extract_tenant_id_reads_string_field() {
        let p = serde_json::json!({ "tenant_id": "acme", "other": 1 });
        assert_eq!(extract_tenant_id(&p), Some("acme".into()));
    }

    #[test]
    fn extract_tenant_id_missing_field_yields_none() {
        let p = serde_json::json!({ "agent_id": "a1" });
        assert_eq!(extract_tenant_id(&p), None);
    }

    #[test]
    fn extract_tenant_id_non_string_yields_none() {
        let p = serde_json::json!({ "tenant_id": 42 });
        assert_eq!(extract_tenant_id(&p), None);
    }

    #[test]
    fn extract_tenant_id_non_object_yields_none() {
        let p = serde_json::json!(["a", "b"]);
        assert_eq!(extract_tenant_id(&p), None);
        let p = serde_json::json!("scalar");
        assert_eq!(extract_tenant_id(&p), None);
    }

    #[test]
    fn audit_row_serde_skips_none_tenant_id() {
        let row = AdminAuditRow {
            microapp_id: "a".into(),
            method: "nexo/admin/echo".into(),
            capability: "echo".into(),
            args_hash: "h".into(),
            started_at_ms: 1,
            result: AdminAuditResult::Ok,
            duration_ms: 1,
            tenant_id: None,
        };
        let json = serde_json::to_string(&row).unwrap();
        assert!(!json.contains("tenant_id"), "None tenant skips field");
    }

    #[test]
    fn audit_row_serde_emits_some_tenant_id() {
        let row = AdminAuditRow {
            microapp_id: "a".into(),
            method: "nexo/admin/echo".into(),
            capability: "echo".into(),
            args_hash: "h".into(),
            started_at_ms: 1,
            result: AdminAuditResult::Ok,
            duration_ms: 1,
            tenant_id: Some("acme".into()),
        };
        let json = serde_json::to_string(&row).unwrap();
        assert!(json.contains("\"tenant_id\":\"acme\""));
    }
}
