//! 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]`

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

/// V2 version byte.
pub(super) const VERSION: u8 = 0x02;
/// V1 meta size (no hash).
pub(super) const META_SIZE_V1: usize = 17;
/// V2 meta size (with BLAKE3 hash).
pub(super) const META_SIZE_V2: usize = 49; // 17 + 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.
#[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 ─────────────────────────────────────────────────────────────

/// Serialize `CheckpointMeta` to a 49-byte buffer (V2 format).
///
/// Format: `[version: 1][checkpoint_time_ns: 8 LE][wal_sequence: 8 LE][payload_hash: 32]`
#[must_use]
pub fn serialize_meta(meta: &CheckpointMeta) -> Vec<u8> {
    let mut buf = Vec::with_capacity(META_SIZE_V2);
    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);
    debug_assert_eq!(buf.len(), META_SIZE_V2);
    buf
}

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

    match bytes[0] {
        0x01 => {
            // V1: 17 bytes, no hash.
            if bytes.len() != META_SIZE_V1 {
                return Err(format!(
                    "V1 meta: expected {META_SIZE_V1} bytes, got {}",
                    bytes.len()
                ));
            }
            let checkpoint_time_ns = u64::from_le_bytes(
                bytes[1..9]
                    .try_into()
                    .map_err(|_| "V1 offset error at [1..9]".to_string())?,
            );
            let wal_sequence = u64::from_le_bytes(
                bytes[9..17]
                    .try_into()
                    .map_err(|_| "V1 offset error at [9..17]".to_string())?,
            );
            Ok(CheckpointMeta {
                checkpoint_time_ns,
                wal_sequence,
                payload_hash: [0u8; 32], // V1: no hash verification
            })
        }
        0x02 => {
            // V2: 49 bytes, with BLAKE3 hash.
            if bytes.len() != META_SIZE_V2 {
                return Err(format!(
                    "V2 meta: expected {META_SIZE_V2} bytes, got {}",
                    bytes.len()
                ));
            }
            let checkpoint_time_ns = u64::from_le_bytes(
                bytes[1..9]
                    .try_into()
                    .map_err(|_| "V2 offset error at [1..9]".to_string())?,
            );
            let wal_sequence = u64::from_le_bytes(
                bytes[9..17]
                    .try_into()
                    .map_err(|_| "V2 offset error at [9..17]".to_string())?,
            );
            let mut payload_hash = [0u8; 32];
            payload_hash.copy_from_slice(&bytes[17..49]);
            Ok(CheckpointMeta {
                checkpoint_time_ns,
                wal_sequence,
                payload_hash,
            })
        }
        v => Err(format!(
            "unknown checkpoint meta version 0x{v:02x}, expected 0x01 or 0x02"
        )),
    }
}

// ── 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_V2);
        assert_eq!(bytes[0], 0x02);
    }

    #[test]
    fn v2_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_V2);
        assert_eq!(bytes[0], 0x02);

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

    #[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);
        }
    }
}
