//! Append-only structured audit logging for vault operations.
//!
//! Each vault operation (get, set, delete, rotate, …) appends a [`AuditEntry`]
//! as a single JSON line to the profile's `.audit.jsonl` file.  The log file is
//! created with `0o600` permissions on Unix so other local users cannot read it.
//!
//! ## HMAC chain integrity (v2)
//!
//! Each entry carries a `prev_entry_hmac` field: the HMAC-SHA256 of the
//! immediately preceding entry's canonical JSON, keyed by a per-session ephemeral
//! key generated at [`AuditLog::new()`] time.
//!
//! **What this provides:**
//! - Within-session tamper detection: any modification, insertion, or deletion of
//!   entries is detectable via [`AuditLog::verify_chain()`] as long as the
//!   `AuditLog` handle that wrote the entries is still in scope.
//!
//! **Explicit ceiling:**
//! - The chain key is ephemeral — it is generated fresh on each [`AuditLog::new()`]
//!   and is never persisted.  Cross-session verification is therefore not possible
//!   (the key used to write a previous session's entries is gone).  Entries written
//!   by a previous session have `prev_entry_hmac = None` from the perspective of the
//!   new session's chain.  This is an accepted v2 residual; cross-session
//!   verification would require persisting the chain key (e.g. in the vault's
//!   keyring), which is deferred to a post-v2 upgrade.
//! - Filesystem-level tamper (deleting the entire log file, swapping it for another)
//!   is not detectable without an external root of trust.

use std::io::Write;
use std::path::{Path, PathBuf};

use chrono::{DateTime, Utc};
use hmac::{Hmac, Mac};
use rand::RngCore;
use serde::{Deserialize, Serialize};
use sha2::Sha256;
use thiserror::Error;
use uuid::Uuid;

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

use crate::contracts::{
    AuthorityContract, AuthorityInheritMode, AuthorityNetworkPolicy, AuthorityTargetDecision,
    AuthorityTargetEvaluation, AuthorityTrustLevel,
};
use crate::deny_reason::DenyReason;
use crate::errors::{SafeError, SafeResult};
use crate::rbac::RbacProfile;

type HmacSha256 = Hmac<Sha256>;

/// Error type returned by [`AuditLog::verify_chain`].
#[derive(Debug, Error, PartialEq, Eq)]
pub enum AuditVerifyError {
    /// An entry's `prev_entry_hmac` does not match the HMAC computed from the
    /// previous entry's canonical JSON using the session chain key.
    #[error("audit chain broken at entry index {at_entry} (id: {entry_id})")]
    ChainBroken {
        /// Zero-based index of the entry whose `prev_entry_hmac` is wrong.
        at_entry: usize,
        /// The UUID of the offending entry, for human correlation.
        entry_id: String,
    },

    /// A non-empty line in the log could not be parsed as an [`AuditEntry`].
    /// Treated as tampering: a corrupted/garbage line is a verification failure,
    /// not something to silently skip (which would let an attacker mangle a line
    /// to evade chain detection).
    #[error("audit log line {line} is malformed (not a valid audit entry)")]
    MalformedLine {
        /// One-based line number of the unparseable line.
        line: usize,
    },

    /// The log file could not be read.
    #[error("could not read audit log: {0}")]
    Io(String),
}

/// Outcome of an audited vault operation.
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
#[serde(rename_all = "lowercase")]
pub enum AuditStatus {
    /// The operation completed without error.
    Success,
    /// The operation was attempted but failed (reason in [`AuditEntry::message`]).
    Failure,
}

/// Optional structured context for an audit entry.
///
/// This keeps the local audit log compatible with older entries while giving
/// higher layers a place to attach non-plaintext execution metadata such as the
/// chosen authority contract, target, and projected secret set.
#[derive(Debug, Clone, Default, PartialEq, Eq, Serialize, Deserialize)]
pub struct AuditContext {
    #[serde(default, skip_serializing_if = "Option::is_none")]
    pub exec: Option<AuditExecContext>,
    #[serde(default, skip_serializing_if = "Option::is_none")]
    pub cellos: Option<AuditCellosContext>,
    #[serde(default, skip_serializing_if = "Option::is_none")]
    pub clipboard: Option<AuditClipboardContext>,
    #[serde(default, skip_serializing_if = "Option::is_none")]
    pub reveal: Option<AuditRevealContext>,
    #[serde(default, skip_serializing_if = "Option::is_none")]
    pub mcp: Option<AuditMcpContext>,
}

/// CellOS-specific audit context for resolve and revoke operations.
///
/// Enables `tsafe audit --cell-id` to return the full resolve/revoke chain for
/// a given cell without correlating two separate audit systems manually.
#[derive(Debug, Clone, Default, PartialEq, Eq, Serialize, Deserialize)]
pub struct AuditCellosContext {
    /// CellOS cell identifier, e.g. `doom-cell-001`.
    pub cellos_cell_id: String,
    /// The 32-byte hex cell_token registered at supervisor spawn.
    /// Logged for correlation; safe to store (random nonce, not a secret value).
    #[serde(default, skip_serializing_if = "Option::is_none")]
    pub cell_token: Option<String>,
}

/// Clipboard-copy audit context for TUI yank operations.
///
/// Recorded on `clipboard.copy`, `clipboard.copy_failed`, `clipboard.cleared`,
/// and `clipboard.preserved` entries. Never carries the secret value — only
/// metadata about what the daemon did and what the OS guaranteed.
#[derive(Debug, Clone, Default, PartialEq, Eq, Serialize, Deserialize)]
pub struct AuditClipboardContext {
    /// Auto-clear TTL in seconds (e.g. 30).
    pub ttl_secs: u64,
    /// Backend error string when the operation failed; absent on success.
    #[serde(default, skip_serializing_if = "Option::is_none")]
    pub reason: Option<String>,
    /// `Some(true)` when the producer set `org.nspasteboard.ConcealedType`
    /// (macOS, via arboard's `exclude_from_history`) so conformant clipboard
    /// managers skip archiving the value. `Some(false)` when concealment was
    /// attempted but failed. `None` on platforms with no convention (current
    /// Linux/Windows). Distinguishes "we asked the OS to hide it" from
    /// "we just wrote and hoped".
    #[serde(default, skip_serializing_if = "Option::is_none")]
    pub excluded_from_history: Option<bool>,
    /// On `clipboard.cleared` / `clipboard.preserved` entries: `Some(true)`
    /// when the daemon read the clipboard back and the contents matched what
    /// it had written (so the clear / preservation reflects ground truth).
    /// `Some(false)` when the contents differed (user copied something else).
    /// `None` on the original `clipboard.copy` entry (verification hadn't
    /// happened yet).
    #[serde(default, skip_serializing_if = "Option::is_none")]
    pub cleared_verified: Option<bool>,
}

