//! Item metadata serialization and signal classification helpers.

use std::collections::HashMap;

/// Every length in the metadata format is a little-endian `u32`, so the entry
/// count and each key/value byte length must fit in `u32::MAX` (~4.29e9). Item
/// metadata never legitimately approaches this; the bound exists only to keep a
/// pathological/oversized input from silently truncating its length prefix.
const MAX_METADATA_LEN: usize = u32::MAX as usize;

/// Saturate a `usize` length to `u32`, clamping to `u32::MAX` on overflow.
///
/// Pure, assertion-free, total: the saturation half of [`len_as_u32`], split
/// out so the over-limit branch can be unit-tested directly without tripping the
/// debug assertion that fires on the (production-unexpected) oversized input.
const fn saturate_len(len: usize) -> u32 {
    if len > MAX_METADATA_LEN {
        u32::MAX
    } else {
        // In-range by the guard above, so this cast cannot truncate.
        len as u32
    }
}

/// Cast a `usize` length to the `u32` the wire format uses, saturating to
/// `u32::MAX` on overflow instead of truncating.
///
/// On a 64-bit target `usize` can exceed `u32::MAX`, and a bare `as u32` would
/// WRAP (e.g. `u32::MAX as usize + 1` -> `0`), writing a length prefix that
/// mis-frames the record and silently corrupts every following field on read.
/// Saturating instead writes `u32::MAX`, which the bounds-checked
/// [`deserialize_metadata`] treats as a truncated/malformed record and stops on
/// cleanly — a lossy-but-safe degradation rather than silent cross-field
/// corruption. The `debug_assert!` surfaces any such input in test/debug builds
/// so an oversized write is caught at its source rather than only on read-back.
fn len_as_u32(len: usize) -> u32 {
    debug_assert!(
        len <= MAX_METADATA_LEN,
        "metadata length {len} exceeds the u32 wire limit ({MAX_METADATA_LEN}); \
         saturating to u32::MAX (read-back will treat the record as truncated)"
    );
    saturate_len(len)
}

/// Serialize `HashMap<String, String>` as length-prefixed binary pairs.
///
/// This is the **items-only** metadata format (no embedding header).
/// For user/creator entities (which include an optional embedding prefix),
/// see [`entities::serialize_entity`](crate::entities::serialize_entity).
///
/// The two formats are intentionally different: items store only metadata
/// (embeddings go to the HNSW index), while users/creators store an optional
/// embedding inline with their metadata for cold-start preference vectors.
///
/// Format (all lengths little-endian u32):
/// ```text
/// [num_entries: 4 bytes]
/// for each entry:
///   [key_len: 4 bytes][key bytes]
///   [val_len: 4 bytes][value bytes]
/// ```
///
/// Each length is bounded by [`MAX_METADATA_LEN`] (`u32::MAX`). An entry count
/// or key/value length that exceeds it saturates to `u32::MAX` (see
/// [`len_as_u32`]) rather than truncating, so an oversized input degrades to a
/// record [`deserialize_metadata`] reads as truncated instead of one that
/// silently mis-frames the following fields.
pub(super) fn serialize_metadata(map: &HashMap<String, String>) -> Vec<u8> {
    let mut buf = Vec::new();
    buf.extend_from_slice(&len_as_u32(map.len()).to_le_bytes());
    for (k, v) in map {
        buf.extend_from_slice(&len_as_u32(k.len()).to_le_bytes());
        buf.extend_from_slice(k.as_bytes());
        buf.extend_from_slice(&len_as_u32(v.len()).to_le_bytes());
        buf.extend_from_slice(v.as_bytes());
    }
    buf
}

/// Deserialize `HashMap<String, String>` from the binary format produced by
/// [`serialize_metadata`].
///
/// This is the **items-only** metadata format (no embedding header).
/// For user/creator entities, see [`entities::deserialize_entity`](crate::entities::deserialize_entity).
///
/// Returns an empty map if the bytes are empty or malformed -- metadata reads
/// must never panic or fail the query.
pub fn deserialize_metadata(bytes: &[u8]) -> HashMap<String, String> {
    let mut map = HashMap::new();
    if bytes.len() < 4 {
        return map;
    }
    let count = u32::from_le_bytes([bytes[0], bytes[1], bytes[2], bytes[3]]) as usize;
    let mut pos: usize = 4;
    // All `pos + len` bounds checks use `checked_add`: on a 32-bit target a
    // corrupt length prefix could otherwise wrap `pos + len` past `usize::MAX`
    // back to a small value, defeating the check and slicing out of bounds.
    // On overflow we treat the record as truncated/malformed and stop — reads
    // must never panic.
    for _ in 0..count {
        let Some(end) = pos.checked_add(4) else { break };
        if end > bytes.len() {
            break;
        }
        let key_len =
            u32::from_le_bytes([bytes[pos], bytes[pos + 1], bytes[pos + 2], bytes[pos + 3]])
                as usize;
        pos = end;
        let Some(key_end) = pos.checked_add(key_len) else {
            break;
        };
        if key_end > bytes.len() {
            break;
        }
        let key = String::from_utf8_lossy(&bytes[pos..key_end]).to_string();
        pos = key_end;
        let Some(end) = pos.checked_add(4) else { break };
        if end > bytes.len() {
            break;
        }
        let val_len =
            u32::from_le_bytes([bytes[pos], bytes[pos + 1], bytes[pos + 2], bytes[pos + 3]])
                as usize;
        pos = end;
        let Some(val_end) = pos.checked_add(val_len) else {
            break;
        };
        if val_end > bytes.len() {
            break;
        }
        let val = String::from_utf8_lossy(&bytes[pos..val_end]).to_string();
        pos = val_end;
        map.insert(key, val);
    }
    map
}

