//! Binary encoding and decoding for session records.
//!
//! Three record types are persisted:
//! - **Snapshot** — full session state at close time (or on checkpoint).
//! - **Start record** — compact record written on `start_session`; replaced by
//!   a snapshot when the session closes.
//! - **Audit log** — standalone serialisation used for the separate audit keyspace.

mod start_record;

pub use start_record::{deserialize_start_record, serialize_start_record};

use super::{
    audit::AuditEntry, signal_state::SignalSnapEntry, snapshot::SessionSnapshot, types::SessionId,
};

// ── Shared byte-cursor helpers ─────────────────────────────────────────────────
//
// These retire the `read_u32`/`read_u64` closures and the hand-rolled
// length-prefixed-string + audit-entry decode logic that were copy-pasted across
// `deserialize_snapshot`, `deserialize_audit_log`, `start_record.rs`, and
// `saved_search.rs`. Every helper is bounds-checked and returns `None` on any
// short or corrupt read so corruption surfaces to the caller (which logs and
// skips the record) instead of panicking or silently substituting garbage.
//
// Visibility is `pub(in crate::session)` (not `pub(super)`) so the whole session
// slice is single-sourced on this cursor — `saved_search.rs` reuses the same
// length-prefixed/UTF-8 decode instead of hand-rolling a parallel one that could
// drift on bounds-checking or UTF-8 rejection.

/// Read a little-endian `u32` at `*pos`, advancing the cursor. `None` if short.
#[inline]
pub(in crate::session) fn read_u32(bytes: &[u8], pos: &mut usize) -> Option<u32> {
    let end = pos.checked_add(4)?;
    let slice = bytes.get(*pos..end)?;
    // `slice` is exactly 4 bytes, so the conversion cannot fail.
    let v = u32::from_le_bytes(slice.try_into().ok()?);
    *pos = end;
    Some(v)
}

/// Read a little-endian `u64` at `*pos`, advancing the cursor. `None` if short.
#[inline]
pub(in crate::session) fn read_u64(bytes: &[u8], pos: &mut usize) -> Option<u64> {
    let end = pos.checked_add(8)?;
    let slice = bytes.get(*pos..end)?;
    // `slice` is exactly 8 bytes, so the conversion cannot fail.
    let v = u64::from_le_bytes(slice.try_into().ok()?);
    *pos = end;
    Some(v)
}

/// Read `len` bytes at `*pos` as UTF-8, advancing the cursor.
///
/// Returns `None` if the slice is short OR the bytes are not valid UTF-8 — corrupt
/// text in a stored record is *rejected*, not silently replaced (the old
/// `from_utf8_lossy` masked corruption behind U+FFFD replacement characters).
#[inline]
pub(in crate::session) fn read_utf8(bytes: &[u8], pos: &mut usize, len: usize) -> Option<String> {
    let end = pos.checked_add(len)?;
    let slice = bytes.get(*pos..end)?;
    let s = std::str::from_utf8(slice).ok()?.to_owned();
    *pos = end;
    Some(s)
}

/// Read a `[u32 len][bytes]` length-prefixed UTF-8 string.
///
/// The length is bounds-checked against the bytes that remain before any read, so
/// a corrupt length field cannot drive an oversized allocation. Returns `None` on
/// a short read, an over-long length, or invalid UTF-8.
#[inline]
pub(in crate::session) fn read_len_prefixed_utf8(bytes: &[u8], pos: &mut usize) -> Option<String> {
    let len = read_u32(bytes, pos)? as usize;
    // A string cannot be longer than the bytes that remain.
    if len > bytes.len().saturating_sub(*pos) {
        return None;
    }
    read_utf8(bytes, pos, len)
}

/// Capacity to pre-allocate for `count` records of at least `min_record_size`
/// bytes each, given `remaining` unread bytes.
///
/// A `count` field read from untrusted bytes is *not* trusted for sizing: a
/// corrupt value (e.g. `u32::MAX`) would otherwise reserve gigabytes. Each record
/// occupies at least `min_record_size` bytes, so `count` cannot exceed
/// `remaining / min_record_size`. We pre-allocate the smaller of the claimed
/// count and that ceiling, then let the `Vec`/`HashMap` grow if the data really
/// is that large.
#[inline]
pub(super) fn capped_capacity(count: usize, remaining: usize, min_record_size: usize) -> usize {
    let ceiling = remaining / min_record_size.max(1);
    count.min(ceiling)
}

