//! Append-only audit log primitives.
//!
//! The alpha scheme records each event as an NDJSON envelope containing a
//! monotonic sequence number, a rolling chain hash, and a Merkle leaf hash.
//! A final [`AuditIntegritySummary`] commits to the event count, chain head,
//! and Merkle root.

use crate::supervisor::{AuditEntry, UrlOpenRequest};
use crate::trust;
use crate::undo::{
    AuditAttestationSummary, AuditIntegritySummary, ContentHash, NetworkAuditEvent, SessionMetadata,
};
use crate::{NonoError, Result};
use serde::{Deserialize, Serialize};
use sha2::{Digest, Sha256};
use sigstore_verify::types::bundle::SignatureContent;
use std::fs::{File, OpenOptions};
use std::io::{BufRead, BufReader, Seek, SeekFrom, Write};
#[cfg(unix)]
use std::os::unix::ffi::OsStrExt;
use std::path::{Path, PathBuf};

/// Filename used for per-session audit event logs.
pub const AUDIT_EVENTS_FILENAME: &str = "audit-events.ndjson";

/// Domain separator for alpha event leaf hashes.
pub const EVENT_DOMAIN_ALPHA: &[u8] = b"nono.audit.event.alpha\n";
/// Domain separator for alpha rolling chain hashes.
pub const CHAIN_DOMAIN_ALPHA: &[u8] = b"nono.audit.chain.alpha\n";
/// Domain separator for alpha Merkle internal-node hashes.
pub const MERKLE_NODE_DOMAIN_ALPHA: &[u8] = b"nono.audit.merkle.alpha\n";
/// Merkle scheme label emitted by alpha verification.
pub const MERKLE_SCHEME_ALPHA: &str = "alpha";
/// Hash algorithm label emitted by alpha verification.
pub const AUDIT_HASH_ALGORITHM: &str = "sha256";
/// Domain separator for alpha session digests.
pub const SESSION_DIGEST_DOMAIN_ALPHA: &[u8] = b"nono.audit.session-digest.alpha\n";
/// Domain separator for alpha ledger chain links.
pub const LEDGER_CHAIN_DOMAIN_ALPHA: &[u8] = b"nono.audit.ledger.chain.alpha\n";
/// Default filename used for audit attestation bundles in session directories.
pub const AUDIT_ATTESTATION_BUNDLE_FILENAME: &str = "audit-attestation.bundle";
/// Predicate type for alpha audit session attestations.
pub const AUDIT_ATTESTATION_PREDICATE_TYPE_ALPHA: &str =
    "https://nono.sh/attestation/audit-session/alpha";

/// Event payloads written into the alpha audit log.
#[derive(Clone, Serialize, Deserialize)]
#[serde(tag = "type", rename_all = "snake_case")]
pub enum AuditEventPayload {
    /// Session start event.
    SessionStarted {
        /// ISO-8601 start timestamp.
        started: String,
        /// Redacted command line.
        command: Vec<String>,
        /// Redaction policy delta from the secure default, when configured.
        #[serde(default, skip_serializing_if = "Option::is_none")]
        redaction_policy: Option<crate::ScrubPolicyDiff>,
    },
    /// Session end event.
    SessionEnded {
        /// ISO-8601 end timestamp.
        ended: String,
        /// Child process exit code.
        exit_code: i32,
    },
    /// Capability approval decision.
    CapabilityDecision {
        /// Supervisor audit entry.
        entry: AuditEntry,
    },
    /// URL-open request result.
    UrlOpen {
        /// URL-open request.
        request: UrlOpenRequest,
        /// Whether the request succeeded.
        success: bool,
        /// Error message, when the request failed.
        error: Option<String>,
    },
    /// Network audit event.
    Network {
        /// Network audit event emitted by the proxy or sandbox supervisor.
        event: Box<NetworkAuditEvent>,
    },
    /// Sandbox runtime metadata.
    SandboxRuntime {
        /// Sandbox runtime event emitted when execution starts.
        event: SandboxRuntimeAuditEvent,
    },
    /// Tool sandbox command policy decision.
    CommandPolicy {
        /// Command policy decision event.
        event: Box<CommandPolicyAuditEvent>,
    },
}

/// Sandbox runtime metadata captured in the audit log.
#[derive(Clone, Serialize, Deserialize)]
pub struct SandboxRuntimeAuditEvent {
    /// RFC3339 timestamp.
    pub timestamp: String,
    /// Runtime platform name.
    pub platform: String,
    /// Landlock ABI version when available.
    #[serde(default, skip_serializing_if = "Option::is_none")]
    pub landlock_abi: Option<String>,
    /// Whether Landlock execute restrictions were enforced.
    #[serde(default, skip_serializing_if = "Option::is_none")]
    pub landlock_execute_enforced: Option<bool>,
    /// Whether tool sandbox command mediation was active.
    pub tool_sandbox_active: bool,
}

/// Tool sandbox command policy decision captured in the audit log.
#[derive(Clone, Serialize, Deserialize)]
pub struct CommandPolicyAuditEvent {
    /// RFC3339 timestamp.
    pub timestamp: String,
    /// Session identifier.
    #[serde(default, skip_serializing_if = "Option::is_none")]
    pub session_id: Option<String>,
    /// Command name being mediated.
    pub command: String,
    /// Caller label.
    pub caller: String,
    /// Caller kind, when known.
    #[serde(default, skip_serializing_if = "Option::is_none")]
    pub caller_kind: Option<String>,
    /// Caller command, when known.
    #[serde(default, skip_serializing_if = "Option::is_none")]
    pub caller_command: Option<String>,
    /// Caller process id, when known.
    #[serde(default, skip_serializing_if = "Option::is_none")]
    pub caller_pid: Option<u32>,
    /// Shim process id, when known.
    #[serde(default, skip_serializing_if = "Option::is_none")]
    pub shim_pid: Option<u32>,
    /// Session root process id, when known.
    #[serde(default, skip_serializing_if = "Option::is_none")]
    pub session_root_pid: Option<u32>,
    /// Policy decision.
    pub decision: String,
    /// Decision reason.
    pub reason: Option<String>,
    /// Stdio mode used for the launched command.
    pub stdio_mode: String,
    /// Hash of argv bytes.
    pub argv_hash: String,
    /// Hash of environment variable names.
    pub env_name_hash: String,
    /// Hash of current working directory bytes.
    pub cwd_hash: String,
    /// Redacted argv display.
    pub argv_display: Vec<String>,
    /// Redacted environment variable names.
    pub env_names_display: Vec<String>,
    /// Redacted environment entries selected for display.
    #[serde(default, skip_serializing_if = "Vec::is_empty")]
    pub env_display: Vec<CommandPolicyEnvAuditEntry>,
    /// Redacted current working directory display.
    pub cwd_display: String,
    /// Command exit code, when a command was launched.
    pub exit_code: Option<i32>,
    /// Captured stdio accounting.
    #[serde(default, skip_serializing_if = "Option::is_none")]
    pub stdio: Option<CommandPolicyStdioAudit>,
}

/// Redacted command policy environment entry.
#[derive(Clone, Serialize, Deserialize)]
pub struct CommandPolicyEnvAuditEntry {
    /// Environment variable name.
    pub name: String,
    /// Redacted display value.
    pub value_display: String,
}

/// Command policy stdio audit metadata.
#[derive(Clone, Serialize, Deserialize)]
pub struct CommandPolicyStdioAudit {
    /// Stdout accounting.
    #[serde(default, skip_serializing_if = "Option::is_none")]
    pub stdout: Option<CommandPolicyStdioStreamAudit>,
    /// Stderr accounting.
    #[serde(default, skip_serializing_if = "Option::is_none")]
    pub stderr: Option<CommandPolicyStdioStreamAudit>,
}

/// Command policy stdio stream accounting.
#[derive(Clone, Serialize, Deserialize)]
pub struct CommandPolicyStdioStreamAudit {
    /// Total observed bytes.
    pub total_bytes: u64,
    /// Bytes forwarded to the caller.
    pub forwarded_bytes: u64,
    /// Configured byte limit.
    #[serde(default, skip_serializing_if = "Option::is_none")]
    pub max_bytes: Option<u64>,
    /// Whether the configured limit was exceeded.
    pub limit_exceeded: bool,
    /// Action taken when the limit was exceeded.
    #[serde(default, skip_serializing_if = "Option::is_none")]
    pub on_limit: Option<String>,
}

