//! Binary entry serialization for signal checkpoint records.
//!
//! Each `EntitySignalEntry` serializes to a fixed-length record containing the
//! hot-tier decay scores and warm-tier bucketed counters (minute, hour, and day
//! tiers). All payload values use little-endian byte order; a version byte at
//! offset 0 enables backward-compatible format changes.
//!
//! # Format versions
//!
//! - **V1 (983 bytes):** minute + hour tiers only (no 30-day day-bucket tier).
//! - **V2 (1116 bytes):** adds `current_day`, `last_day_rotation_ns`, and the
//!   31 day buckets. V1 records still deserialize cleanly — their day tier is
//!   reconstructed empty, so a 30-day window simply reports 0 until the warm
//!   tier accumulates a day's worth of rolled-up data post-restore.

// Field slices are written as `OFF_FIELD..OFF_FIELD + WIDTH`, which documents
// each field's byte width at the use site. clippy's `..=` rewrite conflates the
// symbolic offset with a literal end and obscures the layout, so it is allowed
// file-wide here (the named-offset codec is the whole point of this module).
#![allow(clippy::range_plus_one)]

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

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

/// V1 entry format: minute + hour tiers only.
const VERSION_V1: u8 = 0x01;
/// V2 entry format: adds the 31-slot day-bucket tier for 30-day windows.
const VERSION_V2: u8 = 0x02;

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

// ── Field byte offsets ──────────────────────────────────────────────────────
//
// Defined ONCE and used by BOTH `serialize_entry` (writes at `buf[OFF..]`) and
// `deserialize_entry` (reads `bytes[OFF..OFF + N]`), so the two directions can
// never silently desync: insert or resize a field and the next offset shifts in
// both paths at once. A const-assert below pins the running offsets to the
// fixed `ENTRY_SIZE_V1`/`ENTRY_SIZE` totals, so any miscount is a compile error.

/// Offset of the version byte (`u8`).
const OFF_VERSION: usize = 0;
/// Offset of `entity_id` (`u64` LE, 8 bytes).
const OFF_ENTITY_ID: usize = OFF_VERSION + 1;
/// Offset of `signal_type_id` (`u16` LE, 2 bytes).
const OFF_SIGNAL_TYPE_ID: usize = OFF_ENTITY_ID + 8;
/// Offset of `flags` (`u16` LE, 2 bytes; bit 0 = velocity enabled).
const OFF_FLAGS: usize = OFF_SIGNAL_TYPE_ID + 2;
/// Offset of `last_update_ns` (`u64` LE, 8 bytes).
const OFF_LAST_UPDATE_NS: usize = OFF_FLAGS + 2;
/// Offset of the three `f64`-bits decay scores (3 × 8 = 24 bytes).
const OFF_DECAY_SCORES: usize = OFF_LAST_UPDATE_NS + 8;
/// Number of decay-score slots serialized.
const DECAY_SCORE_SLOTS: usize = 3;
/// Offset of `current_minute` (`u8`).
const OFF_CURRENT_MINUTE: usize = OFF_DECAY_SCORES + DECAY_SCORE_SLOTS * 8;
/// Offset of `current_hour` (`u8`).
const OFF_CURRENT_HOUR: usize = OFF_CURRENT_MINUTE + 1;
/// Offset of `all_time_count` (`u64` LE, 8 bytes).
const OFF_ALL_TIME_COUNT: usize = OFF_CURRENT_HOUR + 1;
/// Offset of `last_minute_rotation_ns` (`u64` LE, 8 bytes).
const OFF_LAST_MINUTE_ROTATION_NS: usize = OFF_ALL_TIME_COUNT + 8;
/// Offset of `last_hour_rotation_ns` (`u64` LE, 8 bytes).
const OFF_LAST_HOUR_ROTATION_NS: usize = OFF_LAST_MINUTE_ROTATION_NS + 8;
/// Offset of the minute-bucket array (60 × `u32` LE = 240 bytes).
const OFF_MINUTE_BUCKETS: usize = OFF_LAST_HOUR_ROTATION_NS + 8;
/// Offset of the hour-bucket array (168 × `u32` LE = 672 bytes).
const OFF_HOUR_BUCKETS: usize = OFF_MINUTE_BUCKETS + MINUTE_BUCKETS * 4;

