//! Data structures for archived records.
use chrono::{DateTime, FixedOffset, Local, Utc};
use oxilangtag::LanguageTag;
use std::fmt;
use thiserror::Error;
use tracing::instrument;
use url::Url;

use crate::Document;
use datetime::ClientDateTimeRecord;

/// Error type returned by fallible operations on records.
#[derive(Debug, Error)]
#[error(transparent)]
pub struct RecordError(#[from] RecordErrorRepr);

/// Internal error representation for failures that we can run into when working with records.
#[derive(Debug, Error)]
enum RecordErrorRepr {
    /// Error getting data from the record (such as values that failed parsing).
    #[error("Error getting the record data: {0}")]
    Data(#[from] RecordDataError),
}

/// Possible errors that might occur when processing data from records.
#[derive(Debug, Error)]
pub enum RecordDataError {
    /// Error when parsing the url.
    #[error("Couldn't parse the URL {raw}: {msg}")]
    UrlParse {
        /// The original raw URL.
        raw: String,

        /// Error message from the parsing code.
        msg: String,
    },

    /// Error parsing the language tag.
    #[error("Couldn't parse the language")]
    LanguageParse {
        /// The original language record as given by the client.
        raw: String,

        /// Error message from the parsing code.
        msg: String,
    },
}

impl RecordDataError {
    /// Gets the original 'raw' data stored with this error.
    #[must_use]
    pub fn raw_data(&self) -> &str {
        match self {
            Self::UrlParse { raw, .. } | Self::LanguageParse { raw, .. } => raw.as_str(),
        }
    }
}

/// Result alias for fallible operations when constructing
/// or using [`Record`] data that needs parsing, or might be missing.
type DataResult<T> = std::result::Result<T, RecordDataError>;

/// Enum to encode the possible outcomes of parsing a language tag.
#[derive(Debug, Clone, PartialEq)]
#[cfg_attr(feature = "serde", derive(serde::Serialize, serde::Deserialize))]
pub enum LanguageRecord {
    /// Variant containing a successfully parsed language tag.
    Parsed(LanguageTag<String>),

    /// Variant containing the raw value of the language tag if parsing failed.
    Raw {
        /// The original raw value for the language tag, as given by the client.
        value: String,

        /// The error message from the parsing code.
        error_msg: String,
    },
}

impl LanguageRecord {
    /// Constructs a new [`LanguageRecord`], regardless of whether [`oxilangtag`] parsed
    /// it successfully.
    pub fn new(language_tag: String) -> Self {
        language_tag.parse::<LanguageTag<_>>().map_or_else(
            |error| Self::Raw {
                value: language_tag,
                error_msg: error.to_string(),
            },
            Self::Parsed,
        )
    }

    /// Returns the [primary language subtag](https://datatracker.ietf.org/doc/html/rfc5646#section-2.2.1),
    /// as extracted by [`oxilangtag`].
    ///
    /// See [oxilangtag::LanguageTag::primary_language](https://docs.rs/oxilangtag/latest/oxilangtag/struct.LanguageTag.html#method.primary_language)
    ///
    /// ## Errors
    /// If oxilangtag cannot parse the original record's language tag,
    /// the error value will be the original record's language tag as it was specified in the record
    /// (or None if the record didn't have one).
    pub fn primary(&self) -> DataResult<String> {
        match self {
            LanguageRecord::Parsed(language_tag) => Ok(language_tag.primary_language().to_string()),
            LanguageRecord::Raw { value, error_msg } => Err(RecordDataError::LanguageParse {
                raw: value.to_owned(),
                msg: error_msg.to_owned(),
            }),
        }
    }
}

impl fmt::Display for LanguageRecord {
    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
        match self.primary() {
            Ok(tag) => write!(f, "{tag}"),
            Err(RecordDataError::LanguageParse { raw, .. }) => write!(f, "{raw}"),
            Err(_) => unreachable!("there are no other possible error states"),
        }
    }
}

/// Url in the Record.
#[derive(Debug, PartialEq, Clone)]
#[cfg_attr(feature = "serde", derive(serde::Serialize, serde::Deserialize))]
enum UrlRecord {
    /// If we could successfully parse the URL.
    Parsed(Url),
    /// Original Url, if we could not parse it (and the error).
    Raw(String, String),
}

impl UrlRecord {
    /// Constructs a new [`UrlRecord`] regardless of whether [`Url`]
    /// parsed it successfully.
    fn new(url: &str) -> Self {
        url.parse::<Url>()
            .map_or_else(|e| Self::Raw(url.to_string(), e.to_string()), Self::Parsed)
    }

    /// Returns either the string representation of the url if it
    /// had been successfully parsed, or the original string
    /// if it was not.
    fn as_str(&self) -> &str {
        match self {
            Self::Parsed(url) => url.as_str(),
            Self::Raw(raw, _) => raw.as_str(),
        }
    }
}

/// Date time module for flora records.
pub mod datetime {
    use std::fmt::{Display, Formatter};

    use chrono::{DateTime, Datelike, FixedOffset, Local};
    use serde_with::serde_as;
    use tracing::instrument;

    /// A Record to store the local date/time, as provided by a flora client.
    #[serde_as]
    #[derive(Debug, PartialEq, Clone)]
    #[cfg_attr(feature = "serde", derive(serde::Serialize, serde::Deserialize))]
    pub enum ClientDateTimeRecord {
        /// The naive date/time (without any time zone information).
        Parsed(DateTime<FixedOffset>),

        /// The local time of the flora instance, if we couldn't parse the one
        /// given by the client.
        Interpolated {
            /// The interpolated date time value.
            value: DateTime<FixedOffset>,
            /// The original string, as given by the client.
            raw: String,
        },
    }

    impl ClientDateTimeRecord {
        /// Creates a new instance of `DateTimeRecord` from the given RFC 3339 local time string.
        /// Note: If we get an error trying to parse the string, we will use the current local time.
        #[instrument]
        pub fn new(local_datetime_client: &str) -> Self {
            DateTime::parse_from_rfc3339(local_datetime_client)
                .inspect_err(|e| tracing::warn!("couldn't parse {local_datetime_client}: {e}"))
                .map_or_else(
                    |_| Self::Interpolated {
                        value: Local::now().into(),
                        raw: local_datetime_client.to_string(),
                    },
                    Self::Parsed,
                )
        }

        /// Retrieve the year component of the date/time value, regardless
        /// of whether we originally successfully parsed this from the date/time data
        /// submitted by the client, or interpolated it on the server.
        #[must_use]
        pub fn year(&self) -> i32 {
            match self {
                Self::Parsed(dt) => dt.year(),
                Self::Interpolated { value, .. } => value.year(),
            }
        }
    }

    impl Display for ClientDateTimeRecord {
        fn fmt(&self, f: &mut Formatter<'_>) -> std::fmt::Result {
            let dt = match self {
                Self::Parsed(dt) => dt,
                Self::Interpolated { value, .. } => value,
            };
            write!(f, "{}", dt.to_rfc3339())
        }
    }
}

/// Parameters used to construct the [`Record`].
#[derive(Debug)]
#[cfg_attr(feature = "serde", derive(serde::Serialize, serde::Deserialize))]
pub struct RecordParams {
    /// SQLite database ID (if already imported).
    pub id: Option<i64>,

    /// Original URL from which the client got this record.
    pub url: String,

    /// Title for this record (may not match the web page title, if any).
    pub title: String,

    /// RFC 3339 date and time in local time zone, with UTC offset.
    pub client_datetime: String,

    /// Unix timestamp (seconds) of the flora server when the record was first added.
    pub timestamp_flora: Option<i64>,

    /// Web archive holder.
    pub document: Document,

    /// The record's language (if any was specified).
    pub language: Option<String>,

    /// Unix timestamp from the server, present if the record was updated.
    pub updated_at: Option<i64>,
}

impl From<Record> for RecordParams {
    fn from(rec: Record) -> Self {
        let Record {
            id,
            url,
            title,
            client_datetime,
            timestamp_flora,
            language,
            document,
            updated_at,
        } = rec;
        let url = url.as_str().to_string();
        let language = language.map(|lang| lang.to_string());
        // this will return the RFC 3339 representation
        let client_datetime = client_datetime.to_string();

        Self {
            id,
            url,
            title,
            client_datetime,
            timestamp_flora: Some(timestamp_flora),
            document,
            language,
            updated_at,
        }
    }
}

/// Data from the original record.
#[must_use]
#[derive(Debug)]
#[cfg_attr(feature = "serde", derive(serde::Serialize, serde::Deserialize))]
pub struct Record {
    /// Record's ID in SQLite. Assigned by SQLite, so records that haven't yet
    /// been added to SQLite will have it as `None`.
    id: Option<i64>,

    /// Internal [`UrlRecord`] value.
    url: UrlRecord,

    /// Title for this record (may not match the web page title, if any).
    title: String,

    /// Local date/time.
    client_datetime: ClientDateTimeRecord,

    /// Unix timestamp from the flora server when the record was first added.
    timestamp_flora: i64,

    /// The record's language (if any was specified).
    language: Option<LanguageRecord>,

    /// Web archive
    document: Document,

    /// Unix timestamp from the server, present if the record was updated.
    updated_at: Option<i64>,
}

impl Record {
    /// Construct a new Record from the given params.
    #[instrument]
    pub fn new(params: RecordParams) -> Self {
        let RecordParams {
            id,
            url,
            title,
            client_datetime,
            timestamp_flora,
            language,
            document,
            updated_at,
        } = params;
        let url = UrlRecord::new(&url);
        let client_datetime = ClientDateTimeRecord::new(client_datetime.as_str());
        let language = language.map(LanguageRecord::new);
        let timestamp_flora = timestamp_flora.unwrap_or_else(|| Local::now().timestamp());

        Self {
            id,
            url,
            title,
            client_datetime,
            timestamp_flora,
            language,
            document,
            updated_at,
        }
    }

    /// This record's ID in SQLite and Typesense.
    #[must_use]
    pub fn id(&self) -> Option<i64> {
        self.id
    }

    /// Get the url. Not guaranteed to be valid (the client may have provided an invalid one).
    #[must_use]
    pub fn url(&self) -> &str {
        self.url.as_str()
    }

    /// This record's title, as set by the client
    /// (may not match the web page title, if any).
    #[must_use]
    pub fn title(&self) -> &str {
        &self.title
    }

    /// Local date/time as set by the client (or interpolated,
    /// if we failed to parse the value set by the client).
    #[must_use]
    pub fn client_datetime(&self) -> &ClientDateTimeRecord {
        &self.client_datetime
    }

    /// Unix timestamp from the flora server when the record was first added.
    #[must_use]
    pub fn timestamp_archived(&self) -> i64 {
        self.timestamp_flora
    }

    /// Unix timestamp from the server, present if the record was updated.
    #[must_use]
    pub fn updated_at(&self) -> Option<i64> {
        self.updated_at
    }

    /// Returns a new record with `updated_at` set to current timestamp.
    ///
    /// # Notes
    /// This method is used by the server. It is not necessary
    /// to call it on the client, and any timestamp set by the client
    /// may be overwritten by the server.
    pub fn with_updated_at_now(mut self) -> Self {
        self.updated_at = Some(Utc::now().timestamp());
        self
    }

    /// Returns the host component of `Self::url`.
    ///
    /// ## Errors
    /// Returns [`RecordDataError`] if this record's URL couldn't be parsed.
    pub fn host(&self) -> DataResult<Option<String>> {
        match &self.url {
            UrlRecord::Parsed(url) => Ok(url.host().map(|h| h.to_string())),
            UrlRecord::Raw(raw, error) => Err(RecordDataError::UrlParse {
                raw: raw.clone(),
                msg: error.clone(),
            }),
        }
    }

    /// The record's language (if any was specified).
    #[must_use]
    pub fn language(&self) -> Option<&LanguageRecord> {
        self.language.as_ref()
    }

    /// The archived contents stored in this record.
    #[must_use]
    pub fn document(&self) -> &Document {
        &self.document
    }

    /// Returns the document (web page or other data) contained in this [`Record`],
    /// consuming it.
    #[must_use]
    pub fn into_document(self) -> Document {
        self.document
    }
}

impl PartialEq for Record {
    /// Records with identical data compare equal if both have the same id,
    /// if one has an id that is `None`. If the ids are different, the records
    /// will compare unequal.
    fn eq(&self, other: &Self) -> bool {
        if let (Some(this_id), Some(other_id)) = (self.id, other.id)
            && this_id != other_id
        {
            return false;
        }

        // idea: this may result in false positives; should eventually go back
        // to computing and verifying checksums
        self.url == other.url
            && self.client_datetime == other.client_datetime
            && self.timestamp_flora == other.timestamp_flora
    }
}

/// Convert to a [`Record`].
pub trait ToRecord {
    /// The associated error which can be returned from converting to [`Record`].
    type Error: std::error::Error;

    /// Converts a value of this type to a flora record.
    ///
    /// ## Errors
    /// Returns [`std::error::Error`] if the [`Record`] couldn't be created.
    fn to_record(&self) -> std::result::Result<Record, Self::Error>;
}

impl<T> ToRecord for &T
where
    T: ToRecord,
{
    type Error = <T as ToRecord>::Error;

    fn to_record(&self) -> std::result::Result<Record, Self::Error> {
        (*self).to_record()
    }
}

/// A [`Record`]'s metadata.
///
/// Includes all fields except the content.
#[derive(Debug, Clone)]
#[cfg_attr(feature = "serde", derive(serde::Serialize, serde::Deserialize))]
pub struct RecordMeta {
    /// SQLite ID.
    pub id: i64,

    /// Original URL from which the client got this record.
    pub url: String,

    /// Title for this record (may not match the web page title, if any).
    pub title: String,

    /// Local date/time.
    pub client_datetime: ClientDateTimeRecord,

    /// Unix timestamp from the Omry server when the record was first added.
    pub timestamp_flora: i64,

    /// The record's language (if any was specified).
    pub language: Option<LanguageRecord>,

    /// Unix timestamp from the server, present if the record was updated.
    pub updated_at: Option<i64>,
}

/// A record with the raw document data (not deserialized).
///
/// Nearly 1:1 mapping with `DbRecord`, except this requires
/// `id` to be set.
#[derive(Debug)]
#[cfg_attr(feature = "serde", derive(serde::Serialize, serde::Deserialize))]
pub struct RawRecord {
    /// The SQLite row ID of this record.
    pub id: i64,

    /// The original URL that corresponds to this record.
    pub url: String,

    /// The date and time when the client saved or submitted the record.
    pub client_datetime: DateTime<FixedOffset>,

    /// Raw date time, in case we couldn't parse it.
    pub client_datetime_raw: Option<String>,

    /// The timestamp when omry server first got this record.
    pub timestamp_flora: i64,

    /// The page title that the client provided for this record.
    pub title: String,

    /// The language we extracted from this record's metadata, if any.
    pub language: Option<String>,

    /// The binary serialized content of this record's document.
    pub document: Vec<u8>,

    /// Unix timestamp from the server, present if the record was updated.
    pub updated_at: Option<i64>,
}

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

    use pretty_assertions::{assert_eq, assert_ne};
    use proptest::prelude::*;
    use tracing_test::traced_test;

    use crate::document::tests::load_test_web_doc;

    fn test_record_params() -> anyhow::Result<RecordParams> {
        let document = load_test_web_doc()?;
        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]
    fn can_create_record() -> anyhow::Result<()> {
        let record_params = test_record_params()?;
        let record = Record::new(record_params);
        insta::assert_debug_snapshot!(record);
        Ok(())
    }

    #[test]
    fn record_with_some_id_equals_record_with_none_id() -> anyhow::Result<()> {
        let record = Record::new(test_record_params()?);
        let mut also_record = Record::new(test_record_params()?);
        also_record.id = Some(37);
        assert_eq!(record, also_record);
        Ok(())
    }

    #[test]
    fn records_with_different_ids_compare_unequal() -> anyhow::Result<()> {
        let mut record = Record::new(test_record_params()?);
        record.id = Some(37);
        let mut also_record = Record::new(test_record_params()?);
        also_record.id = Some(42);
        assert_ne!(record, also_record);
        Ok(())
    }

    proptest! {
        #[test]
        fn parse_language(s in "[a-z]{2,6}") {
            // https://tools.ietf.org/html/rfc5646
            let language = LanguageRecord::new(s.clone());
            prop_assert!(language.primary().is_ok());
        }
    }

    #[test]
    fn can_parse_rfc3339_datetime() {
        let dt = "2025-08-08T15:28:02-05:00";
        let parsed = datetime::ClientDateTimeRecord::new(dt);
        insta::assert_debug_snapshot!(parsed);
    }

    #[traced_test]
    #[test]
    fn malformed_datetime_traces_warning() {
        let dt = "2025-08-11T21:49:15.031404172";
        let _ = datetime::ClientDateTimeRecord::new(dt);

        logs_assert(|lines: &[&str]| {
            let line = lines
                .first()
                .ok_or_else(|| "No tracing lines".to_string())?;
            let warning_start = line
                .find("WARN")
                .ok_or_else(|| "trace contains no warning".to_string())?;

            let (_timestamp, warning) = line.split_at(warning_start);
            insta::assert_snapshot!(warning);
            Ok(())
        });
    }

    #[test]
    fn can_take_owned_document_from_record() -> anyhow::Result<()> {
        let record_params = test_record_params()?;
        let record = Record::new(record_params);
        insta::assert_debug_snapshot!(record.into_document());
        Ok(())
    }
}