/// One line of `audit-events.ndjson`.
#[derive(Clone, Serialize, Deserialize)]
pub struct AuditEventRecord {
    /// Monotonic sequence number, starting at 0.
    pub sequence: u64,
    /// Previous record's chain hash, or `None` for the first record.
    pub prev_chain: Option<ContentHash>,
    /// Hash of the canonical event JSON bytes.
    pub leaf_hash: ContentHash,
    /// Rolling chain hash over the previous chain hash and this leaf.
    pub chain_hash: ContentHash,
    /// Canonical event JSON bytes used to derive `leaf_hash`.
    #[serde(default, skip_serializing_if = "Option::is_none")]
    pub event_json: Option<String>,
    /// Parsed event payload.
    pub event: AuditEventPayload,
}

/// Result of verifying an alpha audit log.
#[derive(Serialize)]
pub struct AuditVerificationResult {
    /// Hash algorithm used for event leaves and chain/root derivation.
    pub hash_algorithm: String,
    /// Merkle scheme label.
    pub merkle_scheme: String,
    /// Number of verified events.
    pub event_count: u64,
    /// Recomputed rolling chain head.
    pub computed_chain_head: Option<ContentHash>,
    /// Recomputed Merkle root over ordered event leaves.
    pub computed_merkle_root: Option<ContentHash>,
    /// Stored event count from session metadata, when supplied.
    pub stored_event_count: Option<u64>,
    /// Stored chain head from session metadata, when supplied.
    pub stored_chain_head: Option<ContentHash>,
    /// Stored Merkle root from session metadata, when supplied.
    pub stored_merkle_root: Option<ContentHash>,
    /// Whether the stored event count matches the recomputed count.
    pub event_count_matches: bool,
    /// True when all record-level checks passed.
    pub records_verified: bool,
}

#[derive(Serialize)]
struct SessionDigestPayload<'a> {
    session_id: &'a str,
    started: &'a str,
    ended: &'a Option<String>,
    command: &'a [String],
    executable_identity: Option<ExecutableIdentityDigestPayload>,
    tracked_paths: Vec<Vec<u8>>,
    snapshot_count: u32,
    exit_code: &'a Option<i32>,
    merkle_roots: &'a [ContentHash],
    network_events: &'a [NetworkAuditEvent],
    audit_event_count: u64,
    audit_integrity: &'a Option<AuditIntegritySummary>,
    audit_attestation: &'a Option<AuditAttestationSummary>,
}

#[derive(Serialize)]
struct ExecutableIdentityDigestPayload {
    resolved_path: Vec<u8>,
    sha256: ContentHash,
}

/// One line of the append-only session ledger.
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct LedgerRecord {
    /// Monotonic ledger sequence number.
    pub sequence: u64,
    /// Previous ledger record's chain hash, or `None` for the first record.
    pub prev_chain: Option<ContentHash>,
    /// Session ID committed by this ledger entry.
    pub session_id: String,
    /// Digest over protected session metadata fields.
    pub session_digest: ContentHash,
    /// Session completion timestamp used in the ledger link payload.
    pub completed_at: String,
    /// Rolling ledger chain hash.
    pub chain_hash: ContentHash,
}

#[derive(Serialize)]
struct LedgerLinkPayload<'a> {
    sequence: u64,
    session_id: &'a str,
    session_digest: ContentHash,
    completed_at: &'a str,
}

/// Result of checking a session against an append-only ledger.
#[derive(Debug, Clone, Serialize)]
pub struct LedgerVerificationResult {
    /// Hash algorithm used by the ledger.
    pub hash_algorithm: String,
    /// Number of verified ledger entries.
    pub entry_count: u64,
    /// Expected digest for the provided session metadata.
    pub session_digest: ContentHash,
    /// Whether the session ID was present in the ledger.
    pub session_found: bool,
    /// Whether the ledger digest matched the current session metadata digest.
    pub session_digest_matches: bool,
    /// Whether every ledger chain link verified.
    pub ledger_chain_verified: bool,
    /// Final ledger chain head.
    pub ledger_head: Option<ContentHash>,
}

/// Result of checking a signed audit attestation bundle against session metadata.
#[derive(Debug, Clone, Serialize)]
pub struct AuditAttestationVerificationResult {
    /// Whether session metadata referenced an audit attestation.
    pub present: bool,
    /// Predicate type recorded in metadata or the verified bundle.
    pub predicate_type: Option<String>,
    /// Signer key identifier from the attestation metadata.
    pub key_id: Option<String>,
    /// Whether metadata, bundle signer identity, and public key digest agree.
    pub key_id_matches: bool,
    /// Whether the DSSE signature verified with the attested public key.
    pub signature_verified: bool,
    /// Whether the signed Merkle root matches the session integrity summary.
    pub merkle_root_matches: bool,
    /// Whether the signed predicate session ID matches the session metadata.
    pub session_id_matches: bool,
    /// Whether an externally provided public key matches the attested public key.
    pub expected_public_key_matches: Option<bool>,
    /// Human-readable verification failure, if verification did not succeed.
    pub verification_error: Option<String>,
}

#[derive(Serialize)]
struct AuditAttestationPredicate<'a> {
    version: u32,
    session_id: &'a str,
    started: &'a str,
    ended: &'a Option<String>,
    command: &'a [String],
    #[serde(skip_serializing_if = "Option::is_none")]
    redaction_policy: Option<crate::ScrubPolicyDiff>,
    audit_log: AuditLogPredicate<'a>,
    signer: AuditSignerPredicate<'a>,
}

#[derive(Serialize)]
struct AuditLogPredicate<'a> {
    hash_algorithm: &'a str,
    event_count: u64,
    chain_head: &'a ContentHash,
    merkle_root: &'a ContentHash,
}

#[derive(Serialize)]
struct AuditSignerPredicate<'a> {
    kind: &'static str,
    key_id: &'a str,
}

/// Position of a sibling hash in an audit Merkle inclusion proof.
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
#[serde(rename_all = "snake_case")]
pub enum AuditProofDirection {
    /// The sibling hash is the left input to this Merkle node.
    Left,
    /// The sibling hash is the right input to this Merkle node.
    Right,
}

/// One sibling step in an audit Merkle inclusion proof.
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
pub struct AuditProofNode {
    /// Which side of the current hash this sibling occupies.
    pub direction: AuditProofDirection,
    /// Sibling hash.
    pub hash: ContentHash,
}

/// Compact proof that one audit leaf is included in an alpha Merkle root.
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
pub struct AuditInclusionProof {
    /// Zero-based leaf index.
    pub leaf_index: u64,
    /// Total number of leaves in the tree.
    pub leaf_count: u64,
    /// Included audit leaf hash.
    pub leaf_hash: ContentHash,
    /// Claimed alpha Merkle root.
    pub merkle_root: ContentHash,
    /// Sibling path from leaf to root.
    pub siblings: Vec<AuditProofNode>,
}

/// Stateful writer for alpha-scheme audit records.
pub struct AuditRecorder {
    file: File,
    next_sequence: u64,
    previous_chain: Option<ContentHash>,
    leaf_hashes: Vec<ContentHash>,
    redaction_policy: crate::ScrubPolicy,
}

impl AuditRecorder {
    /// Create a recorder with the secure default redaction policy.
    pub fn new(session_dir: PathBuf) -> Result<Self> {
        Self::new_with_policy(session_dir, crate::ScrubPolicy::secure_default())
    }

    /// Create a recorder using a caller-supplied redaction policy.
    pub fn new_with_policy(
        session_dir: PathBuf,
        redaction_policy: crate::ScrubPolicy,
    ) -> Result<Self> {
        let path = session_dir.join(AUDIT_EVENTS_FILENAME);
        let file = OpenOptions::new()
            .create(true)
            .append(true)
            .open(&path)
            .map_err(|e| {
                NonoError::Snapshot(format!(
                    "Failed to open audit event log {}: {e}",
                    path.display()
                ))
            })?;
        Ok(Self {
            file,
            next_sequence: 0,
            previous_chain: None,
            leaf_hashes: Vec::new(),
            redaction_policy,
        })
    }

    /// Record a session start event.
    pub fn record_session_started(&mut self, started: String, command: Vec<String>) -> Result<()> {
        self.append_event(AuditEventPayload::SessionStarted {
            started,
            command: crate::scrub_argv_with_policy(&command, &self.redaction_policy),
            redaction_policy: self
                .redaction_policy
                .diff_from_secure_default()
                .into_option(),
        })
    }

