//! Checkpoint metadata type and serialization.
//!
//! `CheckpointMeta` records the WAL sequence position at checkpoint time so
//! that restore can replay only the events written after the checkpoint.
//!
//! V1 (17 bytes): `[version:1][checkpoint_time_ns:8 LE][wal_sequence:8 LE]`
//! V2 (49 bytes): `[version:1][checkpoint_time_ns:8 LE][wal_sequence:8 LE][payload_hash:32]`
//! V3 (81 bytes): V2 + `[meta_checksum:32]` covering the meta record itself.
//!
//! # Why V3 exists
//!
//! V2's `payload_hash` protects the *entry payloads* but NOT the meta record's
//! own fields. A bit-flip in the serialized `wal_sequence` (or
//! `checkpoint_time_ns`, or `payload_hash`) would deserialize cleanly and pass
//! the payload integrity check, then drive WAL replay from the wrong sequence —
//! silently replaying too many or too few post-checkpoint events. V3 adds a
//! BLAKE3 checksum over the meta record's own bytes so a corrupt meta record is
//! caught at decode time rather than mis-steering recovery.

// ── Constants ─────────────────────────────────────────────────────────────────

/// V3 version byte (current write format: meta record is self-checksummed).
pub(super) const VERSION: u8 = 0x03;
/// V1 meta size (no hash).
pub(super) const META_SIZE_V1: usize = 17;
/// V2 meta size (with payload BLAKE3 hash).
pub(super) const META_SIZE_V2: usize = 49; // 17 + 32
/// V3 meta size (V2 + meta self-checksum).
pub(super) const META_SIZE_V3: usize = 81; // 49 + 32
pub(crate) const META_SUFFIX: &[u8] = b"meta";

// ── CheckpointMeta ────────────────────────────────────────────────────────────

/// Checkpoint sequence metadata stored alongside the signal state.
///
/// V2 adds a BLAKE3 hash of the checkpoint payload (all serialized entries
/// concatenated in key order). If the hash does not match on restore, the
/// checkpoint is treated as corrupt and the system falls back to WAL-only replay.
///
/// V3 additionally protects the meta record itself with a self-checksum (see the
/// module docs); the checksum is computed at serialization and verified at
/// deserialization, so it is intentionally *not* a public field of this struct.
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub struct CheckpointMeta {
    /// Nanosecond timestamp when the checkpoint was taken.
    pub checkpoint_time_ns: u64,
    /// WAL sequence number at checkpoint time.
    pub wal_sequence: u64,
    /// BLAKE3 hash of the checkpoint payload (32 bytes).
    /// Set to `[0u8; 32]` for V1 compatibility (no hash verification).
    pub payload_hash: [u8; 32],
}

// ── Serialization ─────────────────────────────────────────────────────────────

/// Compute the V3 meta self-checksum over the meta record's own bytes.
///
/// Covers `[version][checkpoint_time_ns][wal_sequence][payload_hash]` — i.e.
/// every byte of the V2 record. A bit-flip in any of those fields changes this
/// checksum, so [`deserialize_meta`] can reject a corrupt meta record before it
/// mis-steers WAL replay. The version byte is included so a record reinterpreted
/// under a different version (should the layout ever change) also fails.
fn meta_checksum(
    version: u8,
    checkpoint_time_ns: u64,
    wal_sequence: u64,
    payload_hash: &[u8; 32],
) -> [u8; 32] {
    let mut hasher = blake3::Hasher::new();
    hasher.update(&[version]);
    hasher.update(&checkpoint_time_ns.to_le_bytes());
    hasher.update(&wal_sequence.to_le_bytes());
    hasher.update(payload_hash);
    *hasher.finalize().as_bytes()
}

/// Serialize `CheckpointMeta` to an 81-byte buffer (V3 format).
///
/// Format: `[version: 1][checkpoint_time_ns: 8 LE][wal_sequence: 8 LE][payload_hash: 32][meta_checksum: 32]`
///
/// The trailing `meta_checksum` is a BLAKE3 hash over the preceding 49 bytes
/// (see [`meta_checksum`]); [`deserialize_meta`] verifies it on read so a
/// corrupted meta record is caught rather than silently mis-steering recovery.
#[must_use]
pub fn serialize_meta(meta: &CheckpointMeta) -> Vec<u8> {
    let mut buf = Vec::with_capacity(META_SIZE_V3);
    buf.push(VERSION);
    buf.extend_from_slice(&meta.checkpoint_time_ns.to_le_bytes());
    buf.extend_from_slice(&meta.wal_sequence.to_le_bytes());
    buf.extend_from_slice(&meta.payload_hash);
    let checksum = meta_checksum(
        VERSION,
        meta.checkpoint_time_ns,
        meta.wal_sequence,
        &meta.payload_hash,
    );
    buf.extend_from_slice(&checksum);
    debug_assert_eq!(buf.len(), META_SIZE_V3);
    buf
}