/// Append a `[u32 len][bytes]` length-prefixed string to `buf`.
///
/// The length prefix is a `u32`, so a string longer than 4 GiB cannot be encoded
/// faithfully. Such a string is never legitimate in any session record, so we
/// `debug_assert` the cast in debug builds; in release the truncated length still
/// yields a *rejected* (never mis-parsed) record because every decoder is
/// bounds-checked against the bytes that follow. Visible across the session slice
/// so `saved_search.rs` shares this single length-prefix writer.
#[inline]
pub(in crate::session) fn write_len_prefixed(buf: &mut Vec<u8>, s: &str) {
    debug_assert!(
        u32::try_from(s.len()).is_ok(),
        "length-prefixed string exceeds u32 length prefix"
    );
    buf.extend_from_slice(&(s.len() as u32).to_le_bytes());
    buf.extend_from_slice(s.as_bytes());
}

/// Smallest possible on-disk size of one serialized [`AuditEntry`]:
/// `timestamp(8) + sig_len(4) + accepted(1) + reason_len(4)` with empty strings.
const MIN_AUDIT_ENTRY_SIZE: usize = 8 + 4 + 1 + 4;

/// Serialize one [`AuditEntry`] into `buf`. Shared by the snapshot and standalone
/// audit-log codecs so the wire format cannot drift between them.
fn write_audit_entry(buf: &mut Vec<u8>, e: &AuditEntry) {
    buf.extend_from_slice(&e.timestamp_ns.to_le_bytes());
    write_len_prefixed(buf, &e.signal_type);
    buf.push(u8::from(e.accepted));
    match &e.reason {
        None => buf.extend_from_slice(&0u32.to_le_bytes()),
        Some(r) => write_len_prefixed(buf, r),
    }
}

/// Decode one [`AuditEntry`] at `*pos`, advancing the cursor.
///
/// Returns `None` on any short read or invalid UTF-8 (corrupt audit text is
/// rejected, not replaced). A `reason_len` of `0` decodes to `None`.
fn read_audit_entry(bytes: &[u8], pos: &mut usize) -> Option<AuditEntry> {
    let timestamp_ns = read_u64(bytes, pos)?;
    let signal_type = read_len_prefixed_utf8(bytes, pos)?;
    let accepted = *bytes.get(*pos)? != 0;
    *pos += 1;
    let reason_len = read_u32(bytes, pos)? as usize;
    let reason = if reason_len == 0 {
        None
    } else {
        if reason_len > bytes.len().saturating_sub(*pos) {
            return None;
        }
        Some(read_utf8(bytes, pos, reason_len)?)
    };
    Some(AuditEntry {
        timestamp_ns,
        signal_type,
        accepted,
        reason,
    })
}

/// Format version byte for snapshot serialization.
///
/// v0x01: original format (annotations as plain Vec<String>)
/// v0x02: annotations as Vec<(u64, String)>, `audit_truncated` flag, per-signal stats
/// v0x03: added `started_at_ns`, `closed_at_ns` to snapshot; count to `SignalSnapEntry`
const SNAPSHOT_VERSION: u8 = 0x03;

// ── Snapshot ──────────────────────────────────────────────────────────────────