    /// Record a session end event.
    pub fn record_session_ended(&mut self, ended: String, exit_code: i32) -> Result<()> {
        self.append_event(AuditEventPayload::SessionEnded { ended, exit_code })
    }

    /// Record a capability approval decision.
    pub fn record_capability_decision(&mut self, entry: AuditEntry) -> Result<()> {
        self.append_event(AuditEventPayload::CapabilityDecision { entry })
    }

    /// Record a URL-open request result.
    pub fn record_open_url(
        &mut self,
        request: UrlOpenRequest,
        success: bool,
        error: Option<String>,
    ) -> Result<()> {
        self.append_event(AuditEventPayload::UrlOpen {
            request,
            success,
            error,
        })
    }

    /// Record a network event.
    pub fn record_network_event(&mut self, event: NetworkAuditEvent) -> Result<()> {
        self.append_event(AuditEventPayload::Network {
            event: Box::new(event),
        })
    }

    /// Record sandbox runtime metadata.
    #[cfg_attr(not(target_os = "linux"), allow(dead_code))]
    pub fn record_sandbox_runtime_event(&mut self, event: SandboxRuntimeAuditEvent) -> Result<()> {
        self.append_event(AuditEventPayload::SandboxRuntime { event })
    }

    /// Record a tool sandbox command policy decision.
    #[cfg_attr(not(target_os = "linux"), allow(dead_code))]
    pub fn record_command_policy_event(&mut self, event: CommandPolicyAuditEvent) -> Result<()> {
        self.append_event(AuditEventPayload::CommandPolicy {
            event: Box::new(event),
        })
    }

    /// Number of events appended by this recorder.
    #[must_use]
    pub fn event_count(&self) -> u64 {
        self.leaf_hashes.len() as u64
    }

    /// Final integrity summary for the current log, if at least one event exists.
    #[must_use]
    pub fn finalize(&self) -> Option<AuditIntegritySummary> {
        let chain_head = self.previous_chain?;
        let merkle_root = merkle_root(&self.leaf_hashes);
        Some(AuditIntegritySummary {
            hash_algorithm: AUDIT_HASH_ALGORITHM.to_string(),
            event_count: self.event_count(),
            chain_head,
            merkle_root,
        })
    }

    fn append_event(&mut self, event: AuditEventPayload) -> Result<()> {
        let event_bytes = serde_json::to_vec(&event)
            .map_err(|e| NonoError::Snapshot(format!("Failed to serialize audit event: {e}")))?;
        let leaf_hash = hash_event(&event_bytes);
        let chain_hash = hash_chain(self.previous_chain.as_ref(), &leaf_hash);
        let record = AuditEventRecord {
            sequence: self.next_sequence,
            prev_chain: self.previous_chain,
            leaf_hash,
            chain_hash,
            event_json: Some(String::from_utf8(event_bytes.clone()).map_err(|e| {
                NonoError::Snapshot(format!(
                    "Failed to encode canonical audit event JSON as UTF-8: {e}"
                ))
            })?),
            event,
        };
        let line = serde_json::to_vec(&record)
            .map_err(|e| NonoError::Snapshot(format!("Failed to serialize audit record: {e}")))?;
        self.file
            .write_all(&line)
            .and_then(|_| self.file.write_all(b"\n"))
            .and_then(|_| self.file.flush())
            .map_err(|e| NonoError::Snapshot(format!("Failed to append audit record: {e}")))?;
        self.next_sequence = self.next_sequence.saturating_add(1);
        self.previous_chain = Some(chain_hash);
        self.leaf_hashes.push(leaf_hash);
        Ok(())
    }
}

/// Hash canonical event JSON bytes into an alpha event leaf.
#[must_use]
pub fn hash_event(event_bytes: &[u8]) -> ContentHash {
    let mut hasher = Sha256::new();
    hasher.update(EVENT_DOMAIN_ALPHA);
    hasher.update(event_bytes);
    ContentHash::from_bytes(hasher.finalize().into())
}

/// Hash one alpha rolling-chain link.
#[must_use]
pub fn hash_chain(previous: Option<&ContentHash>, leaf_hash: &ContentHash) -> ContentHash {
    let mut hasher = Sha256::new();
    hasher.update(CHAIN_DOMAIN_ALPHA);
    if let Some(prev) = previous {
        hasher.update(prev.as_bytes());
    } else {
        hasher.update([0u8; 32]);
    }
    hasher.update(leaf_hash.as_bytes());
    ContentHash::from_bytes(hasher.finalize().into())
}

/// Compute the alpha Merkle root over ordered leaves.
#[must_use]
pub fn merkle_root(leaves: &[ContentHash]) -> ContentHash {
    if leaves.is_empty() {
        return ContentHash::from_bytes(Sha256::digest(b"").into());
    }

    let mut level: Vec<[u8; 32]> = leaves.iter().map(|leaf| *leaf.as_bytes()).collect();
    while level.len() > 1 {
        let mut next = Vec::with_capacity(level.len().div_ceil(2));
        for pair in level.chunks(2) {
            let left = pair[0];
            if pair.len() == 1 {
                next.push(left);
                continue;
            }

            let right = pair[1];
            next.push(hash_merkle_node(left, right));
        }
        level = next;
    }
    ContentHash::from_bytes(level[0])
}

/// Build an alpha Merkle inclusion proof for one audit leaf.
pub fn build_inclusion_proof(
    leaves: &[ContentHash],
    leaf_index: usize,
) -> Result<AuditInclusionProof> {
    if leaves.is_empty() {
        return Err(NonoError::Snapshot(
            "Cannot build an audit inclusion proof for an empty log".to_string(),
        ));
    }
    if leaf_index >= leaves.len() {
        return Err(NonoError::Snapshot(format!(
            "Audit inclusion proof leaf index {} is out of range for {} leaves",
            leaf_index,
            leaves.len()
        )));
    }

    let mut siblings = Vec::new();
    let mut index = leaf_index;
    let mut level: Vec<[u8; 32]> = leaves.iter().map(|leaf| *leaf.as_bytes()).collect();
    while level.len() > 1 {
        let sibling_index = if index.is_multiple_of(2) {
            index.saturating_add(1)
        } else {
            index.saturating_sub(1)
        };
        if let Some(sibling) = level.get(sibling_index) {
            siblings.push(AuditProofNode {
                direction: if sibling_index < index {
                    AuditProofDirection::Left
                } else {
                    AuditProofDirection::Right
                },
                hash: ContentHash::from_bytes(*sibling),
            });
        }

        let mut next = Vec::with_capacity(level.len().div_ceil(2));
        for pair in level.chunks(2) {
            let left = pair[0];
            if pair.len() == 1 {
                next.push(left);
                continue;
            }
            next.push(hash_merkle_node(left, pair[1]));
        }
        index /= 2;
        level = next;
    }

    Ok(AuditInclusionProof {
        leaf_index: leaf_index as u64,
        leaf_count: leaves.len() as u64,
        leaf_hash: leaves[leaf_index],
        merkle_root: ContentHash::from_bytes(level[0]),
        siblings,
    })
}

/// Verify an alpha Merkle inclusion proof.
#[must_use]
pub fn verify_inclusion_proof(proof: &AuditInclusionProof) -> bool {
    if proof.leaf_count == 0 || proof.leaf_index >= proof.leaf_count {
        return false;
    }

    let mut computed = *proof.leaf_hash.as_bytes();
    let mut index = proof.leaf_index;
    let mut width = proof.leaf_count;
    let mut siblings = proof.siblings.iter();

    while width > 1 {
        let expected_direction = if index.is_multiple_of(2) {
            if index.saturating_add(1) < width {
                Some(AuditProofDirection::Right)
            } else {
                None
            }
        } else {
            Some(AuditProofDirection::Left)
        };

        if let Some(direction) = expected_direction {
            let Some(node) = siblings.next() else {
                return false;
            };
            if node.direction != direction {
                return false;
            }
            computed = match node.direction {
                AuditProofDirection::Left => hash_merkle_node(*node.hash.as_bytes(), computed),
                AuditProofDirection::Right => hash_merkle_node(computed, *node.hash.as_bytes()),
            };
        }

        index /= 2;
        width = width.div_ceil(2);
    }

    if siblings.next().is_some() {
        return false;
    }

    computed == *proof.merkle_root.as_bytes()
}

