//! 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.

/// 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> {
    #[allow(clippy::cast_possible_truncation)]
    let name_len = s.name.len() as u32;
    #[allow(clippy::cast_possible_truncation)]
    let query_len = s.query_text.len() as u32;

    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());
    buf.extend_from_slice(&name_len.to_le_bytes());
    buf.extend_from_slice(s.name.as_bytes());
    buf.extend_from_slice(&query_len.to_le_bytes());
    buf.extend_from_slice(s.query_text.as_bytes());

    match &s.filter_expr_json {
        Some(json) => {
            buf.push(1u8);
            #[allow(clippy::cast_possible_truncation)]
            let filter_len = json.len() as u32;
            buf.extend_from_slice(&filter_len.to_le_bytes());
            buf.extend_from_slice(json.as_bytes());
        }
        None => {
            buf.push(0u8);
        }
    }

    buf
}

/// Deserialize a saved search from fjall storage.
///
/// Returns `None` if the bytes are malformed.
#[must_use]
pub fn deserialize_saved_search(bytes: &[u8]) -> Option<SavedSearch> {
    // Minimum: 8 + 8 + 4 + 0 + 4 + 0 + 1 = 25 bytes
    if bytes.len() < 25 {
        return None;
    }

    let mut pos = 0;

    let user_id = u64::from_le_bytes([
        bytes[pos],
        bytes[pos + 1],
        bytes[pos + 2],
        bytes[pos + 3],
        bytes[pos + 4],
        bytes[pos + 5],
        bytes[pos + 6],
        bytes[pos + 7],
    ]);
    pos += 8;

    let created_at_ns = u64::from_le_bytes([
        bytes[pos],
        bytes[pos + 1],
        bytes[pos + 2],
        bytes[pos + 3],
        bytes[pos + 4],
        bytes[pos + 5],
        bytes[pos + 6],
        bytes[pos + 7],
    ]);
    pos += 8;

    if pos + 4 > bytes.len() {
        return None;
    }
    let name_len =
        u32::from_le_bytes([bytes[pos], bytes[pos + 1], bytes[pos + 2], bytes[pos + 3]]) as usize;
    pos += 4;

    if pos + name_len > bytes.len() {
        return None;
    }
    let name = String::from_utf8_lossy(&bytes[pos..pos + name_len]).to_string();
    pos += name_len;

    if pos + 4 > bytes.len() {
        return None;
    }
    let query_len =
        u32::from_le_bytes([bytes[pos], bytes[pos + 1], bytes[pos + 2], bytes[pos + 3]]) as usize;
    pos += 4;

    if pos + query_len > bytes.len() {
        return None;
    }
    let query_text = String::from_utf8_lossy(&bytes[pos..pos + query_len]).to_string();
    pos += query_len;

    if pos >= bytes.len() {
        return None;
    }
    let has_filter = bytes[pos];
    pos += 1;

    let filter_expr_json = if has_filter == 1 {
        if pos + 4 > bytes.len() {
            return None;
        }
        let filter_len =
            u32::from_le_bytes([bytes[pos], bytes[pos + 1], bytes[pos + 2], bytes[pos + 3]])
                as usize;
        pos += 4;

        if pos + filter_len > bytes.len() {
            return None;
        }
        let json = String::from_utf8_lossy(&bytes[pos..pos + filter_len]).to_string();
        Some(json)
    } 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());
    }
}