/// Serialize a `SessionSnapshot` to bytes for storage archival.
///
/// Format v0x03:
/// `[version: u8][session_id: u64][user_id: u64][signals_written: u64]`
/// `[signals_rejected: u64][duration_ms: u64][reward_velocity: f64]`
/// `[metadata_count: u32][...kv pairs...][annotations_count: u32][...(ts_u64, len, bytes)...]`
/// `[entities_count: u32][...u64s...][audit_truncated: u8]`
/// `[audit_count: u32][...audit entries...]`
/// `[signal_count: u32][...(name_len, name, decay_f64, window_1h_u64, count_u64)...]`
/// `[started_at_ns: u64][closed_at_ns: u64]`
#[must_use]
pub fn serialize_snapshot(snap: &SessionSnapshot) -> Vec<u8> {
    let mut buf = Vec::new();
    buf.push(SNAPSHOT_VERSION);
    buf.extend_from_slice(&snap.id.as_u64().to_le_bytes());
    buf.extend_from_slice(&snap.user_id.to_le_bytes());
    buf.extend_from_slice(&snap.signals_written.to_le_bytes());
    buf.extend_from_slice(&snap.signals_rejected.to_le_bytes());
    buf.extend_from_slice(&snap.duration_ms.to_le_bytes());
    buf.extend_from_slice(&snap.reward_velocity.to_bits().to_le_bytes());

    // Metadata: count then (key_len, key, val_len, val) pairs.
    buf.extend_from_slice(&(snap.metadata.len() as u32).to_le_bytes());
    for (k, v) in &snap.metadata {
        write_len_prefixed(&mut buf, k);
        write_len_prefixed(&mut buf, v);
    }

    // Annotations v0x02: count then (timestamp_ns: u64, len: u32, bytes).
    buf.extend_from_slice(&(snap.annotations.len() as u32).to_le_bytes());
    for (ts, ann) in &snap.annotations {
        buf.extend_from_slice(&ts.to_le_bytes());
        write_len_prefixed(&mut buf, ann);
    }

    // Signaled entities: count then u64 values.
    buf.extend_from_slice(&(snap.signaled_entities.len() as u32).to_le_bytes());
    for &eid in &snap.signaled_entities {
        buf.extend_from_slice(&eid.to_le_bytes());
    }

    // Audit truncated flag (v0x02).
    buf.push(u8::from(snap.audit_truncated));

    // Audit log: count then serialized entries.
    buf.extend_from_slice(&(snap.audit_log.len() as u32).to_le_bytes());
    for e in &snap.audit_log {
        write_audit_entry(&mut buf, e);
    }

    // Per-signal stats (v0x03): count then (name_len, name, decay_score_f64, window_1h_u64, count_u64).
    buf.extend_from_slice(&(snap.signals.len() as u32).to_le_bytes());
    for (name, entry) in &snap.signals {
        write_len_prefixed(&mut buf, name);
        buf.extend_from_slice(&entry.decay_score.to_bits().to_le_bytes());
        buf.extend_from_slice(&entry.window_1h.to_le_bytes());
        buf.extend_from_slice(&entry.count.to_le_bytes());
    }

    // v0x03: session timing fields.
    buf.extend_from_slice(&snap.started_at_ns.to_le_bytes());
    buf.extend_from_slice(&snap.closed_at_ns.to_le_bytes());

    buf
}