fn hash_merkle_node(left: [u8; 32], right: [u8; 32]) -> [u8; 32] {
    let mut hasher = Sha256::new();
    hasher.update(MERKLE_NODE_DOMAIN_ALPHA);
    hasher.update(left);
    hasher.update(right);
    hasher.finalize().into()
}

/// Compute the alpha session digest used by the append-only ledger.
pub fn compute_session_digest(metadata: &SessionMetadata) -> Result<ContentHash> {
    let payload = SessionDigestPayload {
        session_id: &metadata.session_id,
        started: &metadata.started,
        ended: &metadata.ended,
        command: &metadata.command,
        executable_identity: metadata.executable_identity.as_ref().map(|identity| {
            ExecutableIdentityDigestPayload {
                resolved_path: path_bytes(&identity.resolved_path),
                sha256: identity.sha256,
            }
        }),
        tracked_paths: metadata
            .tracked_paths
            .iter()
            .map(|path| path_bytes(path))
            .collect(),
        snapshot_count: metadata.snapshot_count,
        exit_code: &metadata.exit_code,
        merkle_roots: &metadata.merkle_roots,
        network_events: &metadata.network_events,
        audit_event_count: metadata.audit_event_count,
        audit_integrity: &metadata.audit_integrity,
        audit_attestation: &metadata.audit_attestation,
    };
    let bytes = serde_json::to_vec(&payload).map_err(|e| {
        NonoError::Snapshot(format!("Failed to serialize session digest payload: {e}"))
    })?;
    let mut hasher = Sha256::new();
    hasher.update(SESSION_DIGEST_DOMAIN_ALPHA);
    hasher.update(bytes);
    Ok(ContentHash::from_bytes(hasher.finalize().into()))
}

#[cfg(unix)]
fn path_bytes(path: &std::path::Path) -> Vec<u8> {
    path.as_os_str().as_bytes().to_vec()
}

#[cfg(not(unix))]
fn path_bytes(path: &std::path::Path) -> Vec<u8> {
    path.to_string_lossy().into_owned().into_bytes()
}

/// Validate a session ID before committing it to the global audit ledger.
pub fn validate_ledger_session_id(session_id: &str) -> Result<()> {
    let valid = !session_id.is_empty()
        && session_id.len() <= 64
        && session_id
            .bytes()
            .all(|b| b.is_ascii_alphanumeric() || matches!(b, b'-' | b'_'));
    if valid {
        Ok(())
    } else {
        Err(NonoError::ConfigParse(format!(
            "invalid audit session id: {session_id}"
        )))
    }
}

/// Append one session to an already opened and locked ledger file.
///
/// The caller owns storage decisions: where the ledger lives, whether the
/// file is locked, and how the parent directory is created.
pub fn append_session_to_ledger_file(
    file: &mut std::fs::File,
    metadata: &SessionMetadata,
) -> Result<LedgerRecord> {
    validate_ledger_session_id(&metadata.session_id)?;

    file.seek(SeekFrom::Start(0))
        .map_err(|e| NonoError::Snapshot(format!("Failed to seek audit ledger: {e}")))?;

    let mut previous_chain = None;
    let mut next_sequence = 0u64;
    {
        let reader = BufReader::new(&mut *file);
        for (index, line) in reader.lines().enumerate() {
            let line =
                line.map_err(|e| NonoError::Snapshot(format!("Failed to read audit ledger: {e}")))?;
            if line.trim().is_empty() {
                continue;
            }
            let record: LedgerRecord = serde_json::from_str(&line).map_err(|e| {
                NonoError::Snapshot(format!(
                    "Failed to parse audit ledger line {}: {e}",
                    index.saturating_add(1)
                ))
            })?;
            previous_chain = Some(record.chain_hash);
            next_sequence = record.sequence.saturating_add(1);
        }
    }

    let session_digest = compute_session_digest(metadata)?;
    let completed_at = metadata
        .ended
        .clone()
        .unwrap_or_else(|| metadata.started.clone());
    let chain_hash = hash_ledger_link(
        previous_chain.as_ref(),
        next_sequence,
        &metadata.session_id,
        &session_digest,
        &completed_at,
    )?;
    let record = LedgerRecord {
        sequence: next_sequence,
        prev_chain: previous_chain,
        session_id: metadata.session_id.clone(),
        session_digest,
        completed_at,
        chain_hash,
    };

    file.seek(SeekFrom::End(0))
        .map_err(|e| NonoError::Snapshot(format!("Failed to seek audit ledger for append: {e}")))?;
    let line = serde_json::to_vec(&record).map_err(|e| {
        NonoError::Snapshot(format!("Failed to serialize audit ledger record: {e}"))
    })?;
    file.write_all(&line)
        .and_then(|_| file.write_all(b"\n"))
        .and_then(|_| file.sync_data())
        .map_err(|e| NonoError::Snapshot(format!("Failed to append audit ledger record: {e}")))?;

    Ok(record)
}

/// Verification result for a missing ledger file.
pub fn missing_ledger_verification_result(
    metadata: &SessionMetadata,
) -> Result<LedgerVerificationResult> {
    Ok(LedgerVerificationResult {
        hash_algorithm: AUDIT_HASH_ALGORITHM.to_string(),
        entry_count: 0,
        session_digest: compute_session_digest(metadata)?,
        session_found: false,
        session_digest_matches: false,
        ledger_chain_verified: false,
        ledger_head: None,
    })
}

/// Verify an opened ledger reader and check whether it contains `metadata`.
pub fn verify_session_in_ledger_reader<R: BufRead>(
    reader: R,
    metadata: &SessionMetadata,
) -> Result<LedgerVerificationResult> {
    let expected_digest = compute_session_digest(metadata)?;

    let mut previous_chain = None;
    let mut entry_count = 0u64;
    let mut ledger_head = None;
    let mut session_found = false;
    let mut session_digest_matches = false;

    for (index, line) in reader.lines().enumerate() {
        let line =
            line.map_err(|e| NonoError::Snapshot(format!("Failed to read audit ledger: {e}")))?;
        if line.trim().is_empty() {
            continue;
        }
        let record: LedgerRecord = serde_json::from_str(&line).map_err(|e| {
            NonoError::Snapshot(format!(
                "Failed to parse audit ledger line {}: {e}",
                index.saturating_add(1)
            ))
        })?;
        if record.sequence != entry_count {
            return Err(NonoError::Snapshot(format!(
                "Audit ledger sequence mismatch at line {}",
                index.saturating_add(1)
            )));
        }
        if record.prev_chain != previous_chain {
            return Err(NonoError::Snapshot(format!(
                "Audit ledger prev_chain mismatch at line {}",
                index.saturating_add(1)
            )));
        }
        let chain_hash = hash_ledger_link(
            previous_chain.as_ref(),
            record.sequence,
            &record.session_id,
            &record.session_digest,
            &record.completed_at,
        )?;
        if chain_hash != record.chain_hash {
            return Err(NonoError::Snapshot(format!(
                "Audit ledger chain hash mismatch at line {}",
                index.saturating_add(1)
            )));
        }

        if record.session_id == metadata.session_id {
            session_found = true;
            session_digest_matches = record.session_digest == expected_digest;
        }

        previous_chain = Some(record.chain_hash);
        ledger_head = Some(record.chain_hash);
        entry_count = entry_count.saturating_add(1);
    }

    Ok(LedgerVerificationResult {
        hash_algorithm: AUDIT_HASH_ALGORITHM.to_string(),
        entry_count,
        session_digest: expected_digest,
        session_found,
        session_digest_matches,
        ledger_chain_verified: true,
        ledger_head,
    })
}