/// MCP-server audit context recorded on each `tsafe-mcp` tool call.
///
/// Reused by every `mcp.<tool>` operation written to the profile's
/// `.audit.jsonl` by `tsafe-mcp`. Secret values are never recorded; only the
/// host identifier (from `--audit-source`), the daemon PID, the tool name,
/// the names of any keys injected by `tsafe_run`, and child-process exit
/// metadata for `tsafe_run`. See ADR-006 §6.1.
#[derive(Debug, Clone, Default, PartialEq, Eq, Serialize, Deserialize)]
pub struct AuditMcpContext {
    /// Host identifier set by `tsafe-mcp --audit-source` (e.g.
    /// `mcp:claude-desktop:1234`). When the flag is absent the daemon falls
    /// back to `mcp:unknown:<pid>` per ADR-006 §6.1.
    pub host: String,
    /// PID of the `tsafe-mcp` daemon that produced this entry.
    pub pid: u32,
    /// Tool name (e.g. `tsafe_run`, `tsafe_list_keys`, `tsafe_reveal`).
    pub tool: String,
    /// Vault key names injected into the child process by `tsafe_run`. Values
    /// are NEVER recorded — only the key names that were resolved into the
    /// child's environment.
    #[serde(default, skip_serializing_if = "Vec::is_empty")]
    pub injected_keys: Vec<String>,
    /// Exit code of the child spawned by `tsafe_run`. Absent for every other
    /// tool.
    #[serde(default, skip_serializing_if = "Option::is_none")]
    pub exit_code: Option<i32>,
    /// Wall-clock duration in milliseconds for `tsafe_run`. Absent for every
    /// other tool.
    #[serde(default, skip_serializing_if = "Option::is_none")]
    pub duration_ms: Option<u64>,
}

impl AuditContext {
    pub fn from_exec(exec: AuditExecContext) -> Self {
        Self {
            exec: Some(exec),
            ..Default::default()
        }
    }

    pub fn from_cellos(cellos: AuditCellosContext) -> Self {
        Self {
            cellos: Some(cellos),
            ..Default::default()
        }
    }

    pub fn from_clipboard(clipboard: AuditClipboardContext) -> Self {
        Self {
            clipboard: Some(clipboard),
            ..Default::default()
        }
    }

    pub fn from_reveal(reveal: AuditRevealContext) -> Self {
        Self {
            reveal: Some(reveal),
            ..Default::default()
        }
    }

    /// Build a context that carries only the MCP-server payload.
    ///
    /// Used by `tsafe-mcp` per ADR-006 §6.1 to attach host, pid, tool, and
    /// optional `tsafe_run` execution metadata to every audit entry without
    /// touching the other context variants.
    pub fn from_mcp(mcp: AuditMcpContext) -> Self {
        Self {
            mcp: Some(mcp),
            ..Default::default()
        }
    }
}

/// Reveal audit context for TUI `'r'` operations.
///
/// Recorded on `secret.reveal_started` (user pressed `r`) and
/// `secret.reveal_expired` (auto-conceal fired). User-initiated toggle-off
/// is intentionally not audited — silence implies deliberate user action.
/// Never carries the secret value — only the TTL.
#[derive(Debug, Clone, Default, PartialEq, Eq, Serialize, Deserialize)]
pub struct AuditRevealContext {
    /// Auto-conceal TTL in seconds (e.g. 60).
    pub ttl_secs: u64,
}

/// One `--env ENV_VAR=VAULT_KEY` mapping recorded in the audit trail.
///
/// This lets auditors see which vault key was sourced for each renamed env var
/// without the audit log exposing secret values.
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
pub struct AuditEnvMapping {
    /// The environment variable name that the child process received.
    pub env: String,
    /// The vault key whose value was injected under `env`.
    pub vault_key: String,
}

/// Execution-specific audit context for `tsafe exec`-style operations.
#[derive(Debug, Clone, Default, PartialEq, Eq, Serialize, Deserialize)]
pub struct AuditExecContext {
    #[serde(default, skip_serializing_if = "Option::is_none")]
    pub contract_name: Option<String>,
    #[serde(default, skip_serializing_if = "Option::is_none")]
    pub target: Option<String>,
    #[serde(default, skip_serializing_if = "Option::is_none")]
    pub authority_profile: Option<String>,
    #[serde(default, skip_serializing_if = "Option::is_none")]
    pub authority_namespace: Option<String>,
    #[serde(default, skip_serializing_if = "Option::is_none")]
    pub trust_level: Option<AuthorityTrustLevel>,
    #[serde(default, skip_serializing_if = "Option::is_none")]
    pub access_profile: Option<RbacProfile>,
    #[serde(default, skip_serializing_if = "Option::is_none")]
    pub inherit: Option<AuthorityInheritMode>,
    #[serde(default, skip_serializing_if = "Option::is_none")]
    pub deny_dangerous_env: Option<bool>,
    #[serde(default, skip_serializing_if = "Option::is_none")]
    pub redact_output: Option<bool>,
    #[serde(default, skip_serializing_if = "Option::is_none")]
    pub network: Option<AuthorityNetworkPolicy>,
    #[serde(default, skip_serializing_if = "Vec::is_empty")]
    pub allowed_secrets: Vec<String>,
    #[serde(default, skip_serializing_if = "Vec::is_empty")]
    pub required_secrets: Vec<String>,
    #[serde(default, skip_serializing_if = "Vec::is_empty")]
    pub injected_secrets: Vec<String>,
    #[serde(default, skip_serializing_if = "Vec::is_empty")]
    pub missing_required_secrets: Vec<String>,
    #[serde(default, skip_serializing_if = "Vec::is_empty")]
    pub dropped_env_names: Vec<String>,
    /// Records each `--env ENV_VAR=VAULT_KEY` mapping so the audit trail shows
    /// which vault key was sourced for each renamed env var injection.
    #[serde(default, skip_serializing_if = "Vec::is_empty")]
    pub env_mappings: Vec<AuditEnvMapping>,
    #[serde(default, skip_serializing_if = "Option::is_none")]
    pub target_allowed: Option<bool>,
    #[serde(default, skip_serializing_if = "Option::is_none")]
    pub target_decision: Option<AuthorityTargetDecision>,
    #[serde(default, skip_serializing_if = "Option::is_none")]
    pub matched_target: Option<String>,
    #[serde(default, skip_serializing_if = "Option::is_none")]
    pub deny_reason: Option<DenyReason>,
}

