//! Helper utilities for ingesting HTML files.
use anyhow::Result;
use chrono::{DateTime, FixedOffset, Local};
use dom_smoothie::{Article, Config, Readability};
use indicatif::{ParallelProgressIterator, ProgressBar};
use omry_service::archiving::{Document, RecordParams};
use quick_xml::Reader;
use quick_xml::events::Event;
use rayon::prelude::*;
use regex::Regex;
use std::borrow::Cow;
use std::collections::HashSet;
use std::ffi::OsStr;
use std::fs::File;
use std::io::Read;
use std::path::{Path, PathBuf};
use std::sync::LazyLock;
use tracing::instrument;

/// The default maximum number of HTML elements that [`dom_smoothie`] should parse.
pub const DEFAULT_MAX_ELEMENTS_TO_PARSE: usize = 15000;

/// Options for parsing the HTML files with [`dom_smoothie`].
#[derive(Debug, Clone)]
pub struct ParseOpts {
    /// Corresponds to [`Config::max_elements_to_parse`]
    pub max_elements_to_parse: usize,
}

impl Default for ParseOpts {
    fn default() -> Self {
        Self {
            max_elements_to_parse: DEFAULT_MAX_ELEMENTS_TO_PARSE,
        }
    }
}

impl From<ParseOpts> for Config {
    fn from(value: ParseOpts) -> Self {
        let ParseOpts {
            max_elements_to_parse,
        } = value;

        Self {
            max_elements_to_parse,
            ..Default::default()
        }
    }
}

/// Parses the text content of the given HTML string using [`dom_smoothie`].
///
/// ## Errors
/// Returns [`anyhow::Error`] if we fail to parse the text.
///
/// See [`Readability::new`] and [`Readability::parse`] for specific conditions
/// that can result in errors.
pub fn parse_text_html(html: &str, url: Option<&str>, opts: Option<ParseOpts>) -> Result<Article> {
    let opts = opts.unwrap_or_default();
    let ParseOpts {
        max_elements_to_parse,
    } = opts;

    let cfg = Config {
        max_elements_to_parse,
        ..Default::default()
    };

    let mut readability = Readability::new(html, url, Some(cfg))?;
    Ok(readability.parse()?)
}

/// Creates a new [`SfMetadata`] with the given URL and current local time.
fn metadata_from_local(url: impl Into<String>) -> Metadata {
    let url = url.into();
    let date: DateTime<FixedOffset> = Local::now().into();
    let saved_date = date.to_rfc3339();
    Metadata { url, saved_date }
}