/// Build and sign an alpha audit attestation bundle for a completed session.
///
/// The caller owns key loading and bundle storage. This primitive commits to
/// the audit Merkle root, rolling chain head, event count, session identity,
/// and scrubbed command context, then signs the in-toto statement as DSSE.
pub fn sign_audit_attestation_bundle(
    metadata: &SessionMetadata,
    key_pair: &trust::KeyPair,
    key_id: &str,
    public_key_b64: &str,
    redaction_policy: &crate::ScrubPolicy,
) -> Result<(String, AuditAttestationSummary)> {
    let integrity = metadata
        .audit_integrity
        .as_ref()
        .ok_or_else(|| NonoError::TrustSigning {
            path: metadata.session_id.clone(),
            reason: "audit attestation requires audit integrity to be enabled".to_string(),
        })?;

    let scrubbed_command = crate::scrub_argv_with_policy(&metadata.command, redaction_policy);
    let predicate = serde_json::to_value(AuditAttestationPredicate {
        version: 1,
        session_id: &metadata.session_id,
        started: &metadata.started,
        ended: &metadata.ended,
        command: &scrubbed_command,
        redaction_policy: redaction_policy.diff_from_secure_default().into_option(),
        audit_log: AuditLogPredicate {
            hash_algorithm: &integrity.hash_algorithm,
            event_count: integrity.event_count,
            chain_head: &integrity.chain_head,
            merkle_root: &integrity.merkle_root,
        },
        signer: AuditSignerPredicate {
            kind: "keyed",
            key_id,
        },
    })
    .map_err(|e| NonoError::TrustSigning {
        path: metadata.session_id.clone(),
        reason: format!("failed to serialize audit attestation predicate: {e}"),
    })?;

    let statement = trust::new_statement(
        &format!("audit-session:{}", metadata.session_id),
        &integrity.merkle_root.to_string(),
        predicate,
        AUDIT_ATTESTATION_PREDICATE_TYPE_ALPHA,
    );
    let bundle_json = trust::sign_statement_bundle(&statement, key_pair)?;

    Ok((
        bundle_json,
        AuditAttestationSummary {
            predicate_type: AUDIT_ATTESTATION_PREDICATE_TYPE_ALPHA.to_string(),
            key_id: key_id.to_string(),
            public_key: public_key_b64.to_string(),
            bundle_filename: AUDIT_ATTESTATION_BUNDLE_FILENAME.to_string(),
        },
    ))
}

/// Verify an alpha audit attestation bundle against session metadata.
///
/// The caller is responsible for loading the bundle and any externally pinned
/// public key. This function validates the keyed DSSE signature, key identity,
/// signed Merkle root, and signed session ID. Supplying `expected_public_key`
/// is what gives the result an external trust anchor; without it, verification
/// proves only that the bundle, metadata summary, and embedded public key are
/// internally self-consistent.
pub fn verify_audit_attestation_bundle(
    bundle: &trust::Bundle,
    bundle_path: &Path,
    metadata: &SessionMetadata,
    expected_public_key: Option<&[u8]>,
) -> Result<AuditAttestationVerificationResult> {
    let Some(summary) = metadata.audit_attestation.as_ref() else {
        return Ok(AuditAttestationVerificationResult {
            present: false,
            predicate_type: None,
            key_id: None,
            key_id_matches: false,
            signature_verified: false,
            merkle_root_matches: false,
            session_id_matches: false,
            expected_public_key_matches: expected_public_key.map(|_| false),
            verification_error: expected_public_key.map(|_| {
                "session has no audit attestation to verify against provided public key".to_string()
            }),
        });
    };

    let mut expected_public_key_matches = None;

    let Some(integrity) = metadata.audit_integrity.as_ref() else {
        return Ok(attestation_failure(
            summary,
            expected_public_key_matches,
            "session has audit attestation metadata but no audit integrity summary".to_string(),
        ));
    };

    let predicate_type = match trust::extract_predicate_type(bundle, bundle_path) {
        Ok(predicate_type) => predicate_type,
        Err(err) => {
            return Ok(attestation_failure(
                summary,
                expected_public_key_matches,
                err.to_string(),
            ));
        }
    };
    if predicate_type != AUDIT_ATTESTATION_PREDICATE_TYPE_ALPHA {
        return Ok(attestation_failure(
            summary,
            expected_public_key_matches,
            format!(
                "wrong bundle type: expected {}, got {}",
                AUDIT_ATTESTATION_PREDICATE_TYPE_ALPHA, predicate_type
            ),
        ));
    }

    let signer_identity = match trust::extract_signer_identity(bundle, bundle_path) {
        Ok(identity) => identity,
        Err(err) => {
            return Ok(attestation_failure(
                summary,
                expected_public_key_matches,
                err.to_string(),
            ));
        }
    };
    let signer_key_id = match signer_identity {
        trust::SignerIdentity::Keyed { key_id } => key_id,
        trust::SignerIdentity::Keyless { .. } => {
            return Ok(attestation_failure(
                summary,
                expected_public_key_matches,
                "audit attestation must be keyed".to_string(),
            ));
        }
    };
    let public_key_der = match trust::base64::base64_decode(&summary.public_key) {
        Ok(public_key_der) => public_key_der,
        Err(err) => {
            return Ok(attestation_failure(
                summary,
                expected_public_key_matches,
                format!("invalid attested public key encoding: {err}"),
            ));
        }
    };
    let recomputed_key_id = trust::public_key_id_hex(&public_key_der);
    if recomputed_key_id != summary.key_id {
        return Ok(attestation_failure(
            summary,
            expected_public_key_matches,
            format!(
                "audit attestation metadata key mismatch: expected {}, got {}",
                summary.key_id, recomputed_key_id
            ),
        ));
    }
    if signer_key_id != summary.key_id {
        return Ok(attestation_failure(
            summary,
            expected_public_key_matches,
            format!(
                "audit attestation signer key mismatch: expected {}, got {}",
                summary.key_id, signer_key_id
            ),
        ));
    }
    if let Some(expected_public_key) = expected_public_key
        && expected_public_key != public_key_der.as_slice()
    {
        return Ok(attestation_failure(
            summary,
            Some(false),
            "provided public key does not match the attested signer key".to_string(),
        ));
    }
    if expected_public_key.is_some() {
        expected_public_key_matches = Some(true);
    }
    if let Err(err) = trust::verify_keyed_signature(bundle, &public_key_der, bundle_path) {
        return Ok(attestation_failure(
            summary,
            expected_public_key_matches,
            err.to_string(),
        ));
    }

    let attested_root = match trust::extract_bundle_digest(bundle, bundle_path) {
        Ok(attested_root) => attested_root,
        Err(err) => {
            return Ok(attestation_failure(
                summary,
                expected_public_key_matches,
                err.to_string(),
            ));
        }
    };
    if attested_root != integrity.merkle_root.to_string() {
        return Ok(attestation_failure(
            summary,
            expected_public_key_matches,
            "audit attestation Merkle root does not match session integrity summary".to_string(),
        ));
    }

    let statement = match extract_audit_attestation_statement(bundle) {
        Ok(statement) => statement,
        Err(err) => {
            return Ok(attestation_failure(
                summary,
                expected_public_key_matches,
                err.to_string(),
            ));
        }
    };
    let Some(statement_session_id) = statement
        .predicate
        .get("session_id")
        .and_then(|value| value.as_str())
    else {
        return Ok(attestation_failure(
            summary,
            expected_public_key_matches,
            "audit attestation predicate missing session_id".to_string(),
        ));
    };
    if statement_session_id != metadata.session_id {
        return Ok(attestation_failure(
            summary,
            expected_public_key_matches,
            format!(
                "audit attestation session_id mismatch: expected {}, got {}",
                metadata.session_id, statement_session_id
            ),
        ));
    }

    let Some(audit_log) = statement
        .predicate
        .get("audit_log")
        .and_then(|value| value.as_object())
    else {
        return Ok(attestation_failure(
            summary,
            expected_public_key_matches,
            "audit attestation predicate missing audit_log".to_string(),
        ));
    };
    if audit_log
        .get("hash_algorithm")
        .and_then(|value| value.as_str())
        != Some(integrity.hash_algorithm.as_str())
    {
        return Ok(attestation_failure(
            summary,
            expected_public_key_matches,
            "audit attestation hash_algorithm does not match session integrity summary".to_string(),
        ));
    }
    if audit_log
        .get("event_count")
        .and_then(|value| value.as_u64())
        != Some(integrity.event_count)
    {
        return Ok(attestation_failure(
            summary,
            expected_public_key_matches,
            "audit attestation event_count does not match session integrity summary".to_string(),
        ));
    }
    let chain_head = integrity.chain_head.to_string();
    if audit_log.get("chain_head").and_then(|value| value.as_str()) != Some(chain_head.as_str()) {
        return Ok(attestation_failure(
            summary,
            expected_public_key_matches,
            "audit attestation chain_head does not match session integrity summary".to_string(),
        ));
    }
    if statement
        .predicate
        .get("started")
        .and_then(|value| value.as_str())
        != Some(metadata.started.as_str())
        || statement
            .predicate
            .get("ended")
            .and_then(|value| value.as_str())
            != metadata.ended.as_deref()
    {
        return Ok(attestation_failure(
            summary,
            expected_public_key_matches,
            "audit attestation timestamps do not match session metadata".to_string(),
        ));
    }

    Ok(AuditAttestationVerificationResult {
        present: true,
        predicate_type: Some(predicate_type),
        key_id: Some(summary.key_id.clone()),
        key_id_matches: true,
        signature_verified: true,
        merkle_root_matches: true,
        session_id_matches: true,
        expected_public_key_matches,
        verification_error: None,
    })
}