impl AuditExecContext {
    /// Seed an exec context from an authority contract without committing to a
    /// concrete target or the final injected/dropped sets yet.
    pub fn from_contract(contract: &AuthorityContract) -> Self {
        let resolved = contract.resolved_exec_policy();
        Self {
            contract_name: Some(contract.name.clone()),
            target: None,
            authority_profile: contract.profile.clone(),
            authority_namespace: contract.namespace.clone(),
            trust_level: Some(resolved.trust_level),
            access_profile: Some(resolved.access_profile),
            inherit: Some(resolved.inherit),
            deny_dangerous_env: Some(resolved.deny_dangerous_env),
            redact_output: Some(resolved.redact_output),
            network: Some(contract.network),
            allowed_secrets: contract.allowed_secrets.clone(),
            required_secrets: contract.required_secrets.clone(),
            injected_secrets: Vec::new(),
            missing_required_secrets: Vec::new(),
            dropped_env_names: Vec::new(),
            env_mappings: Vec::new(),
            target_allowed: None,
            target_decision: None,
            matched_target: None,
            deny_reason: None,
        }
    }

    pub fn with_target(mut self, target: impl Into<String>) -> Self {
        self.target = Some(target.into());
        self
    }

    pub fn with_injected_secrets<I, S>(mut self, names: I) -> Self
    where
        I: IntoIterator<Item = S>,
        S: AsRef<str>,
    {
        self.injected_secrets = normalize_names(names);
        self
    }

    pub fn with_missing_required_secrets<I, S>(mut self, names: I) -> Self
    where
        I: IntoIterator<Item = S>,
        S: AsRef<str>,
    {
        self.missing_required_secrets = normalize_names(names);
        self
    }

    pub fn with_dropped_env_names<I, S>(mut self, names: I) -> Self
    where
        I: IntoIterator<Item = S>,
        S: AsRef<str>,
    {
        self.dropped_env_names = normalize_names(names);
        self
    }

    pub fn with_target_allowed(mut self, allowed: bool) -> Self {
        self.target_allowed = Some(allowed);
        self
    }

    pub fn with_target_evaluation(mut self, evaluation: &AuthorityTargetEvaluation) -> Self {
        self.target_allowed = Some(evaluation.decision.is_allowed());
        self.target_decision = Some(evaluation.decision);
        self.matched_target = evaluation.matched_allowlist_entry.clone();
        self
    }
}

fn normalize_names<I, S>(names: I) -> Vec<String>
where
    I: IntoIterator<Item = S>,
    S: AsRef<str>,
{
    let mut out = names
        .into_iter()
        .map(|name| name.as_ref().trim().to_string())
        .filter(|name| !name.is_empty())
        .collect::<Vec<_>>();
    out.sort();
    out.dedup();
    out
}

/// One structured audit event. Written as a single JSON line (JSONL).
///
/// ## Audit integrity contract (v2)
///
/// Entries are **chronologically ordered** (append-only JSONL) and each entry
/// carries a `prev_entry_hmac` field: the hex-encoded HMAC-SHA256 of the
/// immediately preceding entry's canonical JSON, keyed by the session's
/// ephemeral chain key held in [`AuditLog`].
///
/// **What this provides:**
/// - Within-session tamper detection: modification, insertion, or deletion of
///   entries written during a single session can be detected via
///   [`AuditLog::verify_chain`].
///
/// **Explicit ceiling:**
/// - The chain key is ephemeral (generated at [`AuditLog::new()`], never
///   persisted). Cross-session verification is not possible. Old entries
///   written before v2 (no `prev_entry_hmac`) are treated as chain anchors
///   and do not cause verification failures on their own.
/// - Filesystem-level attacks (deleting the file, swapping it entirely) are
///   not detectable without an external root of trust.
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct AuditEntry {
    pub id: String,
    pub timestamp: DateTime<Utc>,
    pub profile: String,
    pub operation: String,
    pub key: Option<String>,
    pub status: AuditStatus,
    pub message: Option<String>,
    #[serde(default, skip_serializing_if = "Option::is_none")]
    pub context: Option<AuditContext>,
    /// HMAC-SHA256 of the previous entry's canonical JSON, hex-encoded.
    ///
    /// `None` for the first entry in a session, for entries written before the
    /// v2 chain was introduced, or after a cross-session chain break.
    /// Verification is only meaningful within a single [`AuditLog`] session.
    #[serde(default, skip_serializing_if = "Option::is_none")]
    pub prev_entry_hmac: Option<String>,
}

impl AuditEntry {
    /// Build a success entry with a new UUID and the current UTC timestamp.
    pub fn success(profile: &str, operation: &str, key: Option<&str>) -> Self {
        Self {
            id: Uuid::new_v4().to_string(),
            timestamp: Utc::now(),
            profile: profile.to_string(),
            operation: operation.to_string(),
            key: key.map(str::to_string),
            status: AuditStatus::Success,
            message: None,
            context: None,
            prev_entry_hmac: None,
        }
    }

    /// Build a failure entry with a new UUID, the current UTC timestamp, and an error message.
    pub fn failure(profile: &str, operation: &str, key: Option<&str>, message: &str) -> Self {
        Self {
            id: Uuid::new_v4().to_string(),
            timestamp: Utc::now(),
            profile: profile.to_string(),
            operation: operation.to_string(),
            key: key.map(str::to_string),
            status: AuditStatus::Failure,
            message: Some(message.to_string()),
            context: None,
            prev_entry_hmac: None,
        }
    }

    /// Attach optional structured context without changing the legacy fields.
    pub fn with_context(mut self, context: AuditContext) -> Self {
        self.context = Some(context);
        self
    }
}

