//! Binary entry serialization for signal checkpoint records.
//!
//! Each `EntitySignalEntry` serializes to a fixed 983-byte record containing
//! the hot-tier decay scores and warm-tier bucketed counters. All payload
//! values use little-endian byte order; a version byte at offset 0 enables
//! future backward-compatible format changes.

use crate::schema::EntityId;

use super::super::SignalTypeId;
use super::super::hot::HotSignalState;
use super::super::ledger::EntitySignalEntry;
use super::super::warm::{BucketedCounter, BucketedCounterSnapshot, HOUR_BUCKETS, MINUTE_BUCKETS};

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

const VERSION: u8 = 0x01;

/// Total size of a serialized entry in bytes.
pub(super) const ENTRY_SIZE: usize = 983;

/// Bit 0 of `flags` field: velocity tracking is enabled for this signal.
const FLAG_VELOCITY_ENABLED: u16 = 0x0001;

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

/// Serialize an `EntitySignalEntry` to a 983-byte buffer.
///
/// # Binary layout (all payload values little-endian)
///
/// ```text
/// Offset  Size  Field
///      0     1  version (0x01)
///      1     8  entity_id (u64 LE)
///      9     2  signal_type_id (u16 LE)
///     11     2  flags (u16 LE) -- bit 0: velocity_enabled
///     13     8  last_update_ns (u64 LE)
///     21     8  decay_score_0 (f64 bits LE)
///     29     8  decay_score_1 (f64 bits LE)
///     37     8  decay_score_2 (f64 bits LE)
///     45     1  current_minute (u8)
///     46     1  current_hour (u8)
///     47     8  all_time_count (u64 LE)
///     55     8  last_minute_rotation_ns (u64 LE)
///     63     8  last_hour_rotation_ns (u64 LE)
///     71   240  minute_buckets (60 x u32 LE)
///    311   672  hour_buckets (168 x u32 LE)
/// Total: 983 bytes
/// ```
#[must_use]
pub fn serialize_entry(
    entity_id: EntityId,
    signal_type_id: SignalTypeId,
    entry: &EntitySignalEntry,
) -> Vec<u8> {
    let mut buf = Vec::with_capacity(ENTRY_SIZE);

    // [0] version
    buf.push(VERSION);

    // [1..9] entity_id LE
    buf.extend_from_slice(&entity_id.as_u64().to_le_bytes());

    // [9..11] signal_type_id LE
    buf.extend_from_slice(&signal_type_id.as_u16().to_le_bytes());

    // [11..13] flags LE -- derived from hot-tier immutable fields
    let flags: u16 = if entry.hot.velocity_enabled() {
        FLAG_VELOCITY_ENABLED
    } else {
        0
    };
    buf.extend_from_slice(&flags.to_le_bytes());

    // [13..21] last_update_ns LE
    buf.extend_from_slice(&entry.hot.last_update_ns().to_le_bytes());

    // [21..45] three decay scores as f64 bits LE
    for i in 0..3 {
        buf.extend_from_slice(&entry.hot.stored_score(i).to_bits().to_le_bytes());
    }

    // Snapshot warm tier (atomic reads of all bucket state)
    let snap = entry.warm.snapshot();

    // [45] current_minute (u8)
    buf.push(snap.current_minute);

    // [46] current_hour (u8)
    buf.push(snap.current_hour);

    // [47..55] all_time_count LE
    buf.extend_from_slice(&snap.all_time_count.to_le_bytes());

    // [55..63] last_minute_rotation_ns LE
    buf.extend_from_slice(&snap.last_minute_rotation_ns.to_le_bytes());

    // [63..71] last_hour_rotation_ns LE
    buf.extend_from_slice(&snap.last_hour_rotation_ns.to_le_bytes());

    // [71..311] minute_buckets (60 x u32 LE = 240 bytes)
    for &bucket in &snap.minute_buckets {
        buf.extend_from_slice(&bucket.to_le_bytes());
    }

    // [311..983] hour_buckets (168 x u32 LE = 672 bytes)
    for &bucket in &snap.hour_buckets {
        buf.extend_from_slice(&bucket.to_le_bytes());
    }

    debug_assert_eq!(buf.len(), ENTRY_SIZE, "serialize_entry produced wrong size");
    buf
}