/// Total size of a V1 serialized entry in bytes (minute + hour tiers); the V1
/// layout ends immediately after the hour-bucket array.
const ENTRY_SIZE_V1: usize = OFF_HOUR_BUCKETS + HOUR_BUCKETS * 4;

/// Offset of `current_day` (`u8`) — V2. The day tier begins here, immediately
/// after the V1 layout (`ENTRY_SIZE_V1`).
const OFF_CURRENT_DAY: usize = ENTRY_SIZE_V1;
/// Offset of `last_day_rotation_ns` (`u64` LE, 8 bytes) — V2.
const OFF_LAST_DAY_ROTATION_NS: usize = OFF_CURRENT_DAY + 1;
/// Offset of the day-bucket array (31 × `u32` LE = 124 bytes) — V2.
const OFF_DAY_BUCKETS: usize = OFF_LAST_DAY_ROTATION_NS + 8;

/// Total size of a V2 serialized entry in bytes.
///
/// V1 (983) + `current_day` (1) + `last_day_rotation_ns` (8) + `day_buckets`
/// (31 × 4 = 124) = 1116. Derived from the offset chain so it cannot drift.
pub(super) const ENTRY_SIZE: usize = OFF_DAY_BUCKETS + DAY_BUCKETS * 4;

// Pin the derived sizes to the documented wire totals: a miscount anywhere in
// the offset chain breaks the build rather than only a roundtrip test.
const _: () = assert!(ENTRY_SIZE_V1 == 983, "V1 entry must be 983 bytes");
const _: () = assert!(ENTRY_SIZE == 1116, "V2 entry must be 1116 bytes");

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