/// Compute HMAC-SHA256 of `entry`'s canonical JSON serialization using `key`.
///
/// The canonical form is `serde_json::to_string(entry)` — the same bytes
/// written to the log file for that entry (before the trailing newline).
/// Returns a lowercase hex-encoded digest.
pub fn compute_entry_hmac(entry: &AuditEntry, key: &[u8; 32]) -> String {
    let json = serde_json::to_string(entry).expect("AuditEntry is always serializable");
    let mut mac =
        HmacSha256::new_from_slice(key).expect("HMAC-SHA256 accepts any key length via padding");
    mac.update(json.as_bytes());
    let result = mac.finalize().into_bytes();
    bytes_to_hex(&result)
}

/// Generate a cryptographically random 32-byte chain key.
fn derive_chain_key() -> [u8; 32] {
    let mut key = [0u8; 32];
    rand::rngs::OsRng.fill_bytes(&mut key);
    key
}

/// Append-only JSONL audit log with within-session HMAC chain integrity.
///
/// The chain key is generated fresh at [`AuditLog::new()`] and is held only
/// in memory for the lifetime of this handle. All entries appended through
/// this handle form a verifiable chain via [`AuditLog::verify_chain()`].
/// Entries from prior sessions (different `AuditLog` instances) have
/// `prev_entry_hmac = None` and act as chain anchors for the new session.
pub struct AuditLog {
    path: PathBuf,
    /// Per-session ephemeral HMAC key. Never persisted.
    chain_key: [u8; 32],
    /// HMAC of the last entry written through this handle. Seeded from the
    /// file's existing last line on the first append of a new session, then
    /// updated after every subsequent append.
    prev_hmac: std::cell::Cell<Option<String>>,
    /// Whether `prev_hmac` has been bootstrapped from the file.
    bootstrapped: std::cell::Cell<bool>,
}

impl AuditLog {
    /// Create a handle to the audit log at `path`. The file is created lazily on first [`Self::append`].
    pub fn new(path: &Path) -> Self {
        Self {
            path: path.to_path_buf(),
            chain_key: derive_chain_key(),
            prev_hmac: std::cell::Cell::new(None),
            bootstrapped: std::cell::Cell::new(false),
        }
    }

    /// Read the last line of the audit log and return the `prev_entry_hmac`
    /// of that entry (if any). Used to bootstrap the chain on the first append
    /// of a new session: the new session starts fresh (no prior HMAC), so
    /// the first entry always has `prev_entry_hmac = None`.
    fn bootstrap_if_needed(&self) {
        if self.bootstrapped.get() {
            return;
        }
        self.bootstrapped.set(true);
        // The new session's chain starts with None — we do not inherit the
        // previous session's HMAC because that key is gone. The first entry
        // of this session is a chain anchor.
        self.prev_hmac.set(None);
    }

    /// Append an entry. The entry's `prev_entry_hmac` is set to the HMAC of
    /// the previously written entry (within this session), then the entry is
    /// written and this handle's chain state is advanced.
    ///
    /// Errors here are non-fatal (caller should `.ok()` if audit is best-effort).
    pub fn append(&self, entry: &AuditEntry) -> SafeResult<()> {
        self.bootstrap_if_needed();

        if let Some(parent) = self.path.parent() {
            std::fs::create_dir_all(parent)?;
            #[cfg(unix)]
            {
                use std::os::unix::fs::PermissionsExt;
                let _ = std::fs::set_permissions(parent, std::fs::Permissions::from_mode(0o700));
            }
        }

        // Clone and set the chain link on our local copy.
        let mut chained = entry.clone();
        chained.prev_entry_hmac = self.prev_hmac.take();

        let mut line = serde_json::to_string(&chained).map_err(SafeError::Serialization)?;
        line.push('\n');

        let mut opts = std::fs::OpenOptions::new();
        opts.create(true).append(true);
        #[cfg(unix)]
        {
            use std::os::unix::fs::OpenOptionsExt;
            opts.mode(0o600);
        }
        let mut file = opts.open(&self.path)?;
        file.write_all(line.as_bytes())?;

        // Advance chain: compute HMAC of what we just wrote (without the newline).
        let next_hmac = compute_entry_hmac(&chained, &self.chain_key);
        self.prev_hmac.set(Some(next_hmac));

        Ok(())
    }

    /// Verify the HMAC chain for all entries written by this session.
    ///
    /// Reads the log file and replays the chain using this session's key.
    /// Entries with `prev_entry_hmac = None` are treated as chain anchors
    /// (session boundaries or pre-v2 entries) — they reset the expected HMAC
    /// to `None` and verification continues from there.
    ///
    /// Returns `Ok(())` if every non-anchor entry's `prev_entry_hmac` matches
    /// the HMAC computed from the previous entry. Returns
    /// [`AuditVerifyError::ChainBroken`] at the first mismatch, or
    /// [`AuditVerifyError::MalformedLine`] if any non-empty line cannot be
    /// parsed as an audit entry (corruption is treated as tampering).
    pub fn verify_chain(&self) -> Result<(), AuditVerifyError> {
        verify_chain_with_key(&self.path, &self.chain_key)
    }

    /// Read entries, most recent first. Silently skip malformed lines.
    pub fn read(&self, limit: Option<usize>) -> SafeResult<Vec<AuditEntry>> {
        if !self.path.exists() {
            return Ok(Vec::new());
        }
        let content = std::fs::read_to_string(&self.path)?;
        let mut entries: Vec<AuditEntry> = content
            .lines()
            .filter(|l| !l.trim().is_empty())
            .filter_map(|l| serde_json::from_str(l).ok())
            .collect();
        entries.reverse(); // most recent first
        if let Some(n) = limit {
            entries.truncate(n);
        }
        Ok(entries)
    }

    /// Read and project entries into plaintext-free explanation sessions.
    pub fn explain(&self, limit: Option<usize>) -> SafeResult<crate::audit_explain::AuditTimeline> {
        Ok(crate::audit_explain::explain_entries(&self.read(limit)?))
    }

    /// Most recent successful audit entry for this profile and operation, if any.
    /// Entries are scanned newest-first (up to `scan_limit` lines worth of entries).
    pub fn last_successful_operation(
        &self,
        profile: &str,
        operation: &str,
        scan_limit: usize,
    ) -> SafeResult<Option<DateTime<Utc>>> {
        let entries = self.read(Some(scan_limit))?;
        Ok(entries
            .into_iter()
            .find(|e| {
                e.profile == profile
                    && e.operation == operation
                    && matches!(e.status, AuditStatus::Success)
            })
            .map(|e| e.timestamp))
    }