/// Deserialize `CheckpointMeta` from bytes.
///
/// Supports V1 (17 bytes, no hash), V2 (49 bytes, payload hash only), and V3
/// (81 bytes, payload hash + meta self-checksum). V1 checkpoints are
/// deserialized with `payload_hash = [0u8; 32]`, which disables payload-hash
/// verification on restore (backward compatible). V3 additionally verifies the
/// meta self-checksum and rejects the record if it does not match.
///
/// # Errors
///
/// Returns `Err` if the slice is empty, the version byte is unknown,
/// the length does not match the expected format size, any sub-slice
/// conversion fails, or (V3 only) the meta self-checksum does not match.
pub fn deserialize_meta(bytes: &[u8]) -> Result<CheckpointMeta, String> {
    if bytes.is_empty() {
        return Err("empty checkpoint meta".to_string());
    }

    match bytes[0] {
        0x01 => decode_v1(bytes),
        0x02 => decode_v2(bytes),
        0x03 => decode_v3(bytes),
        v => Err(format!(
            "unknown checkpoint meta version 0x{v:02x}, expected 0x01, 0x02, or 0x03"
        )),
    }
}

/// Read the two `u64` meta fields common to every version (V1/V2/V3 share the
/// same `[1..9]`/`[9..17]` layout). Shared so the per-version decode arms do not
/// each re-hand-roll the offset slicing and error formatting.
fn read_meta_u64s(bytes: &[u8], version: u8) -> Result<(u64, u64), String> {
    let checkpoint_time_ns = u64::from_le_bytes(
        bytes[1..9]
            .try_into()
            .map_err(|_| format!("V{version} offset error at [1..9]"))?,
    );
    let wal_sequence = u64::from_le_bytes(
        bytes[9..17]
            .try_into()
            .map_err(|_| format!("V{version} offset error at [9..17]"))?,
    );
    Ok((checkpoint_time_ns, wal_sequence))
}

/// Decode a V1 meta record (17 bytes, no hash).
fn decode_v1(bytes: &[u8]) -> Result<CheckpointMeta, String> {
    if bytes.len() != META_SIZE_V1 {
        return Err(format!(
            "V1 meta: expected {META_SIZE_V1} bytes, got {}",
            bytes.len()
        ));
    }
    let (checkpoint_time_ns, wal_sequence) = read_meta_u64s(bytes, 1)?;
    Ok(CheckpointMeta {
        checkpoint_time_ns,
        wal_sequence,
        payload_hash: [0u8; 32], // V1: no hash verification
    })
}

/// Decode a V2 meta record (49 bytes, payload hash, no meta self-checksum).
fn decode_v2(bytes: &[u8]) -> Result<CheckpointMeta, String> {
    if bytes.len() != META_SIZE_V2 {
        return Err(format!(
            "V2 meta: expected {META_SIZE_V2} bytes, got {}",
            bytes.len()
        ));
    }
    let (checkpoint_time_ns, wal_sequence) = read_meta_u64s(bytes, 2)?;
    let mut payload_hash = [0u8; 32];
    payload_hash.copy_from_slice(&bytes[17..49]);
    Ok(CheckpointMeta {
        checkpoint_time_ns,
        wal_sequence,
        payload_hash,
    })
}

/// Decode a V3 meta record (81 bytes) and verify its self-checksum.
///
/// The trailing 32-byte checksum is recomputed over the leading 49 bytes and
/// compared; a mismatch means the meta record itself was corrupted (bit rot,
/// torn write) and is rejected so recovery never replays from a bogus sequence.
fn decode_v3(bytes: &[u8]) -> Result<CheckpointMeta, String> {
    if bytes.len() != META_SIZE_V3 {
        return Err(format!(
            "V3 meta: expected {META_SIZE_V3} bytes, got {}",
            bytes.len()
        ));
    }
    let (checkpoint_time_ns, wal_sequence) = read_meta_u64s(bytes, 3)?;
    let mut payload_hash = [0u8; 32];
    payload_hash.copy_from_slice(&bytes[17..49]);

    let mut stored_checksum = [0u8; 32];
    stored_checksum.copy_from_slice(&bytes[49..81]);
    let expected = meta_checksum(0x03, checkpoint_time_ns, wal_sequence, &payload_hash);
    if stored_checksum != expected {
        return Err("V3 meta self-checksum mismatch (corrupt meta record)".to_string());
    }

    Ok(CheckpointMeta {
        checkpoint_time_ns,
        wal_sequence,
        payload_hash,
    })
}

