//! Immutable, hash-chained audit entry for Agent Assembly governance events.
//!
//! Each [`AuditEntry`] commits to all tamper-meaningful fields via a SHA-256 hash
//! that includes the hash of the preceding entry, forming a tamper-evident chain.
//!
//! Gated on the `alloc` feature because [`AuditEntry::payload`] is an
//! [`alloc::string::String`].

use alloc::string::String;
use sha2::{Digest, Sha256};

use crate::{AgentId, SessionId};

// ---------------------------------------------------------------------------
// AuditEventType
// ---------------------------------------------------------------------------

/// Category of a governance event recorded in an [`AuditEntry`].
///
/// The `#[repr(u32)]` attribute makes `event_type as u32` the canonical
/// 4-byte discriminant used in the SHA-256 hash input.
#[repr(u32)]
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
#[cfg_attr(feature = "serde", derive(serde::Serialize, serde::Deserialize))]
#[cfg_attr(feature = "schemars", derive(schemars::JsonSchema))]
pub enum AuditEventType {
    /// A tool call was intercepted by the governance layer before execution.
    ToolCallIntercepted = 0,
    /// An evaluated action violated an active policy rule.
    PolicyViolation = 1,
    /// A credential or secret present in tool arguments was blocked.
    CredentialLeakBlocked = 2,
    /// Human approval was requested before the action could proceed.
    ApprovalRequested = 3,
    /// A pending human approval request was granted.
    ApprovalGranted = 4,
    /// A pending human approval request was denied.
    ApprovalDenied = 5,
    /// The session budget is approaching its configured limit.
    BudgetLimitApproached = 6,
    /// The session budget has been exhausted; further actions are blocked.
    BudgetLimitExceeded = 7,
    /// A pending human approval request expired before a decision was made.
    ApprovalTimedOut = 8,
    /// An approval request was routed to a team-specific approver queue.
    ApprovalRouted = 9,
    /// An approval request was escalated after the initial approver did not respond.
    ApprovalEscalated = 10,
    /// An agent was force-deregistered by the gateway because it exceeded its configured maximum age.
    AgentForceDeregistered = 11,
    /// An inter-team message was blocked because the target channel is not permitted by policy.
    MessageBlocked = 12,
    /// A `dispatch_tool` call was processed by the gateway: the agent's
    /// placeholder-form args were resolved via the `SecretsStore` and
    /// forwarded to the tool sink. The audit `payload` carries the
    /// **placeholder-form** args — the resolved credential value is never
    /// recorded. (AAASM-1920 / Secret Injection.)
    ToolDispatched = 13,
    /// An agent-to-agent (A2A) call was intercepted. The audit `payload`
    /// carries both `caller_agent_id` (the originating agent) and
    /// `callee_agent_id` (the agent performing the action), so reviewers
    /// can reconstruct cross-agent delegation graphs even when the call
    /// was allowed. Emitted only when the request's `caller_agent_id` is
    /// populated and differs from `agent_id`. (AAASM-1944 / Zero-trust A2A.)
    A2ACallIntercepted = 14,
    /// An impersonation attempt was rejected: the request claimed an
    /// `agent_id` whose registered `credential_token` does not match the
    /// token supplied. The audit `payload` carries `claimed_agent_id` and
    /// the agent whose `credential_token` was actually presented (when
    /// resolvable). The action is denied before policy evaluation runs.
    /// (AAASM-1944 / Zero-trust A2A.)
    A2AImpersonationAttempted = 15,
    /// A sandboxed (WASM/WASI) tool invocation has begun. Emitted by
    /// `aa-proxy` immediately before handing the parsed `tools/call`
    /// envelope to the `aa-sandbox` runtime for execution. Marks the
    /// start of the lifecycle terminated by [`SandboxTerminated`],
    /// [`SandboxFilesystemBlocked`], [`SandboxCpuTimeout`], or
    /// [`SandboxOomKilled`]. (AAASM-1965 / F116 ST-W.)
    ///
    /// [`SandboxTerminated`]: Self::SandboxTerminated
    /// [`SandboxFilesystemBlocked`]: Self::SandboxFilesystemBlocked
    /// [`SandboxCpuTimeout`]: Self::SandboxCpuTimeout
    /// [`SandboxOomKilled`]: Self::SandboxOomKilled
    SandboxStarted = 16,
    /// A sandboxed tool attempted to read or write outside the WASI
    /// preopened-dir allowlist. Surfaced by `aa-sandbox::runtime` when
    /// `path_open` (or another `fd_*` host call) returns `EACCES`
    /// because the requested path is not under any directory passed to
    /// `WasiCtxBuilder::preopened_dir`. (AAASM-1965 / F116 ST-W,
    /// Scenario 1 — filesystem isolation.)
    SandboxFilesystemBlocked = 17,
    /// A sandboxed tool was killed because its wasmtime instruction-fuel
    /// budget (or wall-clock guard) was exhausted. Surfaced by
    /// `aa-sandbox::runtime` when `Store::set_fuel` drains to zero or
    /// the wall-clock watcher fires, mapping to
    /// `SandboxError::CpuTimeout` / `SandboxError::WallClockTimeout`.
    /// (AAASM-1965 / F116 ST-W, Scenario 2 — runaway-loop kill.)
    SandboxCpuTimeout = 18,
    /// A sandboxed tool was killed because wasmtime's `Store::limiter`
    /// rejected a memory-growth request that would exceed the
    /// configured `memory_pages` ceiling. Surfaced by
    /// `aa-sandbox::runtime` mapping to `SandboxError::MemoryExhausted`.
    /// Distinguished from [`SandboxCpuTimeout`] so audit consumers can
    /// tell whether the host pressure was instruction-fuel or memory
    /// pages. (AAASM-1965 / F116 ST-W, Scenario 2 — memory-bomb kill.)
    ///
    /// [`SandboxCpuTimeout`]: Self::SandboxCpuTimeout
    SandboxOomKilled = 19,
    /// A sandboxed tool invocation completed without being killed by
    /// any isolation primitive. Emitted by `aa-sandbox::runtime`
    /// regardless of whether the tool returned a logical success or a
    /// tool-defined error — this variant marks lifecycle completion,
    /// not outcome semantics. (AAASM-1965 / F116 ST-W.)
    SandboxTerminated = 20,
    /// A sandboxed tool's host-function call was denied because the
    /// per-tenant host-function rate limit
    /// (`aa-sandbox::policy::HostFnRateLimit`) was exceeded for the
    /// invocation. A deny outcome that terminates the lifecycle started by
    /// [`SandboxStarted`], emitted by `aa-sandbox::runtime` mapping to
    /// `SandboxError::HostFnRateLimited`. Caps host-fn abuse/fuzzing
    /// throughput so a single tenant cannot brute-force a host-fn weakness
    /// or DoS the host. (AAASM-3617.)
    ///
    /// [`SandboxStarted`]: Self::SandboxStarted
    SandboxHostFnRateLimited = 21,
}

impl AuditEventType {
    /// Returns the string label used in [`Display`] output and log messages.
    ///
    /// [`Display`]: core::fmt::Display
    pub fn as_str(&self) -> &'static str {
        match self {
            Self::ToolCallIntercepted => "ToolCallIntercepted",
            Self::PolicyViolation => "PolicyViolation",
            Self::CredentialLeakBlocked => "CredentialLeakBlocked",
            Self::ApprovalRequested => "ApprovalRequested",
            Self::ApprovalGranted => "ApprovalGranted",
            Self::ApprovalDenied => "ApprovalDenied",
            Self::BudgetLimitApproached => "BudgetLimitApproached",
            Self::BudgetLimitExceeded => "BudgetLimitExceeded",
            Self::ApprovalTimedOut => "ApprovalTimedOut",
            Self::ApprovalRouted => "ApprovalRouted",
            Self::ApprovalEscalated => "ApprovalEscalated",
            Self::AgentForceDeregistered => "AgentForceDeregistered",
            Self::MessageBlocked => "MessageBlocked",
            Self::ToolDispatched => "ToolDispatched",
            Self::A2ACallIntercepted => "A2ACallIntercepted",
            Self::A2AImpersonationAttempted => "A2AImpersonationAttempted",
            Self::SandboxStarted => "SandboxStarted",
            Self::SandboxFilesystemBlocked => "SandboxFilesystemBlocked",
            Self::SandboxCpuTimeout => "SandboxCpuTimeout",
            Self::SandboxOomKilled => "SandboxOomKilled",
            Self::SandboxTerminated => "SandboxTerminated",
            Self::SandboxHostFnRateLimited => "SandboxHostFnRateLimited",
        }
    }
}

// ---------------------------------------------------------------------------
// Lineage
// ---------------------------------------------------------------------------