/// Deserialize a `SessionSnapshot` from bytes (versions 0x02 and 0x03).
///
/// Returns `None` on any malformed input — a short read, an over-long
/// length/count field, or non-UTF-8 text. Corruption is *rejected* rather than
/// best-effort patched: callers (WAL restore, archive read) log a warning and
/// skip the record on `None`, so a corrupt snapshot surfaces instead of silently
/// hydrating with replacement characters or over-allocating from a bad count.
///
/// All `with_capacity` calls are clamped against the remaining byte count: a
/// `count` field cannot describe more records than `remaining / min_record_size`,
/// so a corrupt count cannot drive a memory-amplification allocation.
#[must_use]
#[allow(clippy::too_many_lines)]
pub fn deserialize_snapshot(bytes: &[u8]) -> Option<SessionSnapshot> {
    let mut pos = 0;

    // Version byte check — 0x02 and 0x03 supported.
    let version = *bytes.first()?;
    if version != 0x02 && version != SNAPSHOT_VERSION {
        return None;
    }
    pos += 1;

    let session_id = read_u64(bytes, &mut pos)?;
    let user_id = read_u64(bytes, &mut pos)?;
    let signals_written = read_u64(bytes, &mut pos)?;
    let signals_rejected = read_u64(bytes, &mut pos)?;
    let duration_ms = read_u64(bytes, &mut pos)?;
    let reward_velocity = f64::from_bits(read_u64(bytes, &mut pos)?);

    // Metadata: count then (key, val) length-prefixed pairs. Each pair is at
    // least 8 bytes (two empty u32-prefixed strings).
    let meta_count = read_u32(bytes, &mut pos)? as usize;
    let mut metadata =
        std::collections::HashMap::with_capacity(capped_capacity(meta_count, bytes.len() - pos, 8));
    for _ in 0..meta_count {
        let key = read_len_prefixed_utf8(bytes, &mut pos)?;
        let val = read_len_prefixed_utf8(bytes, &mut pos)?;
        metadata.insert(key, val);
    }

    // Annotations v0x02: (timestamp_ns: u64, len: u32, bytes). At least 12 bytes
    // each (timestamp + empty u32-prefixed string).
    let ann_count = read_u32(bytes, &mut pos)? as usize;
    let mut annotations: Vec<(u64, String)> =
        Vec::with_capacity(capped_capacity(ann_count, bytes.len() - pos, 12));
    for _ in 0..ann_count {
        let ts = read_u64(bytes, &mut pos)?;
        let ann = read_len_prefixed_utf8(bytes, &mut pos)?;
        annotations.push((ts, ann));
    }

    // Signaled entities: count then u64 values (8 bytes each).
    let ent_count = read_u32(bytes, &mut pos)? as usize;
    let mut signaled_entities =
        Vec::with_capacity(capped_capacity(ent_count, bytes.len() - pos, 8));
    for _ in 0..ent_count {
        signaled_entities.push(read_u64(bytes, &mut pos)?);
    }

    // Audit truncated flag (v0x02).
    let audit_truncated = if pos < bytes.len() {
        let v = bytes[pos] != 0;
        pos += 1;
        v
    } else {
        false
    };

    // Audit log: count then serialized entries.
    let audit_log = if pos < bytes.len() {
        let audit_count = read_u32(bytes, &mut pos)? as usize;
        let mut entries = Vec::with_capacity(capped_capacity(
            audit_count,
            bytes.len() - pos,
            MIN_AUDIT_ENTRY_SIZE,
        ));
        for _ in 0..audit_count {
            entries.push(read_audit_entry(bytes, &mut pos)?);
        }
        entries
    } else {
        Vec::new()
    };

    // Per-signal stats (v0x02/v0x03). Each record is at least 24 bytes
    // (empty-name u32 + decay f64 + window u64), or 32 with the v0x03 count u64.
    let signals: std::collections::HashMap<String, SignalSnapEntry> = if pos < bytes.len() {
        let sig_count = read_u32(bytes, &mut pos)? as usize;
        let min_sig_size = if version >= 0x03 {
            4 + 8 + 8 + 8
        } else {
            4 + 8 + 8
        };
        let mut map = std::collections::HashMap::with_capacity(capped_capacity(
            sig_count,
            bytes.len() - pos,
            min_sig_size,
        ));
        for _ in 0..sig_count {
            let name = read_len_prefixed_utf8(bytes, &mut pos)?;
            let decay_score = f64::from_bits(read_u64(bytes, &mut pos)?);
            let window_1h = read_u64(bytes, &mut pos)?;
            // v0x03 adds count; v0x02 defaults to 0.
            let count = if version >= 0x03 {
                read_u64(bytes, &mut pos)?
            } else {
                0
            };
            map.insert(
                name,
                SignalSnapEntry {
                    decay_score,
                    window_1h,
                    count,
                },
            );
        }
        map
    } else {
        std::collections::HashMap::new()
    };

    // v0x03: session timing fields. v0x02 defaults to 0.
    let (started_at_ns, closed_at_ns) = if version >= 0x03 && pos < bytes.len() {
        let s = read_u64(bytes, &mut pos)?;
        let c = read_u64(bytes, &mut pos)?;
        (s, c)
    } else {
        (0, 0)
    };

    Some(SessionSnapshot {
        id: SessionId(session_id),
        user_id,
        signals_written,
        signals_rejected,
        duration_ms,
        metadata,
        annotations,
        reward_velocity,
        signaled_entities,
        audit_log,
        audit_truncated,
        signals,
        started_at_ns,
        closed_at_ns,
    })
}

// ── Audit log ─────────────────────────────────────────────────────────────────

/// Serialize an audit log to bytes for storage archival.
///
/// Shares the per-entry codec ([`write_audit_entry`]) with the snapshot
/// serializer so the two wire formats cannot drift apart.
#[must_use]
pub fn serialize_audit_log(entries: &[AuditEntry]) -> Vec<u8> {
    let mut buf = Vec::new();
    buf.extend_from_slice(&(entries.len() as u32).to_le_bytes());
    for e in entries {
        write_audit_entry(&mut buf, e);
    }
    buf
}

/// Deserialize an audit log from bytes.
///
/// Stops at the first malformed entry (short read or non-UTF-8 text) and returns
/// whatever was decoded before it — a best-effort read of the audit keyspace.
/// The per-entry decode and capacity cap are shared with the snapshot codec.
#[must_use]
pub fn deserialize_audit_log(bytes: &[u8]) -> Vec<AuditEntry> {
    let mut pos = 0;

    let Some(count) = read_u32(bytes, &mut pos) else {
        return Vec::new();
    };
    let mut entries = Vec::with_capacity(capped_capacity(
        count as usize,
        bytes.len() - pos,
        MIN_AUDIT_ENTRY_SIZE,
    ));

    for _ in 0..count {
        let Some(entry) = read_audit_entry(bytes, &mut pos) else {
            break;
        };
        entries.push(entry);
    }

    entries
}