/// Creates a record from a path to an HTML file.
///
/// ## Errors
/// Returns [`anyhow::Error`] if we can't create a record from the given path.
#[instrument]
pub fn record_from_path(path: &Path, opts: Option<ParseOpts>) -> Result<RecordParams> {
    let mut buf = String::new();
    let mut file = File::open(path)?;
    file.read_to_string(&mut buf)?;

    let metadata = Metadata::from_html(&buf).unwrap_or_else(|error| {
        tracing::warn!(
            "Couldn't extract the metadata from the HTML file, defaulting to local time: {error}"
        );
        metadata_from_local(path.to_string_lossy())
    });

    let title = parse_text_html(&buf, Some(metadata.url.as_str()), opts)
        .map(|a| a.title)
        .unwrap_or_default();

    let title = if title.is_empty() {
        path.file_name()
            .and_then(|s| s.to_str())
            .ok_or_else(|| {
                anyhow::anyhow!("couldn't extract the file name from {}", path.display())
            })?
            .to_string()
    } else {
        title
    };

    let document = Document::new_web_page_unchecked(buf.as_bytes().to_vec());

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

/// Checks if the given file path has an extension
/// that corresponds to a file type supported by the Omry backend.
fn has_supported_extension(path: &Path) -> bool {
    // idea: return concrete error variants instead of bool

    static SUPPORTED_EXTENSIONS: LazyLock<HashSet<&OsStr>> = LazyLock::new(|| {
        let mut supported_extensions = HashSet::new();
        supported_extensions.insert("html".as_ref());
        supported_extensions
    });

    let Some(ext) = path.extension() else {
        tracing::info!("File has no extension: {}", path.display());
        return false;
    };

    if !SUPPORTED_EXTENSIONS.contains(ext) {
        tracing::info!(
            "File {} has an unsupported extension: {}",
            path.display(),
            ext.display()
        );

        return false;
    }

    true
}

/// Structure with records ready to be imported into Omry
/// and failed paths, if any.
pub(crate) struct Import {
    /// Records that are ready to be imported.
    /// May not be in the original order.
    pub(crate) prepared: Option<Vec<RecordParams>>,

    /// Paths of files that couldn't be imported.
    /// May not be in the original order.
    // idea: store a specific error type, which will contain a PathBuf.
    pub(crate) failed: Option<Vec<PathBuf>>,
}

/// Finds all top-level HTML files at the given path
/// (or one file, if `path` points to a single file),
/// and constructs instances of [`RecordParams`] from them.
#[instrument]
pub(crate) fn prepare_records(path: &Path, opts: Option<&ParseOpts>) -> Result<Import> {
    let mut to_prepare = vec![];
    let mut failed = vec![];

    if path.is_dir() {
        for item in path.read_dir()? {
            let item = item?.path();
            if has_supported_extension(&item) {
                to_prepare.push(item);
            } else {
                failed.push(item);
            }
        }
    } else if has_supported_extension(path) {
        to_prepare.push(path.to_owned());
    } else {
        failed.push(path.to_owned());
    }

    let prepared = if to_prepare.is_empty() {
        None
    } else {
        let num_to_prepare: u64 = to_prepare.len().try_into()?;
        let pro_bar =
            ProgressBar::new(num_to_prepare).with_message("Preparing records for import...");

        let results = to_prepare
            .par_iter()
            .progress_with(pro_bar)
            .map(|path| record_from_path(path, opts.cloned()))
            .collect::<Vec<_>>();

        let mut prepared = Vec::with_capacity(to_prepare.len());

        for (path, result) in to_prepare.into_iter().zip(results) {
            match result {
                Ok(record_params) => prepared.push(record_params),
                Err(error) => {
                    tracing::error!(
                        "Couldn't prepare a record from file at path {}: {}",
                        path.display(),
                        error
                    );
                    failed.push(path);
                }
            }
        }

        Some(prepared)
    };

    let failed = if failed.is_empty() {
        None
    } else {
        Some(failed)
    };

    Ok(Import { prepared, failed })
}

/// [`SfMetadata`] stores metadata created by SingleFile.
#[derive(Debug)]
struct Metadata {
    /// URL, as created by SingleFile.
    url: String,

    /// Saved date, as created by SingleFile.
    saved_date: String,
}

impl Metadata {
    /// Extracts the expected metadata from a [`SingleFile`] comment,
    /// and creates an instance of [`SfMetadata`] with the respective data.
    fn from_html(input: &str) -> Result<Self> {
        let mut reader = Reader::from_str(input);
        let sf_string = find_singlefile_comment(&mut reader)?;
        parse_singlefile_comment(&sf_string)
    }
}

/// Extracts the string contained by the SingleFile comment with URL and date.
fn find_singlefile_comment<'a>(reader: &'a mut Reader<&'a [u8]>) -> Result<Cow<'a, str>> {
    const SF_COMMENT_PREFIX: &str = "Page saved with SingleFile";

    loop {
        match reader.read_event() {
            Ok(Event::Comment(comment)) => {
                let content = comment.html_content()?;
                if content.contains(SF_COMMENT_PREFIX) {
                    return Ok(content);
                }
            }
            Ok(Event::Eof) => anyhow::bail!("Couldn't find the SingleFile comment"),
            Ok(_) => {}
            Err(error) => anyhow::bail!("error at position {}: {}", reader.error_position(), error),
        }
    }
}

/// Parses the date string extracted from a SingleFile comment.
fn parse_singlefile_date(date: &str) -> Result<DateTime<FixedOffset>> {
    const DT_FORMAT: &str = "%a %b %d %Y %H:%M:%S GMT%z";
    let (date, _) = DateTime::parse_and_remainder(date, DT_FORMAT)?;
    Ok(date)
}

// todo: make associated function of SfMetadata
/// Parses the SingleFile comment with regex to extract the respective metadata.
fn parse_singlefile_comment(input: &str) -> Result<Metadata> {
    static SF_RE: LazyLock<Regex> = LazyLock::new(|| {
        // If we can't parse the regular expression, we can't proceed and should panic anyway,
        // so ok to use `expect` here.
        #[allow(clippy::expect_used)]
        {
            Regex::new(r"((?:url: )(.+))\W+((?:saved date: )(.+))")
                .expect("couldn't parse the regular expression")
        }
    });

    let captures = SF_RE
        .captures(input)
        .ok_or_else(|| anyhow::anyhow!("couldn't find any capture groups in the input"))?;

    // capture group 0 is always the entire match
    // group 1 is the entire URL group (with prefix)
    // group 2 will be the actual URL
    let url = captures
        .get(2)
        .ok_or_else(|| anyhow::anyhow!("couldn't extract the URL"))?
        .as_str()
        .trim()
        .to_string();

    // group 3 is the entire date group (with prefix)
    // so group 4 will the actual date
    let raw_saved_date = captures
        .get(4)
        .ok_or_else(|| anyhow::anyhow!("couldn't extract the date"))?
        .as_str();

    let parsed_date = parse_singlefile_date(raw_saved_date)?;
    let saved_date = parsed_date.to_rfc3339();

    Ok(Metadata { url, saved_date })
}

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

    use anyhow::Context;
    use std::env;
    use std::fs::{self, File};
    use std::path::PathBuf;

    const TEST_WEB_PAGE_PATH: &str = "test_data/pages/example.html";
    const WORKSPACE_DIR_KEY: &str = "CARGO_WORKSPACE_DIR";

    /// Gets the path of the test page ("example.html")
    fn get_test_page_path() -> Result<PathBuf> {
        let workspace_dir_path =
            env::var(WORKSPACE_DIR_KEY).context("CARGO_WORKSPACE_DIR is not set")?;
        let workspace_dir_path = fs::canonicalize(workspace_dir_path)?;
        Ok(workspace_dir_path.join(TEST_WEB_PAGE_PATH))
    }

    fn load_test_html() -> Result<String> {
        let example_page_path = get_test_page_path()?;
        let mut example_file = File::open(&example_page_path)?;
        let mut page_data = String::new();

        example_file.read_to_string(&mut page_data)?;
        Ok(page_data)
    }

    #[test]
    fn can_extract_singlefile_metadata() -> Result<()> {
        let html = load_test_html()?;
        let metadata = Metadata::from_html(&html)?;
        insta::assert_debug_snapshot!(metadata);
        Ok(())
    }

    #[test]
    fn can_create_record_from_path() -> Result<()> {
        let path = get_test_page_path()?;
        let record_params = record_from_path(&path, None)?;
        insta::assert_debug_snapshot!(record_params);
        Ok(())
    }
}