/// Optional agent-topology fields attached to an [`AuditEntry`].
///
/// All fields are `None` for entries emitted without an `AgentContext`
/// (legacy path). `Lineage::default()` passed to
/// [`AuditEntry::new_with_lineage`] produces a hash identical to
/// [`AuditEntry::new`] with the same base fields.
#[derive(Debug, Clone, PartialEq, Default)]
#[cfg_attr(feature = "serde", derive(serde::Serialize, serde::Deserialize))]
pub struct Lineage {
    /// Root agent identifier at the top of the delegation chain.
    #[cfg_attr(feature = "serde", serde(skip_serializing_if = "Option::is_none", default))]
    pub root_agent_id: Option<AgentId>,
    /// Identifier of the agent that directly spawned this agent.
    #[cfg_attr(feature = "serde", serde(skip_serializing_if = "Option::is_none", default))]
    pub parent_agent_id: Option<AgentId>,
    /// Team identifier associated with the agent that produced the entry.
    #[cfg_attr(feature = "serde", serde(skip_serializing_if = "Option::is_none", default))]
    pub team_id: Option<String>,
    /// AAASM-2008 — organization identifier of the agent that produced the
    /// entry. Mirrors `team_id` but at the multi-tenancy tier so audit logs,
    /// compliance exports, and operator queries can filter / scope by Org.
    /// Present when the gateway can resolve `org_id` from the agent's
    /// registration metadata; `None` for entries emitted without an
    /// `AgentContext` or registered without org metadata.
    #[cfg_attr(feature = "serde", serde(skip_serializing_if = "Option::is_none", default))]
    pub org_id: Option<String>,
    /// Human-readable reason the action was delegated to this agent.
    #[cfg_attr(feature = "serde", serde(skip_serializing_if = "Option::is_none", default))]
    pub delegation_reason: Option<String>,
    /// Name of the tool or framework that spawned this agent (e.g. `"langgraph"`).
    #[cfg_attr(feature = "serde", serde(skip_serializing_if = "Option::is_none", default))]
    pub spawned_by_tool: Option<String>,
    /// Delegation depth from the root agent (`0` = root, `1` = first delegate, …).
    #[cfg_attr(feature = "serde", serde(skip_serializing_if = "Option::is_none", default))]
    pub depth: Option<u32>,
}

// ---------------------------------------------------------------------------
// Redaction
// ---------------------------------------------------------------------------

// `AuditEntry` consumes the redaction primitive from the leaf crate
// `aa-security` (AAASM-2567); it is imported privately here and is no longer
// re-exported from `aa-core`. Consumers depend on `aa-security` directly.
// Gated on `std` because `Redaction` holds `aa_security::CredentialFinding`
// values, which live in the `std`-only `scanner` module.
#[cfg(feature = "std")]
use aa_security::Redaction;

// ---------------------------------------------------------------------------
// AuditEntry
// ---------------------------------------------------------------------------

/// An immutable, hash-chained record of a single governance event.
///
/// ## Immutability
///
/// All fields are private. The only way to create an [`AuditEntry`] is via
/// [`AuditEntry::new`]. There are no mutation methods.
///
/// ## Hash chain
///
/// `entry_hash` is a SHA-256 digest computed over all tamper-meaningful fields
/// in a canonical byte order (see [`AuditEntry::new`] for the full sequence).
/// Each entry commits to `previous_hash`, linking entries into a tamper-evident
/// chain. The genesis entry uses `[0u8; 32]` as `previous_hash`.
///
/// ## Tamper detection
///
/// [`AuditEntry::verify_integrity`] re-computes the hash from the stored fields
/// and compares it to the stored `entry_hash`. Any field alteration — including
/// via `unsafe` code — will cause the re-computed hash to diverge.
#[derive(Debug, Clone, PartialEq, Eq)]
#[cfg_attr(feature = "serde", derive(serde::Serialize, serde::Deserialize))]
pub struct AuditEntry {
    seq: u64,
    timestamp_ns: u64,
    event_type: AuditEventType,
    agent_id: AgentId,
    session_id: SessionId,
    payload: String,
    previous_hash: [u8; 32],
    entry_hash: [u8; 32],
    #[cfg_attr(feature = "serde", serde(skip_serializing_if = "Option::is_none", default))]
    root_agent_id: Option<AgentId>,
    #[cfg_attr(feature = "serde", serde(skip_serializing_if = "Option::is_none", default))]
    parent_agent_id: Option<AgentId>,
    #[cfg_attr(feature = "serde", serde(skip_serializing_if = "Option::is_none", default))]
    team_id: Option<String>,
    /// AAASM-2008 — see [`Lineage::org_id`].
    #[cfg_attr(feature = "serde", serde(skip_serializing_if = "Option::is_none", default))]
    org_id: Option<String>,
    #[cfg_attr(feature = "serde", serde(skip_serializing_if = "Option::is_none", default))]
    delegation_reason: Option<String>,
    #[cfg_attr(feature = "serde", serde(skip_serializing_if = "Option::is_none", default))]
    spawned_by_tool: Option<String>,
    #[cfg_attr(feature = "serde", serde(skip_serializing_if = "Option::is_none", default))]
    depth: Option<u32>,
    #[cfg(feature = "std")]
    #[cfg_attr(feature = "serde", serde(skip_serializing_if = "Vec::is_empty", default))]
    credential_findings: alloc::vec::Vec<aa_security::CredentialFinding>,
    #[cfg(feature = "std")]
    #[cfg_attr(feature = "serde", serde(skip_serializing_if = "Option::is_none", default))]
    redacted_payload: Option<String>,
}

impl AuditEntry {
    // -----------------------------------------------------------------------
    // Constructor
    // -----------------------------------------------------------------------

    /// Create a new [`AuditEntry`], computing `entry_hash` over all fields.
    ///
    /// ## Parameters
    ///
    /// - `seq` — monotonic counter within the session; genesis entry is `0`.
    /// - `timestamp_ns` — nanoseconds since the Unix epoch (caller-supplied;
    ///   use `Timestamp::from(SystemTime::now()).as_nanos()` in `std` environments).
    /// - `event_type` — category of the governance event.
    /// - `agent_id` — identifier of the agent that produced the event.
    /// - `session_id` — identifier of the specific agent run.
    /// - `payload` — pre-serialized UTF-8 string (JSON in practice).
    /// - `previous_hash` — `entry_hash` of the preceding entry;
    ///   `[0u8; 32]` for the genesis entry.
    ///
    /// ## Canonical hash input (84 fixed bytes + variable payload)
    ///
    /// ```text
    /// SHA-256(
    ///     seq.to_be_bytes()                  //  8 bytes
    ///     || timestamp_ns.to_be_bytes()      //  8 bytes
    ///     || (event_type as u32).to_be_bytes() // 4 bytes
    ///     || agent_id.as_bytes()             // 16 bytes
    ///     || session_id.as_bytes()           // 16 bytes
    ///     || previous_hash                   // 32 bytes
    ///     || payload.as_bytes()              // variable
    /// )
    /// ```
    pub fn new(
        seq: u64,
        timestamp_ns: u64,
        event_type: AuditEventType,
        agent_id: AgentId,
        session_id: SessionId,
        payload: String,
        previous_hash: [u8; 32],
    ) -> Self {
        let entry_hash = Self::compute_hash(
            seq,
            timestamp_ns,
            &event_type,
            &agent_id,
            &session_id,
            &previous_hash,
            &payload,
            &Lineage::default(),
            #[cfg(feature = "std")]
            &Redaction::default(),
        );
        Self {
            seq,
            timestamp_ns,
            event_type,
            agent_id,
            session_id,
            payload,
            previous_hash,
            entry_hash,
            root_agent_id: None,
            parent_agent_id: None,
            team_id: None,
            org_id: None,
            delegation_reason: None,
            spawned_by_tool: None,
            depth: None,
            #[cfg(feature = "std")]
            credential_findings: alloc::vec::Vec::new(),
            #[cfg(feature = "std")]
            redacted_payload: None,
        }
    }

    /// Create a new [`AuditEntry`] with optional lineage fields, computing `entry_hash`
    /// over all fields including the lineage data.
    ///
    /// When `lineage` is `Lineage::default()` (all fields `None`), the resulting
    /// `entry_hash` is identical to that produced by [`AuditEntry::new`] with the
    /// same base fields, preserving backward compatibility.
    #[allow(clippy::too_many_arguments)]
    pub fn new_with_lineage(
        seq: u64,
        timestamp_ns: u64,
        event_type: AuditEventType,
        agent_id: AgentId,
        session_id: SessionId,
        payload: String,
        previous_hash: [u8; 32],
        lineage: Lineage,
    ) -> Self {
        let entry_hash = Self::compute_hash(
            seq,
            timestamp_ns,
            &event_type,
            &agent_id,
            &session_id,
            &previous_hash,
            &payload,
            &lineage,
            #[cfg(feature = "std")]
            &Redaction::default(),
        );
        Self {
            seq,
            timestamp_ns,
            event_type,
            agent_id,
            session_id,
            payload,
            previous_hash,
            entry_hash,
            root_agent_id: lineage.root_agent_id,
            parent_agent_id: lineage.parent_agent_id,
            team_id: lineage.team_id,
            org_id: lineage.org_id,
            delegation_reason: lineage.delegation_reason,
            spawned_by_tool: lineage.spawned_by_tool,
            depth: lineage.depth,
            #[cfg(feature = "std")]
            credential_findings: alloc::vec::Vec::new(),
            #[cfg(feature = "std")]
            redacted_payload: None,
        }
    }

