//! Data structures that map to format used by the Typesense API.

/// Type alias to denote a Typesense collection field name.
pub type FieldName = String;

/// Module with data structures that we pass as parameters in search queries.
pub mod search_parameters {
    use std::collections::HashMap;
    use std::fmt::{self, Display};
    use std::num::NonZero;
    use thiserror::Error;

    use super::FieldName;

    /// Errors that can be returned when trying to construct the [`SearchParameters`].
    #[derive(Debug, Error)]
    #[error(transparent)]
    pub struct SearchParametersError(#[from] SearchParamsErrorRepr);

    /// Internal error representations that can be converted into
    /// the public [`SearchParametersError`].
    #[derive(Debug, Error)]
    enum SearchParamsErrorRepr {
        /// Error constructing [`SortBy`].
        #[error("Error constructing SortBy: {0}")]
        SortBy(String),
    }

    #[cfg(feature = "serde")]
    use serde::{Deserialize, Serialize};
    #[cfg(feature = "serde")]
    use serde_with::formats::CommaSeparator;
    #[cfg(feature = "serde")]
    use serde_with::{StringWithSeparator, serde_as, skip_serializing_none};

    // Consider using crate::model::Field as the key type, or adding a new type
    // if we don't want to make that one public.
    /// Filter conditions for refining the search results.
    ///
    /// Each key should be a valid field name expected by the schema, and
    /// each value should be one or more filter conditions defined at
    /// <https://typesense.org/docs/27.1/api/search.html#filter-parameters>
    pub type FilterBy = HashMap<FieldName, String>;

    /// Sort order to use in `sort_by`.
    #[cfg_attr(feature = "serde", derive(Serialize, Deserialize))]
    #[derive(Debug, Default, Copy, Clone)]
    pub enum SortOrder {
        /// Ascending.
        Asc,

        /// Descending.
        #[default]
        Desc,
    }

    impl Display for SortOrder {
        fn fmt(&self, fmt: &mut fmt::Formatter<'_>) -> fmt::Result {
            match self {
                Self::Asc => write!(fmt, "asc"),
                Self::Desc => write!(fmt, "desc"),
            }
        }
    }

    /// Type alias corresponding to `buckets` parameter.
    ///
    /// See <https://typesense.org/docs/29.0/api/search.html#text-match-score-bucketing>.
    pub type Buckets = u32;

    /// A field to use in `sort_by`.
    ///
    /// See the documentation for the `sort_by` parameter at
    /// <https://typesense.org/docs/29.0/api/search.html#ranking-and-sorting-parameters>.
    #[cfg_attr(feature = "serde", derive(Serialize, Deserialize))]
    #[derive(Debug, Clone)]
    pub enum SortByField {
        /// 'Regular' (not special) field, as in one we created.
        Regular {
            /// Field name.
            field_name: FieldName,

            /// Sort order.
            order: SortOrder,
        },

        /// `_text_match`, special field for sorting by text similarity score.
        TextMatch {
            /// How many buckets Typesense will divide the results into.
            ///
            /// See <https://typesense.org/docs/29.0/api/search.html#text-match-score-bucketing>.
            buckets: Option<Buckets>,

            /// Sort order.
            order: SortOrder,
        },
    }

    impl SortByField {
        /// Constructs a new [`SortByField::Regular`] field to use in `sort_by`.
        #[must_use]
        pub fn regular(field_name: FieldName, order: SortOrder) -> Self {
            Self::Regular { field_name, order }
        }

        /// Constructs a new [`SortByField::TextMatch`] field to use in `sort_by`.
        #[must_use]
        pub fn text_match(buckets: Option<Buckets>, order: SortOrder) -> Self {
            Self::TextMatch { buckets, order }
        }
    }

    impl Display for SortByField {
        fn fmt(&self, fmt: &mut fmt::Formatter<'_>) -> fmt::Result {
            match self {
                Self::Regular { field_name, order } => write!(fmt, "{field_name}:{order}"),
                Self::TextMatch { buckets, order } => {
                    let buckets = buckets
                        .map(|size| format!("(buckets: {size})"))
                        .unwrap_or_default();

                    write!(fmt, "_text_match{buckets}:{order}")
                }
            }
        }
    }

    /// Maximum number of fields that can be used in `sort_by`, as specified by Typesense.
    ///
    /// See <https://typesense.org/docs/29.0/api/search.html#ranking-and-sorting-parameters>.
    const MAX_SORT_BY_FIELDS: usize = 3;

    /// Corresponds to `sort_by` in search parameters.
    ///
    /// See <https://typesense.org/docs/29.0/api/search.html#ranking-and-sorting-parameters>.
    #[cfg_attr(feature = "serde", derive(Serialize, Deserialize))]
    #[derive(Debug, Clone)]
    pub struct SortBy {
        /// Fields to sort by (up to 3).
        fields: Vec<SortByField>,
    }

    impl SortBy {
        /// Creates a new instance of [`SortBy`] with no fields.
        #[allow(clippy::new_without_default)]
        #[must_use]
        pub fn new() -> Self {
            let fields = Vec::with_capacity(MAX_SORT_BY_FIELDS);
            Self { fields }
        }

        /// Adds a new field to `sort_by`.
        ///
        /// Up to 3 fields can be added (this limit comes from Typesense).
        ///
        /// ## Errors
        ///
        /// Returns an error if you're attempting to add more than 3 fields.
        pub fn add_field(mut self, field: SortByField) -> Result<Self, SearchParametersError> {
            if self.fields.len() == MAX_SORT_BY_FIELDS {
                return Err(SearchParamsErrorRepr::SortBy(format!(
                    "Only up to {MAX_SORT_BY_FIELDS} fields can be added."
                ))
                .into());
            }

            self.fields.push(field);
            Ok(self)
        }

        /// Returns a slice with the fields to sort by.
        #[must_use]
        pub fn fields(&self) -> &[SortByField] {
            &self.fields
        }

        /// Returns a new `sort_by` parameter to sort search hits
        /// by both their text relevance and recency.
        ///
        /// Search hits will be sorted into the given number of buckets
        /// based on their text match score, and sorted by most recent timestamp
        /// within each bucket.
        ///
        /// See <https://typesense.org/docs/guide/ranking-and-relevance.html#ranking-based-on-relevance-and-recency>.
        #[allow(
            clippy::missing_panics_doc,
            reason = "This will not panic unless someone modifies the code to use
            more than 3 fields."
        )]
        #[must_use]
        pub fn relevance_and_recency(buckets: Buckets) -> Self {
            #[allow(
                clippy::unwrap_used,
                reason = "We know we're using fewer than 3 fields"
            )]
            #[allow(clippy::expect_used)]
            SortBy::new()
                .add_field(SortByField::text_match(Some(buckets), SortOrder::Desc))
                .unwrap()
                .add_field(SortByField::regular(
                    "timestamp".to_string(),
                    SortOrder::Desc,
                ))
                .expect("There should be 3 or fewer sort_by fields")
        }
    }

    impl Display for SortBy {
        fn fmt(&self, fmt: &mut fmt::Formatter<'_>) -> fmt::Result {
            let fields = self
                .fields
                .iter()
                .map(ToString::to_string)
                .collect::<Vec<_>>()
                .join(",");

            write!(fmt, "{fields}")
        }
    }

    /// Serializes [`Option<SortBy>`] using its [`Display`] implementation.
    #[allow(clippy::ref_option, reason = "This is what we get from serde.")]
    #[cfg(feature = "serde")]
    fn serialize_sort_by_display<S>(
        sort_by: &Option<SortBy>,
        serializer: S,
    ) -> Result<S::Ok, S::Error>
    where
        S: serde::Serializer,
    {
        let Some(sort_by) = sort_by else {
            return serializer.serialize_str("");
        };

        serializer.serialize_str(sort_by.to_string().as_str())
    }

    /// Struct to encode Typesense search parameters defined at
    /// <https://typesense.org/docs/27.1/api/search.html#search-parameters>
    ///
    /// **Note:** We only implement the parameters that are currently supported by flora.
    #[cfg_attr(feature = "serde", cfg_eval::cfg_eval, serde_as, skip_serializing_none)]
    #[cfg_attr(feature = "serde", derive(Serialize, Deserialize))]
    #[derive(Debug, Clone)]
    pub struct SearchParameters {
        /// The query text to search for.
        /// Corresponds to the `q` parameter in Typesense docs.
        #[cfg_attr(feature = "serde", serde(rename = "q"))]
        pub query: String,

        /// Field names for Typesense to query against.
        #[cfg_attr(
            feature = "serde",
            serde_as(as = "StringWithSeparator::<CommaSeparator, String>")
        )]
        pub query_by: Vec<FieldName>,

        /// Maximum number of typographical errors (0, 1 or 2).
        pub num_typos: Option<u8>,

        /// Filter conditions for refining the search results.
        pub filter_by: Option<FilterBy>,

        /// `sort_by` fields and parameters.
        #[cfg_attr(feature = "serde", serde(serialize_with = "serialize_sort_by_display"))]
        pub sort_by: Option<SortBy>,

        /// Fetch results from this page.
        pub page: Option<NonZero<usize>>,

        /// Number of hits to fetch per page, up to 250 (limit from Typesense.)
        pub per_page: Option<NonZero<u8>>,
    }

    impl SearchParameters {
        /// Creates a new instance of [`SearchParameters`] with the given query against
        /// the given field.
        #[must_use]
        pub fn new_query(query: String, query_by: Vec<FieldName>) -> Self {
            Self {
                query,
                query_by,
                num_typos: Option::default(),
                filter_by: Option::default(),
                sort_by: Option::default(),
                page: Option::default(),
                per_page: Option::default(),
            }
        }

        /// Sets maximum number of typographical errors (0, 1 or 2) that would be tolerated.
        /// See <https://typesense.org/docs/27.1/api/search.html#typo-tolerance-parameters>
        #[must_use]
        pub fn num_typos(mut self, num_typos: u8) -> Self {
            self.num_typos = Some(num_typos);
            self
        }

        /// Sets filter conditions for refining the search results.
        /// See <https://typesense.org/docs/27.1/api/search.html#filter-parameters>
        #[must_use]
        pub fn filter_by(mut self, filter_by: FilterBy) -> Self {
            self.filter_by = Some(filter_by);
            self
        }

        /// Sets fields to sort by, and their sort order.
        ///
        /// See <https://typesense.org/docs/29.0/api/search.html#ranking-and-sorting-parameters>.
        #[must_use]
        pub fn sort_by(mut self, sort_by: SortBy) -> Self {
            self.sort_by = Some(sort_by);
            self
        }

        /// Fetches results from this page.
        ///
        /// See <https://typesense.org/docs/29.0/api/search.html#pagination-parameters>.
        #[must_use]
        pub fn page(mut self, page: usize) -> Self {
            self.page = NonZero::new(page);
            self
        }

        /// Fetches up to this many hits per page (up to 250).
        ///
        /// See <https://typesense.org/docs/29.0/api/search.html#pagination-parameters>.
        #[must_use]
        pub fn per_page(mut self, per_page: u8) -> Self {
            self.per_page = NonZero::new(per_page);
            self
        }
    }

    #[cfg(test)]
    mod tests {
        use anyhow::Result;

        use super::*;

        #[test]
        fn can_construct_search_parameters_with_sort_by() -> Result<()> {
            const BUCKETS: u32 = 3;

            let sort_by = SortBy::relevance_and_recency(BUCKETS);
            let search_params =
                SearchParameters::new_query("hello".to_string(), vec!["text_body".to_string()])
                    .sort_by(sort_by);

            let search_params = serde_json::to_string(&search_params)?;
            insta::assert_snapshot!(search_params);
            Ok(())
        }
    }
}