    /// Return filtered audit entries.
    ///
    /// All parameters are optional:
    /// - `since`   — only entries with `timestamp >= since` are returned.
    /// - `until`   — only entries with `timestamp <= until` are returned.
    /// - `command` — only entries whose `operation` exactly matches this string.
    ///
    /// Results are returned newest-first (matching `read()`).
    pub fn filter_audit(
        &self,
        since: Option<DateTime<Utc>>,
        until: Option<DateTime<Utc>>,
        command: Option<&str>,
    ) -> SafeResult<Vec<AuditEntry>> {
        let entries = self.read(None)?;
        Ok(entries
            .into_iter()
            .filter(|e| {
                if let Some(s) = since {
                    if e.timestamp < s {
                        return false;
                    }
                }
                if let Some(u) = until {
                    if e.timestamp > u {
                        return false;
                    }
                }
                if let Some(cmd) = command {
                    if e.operation != cmd {
                        return false;
                    }
                }
                true
            })
            .collect())
    }

    /// Remove all audit entries with `timestamp < before` (i.e. older than the cutoff).
    ///
    /// Rewrites the log file atomically. Entries at or after `before` are kept.
    /// Returns the number of entries that were removed.
    ///
    /// Note: pruning breaks the HMAC chain because it removes entries; the
    /// retained entries' `prev_entry_hmac` values become invalid relative to
    /// the new session's key. This is expected — prune is a privileged
    /// administrative operation.
    pub fn prune_audit_before(&self, before: DateTime<Utc>) -> SafeResult<usize> {
        if !self.path.exists() {
            return Ok(0);
        }
        let content = std::fs::read_to_string(&self.path)?;
        let mut kept: Vec<&str> = Vec::new();
        let mut removed = 0usize;
        for line in content.lines() {
            if line.trim().is_empty() {
                continue;
            }
            match serde_json::from_str::<AuditEntry>(line) {
                Ok(entry) if entry.timestamp < before => {
                    removed += 1;
                }
                _ => {
                    kept.push(line);
                }
            }
        }
        if removed == 0 {
            return Ok(0);
        }
        let new_content = kept.join("\n") + if kept.is_empty() { "" } else { "\n" };
        let tmp = self.path.with_extension("jsonl.tmp");
        std::fs::write(&tmp, &new_content)?;
        // On Windows rename may fail if the log is open by another process.
        if let Err(e) = std::fs::rename(&tmp, &self.path) {
            let _ = std::fs::remove_file(&tmp); // clean up temp on failure
            return Err(std::io::Error::other(format!(
                "audit prune: failed to rename temp file — log unchanged: {e}"
            ))
            .into());
        }
        // Append a sentinel entry so HMAC-chain readers can distinguish a
        // legitimate prune from tampering. The sentinel carries no secret data.
        // Derive the profile name from the log file stem (e.g. prod.audit.jsonl → prod).
        let profile_name = self
            .path
            .file_stem()
            .and_then(|s| s.to_str())
            .unwrap_or("unknown");
        let mut sentinel = AuditEntry::success(profile_name, "audit-prune", None);
        sentinel.message = Some(format!("pruned {removed} entries older than {before}"));
        self.append(&sentinel)?;
        Ok(removed)
    }
}

/// Return the size in bytes of the audit log file at `path`.
///
/// Returns `Ok(0)` when the file does not exist (no log yet is not an error).
pub fn audit_log_size_bytes(path: &Path) -> SafeResult<u64> {
    match std::fs::metadata(path) {
        Ok(meta) => Ok(meta.len()),
        Err(e) if e.kind() == std::io::ErrorKind::NotFound => Ok(0),
        Err(e) => Err(SafeError::Io(e)),
    }
}

/// Verify the HMAC chain in a log file using the provided key.
///
/// This is the inner implementation used by [`AuditLog::verify_chain`].
/// Entries whose `prev_entry_hmac` is `None` are treated as chain anchors
/// (session starts or pre-v2 entries) and do not cause verification failures.
fn verify_chain_with_key(path: &Path, key: &[u8; 32]) -> Result<(), AuditVerifyError> {
    if !path.exists() {
        return Ok(());
    }
    let content = std::fs::read_to_string(path).map_err(|e| AuditVerifyError::Io(e.to_string()))?;

    // Parse every non-empty line. A line that fails to parse is treated as
    // tampering (MalformedLine) rather than silently skipped — otherwise an
    // attacker could corrupt a line just enough to make it non-JSON and evade
    // the HMAC chain check entirely.
    let mut entries: Vec<AuditEntry> = Vec::new();
    for (idx, line) in content.lines().enumerate() {
        if line.trim().is_empty() {
            continue;
        }
        match serde_json::from_str::<AuditEntry>(line) {
            Ok(entry) => entries.push(entry),
            Err(_) => return Err(AuditVerifyError::MalformedLine { line: idx + 1 }),
        }
    }

    // Walk entries in append order. prev_computed tracks the HMAC of the last
    // entry we processed. For entries with prev_entry_hmac = None we treat
    // them as anchors and reset our expected value to None.
    let mut prev_computed: Option<String> = None;

    for (idx, entry) in entries.iter().enumerate() {
        match &entry.prev_entry_hmac {
            None => {
                // Chain anchor: reset the running HMAC and compute this entry's HMAC
                // for the next iteration.
                prev_computed = Some(compute_entry_hmac(entry, key));
            }
            Some(stored_hmac) => {
                // Verify the stored HMAC matches what we expect.
                match &prev_computed {
                    Some(expected) if expected == stored_hmac => {
                        prev_computed = Some(compute_entry_hmac(entry, key));
                    }
                    _ => {
                        return Err(AuditVerifyError::ChainBroken {
                            at_entry: idx,
                            entry_id: entry.id.clone(),
                        });
                    }
                }
            }
        }
    }

    Ok(())
}

// ── tests ────────────────────────────────────────────────────────────────────

#[cfg(test)]
mod tests {
    use super::*;
    use crate::contracts::{AuthorityContract, AuthorityNetworkPolicy, AuthorityTrust};
    use tempfile::tempdir;