fn attestation_failure(
    summary: &AuditAttestationSummary,
    expected_public_key_matches: Option<bool>,
    verification_error: String,
) -> AuditAttestationVerificationResult {
    AuditAttestationVerificationResult {
        present: true,
        predicate_type: Some(summary.predicate_type.clone()),
        key_id: Some(summary.key_id.clone()),
        key_id_matches: false,
        signature_verified: false,
        merkle_root_matches: false,
        session_id_matches: false,
        expected_public_key_matches,
        verification_error: Some(verification_error),
    }
}

fn extract_audit_attestation_statement(bundle: &trust::Bundle) -> Result<trust::InTotoStatement> {
    let envelope = match &bundle.content {
        SignatureContent::DsseEnvelope(envelope) => envelope,
        _ => {
            return Err(NonoError::TrustVerification {
                path: String::new(),
                reason: "audit attestation bundle missing dsseEnvelope".to_string(),
            });
        }
    };

    serde_json::from_slice(envelope.payload.as_bytes()).map_err(|e| NonoError::TrustVerification {
        path: String::new(),
        reason: format!("invalid audit attestation statement JSON: {e}"),
    })
}

fn hash_ledger_link(
    previous: Option<&ContentHash>,
    sequence: u64,
    session_id: &str,
    session_digest: &ContentHash,
    completed_at: &str,
) -> Result<ContentHash> {
    let payload = LedgerLinkPayload {
        sequence,
        session_id,
        session_digest: *session_digest,
        completed_at,
    };
    let payload_bytes = serde_json::to_vec(&payload).map_err(|e| {
        NonoError::Snapshot(format!(
            "Failed to serialize audit ledger link payload: {e}"
        ))
    })?;
    let mut hasher = Sha256::new();
    hasher.update(LEDGER_CHAIN_DOMAIN_ALPHA);
    if let Some(prev) = previous {
        hasher.update(prev.as_bytes());
    } else {
        hasher.update([0u8; 32]);
    }
    hasher.update(payload_bytes);
    Ok(ContentHash::from_bytes(hasher.finalize().into()))
}

/// Verify an alpha audit log and optionally cross-check stored metadata.
pub fn verify_audit_log(
    session_dir: &Path,
    stored: Option<&AuditIntegritySummary>,
) -> Result<AuditVerificationResult> {
    let path = session_dir.join(AUDIT_EVENTS_FILENAME);
    let file = File::open(&path).map_err(|e| {
        NonoError::Snapshot(format!(
            "Failed to open audit event log {}: {e}",
            path.display()
        ))
    })?;

    let reader = BufReader::new(file);
    let mut previous_chain: Option<ContentHash> = None;
    let mut leaf_hashes = Vec::new();
    let mut computed_chain_head: Option<ContentHash> = None;
    let mut missing_canonical_event_json = false;

    for (index, line) in reader.lines().enumerate() {
        let line = line.map_err(|e| {
            NonoError::Snapshot(format!(
                "Failed to read audit event log {}: {e}",
                path.display()
            ))
        })?;
        if line.trim().is_empty() {
            continue;
        }

        let record: AuditEventRecord = serde_json::from_str(&line).map_err(|e| {
            NonoError::Snapshot(format!(
                "Failed to parse audit event record {} line {}: {e}",
                path.display(),
                index.saturating_add(1)
            ))
        })?;

        let expected_sequence = leaf_hashes.len() as u64;
        if record.sequence != expected_sequence {
            return Err(NonoError::Snapshot(format!(
                "Audit event record sequence mismatch at line {}: expected {}, got {}",
                index.saturating_add(1),
                expected_sequence,
                record.sequence
            )));
        }

        if record.prev_chain != previous_chain {
            return Err(NonoError::Snapshot(format!(
                "Audit event record prev_chain mismatch at line {}",
                index.saturating_add(1)
            )));
        }

        let event_bytes = if let Some(raw) = record.event_json.as_ref() {
            serde_json::from_str::<AuditEventPayload>(raw).map_err(|e| {
                NonoError::Snapshot(format!(
                    "Failed to parse canonical audit event JSON at line {}: {e}",
                    index.saturating_add(1)
                ))
            })?;
            let canonical_event_bytes = serde_json::to_vec(&record.event).map_err(|e| {
                NonoError::Snapshot(format!(
                    "Failed to serialize audit event payload at line {}: {e}",
                    index.saturating_add(1)
                ))
            })?;
            if raw.as_bytes() != canonical_event_bytes.as_slice() {
                return Err(NonoError::Snapshot(format!(
                    "Audit event JSON mismatch at line {}",
                    index.saturating_add(1)
                )));
            }
            raw.as_bytes().to_vec()
        } else {
            missing_canonical_event_json = true;
            serde_json::to_vec(&record.event).map_err(|e| {
                NonoError::Snapshot(format!(
                    "Failed to serialize audit event for verification at line {}: {e}",
                    index.saturating_add(1)
                ))
            })?
        };
        let leaf_hash = hash_event(&event_bytes);
        if record.leaf_hash != leaf_hash {
            return Err(NonoError::Snapshot(format!(
                "Audit event leaf hash mismatch at line {}",
                index.saturating_add(1)
            )));
        }

        let chain_hash = hash_chain(previous_chain.as_ref(), &leaf_hash);
        if record.chain_hash != chain_hash {
            return Err(NonoError::Snapshot(format!(
                "Audit event chain hash mismatch at line {}",
                index.saturating_add(1)
            )));
        }

        previous_chain = Some(chain_hash);
        computed_chain_head = Some(chain_hash);
        leaf_hashes.push(leaf_hash);
    }

    let computed_merkle_root = if leaf_hashes.is_empty() {
        None
    } else {
        Some(merkle_root(&leaf_hashes))
    };

    if stored.is_some() && !leaf_hashes.is_empty() && missing_canonical_event_json {
        return Err(NonoError::Snapshot(
            "Alpha audit log is missing canonical event_json bytes".to_string(),
        ));
    }

    let stored_event_count = stored.map(|s| s.event_count);
    let stored_chain_head = stored.map(|s| s.chain_head);
    let stored_merkle_root = stored.map(|s| s.merkle_root);
    let event_count = leaf_hashes.len() as u64;
    let event_count_matches = stored_event_count
        .map(|count| count == event_count)
        .unwrap_or(true);

    if let Some(stored_head) = stored_chain_head
        && Some(stored_head) != computed_chain_head
    {
        return Err(NonoError::Snapshot(
            "Alpha audit log chain head mismatch".to_string(),
        ));
    }

    if let Some(stored_root) = stored_merkle_root
        && Some(stored_root) != computed_merkle_root
    {
        return Err(NonoError::Snapshot(
            "Alpha audit log Merkle root mismatch".to_string(),
        ));
    }

    Ok(AuditVerificationResult {
        hash_algorithm: AUDIT_HASH_ALGORITHM.to_string(),
        merkle_scheme: MERKLE_SCHEME_ALPHA.to_string(),
        event_count,
        computed_chain_head,
        computed_merkle_root,
        stored_event_count,
        stored_chain_head,
        stored_merkle_root,
        event_count_matches,
        records_verified: true,
    })
}

#[cfg(test)]
#[allow(clippy::unwrap_used)]
mod tests {
    use super::*;
    use crate::AccessMode;
    use crate::supervisor::{ApprovalDecision, ApprovalRequest};
    use crate::undo::{ExecutableIdentity, NetworkAuditDecision, NetworkAuditMode};
    use std::io::BufReader;
    use std::time::{Duration, UNIX_EPOCH};

    #[test]
    fn recorder_produces_integrity_summary() {
        let dir = tempfile::tempdir().unwrap();
        let mut recorder = AuditRecorder::new(dir.path().to_path_buf()).unwrap();
        recorder
            .record_session_started("2026-04-21T00:00:00Z".to_string(), vec!["pwd".to_string()])
            .unwrap();
        recorder
            .record_session_ended("2026-04-21T00:00:01Z".to_string(), 0)
            .unwrap();

        let summary = recorder.finalize().unwrap();
        assert_eq!(summary.event_count, 2);
        assert_eq!(summary.hash_algorithm, AUDIT_HASH_ALGORITHM);
    }