/// Module for Rust counterparts to Typesense's search response,
/// and its associated types.
pub mod search_response {
    use super::FieldName;
    use crate::model::SearchRecord;
    use std::time::Duration;

    #[cfg(feature = "serde")]
    use serde::{Deserialize, Serialize};
    #[cfg(feature = "serde")]
    use serde_with::{DurationMilliSeconds, serde_as};

    /// The object documented under 'highlights' array at
    /// <https://typesense.org/docs/27.1/api/search.html#search>
    #[derive(Debug)]
    #[cfg_attr(feature = "serde", derive(Serialize, Deserialize))]
    pub struct Highlight {
        /// The field that resulted in a search hit.
        pub field: FieldName,

        /// The tokens that matched the search query.
        pub matched_tokens: Vec<String>,

        /// The snippet that gave us the search hit.
        pub snippet: String,
    }

    /// The object documented under the 'hits' array at
    /// <https://typesense.org/docs/27.1/api/search.html#search>
    #[derive(Debug)]
    #[cfg_attr(feature = "serde", derive(Serialize, Deserialize))]
    pub struct Hit {
        /// Search highlights.
        pub highlights: Vec<Highlight>,

        /// The record that matched the search query.
        pub document: SearchRecord,

        /// Text match score.
        /// See <https://typesense.org/docs/guide/ranking-and-relevance.html#text-match-score-type>.
        pub text_match: i64,
    }