    /// Create a new [`AuditEntry`] carrying both lineage data and credential
    /// scanner output, computing `entry_hash` over all tamper-meaningful fields.
    ///
    /// When `redaction == Redaction::default()` (empty findings + `None` payload),
    /// the resulting `entry_hash` is identical to [`AuditEntry::new_with_lineage`]
    /// with the same base fields — so callers that don't have scanner data can
    /// continue using the legacy constructors without any chain divergence.
    ///
    /// Gated on `std` because [`Redaction`] holds
    /// [`CredentialFinding`](aa_security::CredentialFinding) values, which
    /// live in the `std`-only `scanner` module.
    #[cfg(feature = "std")]
    #[allow(clippy::too_many_arguments)]
    pub fn new_with_lineage_and_redaction(
        seq: u64,
        timestamp_ns: u64,
        event_type: AuditEventType,
        agent_id: AgentId,
        session_id: SessionId,
        payload: String,
        previous_hash: [u8; 32],
        lineage: Lineage,
        redaction: Redaction,
    ) -> Self {
        let entry_hash = Self::compute_hash(
            seq,
            timestamp_ns,
            &event_type,
            &agent_id,
            &session_id,
            &previous_hash,
            &payload,
            &lineage,
            &redaction,
        );
        Self {
            seq,
            timestamp_ns,
            event_type,
            agent_id,
            session_id,
            payload,
            previous_hash,
            entry_hash,
            root_agent_id: lineage.root_agent_id,
            parent_agent_id: lineage.parent_agent_id,
            team_id: lineage.team_id,
            org_id: lineage.org_id,
            delegation_reason: lineage.delegation_reason,
            spawned_by_tool: lineage.spawned_by_tool,
            depth: lineage.depth,
            credential_findings: redaction.credential_findings,
            redacted_payload: redaction.redacted_payload,
        }
    }

    // -----------------------------------------------------------------------
    // Getters
    // -----------------------------------------------------------------------

    /// Monotonic sequence counter within the session.
    #[inline]
    pub fn seq(&self) -> u64 {
        self.seq
    }

    /// Nanoseconds since the Unix epoch at the time the entry was created.
    #[inline]
    pub fn timestamp_ns(&self) -> u64 {
        self.timestamp_ns
    }

    /// Category of the governance event.
    #[inline]
    pub fn event_type(&self) -> AuditEventType {
        self.event_type
    }

    /// Identifier of the agent that produced this entry.
    #[inline]
    pub fn agent_id(&self) -> AgentId {
        self.agent_id
    }

    /// Identifier of the specific agent run (session) that produced this entry.
    #[inline]
    pub fn session_id(&self) -> SessionId {
        self.session_id
    }

    /// Pre-serialized UTF-8 payload (JSON in practice).
    #[inline]
    pub fn payload(&self) -> &str {
        &self.payload
    }

    /// SHA-256 hash of the preceding entry; `[0u8; 32]` for the genesis entry.
    #[inline]
    pub fn previous_hash(&self) -> &[u8; 32] {
        &self.previous_hash
    }

    /// SHA-256 hash computed over all tamper-meaningful fields at construction.
    #[inline]
    pub fn entry_hash(&self) -> &[u8; 32] {
        &self.entry_hash
    }

    /// Root agent identifier in the delegation chain, if present.
    #[inline]
    pub fn root_agent_id(&self) -> Option<AgentId> {
        self.root_agent_id
    }

    /// Parent agent identifier that directly spawned this agent, if present.
    #[inline]
    pub fn parent_agent_id(&self) -> Option<AgentId> {
        self.parent_agent_id
    }

    /// Team identifier associated with the agent, if present.
    #[inline]
    pub fn team_id(&self) -> Option<&str> {
        self.team_id.as_deref()
    }

    /// AAASM-2008 — organization identifier associated with the agent, if
    /// present. Mirrors [`Self::team_id`] at the multi-tenancy tier.
    #[inline]
    pub fn org_id(&self) -> Option<&str> {
        self.org_id.as_deref()
    }

    /// Reason this agent was delegated the action, if present.
    #[inline]
    pub fn delegation_reason(&self) -> Option<&str> {
        self.delegation_reason.as_deref()
    }

    /// Name of the tool that spawned this agent, if present.
    #[inline]
    pub fn spawned_by_tool(&self) -> Option<&str> {
        self.spawned_by_tool.as_deref()
    }

    /// Delegation depth from the root agent, if present.
    #[inline]
    pub fn depth(&self) -> Option<u32> {
        self.depth
    }

    /// Credential / PII findings detected by the policy engine's scanner pass.
    ///
    /// Empty when the scan was clean (or when the entry was constructed via a
    /// pre-redaction-aware code path). Each [`CredentialFinding`](aa_security::CredentialFinding)
    /// stores only the kind, byte offset, and `[REDACTED:<kind>]` label —
    /// never the raw secret bytes.
    #[cfg(feature = "std")]
    #[inline]
    pub fn credential_findings(&self) -> &[aa_security::CredentialFinding] {
        &self.credential_findings
    }

    /// Redacted version of the action payload, if the scanner produced findings.
    ///
    /// `None` when the scan was clean. When `Some`, every detected secret has
    /// been replaced with its `[REDACTED:<kind>]` label so the audit trail
    /// itself never leaks the raw secret.
    #[cfg(feature = "std")]
    #[inline]
    pub fn redacted_payload(&self) -> Option<&str> {
        self.redacted_payload.as_deref()
    }

    // -----------------------------------------------------------------------
    // Integrity
    // -----------------------------------------------------------------------

    /// Returns `true` if the stored `entry_hash` matches a fresh re-computation
    /// over the stored fields.
    ///
    /// Returns `false` if any field has been altered after construction — including
    /// via `unsafe` code.
    pub fn verify_integrity(&self) -> bool {
        let lineage = Lineage {
            root_agent_id: self.root_agent_id,
            parent_agent_id: self.parent_agent_id,
            team_id: self.team_id.clone(),
            org_id: self.org_id.clone(),
            delegation_reason: self.delegation_reason.clone(),
            spawned_by_tool: self.spawned_by_tool.clone(),
            depth: self.depth,
        };
        #[cfg(feature = "std")]
        let redaction = Redaction {
            credential_findings: self.credential_findings.clone(),
            redacted_payload: self.redacted_payload.clone(),
        };
        let expected = Self::compute_hash(
            self.seq,
            self.timestamp_ns,
            &self.event_type,
            &self.agent_id,
            &self.session_id,
            &self.previous_hash,
            &self.payload,
            &lineage,
            #[cfg(feature = "std")]
            &redaction,
        );
        expected == self.entry_hash
    }

    // -----------------------------------------------------------------------
    // Private helpers
    // -----------------------------------------------------------------------