    #[test]
    fn record_session_started_scrubs_command_secrets() {
        let dir = tempfile::tempdir().unwrap();
        let mut recorder = AuditRecorder::new(dir.path().to_path_buf()).unwrap();
        recorder
            .record_session_started(
                "2026-04-21T00:00:00Z".to_string(),
                vec![
                    "curl".to_string(),
                    "--password".to_string(),
                    "real-password".to_string(),
                    "-H".to_string(),
                    "Authorization: Bearer real-token".to_string(),
                    "https://example.com/api?token=query-secret".to_string(),
                ],
            )
            .unwrap();

        let contents = std::fs::read_to_string(dir.path().join(AUDIT_EVENTS_FILENAME)).unwrap();

        assert!(contents.contains("[REDACTED]"));
        assert!(!contents.contains("real-password"));
        assert!(!contents.contains("real-token"));
        assert!(!contents.contains("query-secret"));
    }

    #[test]
    fn verifier_round_trips_all_current_audit_event_payload_variants() {
        let dir = tempfile::tempdir().unwrap();
        let mut recorder = AuditRecorder::new(dir.path().to_path_buf()).unwrap();
        recorder
            .record_session_started(
                "2026-04-21T00:00:00Z".to_string(),
                vec!["claude".to_string(), "--debug".to_string()],
            )
            .unwrap();
        recorder
            .record_capability_decision(AuditEntry {
                timestamp: UNIX_EPOCH + Duration::from_secs(5),
                request: ApprovalRequest::Capability {
                    request_id: "req-1".to_string(),
                    path: PathBuf::from("/tmp/example"),
                    access: AccessMode::ReadWrite,
                    reason: Some("need scratch space".to_string()),
                    child_pid: 42,
                    session_id: "sess-1".to_string(),
                },
                decision: ApprovalDecision::Denied {
                    reason: "outside policy".to_string(),
                },
                backend: "terminal".to_string(),
                duration_ms: 12,
            })
            .unwrap();
        recorder
            .record_open_url(
                UrlOpenRequest {
                    request_id: "open-1".to_string(),
                    url: "https://example.com/callback".to_string(),
                    child_pid: 42,
                    session_id: "sess-1".to_string(),
                },
                false,
                Some("blocked".to_string()),
            )
            .unwrap();
        recorder
            .record_network_event(NetworkAuditEvent {
                timestamp_unix_ms: 123,
                mode: NetworkAuditMode::Reverse,
                decision: NetworkAuditDecision::Deny,
                route_id: None,
                auth_mechanism: None,
                auth_outcome: None,
                managed_credential_active: None,
                injection_mode: None,
                denial_category: None,
                endpoint_policy_action: None,
                endpoint_policy_rule: None,
                approval_backend: None,
                credential_capture_action: None,
                credential_capture_name: None,
                credential_capture_command: None,
                credential_capture_argv: None,
                credential_capture_exit_status: None,
                credential_capture_duration_ms: None,
                credential_capture_stdout_bytes: None,
                credential_capture_stderr: None,
                credential_capture_cache_scope: None,
                credential_capture_output_format: None,
                credential_capture_header_names: None,
                credential_capture_stdin_mode: None,
                credential_capture_interactive: None,
                target: "api.example.com".to_string(),
                upstream: None,
                port: Some(443),
                method: Some("POST".to_string()),
                path: Some("/v1/chat".to_string()),
                status: Some(403),
                reason: Some("policy".to_string()),
            })
            .unwrap();
        recorder
            .record_session_ended("2026-04-21T00:00:01Z".to_string(), 7)
            .unwrap();

        let summary = recorder.finalize().unwrap();
        let verified = verify_audit_log(dir.path(), Some(&summary)).unwrap();
        assert_eq!(verified.event_count, 5);
        assert_eq!(verified.merkle_scheme, "alpha");
        assert!(verified.records_verified);
    }

    #[test]
    fn verifier_rejects_alpha_records_missing_event_json() {
        let dir = tempfile::tempdir().unwrap();
        let mut recorder = AuditRecorder::new(dir.path().to_path_buf()).unwrap();
        recorder
            .record_session_started("2026-04-21T00:00:00Z".to_string(), vec!["pwd".to_string()])
            .unwrap();
        recorder
            .record_session_ended("2026-04-21T00:00:01Z".to_string(), 0)
            .unwrap();

        let path = dir.path().join(AUDIT_EVENTS_FILENAME);
        let contents = std::fs::read_to_string(&path).unwrap();
        let rewritten = contents
            .lines()
            .filter(|line| !line.trim().is_empty())
            .map(|line| {
                let mut record: AuditEventRecord = serde_json::from_str(line).unwrap();
                record.event_json = None;
                serde_json::to_string(&record).unwrap()
            })
            .collect::<Vec<_>>()
            .join("\n");
        std::fs::write(&path, format!("{rewritten}\n")).unwrap();

        let summary = recorder.finalize().unwrap();
        let err = match verify_audit_log(dir.path(), Some(&summary)) {
            Ok(_) => panic!("alpha verification should reject records missing event_json"),
            Err(err) => err,
        };
        assert!(
            err.to_string()
                .contains("missing canonical event_json bytes")
        );
    }

    #[test]
    fn inclusion_proof_round_trips_each_leaf() {
        let leaves = vec![
            ContentHash::from_bytes([1; 32]),
            ContentHash::from_bytes([2; 32]),
            ContentHash::from_bytes([3; 32]),
            ContentHash::from_bytes([4; 32]),
            ContentHash::from_bytes([5; 32]),
        ];
        let root = merkle_root(&leaves);

        for index in 0..leaves.len() {
            let proof = build_inclusion_proof(&leaves, index).unwrap();
            assert_eq!(proof.merkle_root, root);
            assert_eq!(proof.leaf_hash, leaves[index]);
            assert!(verify_inclusion_proof(&proof));
        }
    }

    #[test]
    fn inclusion_proof_rejects_tampered_leaf() {
        let leaves = vec![
            ContentHash::from_bytes([1; 32]),
            ContentHash::from_bytes([2; 32]),
            ContentHash::from_bytes([3; 32]),
        ];
        let mut proof = build_inclusion_proof(&leaves, 1).unwrap();
        proof.leaf_hash = ContentHash::from_bytes([9; 32]);

        assert!(!verify_inclusion_proof(&proof));
    }

    fn sample_metadata(id: &str) -> SessionMetadata {
        SessionMetadata {
            session_id: id.to_string(),
            started: "2026-04-21T20:00:00Z".to_string(),
            ended: Some("2026-04-21T20:00:01Z".to_string()),
            command: vec!["/bin/pwd".to_string()],
            executable_identity: None,
            tracked_paths: vec![PathBuf::from("/tmp/work")],
            snapshot_count: 0,
            exit_code: Some(0),
            merkle_roots: Vec::new(),
            network_events: Vec::new(),
            audit_event_count: 2,
            audit_integrity: None,
            audit_attestation: None,
        }
    }

    #[test]
    fn ledger_appends_and_verifies_session_digest() {
        let dir = tempfile::tempdir().unwrap();
        let path = dir.path().join("ledger.ndjson");
        let mut file = OpenOptions::new()
            .create(true)
            .read(true)
            .write(true)
            .truncate(false)
            .open(&path)
            .unwrap();

        let meta = sample_metadata("20260421-200000-11111");
        append_session_to_ledger_file(&mut file, &meta).unwrap();

        let reader = BufReader::new(std::fs::File::open(&path).unwrap());
        let verified = verify_session_in_ledger_reader(reader, &meta).unwrap();
        assert!(verified.session_found);
        assert!(verified.session_digest_matches);
        assert!(verified.ledger_chain_verified);
        assert_eq!(verified.entry_count, 1);
    }

    #[test]
    fn ledger_rejects_malformed_session_id() {
        let dir = tempfile::tempdir().unwrap();
        let path = dir.path().join("ledger.ndjson");
        let mut file = OpenOptions::new()
            .create(true)
            .read(true)
            .write(true)
            .truncate(false)
            .open(&path)
            .unwrap();
        let meta = sample_metadata("real-token\\|real-key");

        let err = match append_session_to_ledger_file(&mut file, &meta) {
            Ok(_) => panic!("malformed session id should be rejected"),
            Err(err) => err,
        };

        assert!(err.to_string().contains("invalid audit session id"));
    }