    #[test]
    fn append_and_read_roundtrip() {
        let dir = tempdir().unwrap();
        let log = AuditLog::new(&dir.path().join("t.jsonl"));
        log.append(&AuditEntry::success("dev", "set", Some("DB_PASS")))
            .unwrap();
        log.append(&AuditEntry::success("dev", "get", Some("DB_PASS")))
            .unwrap();
        log.append(&AuditEntry::failure(
            "dev",
            "get",
            Some("MISSING"),
            "not found",
        ))
        .unwrap();
        let entries = log.read(None).unwrap();
        assert_eq!(entries.len(), 3);
        assert_eq!(entries[0].status, AuditStatus::Failure); // most recent first
    }

    #[test]
    fn limit_truncates() {
        let dir = tempdir().unwrap();
        let log = AuditLog::new(&dir.path().join("t.jsonl"));
        for i in 0..10 {
            log.append(&AuditEntry::success("dev", "op", Some(&format!("K{i}"))))
                .unwrap();
        }
        assert_eq!(log.read(Some(3)).unwrap().len(), 3);
    }

    #[test]
    fn nonexistent_log_returns_empty() {
        let dir = tempdir().unwrap();
        let log = AuditLog::new(&dir.path().join("does-not-exist.jsonl"));
        assert!(log.read(None).unwrap().is_empty());
    }

    #[test]
    fn ids_are_unique() {
        let e1 = AuditEntry::success("p", "op", None);
        let e2 = AuditEntry::success("p", "op", None);
        assert_ne!(e1.id, e2.id);
    }

    #[test]
    fn last_successful_operation_finds_rotate() {
        let dir = tempdir().unwrap();
        let log = AuditLog::new(&dir.path().join("a.jsonl"));
        log.append(&AuditEntry::success("dev", "set", Some("K")))
            .unwrap();
        log.append(&AuditEntry::success("dev", "rotate", None))
            .unwrap();
        log.append(&AuditEntry::success("dev", "get", Some("K")))
            .unwrap();
        assert!(log
            .last_successful_operation("dev", "rotate", 100)
            .unwrap()
            .is_some());
        assert!(log
            .last_successful_operation("dev", "missing-op", 100)
            .unwrap()
            .is_none());
    }

    /// Verifies the HMAC chain integrity contract introduced in v2.
    ///
    /// Three entries are written through a single `AuditLog` handle (one session).
    /// The chain must be intact. Entry 2's JSON is then tampered with on disk
    /// and re-verification must report `ChainBroken { at_entry: 2 }`.
    #[test]
    fn hmac_chain_intact_and_detects_tampering() {
        let dir = tempdir().unwrap();
        let path = dir.path().join("chain.jsonl");
        let log = AuditLog::new(&path);

        log.append(&AuditEntry::success("dev", "set", Some("A")))
            .unwrap();
        log.append(&AuditEntry::success("dev", "get", Some("A")))
            .unwrap();
        log.append(&AuditEntry::failure(
            "dev",
            "get",
            Some("MISSING"),
            "not found",
        ))
        .unwrap();

        // Chain must be intact immediately after writing.
        log.verify_chain()
            .expect("chain must be intact after write");

        // Tamper with entry at index 1 (second line, 0-based) on disk.
        let content = std::fs::read_to_string(&path).unwrap();
        let mut lines: Vec<String> = content.lines().map(|l| l.to_string()).collect();

        // Deserialize line 1, modify its operation field, re-serialize.
        let mut tampered: AuditEntry = serde_json::from_str(&lines[1]).unwrap();
        tampered.operation = "TAMPERED".to_string();
        // Preserve the original prev_entry_hmac — attacker does not know the key.
        lines[1] = serde_json::to_string(&tampered).unwrap();

        let tampered_content = lines.join("\n") + "\n";
        std::fs::write(&path, tampered_content).unwrap();

        // Verification must detect the broken chain at entry index 2 (entry 2's
        // prev_entry_hmac now points to a digest that no longer matches line 1).
        let err = log
            .verify_chain()
            .expect_err("tampered log must fail verification");
        match err {
            AuditVerifyError::ChainBroken { at_entry, .. } => {
                assert_eq!(
                    at_entry, 2,
                    "chain should break at the entry after the tampered one"
                );
            }
            other => panic!("unexpected error: {other}"),
        }
    }

    /// A non-empty line that cannot be parsed as an audit entry must be treated
    /// as tampering (MalformedLine), not silently skipped — otherwise an attacker
    /// could corrupt a line into non-JSON to evade the HMAC chain check.
    #[test]
    fn malformed_line_fails_verification() {
        let dir = tempdir().unwrap();
        let path = dir.path().join("malformed.jsonl");
        let log = AuditLog::new(&path);

        log.append(&AuditEntry::success("dev", "set", Some("A")))
            .unwrap();
        log.append(&AuditEntry::success("dev", "get", Some("A")))
            .unwrap();

        log.verify_chain()
            .expect("chain must be intact after write");

        // Corrupt the second line into invalid JSON.
        let content = std::fs::read_to_string(&path).unwrap();
        let mut lines: Vec<String> = content.lines().map(|l| l.to_string()).collect();
        lines[1] = "{ this is not valid json".to_string();
        std::fs::write(&path, lines.join("\n") + "\n").unwrap();

        let err = log
            .verify_chain()
            .expect_err("corrupted line must fail verification");
        match err {
            AuditVerifyError::MalformedLine { line } => {
                assert_eq!(
                    line, 2,
                    "malformed line number should be reported (1-based)"
                );
            }
            other => panic!("unexpected error: {other}"),
        }
    }

    /// Chain starts fresh (no inherited HMAC) for the first entry in a session.
    #[test]
    fn first_entry_has_no_prev_hmac() {
        let dir = tempdir().unwrap();
        let path = dir.path().join("first.jsonl");
        let log = AuditLog::new(&path);
        log.append(&AuditEntry::success("dev", "set", Some("K")))
            .unwrap();

        let content = std::fs::read_to_string(&path).unwrap();
        let entry: AuditEntry = serde_json::from_str(content.trim()).unwrap();
        assert!(
            entry.prev_entry_hmac.is_none(),
            "first entry must have no prev_entry_hmac"
        );
    }

