//! Saved search storage and serialization.
//!
//! A saved search captures a user's query configuration (query text and optional
//! filter expression) so it can be re-executed later with `retrieve_saved_search`.
//! Persisted to fjall under `Tag::SavedSearch` keys.
//!
//! The wire codec is single-sourced on the session byte-cursor helpers
//! ([`super::serde`]) — [`write_len_prefixed`] for encode, [`read_u64`] /
//! [`read_len_prefixed_utf8`] for decode — so the bounds-checking, the
//! `usize -> u32` length cap, and the UTF-8 *rejection* (no `from_utf8_lossy`)
//! cannot drift from the rest of the session slice.

use super::serde::{read_len_prefixed_utf8, read_u64, write_len_prefixed};

/// A saved search configuration.
#[derive(Debug, Clone)]
pub struct SavedSearch {
    pub user_id: u64,
    pub name: String,
    pub query_text: String,
    /// Optional filter expression serialized as JSON (if serde derives are available).
    pub filter_expr_json: Option<String>,
    pub created_at_ns: u64,
}

/// Summary for `list_saved_searches`.
#[derive(Debug, Clone)]
pub struct SavedSearchInfo {
    pub name: String,
    pub query_text: String,
    pub created_at_ns: u64,
}

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

/// Serialize a saved search for fjall storage.
///
/// Format: `[user_id: 8 LE][created_at_ns: 8 LE][name_len: 4 LE][name bytes]`
///         `[query_len: 4 LE][query bytes][has_filter: 1]`
///         `[if has_filter: [filter_len: 4 LE][filter json bytes]]`
#[must_use]
pub fn serialize_saved_search(s: &SavedSearch) -> Vec<u8> {
    // Length-prefixed strings route through the shared `write_len_prefixed`
    // helper, which carries the `usize -> u32` length-cap `debug_assert` (a
    // saved-search string longer than 4 GiB is never legitimate, and the
    // bounds-checked decoder only ever rejects — never mis-parses — a truncated
    // length). This keeps the codec single-sourced with the rest of the session
    // slice.
    let mut buf = Vec::new();
    buf.extend_from_slice(&s.user_id.to_le_bytes());
    buf.extend_from_slice(&s.created_at_ns.to_le_bytes());
    write_len_prefixed(&mut buf, &s.name);
    write_len_prefixed(&mut buf, &s.query_text);

    match &s.filter_expr_json {
        Some(json) => {
            buf.push(1u8);
            write_len_prefixed(&mut buf, json);
        }
        None => {
            buf.push(0u8);
        }
    }

    buf
}

/// Deserialize a saved search from fjall storage.
///
/// Returns `None` if the bytes are malformed — a short read, a length field that
/// runs past the buffer, or a name/query/filter that is not valid UTF-8.
/// Corruption is *rejected*, never best-effort patched: the decode is routed
/// through the shared session byte-cursor helpers ([`read_u64`] /
/// [`read_len_prefixed_utf8`]), which refuse `from_utf8_lossy` substitution, so a
/// corrupt record surfaces to the caller (which skips it) instead of silently
/// hydrating with U+FFFD replacement characters.
#[must_use]
pub fn deserialize_saved_search(bytes: &[u8]) -> Option<SavedSearch> {
    let mut pos = 0;

    // `[user_id u64][created_at_ns u64]` then length-prefixed name/query.
    let user_id = read_u64(bytes, &mut pos)?;
    let created_at_ns = read_u64(bytes, &mut pos)?;
    let name = read_len_prefixed_utf8(bytes, &mut pos)?;
    let query_text = read_len_prefixed_utf8(bytes, &mut pos)?;

    // `[has_filter u8]` then, if set, the length-prefixed filter JSON.
    let has_filter = *bytes.get(pos)?;
    pos += 1;
    let filter_expr_json = if has_filter == 1 {
        Some(read_len_prefixed_utf8(bytes, &mut pos)?)
    } else {
        None
    };

    Some(SavedSearch {
        user_id,
        name,
        query_text,
        filter_expr_json,
        created_at_ns,
    })
}

