//! Mappings between data structures in flora-model and Typesense.
use dom_smoothie::{Config, Readability, TextMode};
use omry_archiving::{Document, Record};
use thiserror::Error;
use uuid::Uuid;

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

/// The collection name for records in Typesense.
const COLLECTION_NAME: &str = "records";

/// The default sorting field for Typesense records.
const DEFAULT_SORTING_FIELD: &str = "timestamp";

/// Public wrapper for errors that we return when interacting with search APIs fails.
#[derive(Debug, Error)]
#[error(transparent)]
pub struct SearchRecordError(#[from] SearchRecordErrorRepr);

/// Internal errors that we return when search operations fail.
#[derive(Debug, Error)]
enum SearchRecordErrorRepr {
    /// Error when trying to extract readable text from the document body.
    #[error("Failed extracting text from Record body: {0}")]
    Html2Text(#[from] dom_smoothie::ReadabilityError),

    /// The [`Record`] we are trying to import into Typesense is missing an ID.
    #[error("Missing record ID")]
    MissingId,

    /// The document we are trying to extract text from seems not to be a web page.  
    #[error("Unsupported document type: {0}")]
    UnsupportedDocument(String),
}

/// Typesense field types (for the schema), and their respective values.
/// Currently, includes only types used by Omry.
///
/// See <https://typesense.org/docs/29.0/api/collections.html#field-types>.
#[allow(clippy::missing_docs_in_private_items)]
#[derive(Debug, PartialEq)]
#[cfg_attr(feature = "serde", derive(Serialize, Deserialize))]
#[cfg_attr(feature = "serde", serde(rename_all = "lowercase"))]
pub(crate) enum TypesenseType {
    String,
    Int32,
    Int64,
}

/// A builder for programmatically creating fields in a Typesense collection.
/// See <https://typesense.org/docs/29.0/api/collections.html#field-parameters>.
#[allow(clippy::missing_docs_in_private_items)]
struct FieldBuilder {
    name: String,
    typesense_type: TypesenseType,
    facet: Option<bool>,
    optional: Option<bool>,
}

impl FieldBuilder {
    /// Sets the value of the `facet` field to the given value.
    fn facet(mut self, value: bool) -> Self {
        self.facet = Some(value);
        self
    }

    /// Sets the value of the `optional` field to the given value.
    fn optional(mut self, value: bool) -> Self {
        self.optional = Some(value);
        self
    }

    /// Builds the [`Field`] from the values set in this builder.
    fn build(self) -> Field {
        Field {
            name: self.name,
            typesense_type: self.typesense_type,
            facet: self.facet.unwrap_or_default(),
            optional: self.optional.unwrap_or_default(),
        }
    }
}

/// We use this for serde to skip serializing non-required boolean fields.
// `serde` needs us to pass `value` by reference, even though it's trivial to copy.
#[cfg(feature = "serde")]
#[allow(clippy::trivially_copy_pass_by_ref)]
fn is_false(value: &bool) -> bool {
    !*value
}

/// Typesense collection field. Only has field parameters we currently support in Omry.
/// See <https://typesense.org/docs/29.0/api/collections.html#field-parameters>.
#[allow(clippy::missing_docs_in_private_items)]
#[derive(Debug, PartialEq)]
#[cfg_attr(feature = "serde", derive(Serialize, Deserialize))]
pub(crate) struct Field {
    name: String,

    #[cfg_attr(feature = "serde", serde(rename = "type"))]
    typesense_type: TypesenseType,

    #[cfg_attr(feature = "serde", serde(skip_serializing_if = "is_false"))]
    facet: bool,

    #[cfg_attr(feature = "serde", serde(skip_serializing_if = "is_false"))]
    optional: bool,
}

impl Field {
    /// Creates a new builder to configure this field.
    fn builder(name: String, typesense_type: TypesenseType) -> FieldBuilder {
        FieldBuilder {
            name,
            typesense_type,
            facet: None,
            optional: None,
        }
    }
}

/// Typesense collection schema for flora's records.
#[derive(Debug, PartialEq)]
#[cfg_attr(feature = "serde", derive(Serialize, Deserialize))]
pub struct Schema {
    /// Typesense collection name.
    pub name: String,

    /// The Fields expected in this collection.
    pub(crate) fields: Vec<Field>,

    /// The Typesense default sorting field for this collection.
    pub(crate) default_sorting_field: String,
}

/// Typesense collection.
///
/// See <https://typesense.org/docs/27.1/api/collections.html#retrieve-a-collection>.
#[derive(Debug, PartialEq)]
#[cfg_attr(feature = "serde", derive(Serialize, Deserialize))]
pub struct Collection {
    /// Number of documents this collection contains.
    pub num_documents: usize,

    /// This collection's schema.
    #[cfg_attr(feature = "serde", serde(flatten))]
    pub schema: Schema,
}

/// Representation of a flora [`Record`] for Typesense.
#[derive(Debug)]
#[cfg_attr(feature = "serde", derive(Serialize, Deserialize))]
#[cfg_attr(feature = "serde", serde(rename_all = "kebab-case"))]
pub struct SearchRecord {
    /// Typesense ID (UUID v7).
    pub(crate) id: String,

    /// Database (SQLite) ID.
    pub(crate) db_id: i64,

    /// The URL.
    pub(crate) url: String,

    /// The host component of the URL.
    pub(crate) host: Option<String>,

    /// The page title.
    pub(crate) title: String,

    /// The body.
    pub(crate) text_body: String,

    /// Unix timestamp (seconds).
    pub(crate) timestamp: i64,

    /// Year (in client's original time).
    pub(crate) year_local: i32,

    /// Primary language tag (or "" if we couldn't get one).
    /// **Note:** This field will be set to an empty string for both language values that are missing
    /// and ones whose value we couldn't successfully convert to its primary representation
    /// by using `Language::primary`
    pub(crate) language: Option<String>,
}

impl SearchRecord {
    /// Returns the raw (string) ID of this record, as stored in Typesense.
    #[must_use]
    pub fn id(&self) -> &str {
        &self.id
    }

    /// Returns the database (SQLite) ID.
    #[must_use]
    pub fn db_id(&self) -> i64 {
        self.db_id
    }

    /// Returns the URL for this record, as stored in Typesense.
    #[must_use]
    pub fn url(&self) -> &str {
        &self.url
    }

    /// Returns the title for this record, as stored in Typesense.
    #[must_use]
    pub fn title(&self) -> &str {
        &self.title
    }

    /// Returns the text extracted from the original document
    /// when it was ingested, as stored in Typesense.
    #[must_use]
    pub fn text_body(&self) -> &str {
        &self.text_body
    }

    /// The Unix timestamp for this record, as stored
    /// in Typesense.
    #[must_use]
    pub fn timestamp(&self) -> i64 {
        self.timestamp
    }

    /// Returns the year (in client's original time)
    /// for this record, as stored in Typesense.
    #[must_use]
    pub fn year(&self) -> i32 {
        self.year_local
    }

    /// Returns the primary language tag for this record,
    /// as stored in Typesense.
    ///
    /// **Note:** This will return [`None`] if the language tag
    /// couldn't be successfully parsed when the document
    /// was originally ingested.
    /// The corresponding [`Record`] will have the original,
    /// unparsed language tag.
    #[must_use]
    pub fn language(&self) -> Option<&str> {
        self.language.as_deref()
    }

    /// The Typesense collection schema for the search record.
    #[must_use]
    pub fn schema() -> Schema {
        // idea: Replace with custom proc macro to generate schema from fields
        // using serde-like attributes.
        let fields = vec![
            Field::builder("db-id".to_string(), TypesenseType::Int64).build(),
            Field::builder("url".to_string(), TypesenseType::String).build(),
            Field::builder("title".to_string(), TypesenseType::String).build(),
            Field::builder("host".to_string(), TypesenseType::String)
                .facet(true)
                .optional(true)
                .build(),
            Field::builder("text-body".to_string(), TypesenseType::String).build(),
            Field::builder("timestamp".to_string(), TypesenseType::Int64).build(),
            Field::builder("year-local".to_string(), TypesenseType::Int32)
                .facet(true)
                .build(),
            Field::builder("language".to_string(), TypesenseType::String)
                .optional(true)
                .build(),
        ];

        Schema {
            name: COLLECTION_NAME.to_string(),
            fields,
            default_sorting_field: DEFAULT_SORTING_FIELD.to_string(),
        }
    }

    /// Two instances of `SearchRecord` are considered to have the same data if all their fields
    /// except `id` are equal.
    #[must_use]
    pub fn same_data(&self, other: &Self) -> bool {
        self.url == other.url
            && self.host == other.host
            && self.title == other.title
            && self.text_body == other.text_body
            && self.timestamp == other.timestamp
            && self.year_local == other.year_local
            && self.language == other.language
    }
}

impl TryFrom<Record> for SearchRecord {
    type Error = SearchRecordError;

    fn try_from(value: Record) -> Result<Self, Self::Error> {
        let db_id = value.id().ok_or(SearchRecordErrorRepr::MissingId)?;
        let id = Uuid::now_v7().to_string();

        let title = value.title().to_string();
        let url = value.url().to_string();
        let host = value.host().unwrap_or_default();
        let year_local = value.client_datetime().year();
        let timestamp = value.timestamp_archived();

        let language = value.language().map(|lang| {
            lang.primary()
                .unwrap_or_else(|error| error.raw_data().to_string())
        });

        let text_body = extract_record_text(&value)?;

        Ok(Self {
            id,
            db_id,
            url,
            host,
            title,
            text_body,
            timestamp,
            year_local,
            language,
        })
    }
}

/// Two instances of `SearchRecord` are equal if all their fields (including `id`) compare as equal.
impl PartialEq for SearchRecord {
    fn eq(&self, other: &Self) -> bool {
        self.id == other.id
            && self.url == other.url
            && self.host == other.host
            && self.title == other.title
            && self.text_body == other.text_body
            && self.timestamp == other.timestamp
            && self.year_local == other.year_local
            && self.language == other.language
    }
}

/// Extracts the readable text from this record using [`Readability`].
fn extract_record_text(record: &Record) -> Result<String, SearchRecordErrorRepr> {
    let body = match record.document() {
        Document::WebPage(page) => String::from_utf8_lossy(page.data()).to_string(),
        _ => {
            return Err(SearchRecordErrorRepr::UnsupportedDocument(
                "not a web page".to_string(),
            ));
        }
    };

    let config = Config {
        text_mode: TextMode::Markdown,
        ..Default::default()
    };
    let mut readability = Readability::new(body, Some(record.url()), Some(config))?;
    let article = readability.parse()?;
    Ok(article.text_content.to_string())
}

#[cfg(test)]
pub(crate) mod tests {
    use super::*;
    use omry_archiving::{Document, Marshal, RecordParams};
    use pretty_assertions::{assert_eq, assert_ne};
    use std::env;
    use std::fs::{self, File};
    use std::io::prelude::*;

    const WORKSPACE_DIR_KEY: &str = "CARGO_WORKSPACE_DIR";
    const TEST_ARCHIVE_SUBPATH: &str = "test_data/example.postcard";

    fn load_test_document() -> anyhow::Result<Document> {
        let (_, workspace_dir_path) = env::vars()
            .find(|(k, _)| k == WORKSPACE_DIR_KEY)
            .ok_or_else(|| anyhow::anyhow!("{WORKSPACE_DIR_KEY} is not set"))?;
        let workspace_dir_path = fs::canonicalize(workspace_dir_path)?;
        let test_archive_path = workspace_dir_path.join(TEST_ARCHIVE_SUBPATH);

        let mut file = File::open(&test_archive_path)?;
        let mut buf = vec![];
        file.read_to_end(&mut buf)?;

        let archive_holder = Document::from_bytes(&buf)?;
        Ok(archive_holder)
    }

    fn make_test_record_params() -> anyhow::Result<RecordParams> {
        let document = load_test_document()?;
        let url = "https://example.org/".to_string();
        let title = "Example Domain";
        let client_datetime = "2024-10-11T13:49:46-05:00";

        #[allow(clippy::unreadable_literal)]
        let timestamp_flora = Some(1728695243i64);
        let language = Some("en".to_string());

        Ok(RecordParams {
            id: None,
            url,
            title: title.to_string(),
            client_datetime: client_datetime.to_string(),
            timestamp_flora,
            language,
            document,
            updated_at: None,
        })
    }

    #[test]
    #[cfg(feature = "serde")]
    fn can_convert_record_to_search_record() -> anyhow::Result<()> {
        let mut record_params = make_test_record_params()?;
        record_params.id = Some(37);

        let record = Record::new(record_params);
        let search_record = SearchRecord::try_from(record)?;

        insta::assert_ron_snapshot!(search_record, {
            ".id" => "[uuid]",
        });
        Ok(())
    }

    #[test]
    fn record_without_id_fails_to_search_record() -> anyhow::Result<()> {
        let record_params = make_test_record_params()?;
        let record = Record::new(record_params);
        assert!(SearchRecord::try_from(record).is_err());
        Ok(())
    }

    #[test]
    fn can_create_schema() {
        insta::assert_debug_snapshot!(SearchRecord::schema());
    }

    #[test]
    fn records_with_different_ids_compare_unequal() -> anyhow::Result<()> {
        let mut record_params = make_test_record_params()?;
        record_params.id = Some(37);
        let record = Record::new(record_params);
        let search_record: SearchRecord = record.try_into()?;

        let mut also_record_params = make_test_record_params()?;
        also_record_params.id = Some(42);
        let also_record = Record::new(also_record_params);
        let also_search_record: SearchRecord = also_record.try_into()?;

        assert_ne!(search_record, also_search_record);
        Ok(())
    }

    #[test]
    fn different_records_compare_unequal() -> anyhow::Result<()> {
        let mut record_params = make_test_record_params()?;
        record_params.id = Some(37);
        let record = Record::new(record_params);
        let search_record: SearchRecord = record.try_into()?;

        let mut also_record_params = make_test_record_params()?;
        also_record_params.id = Some(42);
        also_record_params.title = "Not Example Domain".to_string();
        let also_record = Record::new(also_record_params);
        let also_search_record: SearchRecord = also_record.try_into()?;

        assert_ne!(search_record, also_search_record);
        Ok(())
    }

    #[test]
    fn records_with_different_ids_but_same_data() -> anyhow::Result<()> {
        let mut record_params = make_test_record_params()?;
        record_params.id = Some(37);
        let record = Record::new(record_params);
        let search_record: SearchRecord = record.try_into()?;

        let mut also_record_params = make_test_record_params()?;
        also_record_params.id = Some(42);
        let also_record = Record::new(also_record_params);
        let also_search_record: SearchRecord = also_record.try_into()?;

        assert!(search_record.same_data(&also_search_record));
        Ok(())
    }

    #[test]
    fn records_with_different_data() -> anyhow::Result<()> {
        let mut record_params = make_test_record_params()?;
        record_params.id = Some(37);
        let record = Record::new(record_params);
        let search_record: SearchRecord = record.try_into()?;

        let mut also_record_params = make_test_record_params()?;
        also_record_params.id = Some(42);
        also_record_params.title = "Not Example Domain".to_string();
        let also_record = Record::new(also_record_params);
        let also_search_record: SearchRecord = also_record.try_into()?;

        assert!(!search_record.same_data(&also_search_record));
        Ok(())
    }

    #[test]
    fn can_get_record_id() -> anyhow::Result<()> {
        let mut record_params = make_test_record_params()?;
        let id = 37;
        record_params.id = Some(id);
        let record = Record::new(record_params);

        let search_record: SearchRecord = record.try_into()?;
        assert_eq!(search_record.db_id(), id);
        Ok(())
    }

    #[test]
    fn can_use_search_record_getters() -> anyhow::Result<()> {
        let mut record_params = make_test_record_params()?;
        record_params.id = Some(37);

        let record = Record::new(record_params);
        let search_record = SearchRecord::try_from(record)?;

        assert!(search_record.id().parse::<Uuid>().is_ok());

        insta::assert_snapshot!(search_record.db_id());
        insta::assert_snapshot!(search_record.url());
        insta::assert_snapshot!(search_record.title());
        insta::assert_snapshot!(search_record.text_body());
        insta::assert_snapshot!(search_record.timestamp());
        insta::assert_snapshot!(search_record.year());
        insta::assert_debug_snapshot!(search_record.language());

        Ok(())
    }
}