    /// Subsequent entries within a session carry the chain HMAC.
    #[test]
    fn subsequent_entries_carry_prev_hmac() {
        let dir = tempdir().unwrap();
        let path = dir.path().join("chain2.jsonl");
        let log = AuditLog::new(&path);
        log.append(&AuditEntry::success("dev", "set", Some("A")))
            .unwrap();
        log.append(&AuditEntry::success("dev", "get", Some("A")))
            .unwrap();

        let content = std::fs::read_to_string(&path).unwrap();
        let lines: Vec<&str> = content.lines().collect();
        let second: AuditEntry = serde_json::from_str(lines[1]).unwrap();
        assert!(
            second.prev_entry_hmac.is_some(),
            "second entry must carry prev_entry_hmac"
        );
    }

    /// Old entries without `prev_entry_hmac` deserialize cleanly (backward compat).
    #[test]
    fn old_entries_deserialize_without_prev_hmac() {
        let raw = r#"{"id":"1","timestamp":"2026-04-08T20:30:00Z","profile":"dev","operation":"exec","key":null,"status":"success","message":null}"#;
        let entry: AuditEntry = serde_json::from_str(raw).unwrap();
        assert!(entry.prev_entry_hmac.is_none());
        assert!(entry.context.is_none());
    }

    /// Documents the current audit integrity contract (v2).
    #[test]
    fn audit_integrity_contract_v2() {
        let dir = tempdir().unwrap();
        let log = AuditLog::new(&dir.path().join("integrity.jsonl"));

        log.append(&AuditEntry::success("dev", "set", Some("A")))
            .unwrap();
        log.append(&AuditEntry::success("dev", "get", Some("A")))
            .unwrap();
        log.append(&AuditEntry::failure(
            "dev",
            "get",
            Some("MISSING"),
            "not found",
        ))
        .unwrap();

        let entries = log.read(None).unwrap();
        assert_eq!(entries.len(), 3, "all appended entries must be retained");

        // Most-recent-first order (failure was last appended).
        assert_eq!(entries[0].status, AuditStatus::Failure);
        assert_eq!(entries[1].operation, "get");
        assert_eq!(entries[2].operation, "set");

        // Every entry carries a unique UUID.
        let ids: std::collections::HashSet<_> = entries.iter().map(|e| &e.id).collect();
        assert_eq!(ids.len(), 3, "every entry must have a distinct UUID");

        // Timestamps are monotonically non-decreasing in append order.
        let mut ordered = entries.clone();
        ordered.reverse();
        for w in ordered.windows(2) {
            assert!(
                w[0].timestamp <= w[1].timestamp,
                "timestamps should be non-decreasing in append order"
            );
        }

        // Chain must be intact.
        log.verify_chain()
            .expect("integrity contract: chain must be intact");
    }

    #[test]
    fn old_entries_deserialize_without_context() {
        let raw = r#"{"id":"1","timestamp":"2026-04-08T20:30:00Z","profile":"dev","operation":"exec","key":null,"status":"success","message":null}"#;
        let entry: AuditEntry = serde_json::from_str(raw).unwrap();
        assert!(entry.context.is_none());
    }

    // ── Task 1.9: filter_audit and prune_audit_before ─────────────────────────

    #[test]
    fn filter_audit_by_command() {
        let dir = tempdir().unwrap();
        let log = AuditLog::new(&dir.path().join("t.jsonl"));
        log.append(&AuditEntry::success("dev", "get", Some("A")))
            .unwrap();
        log.append(&AuditEntry::success("dev", "set", Some("B")))
            .unwrap();
        log.append(&AuditEntry::success("dev", "get", Some("C")))
            .unwrap();

        let gets = log.filter_audit(None, None, Some("get")).unwrap();
        assert_eq!(gets.len(), 2);
        assert!(gets.iter().all(|e| e.operation == "get"));

        let sets = log.filter_audit(None, None, Some("set")).unwrap();
        assert_eq!(sets.len(), 1);
    }

    #[test]
    fn filter_audit_by_time_range() {
        use chrono::Duration;
        let dir = tempdir().unwrap();
        let log = AuditLog::new(&dir.path().join("t.jsonl"));

        let now = Utc::now();
        let old = now - Duration::hours(2);
        let recent = now - Duration::minutes(30);

        // Inject entries with specific timestamps.
        let mut e_old = AuditEntry::success("dev", "get", Some("OLD"));
        e_old.timestamp = old;
        let mut e_recent = AuditEntry::success("dev", "get", Some("RECENT"));
        e_recent.timestamp = recent;
        let mut e_now = AuditEntry::success("dev", "set", Some("NOW"));
        e_now.timestamp = now;
        log.append(&e_old).unwrap();
        log.append(&e_recent).unwrap();
        log.append(&e_now).unwrap();

        // since = 1 hour ago → excludes old
        let since_cutoff = now - Duration::hours(1);
        let results = log.filter_audit(Some(since_cutoff), None, None).unwrap();
        assert_eq!(results.len(), 2);

        // until = 1 hour ago → includes only old
        let results = log
            .filter_audit(None, Some(now - Duration::hours(1)), None)
            .unwrap();
        assert_eq!(results.len(), 1);
        assert_eq!(results[0].key.as_deref(), Some("OLD"));
    }

    #[test]
    fn filter_audit_combined_since_and_command() {
        use chrono::Duration;
        let dir = tempdir().unwrap();
        let log = AuditLog::new(&dir.path().join("t.jsonl"));

        let now = Utc::now();
        let mut old_get = AuditEntry::success("dev", "get", Some("OLD"));
        old_get.timestamp = now - Duration::hours(3);
        let mut new_get = AuditEntry::success("dev", "get", Some("NEW"));
        new_get.timestamp = now - Duration::minutes(5);
        let mut new_set = AuditEntry::success("dev", "set", Some("S"));
        new_set.timestamp = now - Duration::minutes(5);
        log.append(&old_get).unwrap();
        log.append(&new_get).unwrap();
        log.append(&new_set).unwrap();

        let results = log
            .filter_audit(Some(now - Duration::hours(1)), None, Some("get"))
            .unwrap();
        assert_eq!(results.len(), 1);
        assert_eq!(results[0].key.as_deref(), Some("NEW"));
    }

    #[test]
    fn filter_audit_empty_log_returns_empty() {
        let dir = tempdir().unwrap();
        let log = AuditLog::new(&dir.path().join("missing.jsonl"));
        let results = log.filter_audit(None, None, Some("get")).unwrap();
        assert!(results.is_empty());
    }