// ── Unit tests ────────────────────────────────────────────────────────────────

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

    #[test]
    fn serialize_deserialize_no_filter() {
        let ss = SavedSearch {
            user_id: 42,
            name: "my-search".into(),
            query_text: "rust database".into(),
            filter_expr_json: None,
            created_at_ns: 123_456,
        };
        let bytes = serialize_saved_search(&ss);
        let d = deserialize_saved_search(&bytes).unwrap();
        assert_eq!(d.user_id, 42);
        assert_eq!(d.name, "my-search");
        assert_eq!(d.query_text, "rust database");
        assert!(d.filter_expr_json.is_none());
        assert_eq!(d.created_at_ns, 123_456);
    }

    #[test]
    fn serialize_deserialize_with_filter() {
        let ss = SavedSearch {
            user_id: 7,
            name: "jazz-search".into(),
            query_text: "jazz".into(),
            filter_expr_json: Some(r#"{"category":"jazz"}"#.into()),
            created_at_ns: 999,
        };
        let bytes = serialize_saved_search(&ss);
        let d = deserialize_saved_search(&bytes).unwrap();
        assert_eq!(d.user_id, 7);
        assert_eq!(d.name, "jazz-search");
        assert_eq!(d.query_text, "jazz");
        assert_eq!(
            d.filter_expr_json.as_deref(),
            Some(r#"{"category":"jazz"}"#)
        );
    }

    #[test]
    fn deserialize_too_short() {
        assert!(deserialize_saved_search(&[0u8; 10]).is_none());
    }

    #[test]
    fn deserialize_rejects_overlong_name_len() {
        // Regression (S/session): the decode now routes through the shared
        // `read_len_prefixed_utf8` cursor, so a name_len that runs past the
        // buffer must be rejected by the shared bounds check (not the old
        // hand-rolled `pos + name_len > bytes.len()` guard).
        let mut buf = Vec::new();
        buf.extend_from_slice(&1u64.to_le_bytes()); // user_id
        buf.extend_from_slice(&2u64.to_le_bytes()); // created_at_ns
        buf.extend_from_slice(&100u32.to_le_bytes()); // name_len = 100
        buf.extend_from_slice(b"short"); // only 5 bytes present
        assert!(
            deserialize_saved_search(&buf).is_none(),
            "an over-long name length must be rejected by the shared cursor"
        );
    }

    /// Build a serialized saved search, then overwrite the bytes at `find` with
    /// `0xFF, 0xFE` (an invalid UTF-8 sequence) and assert the record is rejected.
    fn assert_corrupting_rejects(ss: &SavedSearch, find: &[u8]) {
        let mut bytes = serialize_saved_search(ss);
        let idx = bytes
            .windows(find.len())
            .position(|w| w == find)
            .expect("target bytes present in record");
        bytes[idx] = 0xFF;
        bytes[idx + 1] = 0xFE;
        assert!(
            deserialize_saved_search(&bytes).is_none(),
            "non-UTF-8 bytes must be rejected, not lossily replaced"
        );
    }

    #[test]
    fn deserialize_rejects_non_utf8_name() {
        // Regression (W40): the name field previously used `from_utf8_lossy`,
        // which masked corruption with U+FFFD instead of rejecting it.
        let ss = SavedSearch {
            user_id: 1,
            name: "nm".into(),
            query_text: "qq".into(),
            filter_expr_json: None,
            created_at_ns: 1,
        };
        assert_corrupting_rejects(&ss, b"nm");
    }

    #[test]
    fn deserialize_rejects_non_utf8_query() {
        let ss = SavedSearch {
            user_id: 1,
            name: "nm".into(),
            query_text: "qq".into(),
            filter_expr_json: None,
            created_at_ns: 1,
        };
        assert_corrupting_rejects(&ss, b"qq");
    }

    #[test]
    fn deserialize_rejects_non_utf8_filter() {
        let ss = SavedSearch {
            user_id: 1,
            name: "nm".into(),
            query_text: "qq".into(),
            filter_expr_json: Some("ff".into()),
            created_at_ns: 1,
        };
        assert_corrupting_rejects(&ss, b"ff");
    }
}