/// Deserialize an `EntitySignalEntry` from bytes.
///
/// Returns `(entity_id, signal_type_id, entry)` on success.
///
/// # Errors
///
/// Returns `Err` if:
/// - The slice is not exactly `ENTRY_SIZE` (983) bytes
/// - The version byte is not `0x01`
/// - Any sub-slice conversion fails due to offset math errors
pub fn deserialize_entry(
    bytes: &[u8],
) -> Result<(EntityId, SignalTypeId, EntitySignalEntry), String> {
    if bytes.len() != ENTRY_SIZE {
        return Err(format!("expected {ENTRY_SIZE} bytes, got {}", bytes.len()));
    }

    // [0] version check
    if bytes[0] != VERSION {
        return Err(format!(
            "unknown checkpoint version 0x{:02x}, expected 0x{:02x}",
            bytes[0], VERSION
        ));
    }

    // [1..9] entity_id LE
    let entity_id_val = u64::from_le_bytes(
        bytes[1..9]
            .try_into()
            .map_err(|_| "offset math error at entity_id [1..9]".to_string())?,
    );
    let entity_id = EntityId::new(entity_id_val);

    // [9..11] signal_type_id LE
    let signal_type_id_val = u16::from_le_bytes(
        bytes[9..11]
            .try_into()
            .map_err(|_| "offset math error at signal_type_id [9..11]".to_string())?,
    );
    let signal_type_id = SignalTypeId::new(signal_type_id_val);

    // [11..13] flags LE
    let flags = u16::from_le_bytes(
        bytes[11..13]
            .try_into()
            .map_err(|_| "offset math error at flags [11..13]".to_string())?,
    );
    let velocity_enabled = (flags & FLAG_VELOCITY_ENABLED) != 0;

    // [13..21] last_update_ns LE
    let last_update_ns = u64::from_le_bytes(
        bytes[13..21]
            .try_into()
            .map_err(|_| "offset math error at last_update_ns [13..21]".to_string())?,
    );

    // [21..45] three decay scores as f64 bits LE
    let score_0 = f64::from_bits(u64::from_le_bytes(
        bytes[21..29]
            .try_into()
            .map_err(|_| "offset math error at score_0 [21..29]".to_string())?,
    ));
    let score_1 = f64::from_bits(u64::from_le_bytes(
        bytes[29..37]
            .try_into()
            .map_err(|_| "offset math error at score_1 [29..37]".to_string())?,
    ));
    let score_2 = f64::from_bits(u64::from_le_bytes(
        bytes[37..45]
            .try_into()
            .map_err(|_| "offset math error at score_2 [37..45]".to_string())?,
    ));

    // [45] current_minute (u8)
    let current_minute = bytes[45];

    // [46] current_hour (u8)
    let current_hour = bytes[46];

    // [47..55] all_time_count LE
    let all_time_count = u64::from_le_bytes(
        bytes[47..55]
            .try_into()
            .map_err(|_| "offset math error at all_time_count [47..55]".to_string())?,
    );

    // [55..63] last_minute_rotation_ns LE
    let last_minute_rotation_ns = u64::from_le_bytes(
        bytes[55..63]
            .try_into()
            .map_err(|_| "offset math error at last_minute_rotation_ns [55..63]".to_string())?,
    );

    // [63..71] last_hour_rotation_ns LE
    let last_hour_rotation_ns = u64::from_le_bytes(
        bytes[63..71]
            .try_into()
            .map_err(|_| "offset math error at last_hour_rotation_ns [63..71]".to_string())?,
    );

    // [71..311] minute_buckets (60 x u32 LE)
    let mut minute_buckets = [0u32; MINUTE_BUCKETS];
    for (i, bucket) in minute_buckets.iter_mut().enumerate() {
        let off = 71 + i * 4;
        *bucket = u32::from_le_bytes(bytes[off..off + 4].try_into().map_err(|_| {
            format!(
                "offset math error at minute_bucket[{i}] [{off}..{}]",
                off + 4
            )
        })?);
    }

    // [311..983] hour_buckets (168 x u32 LE)
    let mut hour_buckets = [0u32; HOUR_BUCKETS];
    for (i, bucket) in hour_buckets.iter_mut().enumerate() {
        let off = 311 + i * 4;
        *bucket =
            u32::from_le_bytes(bytes[off..off + 4].try_into().map_err(|_| {
                format!("offset math error at hour_bucket[{i}] [{off}..{}]", off + 4)
            })?);
    }

    // Reconstruct hot tier
    let hot = HotSignalState::with_flags(entity_id_val, signal_type_id_val, velocity_enabled);
    hot.restore(last_update_ns, &[score_0, score_1, score_2]);

    // Reconstruct warm tier from snapshot
    let warm = BucketedCounter::new();
    warm.restore(&BucketedCounterSnapshot {
        minute_buckets,
        hour_buckets,
        current_minute,
        current_hour,
        all_time_count,
        last_minute_rotation_ns,
        last_hour_rotation_ns,
    });

    Ok((entity_id, signal_type_id, EntitySignalEntry { hot, warm }))
}

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

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

    #[test]
    fn serialize_entry_version_byte() {
        let entry = EntitySignalEntry {
            hot: HotSignalState::new(1, 0),
            warm: BucketedCounter::new(),
        };
        let bytes = serialize_entry(EntityId::new(1), SignalTypeId::new(0), &entry);
        assert_eq!(bytes[0], 0x01, "version byte should be 0x01");
    }

    #[test]
    fn serialize_entry_correct_length() {
        let entry = EntitySignalEntry {
            hot: HotSignalState::new(42, 3),
            warm: BucketedCounter::new(),
        };
        let bytes = serialize_entry(EntityId::new(42), SignalTypeId::new(3), &entry);
        assert_eq!(bytes.len(), ENTRY_SIZE);
    }

    #[test]
    fn deserialize_entry_rejects_wrong_version() {
        let bytes = vec![0x00u8; ENTRY_SIZE];
        assert!(deserialize_entry(&bytes).is_err());
    }

    #[test]
    fn deserialize_entry_rejects_truncated_data() {
        let result = deserialize_entry(&[0x01, 0x00]);
        assert!(result.is_err());
    }

    #[test]
    fn deserialize_entry_rejects_wrong_length() {
        let bytes = vec![0x01u8; ENTRY_SIZE - 1];
        assert!(deserialize_entry(&bytes).is_err());
    }

    #[test]
    fn serialize_deserialize_entry_roundtrip() {
        let entity_id = EntityId::new(99);
        let signal_type_id = SignalTypeId::new(2);

        let hot = HotSignalState::with_flags(99, 2, true);
        hot.restore(1_000_000_000_000, &[3.125, 2.71, 1.41]);

        let warm = BucketedCounter::with_start_time(1_000_000_000_000);
        warm.increment(1_000_000_000_000);
        warm.increment(1_000_000_000_001);

        let entry = EntitySignalEntry { hot, warm };
        let bytes = serialize_entry(entity_id, signal_type_id, &entry);
        assert_eq!(bytes.len(), ENTRY_SIZE);

        let (eid, stid, restored) = deserialize_entry(&bytes).expect("deserialize ok");
        assert_eq!(eid, entity_id);
        assert_eq!(stid, signal_type_id);
        assert!((restored.hot.stored_score(0) - 3.125).abs() < 1e-15);
        assert!((restored.hot.stored_score(1) - 2.71).abs() < 1e-15);
        assert!((restored.hot.stored_score(2) - 1.41).abs() < 1e-15);
        assert_eq!(restored.hot.last_update_ns(), 1_000_000_000_000);
        assert!(restored.hot.velocity_enabled());
        assert_eq!(restored.warm.all_time_count(), 2);
    }
}

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

    use super::*;

    // Entry serialization roundtrip for arbitrary hot-tier state.
    proptest! {
        #[test]
        fn serialize_deserialize_entry_roundtrip(
            entity_id_val in 1u64..1_000_000,
            signal_type_id_val in 0u16..64,
            score_0 in 0.0f64..1e12,
            score_1 in 0.0f64..1e12,
            score_2 in 0.0f64..1e12,
            last_update in 0u64..2_000_000_000_000u64,
            all_time in 0u64..1_000_000,
        ) {
            let entity_id = EntityId::new(entity_id_val);
            let signal_type_id = SignalTypeId::new(signal_type_id_val);

            let hot = HotSignalState::new(entity_id_val, signal_type_id_val);
            hot.restore(last_update, &[score_0, score_1, score_2]);

            let warm = BucketedCounter::new();
            warm.increment_by(all_time as u32, 0);

            let entry = EntitySignalEntry { hot, warm };
            let bytes = serialize_entry(entity_id, signal_type_id, &entry);
            let (eid, stid, restored) = deserialize_entry(&bytes).unwrap();

            prop_assert_eq!(eid, entity_id);
            prop_assert_eq!(stid, signal_type_id);
            prop_assert!((restored.hot.stored_score(0) - score_0).abs() < 1e-15);
            prop_assert!((restored.hot.stored_score(1) - score_1).abs() < 1e-15);
            prop_assert!((restored.hot.stored_score(2) - score_2).abs() < 1e-15);
            prop_assert_eq!(restored.hot.last_update_ns(), last_update);
        }
    }
}