// ── Unit tests ────────────────────────────────────────────────────────────────

#[cfg(test)]
#[allow(clippy::unwrap_used)]
mod tests {
    use std::collections::HashMap;

    use super::{
        super::{signal_state::SignalSnapEntry, types::SessionId},
        *,
    };

    /// Build a minimal valid v0x03 snapshot with one metadata pair, used as a
    /// base to mutate into corrupt forms.
    fn minimal_snapshot() -> SessionSnapshot {
        let mut metadata = HashMap::new();
        metadata.insert("agent".to_string(), "planner".to_string());
        SessionSnapshot {
            id: SessionId(1),
            user_id: 1,
            signals_written: 0,
            signals_rejected: 0,
            duration_ms: 0,
            metadata,
            annotations: vec![(1u64, "hint".to_string())],
            reward_velocity: 0.0,
            signaled_entities: vec![9],
            audit_log: vec![AuditEntry {
                timestamp_ns: 1,
                signal_type: "view".to_string(),
                accepted: true,
                reason: None,
            }],
            audit_truncated: false,
            signals: HashMap::new(),
            started_at_ns: 1,
            closed_at_ns: 2,
        }
    }

    #[test]
    fn capped_capacity_clamps_oversized_count() {
        // 1000 claimed records, but only 32 bytes remain at 8 bytes/record => 4.
        assert_eq!(capped_capacity(1000, 32, 8), 4);
        // Honest count under the ceiling is preserved.
        assert_eq!(capped_capacity(3, 1000, 8), 3);
        // Zero min_record_size does not divide-by-zero.
        assert_eq!(capped_capacity(5, 100, 0), 5);
    }

    #[test]
    fn read_helpers_reject_short_and_non_utf8() {
        // Short u32/u64 reads.
        let mut pos = 0;
        assert!(read_u32(&[0u8; 3], &mut pos).is_none());
        let mut pos = 0;
        assert!(read_u64(&[0u8; 7], &mut pos).is_none());

        // Non-UTF-8 bytes are rejected, not lossily replaced.
        let mut pos = 0;
        assert!(read_utf8(&[0xFF, 0xFE], &mut pos, 2).is_none());

        // Length-prefixed string with a length exceeding the buffer is rejected.
        let mut buf = Vec::new();
        buf.extend_from_slice(&100u32.to_le_bytes()); // claims 100 bytes
        buf.extend_from_slice(b"short"); // only 5 present
        let mut pos = 0;
        assert!(read_len_prefixed_utf8(&buf, &mut pos).is_none());
    }

    #[test]
    fn deserialize_snapshot_rejects_non_utf8_metadata() {
        // Hand-build a v0x03 header with one metadata pair whose key is invalid
        // UTF-8. The whole snapshot must be rejected (None), not patched.
        let mut buf = Vec::new();
        buf.push(SNAPSHOT_VERSION);
        for _ in 0..6 {
            buf.extend_from_slice(&0u64.to_le_bytes()); // id,user,written,rejected,duration,reward
        }
        buf.extend_from_slice(&1u32.to_le_bytes()); // meta_count = 1
        buf.extend_from_slice(&2u32.to_le_bytes()); // key_len = 2
        buf.extend_from_slice(&[0xFF, 0xFE]); // invalid UTF-8 key
        assert!(
            deserialize_snapshot(&buf).is_none(),
            "non-UTF-8 metadata key must be rejected"
        );
    }

    #[test]
    fn deserialize_snapshot_rejects_oversized_meta_count_without_oom() {
        // A corrupt meta_count of u32::MAX with no follow-on bytes must return
        // None promptly (capped pre-allocation) rather than reserving GBs.
        let mut buf = Vec::new();
        buf.push(SNAPSHOT_VERSION);
        for _ in 0..6 {
            buf.extend_from_slice(&0u64.to_le_bytes());
        }
        buf.extend_from_slice(&u32::MAX.to_le_bytes()); // meta_count = 4 billion
        // No metadata bytes follow.
        assert!(
            deserialize_snapshot(&buf).is_none(),
            "oversized meta_count must be rejected on the first short read"
        );
    }