/// Returns `true` for positive engagement signal types that should update
/// the user's preference vector.
///
/// These are high-intent signals: the user explicitly chose to engage with
/// the content. Views are excluded because they are low-signal (the user
/// may have scrolled past without interest).
pub(super) fn is_positive_engagement_signal(signal_type: &str) -> bool {
    matches!(
        signal_type,
        "like" | "share" | "completion" | "search_click"
    )
}

/// The `UserStateIndex` map a signal type drives, if any.
///
/// This is the single classifier that wires `signal_with_context` to the
/// completion / saved / liked maps the `InProgress` filter and `DateSaved` sort
/// read. Keeping it here (next to the other signal classifiers) means the write
/// path and any rebuild path classify identically.
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub(super) enum UserStateSignal {
    /// `completion`: record a completion ratio (`record_completion`).
    Completion,
    /// `save` / `bookmark`: record a timestamped save (`add_save_timestamped`).
    Save,
    /// `like`: record a like (`add_like`).
    Like,
}

/// Classify a signal type into the `UserStateIndex` map it populates, or `None`.
pub(super) fn classify_user_state_signal(signal_type: &str) -> Option<UserStateSignal> {
    match signal_type {
        "completion" => Some(UserStateSignal::Completion),
        "save" | "bookmark" => Some(UserStateSignal::Save),
        "like" => Some(UserStateSignal::Like),
        _ => None,
    }
}

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

    #[test]
    fn round_trips_metadata() {
        let mut m = HashMap::new();
        m.insert("title".to_string(), "Hello, world".to_string());
        m.insert("lang".to_string(), "en".to_string());
        let bytes = serialize_metadata(&m);
        assert_eq!(deserialize_metadata(&bytes), m);
    }

    #[test]
    fn empty_and_short_input_yields_empty_map() {
        assert!(deserialize_metadata(&[]).is_empty());
        assert!(deserialize_metadata(&[1, 2, 3]).is_empty());
    }

    #[test]
    fn truncated_length_prefix_does_not_panic() {
        // Claims one entry with a 0xFFFF_FFFF key length but provides no bytes.
        // The bounds check (now checked_add) must stop instead of slicing OOB.
        let mut bytes = 1u32.to_le_bytes().to_vec(); // count = 1
        bytes.extend_from_slice(&u32::MAX.to_le_bytes()); // key_len = u32::MAX
        let out = deserialize_metadata(&bytes);
        assert!(
            out.is_empty(),
            "malformed input must yield empty, not panic"
        );
    }

    #[test]
    fn near_usize_max_length_does_not_wrap() {
        // A length close to u32::MAX after a non-zero pos must not wrap the
        // bounds check on any target width; the record is treated as truncated.
        let mut bytes = 1u32.to_le_bytes().to_vec(); // count = 1
        bytes.extend_from_slice(&3u32.to_le_bytes()); // key_len = 3
        bytes.extend_from_slice(b"key");
        bytes.extend_from_slice(&u32::MAX.to_le_bytes()); // val_len = u32::MAX
        let out = deserialize_metadata(&bytes);
        assert!(out.is_empty(), "oversize value length must not slice OOB");
    }

    #[test]
    fn saturate_len_clamps_over_u32_max() {
        // In-range lengths pass through unchanged (also the path len_as_u32 takes
        // for every legitimate write).
        assert_eq!(saturate_len(0), 0);
        assert_eq!(saturate_len(42), 42);
        assert_eq!(saturate_len(MAX_METADATA_LEN), u32::MAX);
        assert_eq!(len_as_u32(MAX_METADATA_LEN), u32::MAX);

        // Over the u32 limit, the conversion must SATURATE to u32::MAX, never
        // wrap. (Only reachable where usize is wider than u32, i.e. 64-bit
        // targets; on a 32-bit target usize cannot exceed u32::MAX.) We exercise
        // `saturate_len` directly rather than `len_as_u32` because the latter's
        // debug_assert deliberately fires on this production-unexpected input.
        #[cfg(target_pointer_width = "64")]
        {
            // A bare `as u32` would wrap this to 0; saturation keeps it u32::MAX.
            assert_eq!(saturate_len(MAX_METADATA_LEN + 1), u32::MAX);
            assert_eq!(saturate_len(usize::MAX), u32::MAX);
        }
    }
}