    /// Canonical SHA-256 computation over all tamper-meaningful fields.
    ///
    /// Field order and encoding are fixed — see [`AuditEntry::new`] for the
    /// documented byte sequence. Lineage fields append only when `Some`;
    /// when all lineage fields are `None`, output equals the pre-AAASM-934 hash exactly.
    /// Redaction bytes append only when `redaction != Redaction::default()`;
    /// the default value (empty findings + `None` payload) contributes 0 bytes,
    /// so existing chains verify unchanged.
    #[allow(clippy::too_many_arguments)]
    fn compute_hash(
        seq: u64,
        timestamp_ns: u64,
        event_type: &AuditEventType,
        agent_id: &AgentId,
        session_id: &SessionId,
        previous_hash: &[u8; 32],
        payload: &str,
        lineage: &Lineage,
        #[cfg(feature = "std")] redaction: &Redaction,
    ) -> [u8; 32] {
        let mut hasher = Sha256::new();
        hasher.update(seq.to_be_bytes());
        hasher.update(timestamp_ns.to_be_bytes());
        hasher.update((*event_type as u32).to_be_bytes());
        hasher.update(agent_id.as_bytes());
        hasher.update(session_id.as_bytes());
        hasher.update(previous_hash);
        hasher.update(payload.as_bytes());
        // Lineage — append only when present; None contributes 0 bytes.
        // When all fields are None, hash equals pre-AAASM-934 output exactly.
        if let Some(id) = &lineage.root_agent_id {
            hasher.update(id.as_bytes());
        }
        if let Some(id) = &lineage.parent_agent_id {
            hasher.update(id.as_bytes());
        }
        if let Some(s) = &lineage.team_id {
            hasher.update((s.len() as u32).to_be_bytes());
            hasher.update(s.as_bytes());
        }
        if let Some(s) = &lineage.delegation_reason {
            hasher.update((s.len() as u32).to_be_bytes());
            hasher.update(s.as_bytes());
        }
        if let Some(s) = &lineage.spawned_by_tool {
            hasher.update((s.len() as u32).to_be_bytes());
            hasher.update(s.as_bytes());
        }
        if let Some(d) = lineage.depth {
            hasher.update(d.to_be_bytes());
        }
        // AAASM-2008 — org_id appended last in the lineage section so that
        // entries with org_id=None hash identically to pre-AAASM-2008 output.
        if let Some(s) = &lineage.org_id {
            hasher.update((s.len() as u32).to_be_bytes());
            hasher.update(s.as_bytes());
        }
        // Redaction — append only when non-default; empty Vec + None contributes 0 bytes
        // so entries constructed via new() / new_with_lineage() hash exactly as before.
        #[cfg(feature = "std")]
        {
            if !redaction.credential_findings.is_empty() || redaction.redacted_payload.is_some() {
                hasher.update((redaction.credential_findings.len() as u32).to_be_bytes());
                for finding in &redaction.credential_findings {
                    hasher.update((finding.offset as u64).to_be_bytes());
                    hasher.update((finding.matched.len() as u32).to_be_bytes());
                    hasher.update(finding.matched.as_bytes());
                }
                if let Some(s) = &redaction.redacted_payload {
                    hasher.update([1u8]);
                    hasher.update((s.len() as u32).to_be_bytes());
                    hasher.update(s.as_bytes());
                } else {
                    hasher.update([0u8]);
                }
            }
        }
        hasher.finalize().into()
    }
}

// ---------------------------------------------------------------------------
// Tool-dispatch helper (AAASM-1920 / Secret Injection)
// ---------------------------------------------------------------------------

/// Build an [`AuditEntry`] for a `dispatch_tool` call, carrying the
/// **placeholder-form** args as the `payload`.
///
/// The caller passes `placeholder_args` as it was *received* from the agent
/// — before any `${NAME}` token is resolved by
/// `aa-gateway::secrets::resolver::resolve_placeholders`. The resolved
/// credential value is forwarded to the tool sink separately, but it must
/// never appear in `payload`. This helper centralises that contract so
/// every dispatch_tool handler (HTTP, gRPC) emits the same shape.
///
/// Returns an entry tagged [`AuditEventType::ToolDispatched`].
///
/// Gated on `feature = "std"` because it relies on `serde_json::to_string`.
#[cfg(feature = "std")]
pub fn audit_entry_for_tool_dispatch(
    seq: u64,
    timestamp_ns: u64,
    agent_id: AgentId,
    session_id: SessionId,
    placeholder_args: &serde_json::Value,
    previous_hash: [u8; 32],
) -> AuditEntry {
    let payload = serde_json::to_string(placeholder_args).unwrap_or_else(|_| {
        // Malformed input is implausible for a `serde_json::Value` — this
        // branch exists so the helper is total. A non-secret stand-in is
        // recorded so the audit chain stays unbroken.
        String::from("{\"error\":\"failed to serialize placeholder args\"}")
    });
    AuditEntry::new(
        seq,
        timestamp_ns,
        AuditEventType::ToolDispatched,
        agent_id,
        session_id,
        payload,
        previous_hash,
    )
}

// ---------------------------------------------------------------------------
// Display
// ---------------------------------------------------------------------------

impl core::fmt::Display for AuditEntry {
    /// Human-readable one-line representation suitable for log output.
    ///
    /// Format: `[seq=N ts=T agent=HEX session=HEX event=TypeName]`
    ///
    /// `payload` is omitted from `Display` — it may be arbitrarily large.
    /// Use [`AuditEntry::payload`] to access the full payload string.
    fn fmt(&self, f: &mut core::fmt::Formatter<'_>) -> core::fmt::Result {
        write!(f, "[seq={} ts={} agent=", self.seq, self.timestamp_ns)?;
        for b in self.agent_id.as_bytes() {
            write!(f, "{:02x}", b)?;
        }
        write!(f, " session=")?;
        for b in self.session_id.as_bytes() {
            write!(f, "{:02x}", b)?;
        }
        write!(f, " event={}]", self.event_type.as_str())
    }
}

// ---------------------------------------------------------------------------
// AuditLogError
// ---------------------------------------------------------------------------

/// Error returned by [`AuditLog::push`] when an appended entry violates
/// the log's monotonicity or hash-chain invariants.
#[derive(Debug, Clone, PartialEq, Eq)]
pub enum AuditLogError {
    /// The entry's `seq` did not equal the log's expected next sequence number.
    SequenceGap {
        /// The sequence number the log expected.
        expected: u64,
        /// The sequence number the entry carried.
        got: u64,
    },
    /// The entry's `previous_hash` did not match the `entry_hash` of the
    /// last entry in the log (or the genesis zero-hash for the first entry).
    HashChainBroken {
        /// The `seq` of the entry that broke the chain.
        at_seq: u64,
    },
}

impl core::fmt::Display for AuditLogError {
    fn fmt(&self, f: &mut core::fmt::Formatter<'_>) -> core::fmt::Result {
        match self {
            Self::SequenceGap { expected, got } => {
                write!(f, "audit log sequence gap: expected seq={expected}, got seq={got}")
            }
            Self::HashChainBroken { at_seq } => {
                write!(f, "audit log hash chain broken at seq={at_seq}")
            }
        }
    }
}

// ---------------------------------------------------------------------------
// AuditLog
// ---------------------------------------------------------------------------

/// A session-scoped, append-only sequence of [`AuditEntry`] records that
/// enforces monotonic sequence numbers and hash-chain continuity on every append.
///
/// ## Invariants
///
/// - Every entry's `seq` equals the previous entry's `seq + 1` (genesis: `seq = 0`).
/// - Every entry's `previous_hash` equals the preceding entry's `entry_hash`
///   (genesis entry uses `[0u8; 32]`).
///
/// Both invariants are checked by [`AuditLog::push`] at append time.
/// [`AuditLog::verify_chain`] re-validates them across the entire stored log.
pub struct AuditLog {
    agent_id: AgentId,
    session_id: SessionId,
    entries: alloc::vec::Vec<AuditEntry>,
    /// The `seq` value the next appended entry must carry.
    next_seq: u64,
    /// The `entry_hash` of the last appended entry; `[0u8; 32]` before any entry.
    last_hash: [u8; 32],
}

impl AuditLog {
    /// Create a new, empty [`AuditLog`] for the given agent and session.
    ///
    /// The log starts with `next_seq = 0` and `last_hash = [0u8; 32]` (the
    /// genesis previous-hash sentinel).
    pub fn new(agent_id: AgentId, session_id: SessionId) -> Self {
        Self {
            agent_id,
            session_id,
            entries: alloc::vec::Vec::new(),
            next_seq: 0,
            last_hash: [0u8; 32],
        }
    }

    /// Read-only view of all entries in append order.
    pub fn entries(&self) -> &[AuditEntry] {
        &self.entries
    }

    /// Number of entries currently stored in the log.
    pub fn len(&self) -> usize {
        self.entries.len()
    }

    /// Returns `true` if the log contains no entries.
    pub fn is_empty(&self) -> bool {
        self.entries.is_empty()
    }

    /// The agent identifier associated with this log.
    pub fn agent_id(&self) -> AgentId {
        self.agent_id
    }

    /// The session identifier associated with this log.
    pub fn session_id(&self) -> SessionId {
        self.session_id
    }

    /// Append a pre-built [`AuditEntry`] to the log, validating both invariants.
    ///
    /// ## Errors
    ///
    /// - [`AuditLogError::SequenceGap`] if `entry.seq() != self.next_seq`.
    /// - [`AuditLogError::HashChainBroken`] if `entry.previous_hash() != &self.last_hash`.
    ///
    /// On error the log is not modified.
    pub fn push(&mut self, entry: AuditEntry) -> Result<(), AuditLogError> {
        if entry.seq() != self.next_seq {
            return Err(AuditLogError::SequenceGap {
                expected: self.next_seq,
                got: entry.seq(),
            });
        }
        if entry.previous_hash() != &self.last_hash {
            return Err(AuditLogError::HashChainBroken { at_seq: entry.seq() });
        }
        self.last_hash = *entry.entry_hash();
        self.next_seq += 1;
        self.entries.push(entry);
        Ok(())
    }