    #[test]
    fn prune_audit_before_removes_old_entries() {
        use chrono::Duration;
        let dir = tempdir().unwrap();
        let log = AuditLog::new(&dir.path().join("t.jsonl"));

        let now = Utc::now();
        let mut old = AuditEntry::success("dev", "get", Some("A"));
        old.timestamp = now - Duration::hours(48);
        let mut recent = AuditEntry::success("dev", "set", Some("B"));
        recent.timestamp = now - Duration::hours(1);
        log.append(&old).unwrap();
        log.append(&recent).unwrap();

        let cutoff = now - Duration::hours(24);
        let removed = log.prune_audit_before(cutoff).unwrap();
        assert_eq!(removed, 1);

        let remaining = log.read(None).unwrap();
        assert_eq!(remaining.len(), 2);
        assert_eq!(remaining[0].operation, "audit-prune");
        assert_eq!(remaining[0].key.as_deref(), None);
        assert!(remaining[0]
            .message
            .as_deref()
            .is_some_and(|message| message.contains("pruned 1 entries")));
        assert_eq!(remaining[1].key.as_deref(), Some("B"));
    }

    #[test]
    fn prune_audit_before_noop_on_empty_log() {
        let dir = tempdir().unwrap();
        let log = AuditLog::new(&dir.path().join("missing.jsonl"));
        let removed = log.prune_audit_before(Utc::now()).unwrap();
        assert_eq!(removed, 0);
    }

    #[test]
    fn prune_audit_before_keeps_all_if_none_old() {
        use chrono::Duration;
        let dir = tempdir().unwrap();
        let log = AuditLog::new(&dir.path().join("t.jsonl"));
        log.append(&AuditEntry::success("dev", "get", Some("A")))
            .unwrap();
        log.append(&AuditEntry::success("dev", "set", Some("B")))
            .unwrap();

        // Prune entries older than 1 day — all entries are recent.
        let removed = log
            .prune_audit_before(Utc::now() - Duration::days(1))
            .unwrap();
        assert_eq!(removed, 0);
        assert_eq!(log.read(None).unwrap().len(), 2);
    }

    #[test]
    fn mcp_context_roundtrips_through_serde() {
        let entry = AuditEntry::success("dev", "mcp.run", Some("DEMO_KEY")).with_context(
            AuditContext::from_mcp(AuditMcpContext {
                host: "mcp:claude-desktop:1234".to_string(),
                pid: 5678,
                tool: "tsafe_run".to_string(),
                injected_keys: vec!["DEMO_KEY".to_string()],
                exit_code: Some(0),
                duration_ms: Some(42),
            }),
        );

        let encoded = serde_json::to_string(&entry).expect("AuditEntry serializes");
        let decoded: AuditEntry =
            serde_json::from_str(&encoded).expect("AuditEntry round-trips through serde_json");

        let ctx = decoded.context.expect("context present after round-trip");
        let mcp = ctx.mcp.expect("mcp variant present after round-trip");
        assert_eq!(mcp.host, "mcp:claude-desktop:1234");
        assert_eq!(mcp.pid, 5678);
        assert_eq!(mcp.tool, "tsafe_run");
        assert_eq!(mcp.injected_keys, vec!["DEMO_KEY".to_string()]);
        assert_eq!(mcp.exit_code, Some(0));
        assert_eq!(mcp.duration_ms, Some(42));
        assert!(ctx.exec.is_none());
        assert!(ctx.cellos.is_none());
        assert!(ctx.clipboard.is_none());
        assert!(ctx.reveal.is_none());
    }

    #[test]
    fn mcp_context_default_serializes_minimally() {
        let json = serde_json::to_string(&AuditMcpContext::default())
            .expect("default AuditMcpContext serializes");
        // host/pid/tool are mandatory; injected_keys/exit_code/duration_ms skip when default.
        assert!(json.contains("\"host\":\"\""));
        assert!(json.contains("\"pid\":0"));
        assert!(json.contains("\"tool\":\"\""));
        assert!(!json.contains("injected_keys"));
        assert!(!json.contains("exit_code"));
        assert!(!json.contains("duration_ms"));
    }

    #[test]
    fn exec_context_from_contract_seeds_trust_shape() {
        let contract = AuthorityContract {
            name: "deploy".into(),
            profile: Some("work".into()),
            namespace: Some("infra".into()),
            access_profile: RbacProfile::ReadOnly,
            allow_all_secrets: false,
            allowed_secrets: vec!["API_KEY".into(), "DB_PASSWORD".into()],
            required_secrets: vec!["DB_PASSWORD".into()],
            allowed_targets: vec!["terraform".into()],
            trust: AuthorityTrust::Hardened,
            network: AuthorityNetworkPolicy::Restricted,
        };

        let exec = AuditExecContext::from_contract(&contract)
            .with_target("terraform")
            .with_injected_secrets(["DB_PASSWORD", "DB_PASSWORD", "API_KEY"])
            .with_missing_required_secrets(["DB_PASSWORD"])
            .with_dropped_env_names(["OPENAI_API_KEY", "OPENAI_API_KEY"])
            .with_target_evaluation(&contract.evaluate_target(Some("terraform")));

        assert_eq!(exec.contract_name.as_deref(), Some("deploy"));
        assert_eq!(exec.authority_profile.as_deref(), Some("work"));
        assert_eq!(exec.authority_namespace.as_deref(), Some("infra"));
        assert_eq!(exec.access_profile, Some(RbacProfile::ReadOnly));
        assert_eq!(exec.allowed_secrets, vec!["API_KEY", "DB_PASSWORD"]);
        assert_eq!(exec.required_secrets, vec!["DB_PASSWORD"]);
        assert_eq!(exec.injected_secrets, vec!["API_KEY", "DB_PASSWORD"]);
        assert_eq!(exec.missing_required_secrets, vec!["DB_PASSWORD"]);
        assert_eq!(exec.dropped_env_names, vec!["OPENAI_API_KEY"]);
        assert_eq!(exec.target_allowed, Some(true));
        assert_eq!(
            exec.target_decision,
            Some(AuthorityTargetDecision::AllowedExact)
        );
        assert_eq!(exec.matched_target.as_deref(), Some("terraform"));
    }
}