    /// The original parameters of the search query.
    /// See <https://typesense.org/docs/27.1/api/search.html#search>
    ///
    /// **Note:** We aren't reusing the [`crate::SearchParameters`] type here
    /// because the fields that Typesense requires for the query we send
    /// don't exactly match the fields it returns in the `request_params` object.
    #[derive(Debug, Clone)]
    #[cfg_attr(feature = "serde", derive(Serialize, Deserialize))]
    pub struct RequestParams {
        /// The collection Typesense searched against.
        pub collection_name: String,

        /// The number of search results per page that Typesense returned.
        pub per_page: usize,

        /// The original search query.
        /// Corresponds to the `q` attribute in the Typesense docs.
        #[cfg_attr(feature = "serde", serde(rename(deserialize = "q")))]
        pub query: String,
    }

    /// The search response object documented under
    /// <https://typesense.org/docs/27.1/api/search.html#search>
    #[derive(Debug)]
    #[cfg_attr(feature = "serde", cfg_eval::cfg_eval, serde_as)]
    #[cfg_attr(feature = "serde", derive(Serialize, Deserialize))]
    pub struct SearchResponse {
        /// How many matching documents Typesense found.
        pub found: usize,

        /// How many documents Typesense searched against.
        pub out_of: usize,

        /// How many pages of results Typesense returned.
        pub page: usize,

        /// The parameters of the original search request.
        pub request_params: RequestParams,

        /// Time it took for Typesense to run the search.
        #[cfg_attr(feature = "serde", serde(rename = "search_time_ms"))]
        #[cfg_attr(feature = "serde", serde_as(as = "DurationMilliSeconds"))]
        pub search_time: Duration,

        /// Search hits.
        pub hits: Vec<Hit>,
    }
}