    /// Build and append the next [`AuditEntry`] in one atomic step.
    ///
    /// `seq` and `previous_hash` are derived automatically from the log's
    /// current state, eliminating the risk of caller-side sequencing errors.
    ///
    /// ## Parameters
    ///
    /// - `event_type` — category of the governance event.
    /// - `timestamp_ns` — nanoseconds since Unix epoch (caller-supplied for
    ///   `no_std` compatibility).
    /// - `payload` — pre-serialized UTF-8 string (JSON in practice).
    ///
    /// Returns a reference to the newly appended entry.
    pub fn next_entry(&mut self, event_type: AuditEventType, timestamp_ns: u64, payload: String) -> &AuditEntry {
        let entry = AuditEntry::new(
            self.next_seq,
            timestamp_ns,
            event_type,
            self.agent_id,
            self.session_id,
            payload,
            self.last_hash,
        );
        // next_entry constructs the entry with the correct seq and previous_hash,
        // so push() cannot fail here.
        self.push(entry).expect("next_entry invariant: push cannot fail");
        self.entries.last().expect("entry was just pushed")
    }

    /// Build and append the next [`AuditEntry`] with lineage fields in one atomic step.
    ///
    /// Equivalent to [`AuditLog::next_entry`] but attaches agent-topology metadata.
    /// `seq` and `previous_hash` are derived automatically from the log's current state.
    ///
    /// ## Parameters
    ///
    /// - `event_type` — category of the governance event.
    /// - `timestamp_ns` — nanoseconds since Unix epoch (caller-supplied for `no_std` compatibility).
    /// - `payload` — pre-serialized UTF-8 string (JSON in practice).
    /// - `lineage` — optional agent-topology fields; `Lineage::default()` produces the same hash
    ///   as [`AuditLog::next_entry`] with the same base fields.
    ///
    /// Returns a reference to the newly appended entry.
    pub fn next_entry_with_lineage(
        &mut self,
        event_type: AuditEventType,
        timestamp_ns: u64,
        payload: String,
        lineage: Lineage,
    ) -> &AuditEntry {
        let entry = AuditEntry::new_with_lineage(
            self.next_seq,
            timestamp_ns,
            event_type,
            self.agent_id,
            self.session_id,
            payload,
            self.last_hash,
            lineage,
        );
        self.push(entry)
            .expect("next_entry_with_lineage invariant: push cannot fail");
        self.entries.last().expect("entry was just pushed")
    }

    /// Re-validate the entire log in O(n), checking both invariants for every entry.
    ///
    /// Returns `true` if:
    /// - Every entry passes [`AuditEntry::verify_integrity`] (SHA-256 matches stored hash).
    /// - Every entry's `seq` is exactly one greater than the previous entry's `seq`
    ///   (first entry must have `seq = 0`).
    /// - Every entry's `previous_hash` matches the preceding entry's `entry_hash`
    ///   (first entry must have `previous_hash = [0u8; 32]`).
    ///
    /// Returns `true` for an empty log (vacuously valid).
    pub fn verify_chain(&self) -> bool {
        let mut expected_prev_hash: [u8; 32] = [0u8; 32];

        for (expected_seq, entry) in self.entries.iter().enumerate() {
            if !entry.verify_integrity() {
                return false;
            }
            if entry.seq() != expected_seq as u64 {
                return false;
            }
            if entry.previous_hash() != &expected_prev_hash {
                return false;
            }
            expected_prev_hash = *entry.entry_hash();
        }
        true
    }
}