    #[test]
    fn session_digest_changes_when_protected_fields_change() {
        let base = SessionMetadata {
            session_id: "20260421-200000-11111".to_string(),
            started: "2026-04-21T20:00:00Z".to_string(),
            ended: Some("2026-04-21T20:00:01Z".to_string()),
            command: vec!["/bin/pwd".to_string()],
            executable_identity: Some(ExecutableIdentity {
                resolved_path: PathBuf::from("/bin/pwd"),
                sha256: ContentHash::from_bytes([9; 32]),
            }),
            tracked_paths: vec![PathBuf::from("/tmp/work")],
            snapshot_count: 3,
            exit_code: Some(7),
            merkle_roots: vec![ContentHash::from_bytes([1; 32])],
            network_events: vec![NetworkAuditEvent {
                timestamp_unix_ms: 5,
                mode: NetworkAuditMode::Connect,
                decision: NetworkAuditDecision::Allow,
                route_id: None,
                auth_mechanism: None,
                auth_outcome: None,
                managed_credential_active: None,
                injection_mode: None,
                denial_category: None,
                endpoint_policy_action: None,
                endpoint_policy_rule: None,
                approval_backend: None,
                credential_capture_action: None,
                credential_capture_name: None,
                credential_capture_command: None,
                credential_capture_argv: None,
                credential_capture_exit_status: None,
                credential_capture_duration_ms: None,
                credential_capture_stdout_bytes: None,
                credential_capture_stderr: None,
                credential_capture_cache_scope: None,
                credential_capture_output_format: None,
                credential_capture_header_names: None,
                credential_capture_stdin_mode: None,
                credential_capture_interactive: None,
                target: "example.com".to_string(),
                upstream: None,
                port: Some(443),
                method: Some("GET".to_string()),
                path: Some("/".to_string()),
                status: Some(200),
                reason: None,
            }],
            audit_event_count: 9,
            audit_integrity: Some(AuditIntegritySummary {
                hash_algorithm: "sha256".to_string(),
                event_count: 9,
                chain_head: ContentHash::from_bytes([2; 32]),
                merkle_root: ContentHash::from_bytes([3; 32]),
            }),
            audit_attestation: None,
        };
        let base_digest = compute_session_digest(&base).unwrap();

        let mut changed = base.clone();
        changed.session_id.push('x');
        assert_ne!(base_digest, compute_session_digest(&changed).unwrap());

        let mut changed = base.clone();
        changed.network_events[0].target = "other.example.com".to_string();
        assert_ne!(base_digest, compute_session_digest(&changed).unwrap());

        let mut changed = base.clone();
        changed.audit_integrity = Some(AuditIntegritySummary {
            hash_algorithm: "sha256".to_string(),
            event_count: 9,
            chain_head: ContentHash::from_bytes([8; 32]),
            merkle_root: ContentHash::from_bytes([3; 32]),
        });
        assert_ne!(base_digest, compute_session_digest(&changed).unwrap());
    }

    #[test]
    fn audit_attestation_bundle_round_trips_in_core() {
        let key_pair = crate::trust::generate_signing_key().unwrap();
        let key_id = crate::trust::key_id_hex(&key_pair).unwrap();
        let public_key = crate::trust::export_public_key(&key_pair).unwrap();
        let public_key_b64 = crate::trust::base64::base64_encode(public_key.as_bytes());

        let mut meta = sample_metadata("20260421-200000-11111");
        meta.audit_integrity = Some(AuditIntegritySummary {
            hash_algorithm: AUDIT_HASH_ALGORITHM.to_string(),
            event_count: 2,
            chain_head: ContentHash::from_bytes([0x11; 32]),
            merkle_root: ContentHash::from_bytes([0x22; 32]),
        });

        let (bundle_json, summary) = sign_audit_attestation_bundle(
            &meta,
            &key_pair,
            &key_id,
            &public_key_b64,
            &crate::ScrubPolicy::secure_default(),
        )
        .unwrap();
        meta.audit_attestation = Some(summary);

        let bundle_path = Path::new("audit-attestation.bundle");
        let bundle = crate::trust::load_bundle_from_str(&bundle_json, bundle_path).unwrap();
        let verified = verify_audit_attestation_bundle(
            &bundle,
            bundle_path,
            &meta,
            Some(public_key.as_bytes()),
        )
        .unwrap();

        assert!(verified.present);
        assert!(verified.key_id_matches);
        assert!(verified.signature_verified);
        assert!(verified.merkle_root_matches);
        assert!(verified.session_id_matches);
        assert_eq!(verified.expected_public_key_matches, Some(true));
        assert!(verified.verification_error.is_none());

        let mut tampered_bundle_value: serde_json::Value =
            serde_json::from_str(&bundle_json).unwrap();
        tampered_bundle_value["dsseEnvelope"]["payload"] =
            serde_json::Value::String(crate::trust::base64::base64_encode(b"tampered"));
        let tampered_bundle = crate::trust::load_bundle_from_str(
            &serde_json::to_string(&tampered_bundle_value).unwrap(),
            bundle_path,
        )
        .unwrap();
        let verified = verify_audit_attestation_bundle(
            &tampered_bundle,
            bundle_path,
            &meta,
            Some(public_key.as_bytes()),
        )
        .unwrap();
        assert!(!verified.signature_verified);
        assert_eq!(verified.expected_public_key_matches, None);

        let mut changed = meta.clone();
        changed.audit_integrity = Some(AuditIntegritySummary {
            hash_algorithm: AUDIT_HASH_ALGORITHM.to_string(),
            event_count: 3,
            chain_head: ContentHash::from_bytes([0x11; 32]),
            merkle_root: ContentHash::from_bytes([0x22; 32]),
        });
        let verified = verify_audit_attestation_bundle(
            &bundle,
            bundle_path,
            &changed,
            Some(public_key.as_bytes()),
        )
        .unwrap();
        assert!(!verified.signature_verified);
        assert_eq!(verified.expected_public_key_matches, Some(true));
        assert!(
            verified
                .verification_error
                .as_deref()
                .is_some_and(|err| err.contains("event_count"))
        );
    }

    /// Golden vectors shared with the Python port in
    /// nono-py/tests/test_audit.py (TestRustGoldenVectors keeps the same
    /// values). If this test fails, the wire format diverged across
    /// language bindings — fix the divergence, never the vector.
    #[test]
    fn rust_compatibility_golden_vectors() {
        let meta = sample_metadata("20260421-200000-11111");
        assert_eq!(
            compute_session_digest(&meta).unwrap().to_string(),
            "3a1ed53d426d6ea2544cec6cf6b95ccdc31fda4570d86931239ee0f7d7d39012"
        );

        let dir = tempfile::tempdir().unwrap();
        let mut file = OpenOptions::new()
            .create(true)
            .read(true)
            .write(true)
            .truncate(false)
            .open(dir.path().join("ledger.ndjson"))
            .unwrap();
        let record = append_session_to_ledger_file(&mut file, &meta).unwrap();
        assert_eq!(
            record.chain_hash.to_string(),
            "8b6dbc155d44df05e6b5e9948fb8fff142222b4b41fb37284fb0d1217000e9bb"
        );

        let leaves = vec![
            ContentHash::from_bytes([1; 32]),
            ContentHash::from_bytes([2; 32]),
            ContentHash::from_bytes([3; 32]),
            ContentHash::from_bytes([4; 32]),
            ContentHash::from_bytes([5; 32]),
        ];
        let proof = build_inclusion_proof(&leaves, 2).unwrap();
        assert_eq!(
            serde_json::to_string(&proof).unwrap(),
            concat!(
                r#"{"leaf_index":2,"leaf_count":5,"#,
                r#""leaf_hash":"0303030303030303030303030303030303030303030303030303030303030303","#,
                r#""merkle_root":"87f9319b8dbb3d3fd55d419aabf3c218aafd2dfd82d5e30fb22e8e89c10c0160","#,
                r#""siblings":[{"direction":"right","hash":"0404040404040404040404040404040404040404040404040404040404040404"},"#,
                r#"{"direction":"left","hash":"85fb11ff61817c3aa118af30f054a3ea63c042902722cf8ae35e704fff9624fe"},"#,
                r#"{"direction":"right","hash":"0505050505050505050505050505050505050505050505050505050505050505"}]}"#
            )
        );
    }
}