/// Serialize an `EntitySignalEntry` to a 1116-byte V2 buffer.
///
/// # Binary layout (all payload values little-endian)
///
/// The offsets below are **not** hand-maintained: each is a named `OFF_*`
/// constant derived from the previous field's size, and both this function and
/// [`deserialize_entry`] index from those same constants. The byte offsets in
/// the table are the current evaluated values, shown for orientation only — the
/// constants are the single source of truth, and a `const`-assert pins the
/// derived `ENTRY_SIZE_V1`/`ENTRY_SIZE` totals so a miscount fails the build.
///
/// ```text
/// Const                          Off  Size  Field
/// OFF_VERSION                      0     1  version (0x02)
/// OFF_ENTITY_ID                    1     8  entity_id (u64 LE)
/// OFF_SIGNAL_TYPE_ID               9     2  signal_type_id (u16 LE)
/// OFF_FLAGS                       11     2  flags (u16 LE) -- bit 0: velocity_enabled
/// OFF_LAST_UPDATE_NS             13     8  last_update_ns (u64 LE)
/// OFF_DECAY_SCORES               21    24  decay_score_0..2 (3 x f64 bits LE)
/// OFF_CURRENT_MINUTE             45     1  current_minute (u8)
/// OFF_CURRENT_HOUR               46     1  current_hour (u8)
/// OFF_ALL_TIME_COUNT             47     8  all_time_count (u64 LE)
/// OFF_LAST_MINUTE_ROTATION_NS    55     8  last_minute_rotation_ns (u64 LE)
/// OFF_LAST_HOUR_ROTATION_NS      63     8  last_hour_rotation_ns (u64 LE)
/// OFF_MINUTE_BUCKETS             71   240  minute_buckets (60 x u32 LE)
/// OFF_HOUR_BUCKETS              311   672  hour_buckets (168 x u32 LE)
/// OFF_CURRENT_DAY              983     1  current_day (u8)               -- V2
/// OFF_LAST_DAY_ROTATION_NS    984     8  last_day_rotation_ns (u64 LE)  -- V2
/// OFF_DAY_BUCKETS             992   124  day_buckets (31 x u32 LE)      -- V2
/// Total: 1116 bytes
/// ```
#[must_use]
pub fn serialize_entry(
    entity_id: EntityId,
    signal_type_id: SignalTypeId,
    entry: &EntitySignalEntry,
) -> Vec<u8> {
    // Pre-size and write each field at its named offset (the same constants
    // `deserialize_entry` reads from), so the two directions can never desync.
    let mut buf = vec![0u8; ENTRY_SIZE];

    buf[OFF_VERSION] = VERSION_V2;
    buf[OFF_ENTITY_ID..OFF_ENTITY_ID + 8].copy_from_slice(&entity_id.as_u64().to_le_bytes());
    buf[OFF_SIGNAL_TYPE_ID..OFF_SIGNAL_TYPE_ID + 2]
        .copy_from_slice(&signal_type_id.as_u16().to_le_bytes());

    // flags -- derived from hot-tier immutable fields
    let flags: u16 = if entry.hot.velocity_enabled() {
        FLAG_VELOCITY_ENABLED
    } else {
        0
    };
    buf[OFF_FLAGS..OFF_FLAGS + 2].copy_from_slice(&flags.to_le_bytes());
    buf[OFF_LAST_UPDATE_NS..OFF_LAST_UPDATE_NS + 8]
        .copy_from_slice(&entry.hot.last_update_ns().to_le_bytes());

    // three decay scores as f64 bits LE
    for i in 0..DECAY_SCORE_SLOTS {
        let off = OFF_DECAY_SCORES + i * 8;
        buf[off..off + 8].copy_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();

    buf[OFF_CURRENT_MINUTE] = snap.current_minute;
    buf[OFF_CURRENT_HOUR] = snap.current_hour;
    buf[OFF_ALL_TIME_COUNT..OFF_ALL_TIME_COUNT + 8]
        .copy_from_slice(&snap.all_time_count.to_le_bytes());
    buf[OFF_LAST_MINUTE_ROTATION_NS..OFF_LAST_MINUTE_ROTATION_NS + 8]
        .copy_from_slice(&snap.last_minute_rotation_ns.to_le_bytes());
    buf[OFF_LAST_HOUR_ROTATION_NS..OFF_LAST_HOUR_ROTATION_NS + 8]
        .copy_from_slice(&snap.last_hour_rotation_ns.to_le_bytes());

    write_u32_buckets(&mut buf, OFF_MINUTE_BUCKETS, &snap.minute_buckets);
    write_u32_buckets(&mut buf, OFF_HOUR_BUCKETS, &snap.hour_buckets);

    // ── V2 day tier ──────────────────────────────────────────────────────────
    buf[OFF_CURRENT_DAY] = snap.current_day;
    buf[OFF_LAST_DAY_ROTATION_NS..OFF_LAST_DAY_ROTATION_NS + 8]
        .copy_from_slice(&snap.last_day_rotation_ns.to_le_bytes());
    write_u32_buckets(&mut buf, OFF_DAY_BUCKETS, &snap.day_buckets);

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

/// Write a `u32` bucket array as little-endian bytes starting at `start_off`.
/// The inverse of [`parse_u32_buckets`]; both index from the same named offsets.
fn write_u32_buckets(buf: &mut [u8], start_off: usize, buckets: &[u32]) {
    for (i, &bucket) in buckets.iter().enumerate() {
        let off = start_off + i * 4;
        buf[off..off + 4].copy_from_slice(&bucket.to_le_bytes());
    }
}

/// Deserialize an `EntitySignalEntry` from bytes.
///
/// Returns `(entity_id, signal_type_id, entry)` on success. Both the V1
/// (983-byte, no day tier) and V2 (1116-byte) formats are accepted: a V1
/// record reconstructs with an empty day tier, so 30-day windows read 0 until
/// the warm tier accumulates a day of rolled-up data post-restore — a clean,
/// non-corrupting old → new recovery.
///
/// # Errors
///
/// Returns `Err` if:
/// - The slice length does not match the declared version's record size
/// - The version byte is not `0x01` (V1) or `0x02` (V2)
/// - Any sub-slice conversion fails due to offset math errors
pub fn deserialize_entry(
    bytes: &[u8],
) -> Result<(EntityId, SignalTypeId, EntitySignalEntry), String> {
    // [0] version dispatch — determines the expected record length and whether
    // a V2 day tier is present.
    let version = bytes.first().copied().ok_or("empty checkpoint entry")?;
    let expected_size = match version {
        VERSION_V1 => ENTRY_SIZE_V1,
        VERSION_V2 => ENTRY_SIZE,
        v => {
            return Err(format!(
                "unknown checkpoint version 0x{v:02x}, expected 0x{VERSION_V1:02x} or 0x{VERSION_V2:02x}"
            ));
        }
    };
    if bytes.len() != expected_size {
        return Err(format!(
            "v{version:#04x} entry: expected {expected_size} bytes, got {}",
            bytes.len()
        ));
    }

    // entity_id LE
    let entity_id_val = read_u64(bytes, OFF_ENTITY_ID, "entity_id")?;
    let entity_id = EntityId::new(entity_id_val);

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

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

    // last_update_ns LE
    let last_update_ns = read_u64(bytes, OFF_LAST_UPDATE_NS, "last_update_ns")?;

    // three decay scores as f64 bits LE
    let score_0 = f64::from_bits(read_u64(bytes, OFF_DECAY_SCORES, "score_0")?);
    let score_1 = f64::from_bits(read_u64(bytes, OFF_DECAY_SCORES + 8, "score_1")?);
    let score_2 = f64::from_bits(read_u64(bytes, OFF_DECAY_SCORES + 16, "score_2")?);

    // current_minute / current_hour (u8)
    let current_minute = bytes[OFF_CURRENT_MINUTE];
    let current_hour = bytes[OFF_CURRENT_HOUR];

    // all_time_count LE
    let all_time_count = read_u64(bytes, OFF_ALL_TIME_COUNT, "all_time_count")?;

    // last_minute_rotation_ns / last_hour_rotation_ns LE
    let last_minute_rotation_ns = read_u64(
        bytes,
        OFF_LAST_MINUTE_ROTATION_NS,
        "last_minute_rotation_ns",
    )?;
    let last_hour_rotation_ns =
        read_u64(bytes, OFF_LAST_HOUR_ROTATION_NS, "last_hour_rotation_ns")?;

    // minute_buckets (60 x u32 LE) / hour_buckets (168 x u32 LE)
    let minute_buckets: [u32; MINUTE_BUCKETS] =
        parse_u32_buckets(bytes, OFF_MINUTE_BUCKETS, "minute_bucket")?;
    let hour_buckets: [u32; HOUR_BUCKETS] =
        parse_u32_buckets(bytes, OFF_HOUR_BUCKETS, "hour_bucket")?;

    // V2 day tier (V1 records have no day tier — reconstruct it empty).
    let (current_day, last_day_rotation_ns, day_buckets) = if version == VERSION_V2 {
        parse_day_tier(bytes)?
    } else {
        (0u8, 0u64, [0u32; DAY_BUCKETS])
    };

    // 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,
        day_buckets,
        current_minute,
        current_hour,
        current_day,
        all_time_count,
        last_minute_rotation_ns,
        last_hour_rotation_ns,
        last_day_rotation_ns,
    });

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

/// Parse the V2 day tier from the tail of a 1116-byte entry buffer.
///
/// Returns `(current_day, last_day_rotation_ns, day_buckets)`. The caller has
/// already verified the buffer is `ENTRY_SIZE` bytes, so the offsets are in
/// bounds; the `try_into` conversions only fail on impossible offset math.
fn parse_day_tier(bytes: &[u8]) -> Result<(u8, u64, [u32; DAY_BUCKETS]), String> {
    // current_day (u8)
    let current_day = bytes[OFF_CURRENT_DAY];

    // last_day_rotation_ns LE
    let last_day_rotation_ns = read_u64(bytes, OFF_LAST_DAY_ROTATION_NS, "last_day_rotation_ns")?;

    // day_buckets (31 x u32 LE)
    let day_buckets: [u32; DAY_BUCKETS] = parse_u32_buckets(bytes, OFF_DAY_BUCKETS, "day_bucket")?;

    Ok((current_day, last_day_rotation_ns, day_buckets))
}

/// Read a little-endian `u64` at `off` in `bytes`. `field` names it for errors.
/// The caller has already length-checked the buffer, so the `try_into` only
/// fails on impossible offset math.
fn read_u64(bytes: &[u8], off: usize, field: &str) -> Result<u64, String> {
    Ok(u64::from_le_bytes(bytes[off..off + 8].try_into().map_err(
        |_| format!("offset math error at {field} [{off}..{}]", off + 8),
    )?))
}

/// Parse a fixed-size array of `N` little-endian `u32` buckets starting at
/// `start_off` in `bytes`. `field` names the array for error messages.
///
/// The caller has already verified the buffer length covers the array, so the
/// `try_into` conversions only fail on impossible offset math.
fn parse_u32_buckets<const N: usize>(
    bytes: &[u8],
    start_off: usize,
    field: &str,
) -> Result<[u32; N], String> {
    let mut buckets = [0u32; N];
    for (i, bucket) in buckets.iter_mut().enumerate() {
        let off = start_off + i * 4;
        *bucket = u32::from_le_bytes(
            bytes[off..off + 4]
                .try_into()
                .map_err(|_| format!("offset math error at {field}[{i}] [{off}..{}]", off + 4))?,
        );
    }
    Ok(buckets)
}

// ── 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], 0x02, "version byte should be 0x02 (V2 day tier)");
    }

    #[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);
        assert_eq!(ENTRY_SIZE, 1116, "V2 entry must be 1116 bytes");
    }

    #[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(&[0x02, 0x00]);
        assert!(result.is_err());
    }

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

    #[test]
    fn deserialize_v1_entry_backward_compatible() {
        // A V1 record (version 0x01, 983 bytes) must deserialize cleanly with an
        // empty day tier — a clean old → new recovery, never silent corruption.
        let mut bytes = vec![0u8; ENTRY_SIZE_V1];
        bytes[OFF_VERSION] = VERSION_V1;
        // entity_id = 7
        bytes[OFF_ENTITY_ID..OFF_ENTITY_ID + 8].copy_from_slice(&7u64.to_le_bytes());
        // signal_type_id = 3
        bytes[OFF_SIGNAL_TYPE_ID..OFF_SIGNAL_TYPE_ID + 2].copy_from_slice(&3u16.to_le_bytes());
        // all_time_count = 42
        bytes[OFF_ALL_TIME_COUNT..OFF_ALL_TIME_COUNT + 8].copy_from_slice(&42u64.to_le_bytes());

        let (eid, stid, restored) = deserialize_entry(&bytes).expect("v1 deserialize ok");
        assert_eq!(eid, EntityId::new(7));
        assert_eq!(stid, SignalTypeId::new(3));
        assert_eq!(restored.warm.all_time_count(), 42);
        // No day tier in V1: the 30-day window starts empty.
        assert_eq!(
            restored
                .warm
                .windowed_count(crate::schema::Window::ThirtyDays, 0),
            0
        );
    }

    #[test]
    fn serialize_deserialize_day_tier_roundtrip() {
        use crate::schema::Window;

        // Accumulate events across enough day boundaries to populate day buckets.
        let warm = BucketedCounter::with_start_time(0);
        for day in 0..5u64 {
            warm.increment(day * 86_400_000_000_000 + 1_000_000_000);
        }
        warm.increment(5 * 86_400_000_000_000 + 1_000_000_000);
        // Read both sides of the roundtrip as of the last event so neither side's
        // read-time rotation ages a day bucket out and skews the comparison.
        let read_ns = 5 * 86_400_000_000_000 + 1_000_000_000;
        let day_count_before = warm.windowed_count(Window::ThirtyDays, read_ns);
        assert!(day_count_before > 0, "expected non-empty day tier to test");

        let entry = EntitySignalEntry {
            hot: HotSignalState::new(5, 1),
            warm,
        };
        let bytes = serialize_entry(EntityId::new(5), SignalTypeId::new(1), &entry);
        assert_eq!(bytes.len(), ENTRY_SIZE);

        let (_, _, restored) = deserialize_entry(&bytes).expect("deserialize ok");
        assert_eq!(
            restored.warm.windowed_count(Window::ThirtyDays, read_ns),
            day_count_before,
            "30-day window must survive checkpoint roundtrip"
        );
    }

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