// ---------------------------------------------------------------------------
// Tests
// ---------------------------------------------------------------------------

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

    // Shared test fixtures
    const AGENT_BYTES: [u8; 16] = [1, 2, 3, 4, 5, 6, 7, 8, 9, 10, 11, 12, 13, 14, 15, 16];
    const SESSION_BYTES: [u8; 16] = [17, 18, 19, 20, 21, 22, 23, 24, 25, 26, 27, 28, 29, 30, 31, 32];
    const GENESIS_HASH: [u8; 32] = [0u8; 32];

    fn make_entry(seq: u64) -> AuditEntry {
        AuditEntry::new(
            seq,
            1_714_222_134_000_000_000,
            AuditEventType::ToolCallIntercepted,
            AgentId::from_bytes(AGENT_BYTES),
            SessionId::from_bytes(SESSION_BYTES),
            alloc::string::String::from("{\"tool\":\"bash\",\"args\":{\"cmd\":\"ls\"}}"),
            GENESIS_HASH,
        )
    }

    // --- AuditEventType ---

    #[test]
    fn event_type_as_str_all_variants() {
        assert_eq!(AuditEventType::ToolCallIntercepted.as_str(), "ToolCallIntercepted");
        assert_eq!(AuditEventType::PolicyViolation.as_str(), "PolicyViolation");
        assert_eq!(AuditEventType::CredentialLeakBlocked.as_str(), "CredentialLeakBlocked");
        assert_eq!(AuditEventType::ApprovalRequested.as_str(), "ApprovalRequested");
        assert_eq!(AuditEventType::ApprovalGranted.as_str(), "ApprovalGranted");
        assert_eq!(AuditEventType::ApprovalDenied.as_str(), "ApprovalDenied");
        assert_eq!(AuditEventType::BudgetLimitApproached.as_str(), "BudgetLimitApproached");
        assert_eq!(AuditEventType::BudgetLimitExceeded.as_str(), "BudgetLimitExceeded");
        assert_eq!(AuditEventType::ApprovalTimedOut.as_str(), "ApprovalTimedOut");
        assert_eq!(AuditEventType::ApprovalRouted.as_str(), "ApprovalRouted");
        assert_eq!(AuditEventType::ApprovalEscalated.as_str(), "ApprovalEscalated");
        assert_eq!(AuditEventType::ToolDispatched.as_str(), "ToolDispatched");
        assert_eq!(AuditEventType::SandboxStarted.as_str(), "SandboxStarted");
        assert_eq!(
            AuditEventType::SandboxFilesystemBlocked.as_str(),
            "SandboxFilesystemBlocked"
        );
        assert_eq!(AuditEventType::SandboxCpuTimeout.as_str(), "SandboxCpuTimeout");
        assert_eq!(AuditEventType::SandboxOomKilled.as_str(), "SandboxOomKilled");
        assert_eq!(AuditEventType::SandboxTerminated.as_str(), "SandboxTerminated");
        assert_eq!(
            AuditEventType::SandboxHostFnRateLimited.as_str(),
            "SandboxHostFnRateLimited"
        );
    }

    #[test]
    fn event_type_discriminants_are_0_through_10() {
        assert_eq!(AuditEventType::ToolCallIntercepted as u32, 0);
        assert_eq!(AuditEventType::PolicyViolation as u32, 1);
        assert_eq!(AuditEventType::CredentialLeakBlocked as u32, 2);
        assert_eq!(AuditEventType::ApprovalRequested as u32, 3);
        assert_eq!(AuditEventType::ApprovalGranted as u32, 4);
        assert_eq!(AuditEventType::ApprovalDenied as u32, 5);
        assert_eq!(AuditEventType::BudgetLimitApproached as u32, 6);
        assert_eq!(AuditEventType::BudgetLimitExceeded as u32, 7);
        assert_eq!(AuditEventType::ApprovalTimedOut as u32, 8);
        assert_eq!(AuditEventType::ApprovalRouted as u32, 9);
        assert_eq!(AuditEventType::ApprovalEscalated as u32, 10);
        assert_eq!(AuditEventType::ToolDispatched as u32, 13);
        assert_eq!(AuditEventType::SandboxStarted as u32, 16);
        assert_eq!(AuditEventType::SandboxFilesystemBlocked as u32, 17);
        assert_eq!(AuditEventType::SandboxCpuTimeout as u32, 18);
        assert_eq!(AuditEventType::SandboxOomKilled as u32, 19);
        assert_eq!(AuditEventType::SandboxTerminated as u32, 20);
        assert_eq!(AuditEventType::SandboxHostFnRateLimited as u32, 21);
    }

    #[test]
    fn event_type_variants_are_all_distinct() {
        let variants = [
            AuditEventType::ToolCallIntercepted,
            AuditEventType::PolicyViolation,
            AuditEventType::CredentialLeakBlocked,
            AuditEventType::ApprovalRequested,
            AuditEventType::ApprovalGranted,
            AuditEventType::ApprovalDenied,
            AuditEventType::BudgetLimitApproached,
            AuditEventType::BudgetLimitExceeded,
            AuditEventType::ApprovalTimedOut,
            AuditEventType::ApprovalRouted,
            AuditEventType::ApprovalEscalated,
            AuditEventType::ToolDispatched,
            AuditEventType::SandboxStarted,
            AuditEventType::SandboxFilesystemBlocked,
            AuditEventType::SandboxCpuTimeout,
            AuditEventType::SandboxOomKilled,
            AuditEventType::SandboxTerminated,
            AuditEventType::SandboxHostFnRateLimited,
        ];
        for i in 0..variants.len() {
            for j in (i + 1)..variants.len() {
                assert_ne!(variants[i], variants[j]);
            }
        }
    }

    // --- AuditEntry::new() and getters ---

    #[test]
    fn new_produces_nonzero_entry_hash() {
        let entry = make_entry(0);
        assert_ne!(entry.entry_hash(), &[0u8; 32]);
    }

    #[test]
    fn getters_return_correct_values() {
        let payload = alloc::string::String::from("{\"k\":\"v\"}");
        let entry = AuditEntry::new(
            42,
            999_000_000,
            AuditEventType::PolicyViolation,
            AgentId::from_bytes(AGENT_BYTES),
            SessionId::from_bytes(SESSION_BYTES),
            payload.clone(),
            GENESIS_HASH,
        );
        assert_eq!(entry.seq(), 42);
        assert_eq!(entry.timestamp_ns(), 999_000_000);
        assert_eq!(entry.event_type(), AuditEventType::PolicyViolation);
        assert_eq!(entry.agent_id(), AgentId::from_bytes(AGENT_BYTES));
        assert_eq!(entry.session_id(), SessionId::from_bytes(SESSION_BYTES));
        assert_eq!(entry.payload(), "{\"k\":\"v\"}");
        assert_eq!(entry.previous_hash(), &GENESIS_HASH);
    }

    #[test]
    fn genesis_entry_uses_zero_previous_hash() {
        let entry = make_entry(0);
        assert_eq!(entry.previous_hash(), &[0u8; 32]);
    }

    // --- verify_integrity() ---

    #[test]
    fn verify_integrity_true_for_untampered_entry() {
        assert!(make_entry(0).verify_integrity());
    }

    #[test]
    fn verify_integrity_false_after_seq_tamper() {
        let mut entry = make_entry(0);
        // SAFETY: deliberate tampering to test integrity detection.
        unsafe {
            let ptr = &mut entry.seq as *mut u64;
            *ptr = 999;
        }
        assert!(!entry.verify_integrity());
    }

    #[test]
    fn verify_integrity_false_after_payload_tamper() {
        let mut entry = make_entry(0);
        // SAFETY: deliberate tampering to test integrity detection.
        unsafe {
            let ptr = entry.payload.as_mut_vec();
            if let Some(b) = ptr.first_mut() {
                *b = b'X';
            }
        }
        assert!(!entry.verify_integrity());
    }

    #[test]
    fn verify_integrity_false_after_event_type_tamper() {
        let mut entry = make_entry(0);
        // SAFETY: deliberate tampering to test integrity detection.
        unsafe {
            let ptr = &mut entry.event_type as *mut AuditEventType;
            *ptr = AuditEventType::BudgetLimitExceeded;
        }
        assert!(!entry.verify_integrity());
    }

    #[test]
    fn verify_integrity_false_after_previous_hash_tamper() {
        let mut entry = make_entry(0);
        // SAFETY: deliberate tampering to test integrity detection.
        unsafe {
            let ptr = &mut entry.previous_hash as *mut [u8; 32];
            (*ptr)[0] = 0xFF;
        }
        assert!(!entry.verify_integrity());
    }

    // --- Hash chain linkage ---

    #[test]
    fn chained_entries_have_distinct_hashes() {
        let first = make_entry(0);
        let second = AuditEntry::new(
            1,
            1_714_222_134_000_000_001,
            AuditEventType::PolicyViolation,
            AgentId::from_bytes(AGENT_BYTES),
            SessionId::from_bytes(SESSION_BYTES),
            alloc::string::String::from("{\"rule\":\"deny\"}"),
            *first.entry_hash(),
        );
        assert_ne!(first.entry_hash(), second.entry_hash());
        assert_eq!(second.previous_hash(), first.entry_hash());
        assert!(second.verify_integrity());
    }

    #[test]
    fn different_seq_produces_different_hash() {
        let a = make_entry(0);
        let b = make_entry(1);
        assert_ne!(a.entry_hash(), b.entry_hash());
    }

    #[test]
    fn different_previous_hash_produces_different_entry_hash() {
        let prev_a = [0u8; 32];
        let mut prev_b = [0u8; 32];
        prev_b[0] = 1;

        let a = AuditEntry::new(
            0,
            0,
            AuditEventType::ToolCallIntercepted,
            AgentId::from_bytes(AGENT_BYTES),
            SessionId::from_bytes(SESSION_BYTES),
            alloc::string::String::from("{}"),
            prev_a,
        );
        let b = AuditEntry::new(
            0,
            0,
            AuditEventType::ToolCallIntercepted,
            AgentId::from_bytes(AGENT_BYTES),
            SessionId::from_bytes(SESSION_BYTES),
            alloc::string::String::from("{}"),
            prev_b,
        );
        assert_ne!(a.entry_hash(), b.entry_hash());
    }

    // --- Display ---

    #[test]
    fn display_contains_seq_ts_and_event_name() {
        let entry = make_entry(7);
        let s = alloc::format!("{}", entry);
        assert!(s.starts_with('['));
        assert!(s.ends_with(']'));
        assert!(s.contains("seq=7"));
        assert!(s.contains("ts=1714222134000000000"));
        assert!(s.contains("event=ToolCallIntercepted"));
    }

    #[test]
    fn display_contains_agent_and_session_hex() {
        let entry = make_entry(0);
        let s = alloc::format!("{}", entry);
        // AGENT_BYTES starts with 01 02 03 04
        assert!(s.contains("agent=01020304"));
        // SESSION_BYTES starts with 11 12 13 14
        assert!(s.contains("session=11121314"));
    }

    #[test]
    fn display_does_not_contain_payload() {
        let entry = make_entry(0);
        let s = alloc::format!("{}", entry);
        assert!(!s.contains("bash"));
    }

    #[test]
    fn display_round_trips_sandbox_event_names() {
        // For each Sandbox* lifecycle variant introduced under AAASM-1965,
        // assert that AuditEntry's Display surfaces the variant's `as_str()`
        // label verbatim. Auditors grep the JSONL log by these tokens.
        let sandbox_events = [
            (AuditEventType::SandboxStarted, "event=SandboxStarted]"),
            (
                AuditEventType::SandboxFilesystemBlocked,
                "event=SandboxFilesystemBlocked]",
            ),
            (AuditEventType::SandboxCpuTimeout, "event=SandboxCpuTimeout]"),
            (AuditEventType::SandboxOomKilled, "event=SandboxOomKilled]"),
            (AuditEventType::SandboxTerminated, "event=SandboxTerminated]"),
            (
                AuditEventType::SandboxHostFnRateLimited,
                "event=SandboxHostFnRateLimited]",
            ),
        ];
        for (event_type, expected_tail) in sandbox_events {
            let entry = AuditEntry::new(
                0,
                1_714_222_134_000_000_000,
                event_type,
                AgentId::from_bytes(AGENT_BYTES),
                SessionId::from_bytes(SESSION_BYTES),
                alloc::string::String::from("{}"),
                GENESIS_HASH,
            );
            let rendered = alloc::format!("{}", entry);
            assert!(
                rendered.ends_with(expected_tail),
                "Display for {:?} should end with `{}` but was `{}`",
                event_type,
                expected_tail,
                rendered,
            );
        }
    }

    // --- AuditLog helpers ---

    fn make_log() -> AuditLog {
        AuditLog::new(AgentId::from_bytes(AGENT_BYTES), SessionId::from_bytes(SESSION_BYTES))
    }

    fn make_valid_entry(seq: u64, previous_hash: [u8; 32]) -> AuditEntry {
        AuditEntry::new(
            seq,
            1_000_000_000,
            AuditEventType::ToolCallIntercepted,
            AgentId::from_bytes(AGENT_BYTES),
            SessionId::from_bytes(SESSION_BYTES),
            alloc::string::String::from("{}"),
            previous_hash,
        )
    }

    // --- AuditLog::push() ---

    #[test]
    fn push_genesis_entry_succeeds() {
        let mut log = make_log();
        let entry = make_valid_entry(0, GENESIS_HASH);
        assert!(log.push(entry).is_ok());
        assert_eq!(log.len(), 1);
    }

    #[test]
    fn push_rejects_seq_gap_skipping_forward() {
        let mut log = make_log();
        let entry = make_valid_entry(2, GENESIS_HASH); // expected seq=0
        let err = log.push(entry).unwrap_err();
        assert_eq!(err, AuditLogError::SequenceGap { expected: 0, got: 2 });
        assert!(log.is_empty(), "log must be unmodified on error");
    }

    #[test]
    fn push_rejects_seq_going_backward() {
        let mut log = make_log();
        let e0 = make_valid_entry(0, GENESIS_HASH);
        let hash0 = *e0.entry_hash();
        log.push(e0).unwrap();

        let e_back = make_valid_entry(0, hash0); // duplicate seq=0
        let err = log.push(e_back).unwrap_err();
        assert_eq!(err, AuditLogError::SequenceGap { expected: 1, got: 0 });
        assert_eq!(log.len(), 1, "log must be unmodified on error");
    }

    #[test]
    fn push_rejects_broken_hash_chain() {
        let mut log = make_log();
        let e0 = make_valid_entry(0, GENESIS_HASH);
        log.push(e0).unwrap();

        let wrong_prev = [0xAB; 32]; // not equal to e0.entry_hash()
        let e1 = make_valid_entry(1, wrong_prev);
        let err = log.push(e1).unwrap_err();
        assert_eq!(err, AuditLogError::HashChainBroken { at_seq: 1 });
        assert_eq!(log.len(), 1, "log must be unmodified on error");
    }

    #[test]
    fn push_two_valid_entries_succeeds() {
        let mut log = make_log();
        let e0 = make_valid_entry(0, GENESIS_HASH);
        let hash0 = *e0.entry_hash();
        log.push(e0).unwrap();

        let e1 = make_valid_entry(1, hash0);
        log.push(e1).unwrap();

        assert_eq!(log.len(), 2);
        assert_eq!(log.entries()[0].seq(), 0);
        assert_eq!(log.entries()[1].seq(), 1);
    }

    #[test]
    fn audit_log_error_display_sequence_gap() {
        let err = AuditLogError::SequenceGap { expected: 3, got: 7 };
        let s = alloc::format!("{}", err);
        assert!(s.contains("expected seq=3"));
        assert!(s.contains("got seq=7"));
    }

    #[test]
    fn audit_log_error_display_hash_chain_broken() {
        let err = AuditLogError::HashChainBroken { at_seq: 5 };
        let s = alloc::format!("{}", err);
        assert!(s.contains("at_seq=5") || s.contains("at seq=5"));
    }

    // --- AuditLog::next_entry() ---

    #[test]
    fn next_entry_genesis_has_seq_zero_and_zero_prev_hash() {
        let mut log = make_log();
        let e = log.next_entry(
            AuditEventType::ToolCallIntercepted,
            1_000,
            alloc::string::String::from("{}"),
        );
        assert_eq!(e.seq(), 0);
        assert_eq!(e.previous_hash(), &GENESIS_HASH);
        assert!(e.verify_integrity());
    }

    #[test]
    fn next_entry_auto_increments_seq() {
        let mut log = make_log();
        log.next_entry(
            AuditEventType::ToolCallIntercepted,
            1_000,
            alloc::string::String::from("{}"),
        );
        log.next_entry(
            AuditEventType::PolicyViolation,
            2_000,
            alloc::string::String::from("{}"),
        );
        log.next_entry(
            AuditEventType::ApprovalGranted,
            3_000,
            alloc::string::String::from("{}"),
        );

        assert_eq!(log.len(), 3);
        assert_eq!(log.entries()[0].seq(), 0);
        assert_eq!(log.entries()[1].seq(), 1);
        assert_eq!(log.entries()[2].seq(), 2);
    }

    #[test]
    fn next_entry_links_previous_hash_correctly() {
        let mut log = make_log();
        log.next_entry(
            AuditEventType::ToolCallIntercepted,
            1_000,
            alloc::string::String::from("{}"),
        );
        log.next_entry(
            AuditEventType::PolicyViolation,
            2_000,
            alloc::string::String::from("{}"),
        );

        let e0_hash = *log.entries()[0].entry_hash();
        assert_eq!(log.entries()[1].previous_hash(), &e0_hash);
    }

    #[test]
    fn next_entry_mixed_with_push_works_correctly() {
        let mut log = make_log();
        // First entry via next_entry
        log.next_entry(
            AuditEventType::ToolCallIntercepted,
            1_000,
            alloc::string::String::from("{}"),
        );
        let hash0 = *log.entries()[0].entry_hash();

        // Second entry via manual push with correct seq and previous_hash
        let e1 = make_valid_entry(1, hash0);
        log.push(e1).unwrap();

        // Third entry via next_entry — should pick up seq=2 and hash1
        log.next_entry(
            AuditEventType::ApprovalGranted,
            3_000,
            alloc::string::String::from("{}"),
        );

        assert_eq!(log.len(), 3);
        assert_eq!(log.entries()[2].seq(), 2);
        assert_eq!(log.entries()[2].previous_hash(), log.entries()[1].entry_hash());
    }

    #[test]
    fn next_entry_all_entries_pass_verify_integrity() {
        let mut log = make_log();
        for i in 0..5 {
            log.next_entry(
                AuditEventType::ToolCallIntercepted,
                i * 1_000,
                alloc::string::String::from("{}"),
            );
        }
        for entry in log.entries() {
            assert!(entry.verify_integrity());
        }
    }

    // --- AuditLog::verify_chain() ---

    #[test]
    fn verify_chain_empty_log_returns_true() {
        assert!(make_log().verify_chain());
    }

    #[test]
    fn verify_chain_valid_log_returns_true() {
        let mut log = make_log();
        for i in 0..4 {
            log.next_entry(
                AuditEventType::ToolCallIntercepted,
                i * 1_000,
                alloc::string::String::from("{}"),
            );
        }
        assert!(log.verify_chain());
    }

    #[test]
    fn verify_chain_false_after_unsafe_seq_tamper() {
        let mut log = make_log();
        log.next_entry(
            AuditEventType::ToolCallIntercepted,
            1_000,
            alloc::string::String::from("{}"),
        );
        log.next_entry(
            AuditEventType::PolicyViolation,
            2_000,
            alloc::string::String::from("{}"),
        );

        // Tamper the seq of the first entry.
        // SAFETY: deliberate tampering to test verify_chain detection.
        unsafe {
            let entry = &mut *(log.entries.as_mut_ptr());
            let ptr = &mut entry.seq as *mut u64;
            *ptr = 99;
        }
        assert!(!log.verify_chain());
    }

    #[test]
    fn verify_chain_false_after_unsafe_payload_tamper() {
        let mut log = make_log();
        log.next_entry(
            AuditEventType::ToolCallIntercepted,
            1_000,
            alloc::string::String::from("{}"),
        );
        log.next_entry(
            AuditEventType::PolicyViolation,
            2_000,
            alloc::string::String::from("{}"),
        );

        // Tamper the payload of the second entry — breaks its verify_integrity().
        // SAFETY: deliberate tampering to test verify_chain detection.
        unsafe {
            let entry = &mut *(log.entries.as_mut_ptr().add(1));
            if let Some(b) = entry.payload.as_mut_vec().first_mut() {
                *b = b'X';
            }
        }
        assert!(!log.verify_chain());
    }

    #[test]
    fn verify_chain_false_after_unsafe_previous_hash_tamper() {
        let mut log = make_log();
        log.next_entry(
            AuditEventType::ToolCallIntercepted,
            1_000,
            alloc::string::String::from("{}"),
        );
        log.next_entry(
            AuditEventType::PolicyViolation,
            2_000,
            alloc::string::String::from("{}"),
        );

        // Tamper previous_hash of the second entry — breaks chain linkage check.
        // SAFETY: deliberate tampering to test verify_chain detection.
        unsafe {
            let entry = &mut *(log.entries.as_mut_ptr().add(1));
            let ptr = &mut entry.previous_hash as *mut [u8; 32];
            (*ptr)[0] = 0xFF;
        }
        assert!(!log.verify_chain());
    }

    // --- audit_entry_for_tool_dispatch (AAASM-1920 Secret Injection) ---

    #[test]
    fn tool_dispatch_helper_emits_placeholder_form_payload() {
        // Synthetic — fabricated for this test only.
        let real_secret = "real-secret-abc-DEADBEEF-0001";
        let placeholder_args = serde_json::json!({
            "connection_string": "${DB_PASSWORD}"
        });

        let entry = audit_entry_for_tool_dispatch(
            42,
            1_714_222_134_000_000_000,
            AgentId::from_bytes(AGENT_BYTES),
            SessionId::from_bytes(SESSION_BYTES),
            &placeholder_args,
            GENESIS_HASH,
        );

        assert_eq!(entry.event_type(), AuditEventType::ToolDispatched);
        // Payload carries the placeholder-form, not the resolved value.
        assert!(entry.payload().contains("${DB_PASSWORD}"));
        assert!(
            !entry.payload().contains(real_secret),
            "audit payload MUST NOT contain the resolved credential — placeholder-form contract"
        );
    }
}