// ── Tests ─────────────────────────────────────────────────────────────────────

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

    #[test]
    fn serialize_meta_correct_length() {
        let meta = CheckpointMeta {
            checkpoint_time_ns: 123_456,
            wal_sequence: 78,
            payload_hash: [0u8; 32],
        };
        let bytes = serialize_meta(&meta);
        assert_eq!(bytes.len(), META_SIZE_V3);
        assert_eq!(bytes[0], 0x03);
    }

    #[test]
    fn v3_serialize_deserialize_roundtrip() {
        let meta = CheckpointMeta {
            checkpoint_time_ns: 1_700_000_000_000_000_000,
            wal_sequence: 42_000,
            payload_hash: *blake3::hash(b"test payload").as_bytes(),
        };
        let bytes = serialize_meta(&meta);
        assert_eq!(bytes.len(), META_SIZE_V3);
        assert_eq!(bytes[0], 0x03);

        let restored = deserialize_meta(&bytes).unwrap();
        assert_eq!(restored, meta);
    }

    /// Regression for W19: a bit-flip anywhere in the meta record — including the
    /// `wal_sequence` and `checkpoint_time_ns` fields the payload hash never
    /// covered — must be rejected by the V3 self-checksum, so recovery never
    /// replays the WAL from a corrupted sequence.
    #[test]
    fn v3_rejects_corrupt_meta_fields() {
        let meta = CheckpointMeta {
            checkpoint_time_ns: 1_700_000_000_000_000_000,
            wal_sequence: 42_000,
            payload_hash: *blake3::hash(b"test payload").as_bytes(),
        };
        let good = serialize_meta(&meta);
        assert!(deserialize_meta(&good).is_ok());

        // Flip a byte inside wal_sequence ([9..17]) WITHOUT touching the trailing
        // checksum: V2 would have accepted this and replayed from a bogus seq.
        let mut corrupt_seq = good.clone();
        corrupt_seq[9] ^= 0xFF;
        assert!(
            deserialize_meta(&corrupt_seq).is_err(),
            "corrupted wal_sequence must fail the meta self-checksum"
        );

        // Flip a byte inside checkpoint_time_ns ([1..9]).
        let mut corrupt_time = good.clone();
        corrupt_time[1] ^= 0xFF;
        assert!(
            deserialize_meta(&corrupt_time).is_err(),
            "corrupted checkpoint_time_ns must fail the meta self-checksum"
        );

        // Flip a byte inside the payload_hash ([17..49]).
        let mut corrupt_hash = good;
        corrupt_hash[17] ^= 0xFF;
        assert!(
            deserialize_meta(&corrupt_hash).is_err(),
            "corrupted payload_hash must fail the meta self-checksum"
        );
    }

    /// A V2 record (no meta self-checksum) still deserializes: existing
    /// on-disk checkpoints written before V3 must remain readable.
    #[test]
    fn v2_deserialization_backward_compatible() {
        let payload_hash = *blake3::hash(b"legacy payload").as_bytes();
        let mut bytes = Vec::with_capacity(META_SIZE_V2);
        bytes.push(0x02);
        bytes.extend_from_slice(&7_000u64.to_le_bytes());
        bytes.extend_from_slice(&99u64.to_le_bytes());
        bytes.extend_from_slice(&payload_hash);
        assert_eq!(bytes.len(), META_SIZE_V2);

        let meta = deserialize_meta(&bytes).unwrap();
        assert_eq!(meta.checkpoint_time_ns, 7_000);
        assert_eq!(meta.wal_sequence, 99);
        assert_eq!(meta.payload_hash, payload_hash);
    }

    #[test]
    fn v1_deserialization_backward_compatible() {
        // Simulate a V1 checkpoint (version 0x01, 17 bytes).
        let mut bytes = Vec::with_capacity(17);
        bytes.push(0x01);
        bytes.extend_from_slice(&1_000u64.to_le_bytes());
        bytes.extend_from_slice(&42u64.to_le_bytes());
        assert_eq!(bytes.len(), 17);

        let meta = deserialize_meta(&bytes).unwrap();
        assert_eq!(meta.checkpoint_time_ns, 1_000);
        assert_eq!(meta.wal_sequence, 42);
        assert_eq!(meta.payload_hash, [0u8; 32]); // V1 has no hash
    }

    #[test]
    fn deserialize_meta_rejects_wrong_version() {
        let mut bytes = serialize_meta(&CheckpointMeta {
            checkpoint_time_ns: 1,
            wal_sequence: 1,
            payload_hash: [0u8; 32],
        });
        bytes[0] = 0xFF;
        assert!(deserialize_meta(&bytes).is_err());
    }

    #[test]
    fn deserialize_meta_rejects_truncated() {
        assert!(deserialize_meta(&[0x02, 0x00]).is_err());
    }
}

#[cfg(test)]
#[allow(clippy::unwrap_used)]
mod proptests {
    use proptest::prelude::*;

    use super::*;

    proptest! {
        #[test]
        fn serialize_deserialize_meta_roundtrip(
            checkpoint_time_ns: u64,
            wal_sequence: u64,
            hash_bytes in proptest::collection::vec(proptest::prelude::any::<u8>(), 32..=32),
        ) {
            let mut payload_hash = [0u8; 32];
            payload_hash.copy_from_slice(&hash_bytes);
            let meta = CheckpointMeta { checkpoint_time_ns, wal_sequence, payload_hash };
            let bytes = serialize_meta(&meta);
            let restored = deserialize_meta(&bytes).unwrap();
            prop_assert_eq!(restored, meta);
        }
    }
}