    #[test]
    fn deserialize_snapshot_rejects_non_utf8_annotation() {
        let mut snap = minimal_snapshot();
        snap.annotations = vec![(1u64, "ok".to_string())];
        let mut bytes = serialize_snapshot(&snap);
        // The annotation bytes "ok" are the last len-prefixed string before the
        // entities section. Locate and corrupt them: find the ASCII "ok".
        let idx = bytes
            .windows(2)
            .position(|w| w == b"ok")
            .expect("annotation bytes present");
        bytes[idx] = 0xFF;
        bytes[idx + 1] = 0xFE;
        assert!(
            deserialize_snapshot(&bytes).is_none(),
            "non-UTF-8 annotation must be rejected"
        );
    }

    #[test]
    fn deserialize_audit_log_stops_at_non_utf8_entry() {
        // Two entries; corrupt the first entry's signal_type to non-UTF-8. The
        // decoder stops at the bad entry and returns what preceded it (none).
        let mut buf = Vec::new();
        buf.extend_from_slice(&1u32.to_le_bytes()); // count = 1
        buf.extend_from_slice(&5u64.to_le_bytes()); // timestamp
        buf.extend_from_slice(&2u32.to_le_bytes()); // sig_len = 2
        buf.extend_from_slice(&[0xFF, 0xFE]); // invalid UTF-8 signal_type
        let entries = deserialize_audit_log(&buf);
        assert!(
            entries.is_empty(),
            "corrupt audit entry must not be decoded"
        );
    }

    #[test]
    fn snapshot_roundtrip() {
        let snap = SessionSnapshot {
            id: SessionId(7),
            user_id: 42,
            signals_written: 10,
            signals_rejected: 2,
            duration_ms: 1234,
            metadata: {
                let mut m = HashMap::new();
                m.insert("agent".to_string(), "planner".to_string());
                m
            },
            annotations: vec![(1_000_000_u64, "rust programming".to_string())],
            reward_velocity: 0.75,
            signaled_entities: vec![1, 2, 3],
            audit_log: Vec::new(),
            audit_truncated: false,
            signals: {
                let mut s = HashMap::new();
                s.insert(
                    "reward".to_string(),
                    SignalSnapEntry {
                        decay_score: 0.75,
                        window_1h: 3,
                        count: 5,
                    },
                );
                s
            },
            started_at_ns: 1_700_000_000_000_000_000,
            closed_at_ns: 1_700_000_001_000_000_000,
        };

        let bytes = serialize_snapshot(&snap);
        let restored = deserialize_snapshot(&bytes).unwrap();

        assert_eq!(restored.id, snap.id);
        assert_eq!(restored.user_id, snap.user_id);
        assert_eq!(restored.signals_written, snap.signals_written);
        assert_eq!(restored.signals_rejected, snap.signals_rejected);
        assert_eq!(restored.duration_ms, snap.duration_ms);
        assert_eq!(restored.annotations, snap.annotations);
        assert!((restored.reward_velocity - snap.reward_velocity).abs() < 1e-10);
        assert_eq!(restored.signaled_entities, snap.signaled_entities);
        assert_eq!(restored.metadata.get("agent").unwrap(), "planner");
        assert!(!restored.audit_truncated);
        let reward_snap = restored.signals.get("reward").unwrap();
        assert!((reward_snap.decay_score - 0.75).abs() < 1e-10);
        assert_eq!(reward_snap.window_1h, 3);
        assert_eq!(reward_snap.count, 5);
        assert_eq!(restored.started_at_ns, 1_700_000_000_000_000_000);
        assert_eq!(restored.closed_at_ns, 1_700_000_001_000_000_000);
    }

    #[test]
    fn audit_log_roundtrip() {
        let entries = vec![
            AuditEntry {
                timestamp_ns: 1000,
                signal_type: "view".to_string(),
                accepted: true,
                reason: None,
            },
            AuditEntry {
                timestamp_ns: 2000,
                signal_type: "block".to_string(),
                accepted: false,
                reason: Some("signal denied by policy".to_string()),
            },
        ];

        let bytes = serialize_audit_log(&entries);
        let restored = deserialize_audit_log(&bytes);

        assert_eq!(restored.len(), 2);
        assert_eq!(restored[0].signal_type, "view");
        assert!(restored[0].accepted);
        assert!(restored[0].reason.is_none());
        assert_eq!(restored[1].signal_type, "block");
        assert!(!restored[1].accepted);
        assert_eq!(
            restored[1].reason.as_deref().unwrap(),
            "signal denied by policy"
        );
    }
}