#[cfg(all(test, feature = "alloc", feature = "serde"))]
mod lineage_tests {
    use super::*;

    const AGENT: AgentId = AgentId::from_bytes([1u8; 16]);
    const SESSION: SessionId = SessionId::from_bytes([2u8; 16]);
    const ROOT: AgentId = AgentId::from_bytes([7u8; 16]);
    const PARENT: AgentId = AgentId::from_bytes([9u8; 16]);

    fn base_entry() -> AuditEntry {
        AuditEntry::new(
            0,
            1_700_000_000_000_000_000,
            AuditEventType::ToolCallIntercepted,
            AGENT,
            SESSION,
            r#"{"tool":"bash"}"#.into(),
            [0u8; 32],
        )
    }

    #[test]
    fn lineage_default_is_all_none() {
        let l = Lineage::default();
        assert!(l.root_agent_id.is_none());
        assert!(l.parent_agent_id.is_none());
        assert!(l.team_id.is_none());
        assert!(l.delegation_reason.is_none());
        assert!(l.spawned_by_tool.is_none());
        assert!(l.depth.is_none());
    }

    #[test]
    fn new_with_empty_lineage_produces_same_hash_as_new() {
        let legacy = base_entry();
        let with_lineage = AuditEntry::new_with_lineage(
            0,
            1_700_000_000_000_000_000,
            AuditEventType::ToolCallIntercepted,
            AGENT,
            SESSION,
            r#"{"tool":"bash"}"#.into(),
            [0u8; 32],
            Lineage::default(),
        );
        assert_eq!(
            legacy.entry_hash(),
            with_lineage.entry_hash(),
            "Lineage::default() must not change the hash"
        );
    }

    #[test]
    fn new_with_lineage_getters_return_correct_values() {
        let lineage = Lineage {
            root_agent_id: Some(ROOT),
            parent_agent_id: Some(PARENT),
            team_id: Some("team-alpha".into()),
            org_id: None,
            delegation_reason: Some("summarise".into()),
            spawned_by_tool: Some("langgraph".into()),
            depth: Some(2),
        };
        let entry = AuditEntry::new_with_lineage(
            0,
            1_000,
            AuditEventType::PolicyViolation,
            AGENT,
            SESSION,
            "{}".into(),
            [0u8; 32],
            lineage,
        );
        assert_eq!(entry.root_agent_id(), Some(ROOT));
        assert_eq!(entry.parent_agent_id(), Some(PARENT));
        assert_eq!(entry.team_id(), Some("team-alpha"));
        assert_eq!(entry.delegation_reason(), Some("summarise"));
        assert_eq!(entry.spawned_by_tool(), Some("langgraph"));
        assert_eq!(entry.depth(), Some(2));
    }

    #[test]
    fn verify_integrity_true_with_lineage() {
        let lineage = Lineage {
            root_agent_id: Some(ROOT),
            team_id: Some("ops".into()),
            depth: Some(1),
            ..Lineage::default()
        };
        let entry = AuditEntry::new_with_lineage(
            0,
            1_000,
            AuditEventType::ToolCallIntercepted,
            AGENT,
            SESSION,
            "{}".into(),
            [0u8; 32],
            lineage,
        );
        assert!(entry.verify_integrity());
    }

    #[test]
    fn lineage_fields_change_hash() {
        let no_lineage = base_entry();
        let lineage = Lineage {
            depth: Some(1),
            ..Lineage::default()
        };
        let with_depth = AuditEntry::new_with_lineage(
            0,
            1_700_000_000_000_000_000,
            AuditEventType::ToolCallIntercepted,
            AGENT,
            SESSION,
            r#"{"tool":"bash"}"#.into(),
            [0u8; 32],
            lineage,
        );
        assert_ne!(
            no_lineage.entry_hash(),
            with_depth.entry_hash(),
            "A present lineage field must change the hash"
        );
    }

    #[test]
    fn serde_round_trip_with_lineage() {
        let lineage = Lineage {
            root_agent_id: Some(ROOT),
            parent_agent_id: Some(PARENT),
            team_id: Some("t1".into()),
            org_id: Some("o1".into()),
            delegation_reason: Some("r".into()),
            spawned_by_tool: Some("s".into()),
            depth: Some(3),
        };
        let entry = AuditEntry::new_with_lineage(
            0,
            1_000,
            AuditEventType::ToolCallIntercepted,
            AGENT,
            SESSION,
            "{}".into(),
            [0u8; 32],
            lineage,
        );
        let json = serde_json::to_string(&entry).unwrap();
        let restored: AuditEntry = serde_json::from_str(&json).unwrap();
        assert_eq!(entry.entry_hash(), restored.entry_hash());
        assert_eq!(restored.root_agent_id(), Some(ROOT));
        assert_eq!(restored.depth(), Some(3));
    }

    #[test]
    fn legacy_jsonl_without_lineage_fields_deserialises_and_verifies() {
        let pre_change_entry = AuditEntry::new(
            0,
            1_700_000_000_000_000_000,
            AuditEventType::ToolCallIntercepted,
            AGENT,
            SESSION,
            r#"{"tool":"bash"}"#.into(),
            [0u8; 32],
        );
        let json = serde_json::to_string(&pre_change_entry).unwrap();
        assert!(!json.contains("root_agent_id"), "None fields must not appear in JSON");
        let restored: AuditEntry = serde_json::from_str(&json).unwrap();
        assert!(restored.root_agent_id().is_none());
        assert!(
            restored.verify_integrity(),
            "Legacy entries must still verify after adding lineage fields"
        );
    }

    #[test]
    fn next_entry_with_lineage_links_chain() {
        let mut log = AuditLog::new(AGENT, SESSION);
        let lineage = Lineage {
            depth: Some(1),
            team_id: Some("t".into()),
            ..Lineage::default()
        };
        log.next_entry_with_lineage(AuditEventType::ToolCallIntercepted, 1_000, "{}".into(), lineage.clone());
        log.next_entry_with_lineage(AuditEventType::PolicyViolation, 2_000, "{}".into(), lineage);
        assert!(log.verify_chain());
        assert_eq!(log.len(), 2);
    }
}

#[cfg(all(test, feature = "std", feature = "serde"))]
mod redaction_tests {
    use super::*;
    use aa_security::CredentialScanner;

    const AGENT: AgentId = AgentId::from_bytes([3u8; 16]);
    const SESSION: SessionId = SessionId::from_bytes([4u8; 16]);

    /// Synthetic AWS access key from AWS public documentation. Not a real credential.
    const FAKE_AWS_ACCESS_KEY: &str = "AKIAIOSFODNN7EXAMPLE";

    fn build_redaction_for_fake_secret() -> Redaction {
        let scanner = CredentialScanner::new();
        let scan = scanner.scan(FAKE_AWS_ACCESS_KEY);
        assert!(
            !scan.findings.is_empty(),
            "scanner must detect the synthetic AWS access key — fixture invariant",
        );
        let redacted = scan.redact(FAKE_AWS_ACCESS_KEY);
        Redaction {
            credential_findings: scan.findings,
            redacted_payload: Some(redacted),
        }
    }

    #[test]
    fn audit_entry_with_redaction_never_serializes_the_raw_secret() {
        let redaction = build_redaction_for_fake_secret();
        // Decision metadata only — no raw secret bytes in the audit payload itself.
        let payload = String::from(r#"{"action_type":"tool_call","decision":"redact"}"#);
        let entry = AuditEntry::new_with_lineage_and_redaction(
            0,
            1_700_000_000_000_000_000,
            AuditEventType::CredentialLeakBlocked,
            AGENT,
            SESSION,
            payload,
            [0u8; 32],
            Lineage::default(),
            redaction,
        );

        let serialized = serde_json::to_string(&entry).expect("AuditEntry must serialize");

        // Primary security invariant: the raw secret bytes never appear in the
        // serialized AuditEntry — neither in `payload`, nor in `credential_findings`,
        // nor in `redacted_payload`.
        assert!(
            !serialized.contains(FAKE_AWS_ACCESS_KEY),
            "SECURITY INVARIANT VIOLATED: raw secret appears in serialized AuditEntry: {serialized}",
        );

        // Secondary sanity check: the redaction label IS present, proving findings were attached.
        assert!(
            serialized.contains("[REDACTED:AwsAccessKey]"),
            "serialized AuditEntry must carry the [REDACTED:AwsAccessKey] label, got: {serialized}",
        );

        // Tamper-evidence holds: the entry validates against its recorded hash.
        assert!(
            entry.verify_integrity(),
            "verify_integrity must pass on a freshly constructed redacted entry",
        );
    }

    #[test]
    fn redaction_default_preserves_legacy_hash() {
        // new() and new_with_lineage_and_redaction(_, _, ..., Redaction::default())
        // must produce identical entry_hash for the same base fields. This guarantees
        // the existing audit chain on disk continues to verify after this PR lands.
        let payload = String::from(r#"{"tool":"bash"}"#);
        let legacy = AuditEntry::new(
            0,
            1_700_000_000_000_000_000,
            AuditEventType::ToolCallIntercepted,
            AGENT,
            SESSION,
            payload.clone(),
            [0u8; 32],
        );
        let with_default_redaction = AuditEntry::new_with_lineage_and_redaction(
            0,
            1_700_000_000_000_000_000,
            AuditEventType::ToolCallIntercepted,
            AGENT,
            SESSION,
            payload,
            [0u8; 32],
            Lineage::default(),
            Redaction::default(),
        );
        assert_eq!(
            legacy.entry_hash(),
            with_default_redaction.entry_hash(),
            "Redaction::default() must contribute 0 bytes to the hash so legacy chains keep verifying",
        );
    }
}
