//! Helpers to display records.
use crate::ingest::{ParseOpts, parse_text_html};
use anyhow::Result;
use dom_smoothie::Article;
pub(crate) use highlights::colorize_highlight;
use omry_service::archiving::Document;
use std::borrow::Borrow;

/// Module with functions for viewing the records' HTML data in a browser.
mod browse {
    use anyhow::Result;
    use omry_service::archiving::Document;
    use std::borrow::Cow;

    /// Gets this record's HTML content.
    ///
    /// ## Errors
    /// Returns an error if the data contained by this record is not [`Document::WebPage`].
    pub(super) fn get_html(document: &Document) -> Result<Cow<'_, str>> {
        let html = match document {
            Document::WebPage(page) => String::from_utf8_lossy(page.data()),
            _ => anyhow::bail!(
                "The document contained in the given record isn't a web page: {document:#?}",
            ),
        };
        Ok(html)
    }
}

/// This module hosts functionality to find
/// and colorize search highlights.
mod highlights {
    use aho_corasick::{AhoCorasick, MatchKind};
    use anyhow::Result;
    use owo_colors::OwoColorize;
    use quick_xml::events::Event;
    use quick_xml::reader::Reader;
    use tracing::instrument;

    /// Encodes boundaries and text of a Typesense search highlight.
    #[derive(Debug)]
    pub(super) struct SearchHighlight {
        /// Start index of the highlight.
        pub(crate) start: usize,

        /// End index of the highlight.
        pub(crate) end: usize,

        /// The text of this highlight (between the \<mark\> tag).
        pub(crate) text: String,
    }

    /// Finds Typesense highlight (`\<mark\>...\</mark\>`) in the given text.
    ///
    /// There should only be one highlight in the given text.
    #[instrument]
    pub(super) fn find_highlight(snippet: &str) -> Result<Vec<SearchHighlight>> {
        const MARK: &[u8] = b"mark";

        let mut reader = Reader::from_str(snippet);

        let mut mark_start = None;
        let mut text = None;
        let mut prev_position = 0;

        let mut highlights = vec![];

        loop {
            match reader.read_event() {
                Ok(Event::Eof) => break,
                Ok(Event::Start(event)) if event.name().as_ref() == MARK => {
                    mark_start = Some(usize::try_from(prev_position)?);
                }
                Ok(Event::End(event)) if event.name().as_ref() == MARK => {
                    let (Some(start), Some(text)) = (mark_start, text.take()) else {
                        anyhow::bail!(
                            "Found highlight end, but there is no start; position: {}",
                            reader.error_position()
                        );
                    };

                    let end = usize::try_from(reader.buffer_position())?;
                    let highlight = SearchHighlight { start, end, text };

                    highlights.push(highlight);
                    mark_start = None;
                }
                Ok(Event::Text(event)) if mark_start.is_some() => {
                    let content = event.html_content()?;
                    let highlight_text = content.as_ref();
                    text = Some(highlight_text.to_string());
                }
                Err(error) => {
                    anyhow::bail!("{error}");
                }
                _ => {}
            }

            prev_position = reader.buffer_position();
        }

        Ok(highlights)
    }

    /// Colorizes all the Typesense highlights in the given snippet.
    pub(crate) fn colorize_highlight(snippet: &str) -> Result<String> {
        let highlights = find_highlight(snippet)?;

        let mut full_substrings = Vec::with_capacity(highlights.len());
        let mut bolded_highlights = Vec::with_capacity(highlights.len());

        for highlight in highlights {
            let Some(full_substr) = snippet.get(highlight.start..highlight.end) else {
                anyhow::bail!("Highlight start/end indexes aren't on a valid UTF-8 boundary");
            };

            full_substrings.push(full_substr);

            let bolded = format!("{}", highlight.text.bold());
            bolded_highlights.push(bolded);
        }

        let ac = AhoCorasick::builder()
            .match_kind(MatchKind::LeftmostFirst)
            .build(&full_substrings)?;

        let colorized = ac.replace_all(snippet, &bolded_highlights);
        Ok(colorized)
    }
}

/// Parses the text content of the given record's HTML.
///
/// ## Errors
/// Returns [`anyhow::Error`] if we can't extract the text content
/// (e.g. if the record is not a web page.)
pub fn extract_document_text(
    document: &Document,
    url: &str,
    opts: Option<ParseOpts>,
) -> Result<Article> {
    let html = browse::get_html(document)?;
    parse_text_html(html.borrow(), Some(url), opts)
}

/// Check if we are inside WSL on Windows, and interoperability with Windows tools is
/// enabled.
///
/// (Taken almost verbatim from
/// <https://github.com/amodm/webbrowser-rs/blob/659622914af8da27f4619fdcd704fcd616245a76/src/unix.rs#L131>)
pub(crate) fn is_wsl() -> bool {
    // we should check in procfs only on linux, as for non-linux it will likely be
    // a disk hit, which we should avoid.
    if cfg!(target_os = "linux") {
        // we check if interop with windows tools is allowed, as if it isn't, we won't
        // be able to invoke windows commands anyways.
        // See: https://learn.microsoft.com/en-us/windows/wsl/filesystems#disable-interoperability
        if let Ok(s) = std::fs::read_to_string("/proc/sys/fs/binfmt_misc/WSLInterop") {
            s.contains("enabled")
        } else {
            false
        }
    } else {
        // we short-circuit and return false on non-linux
        false
    }
}

/// Rewrites the `file://` URLs to correctly open in the Windows host-side browser
/// when using WSL.
///
/// No-op when not using WSL,
/// or when passed a non-file URL.
pub(crate) fn rewrite_url(url: &str) -> &str {
    const SCHEME: &str = "file://";

    if !is_wsl() {
        return url;
    }

    let Some(rest) = url.strip_prefix(SCHEME) else {
        return url;
    };

    rest
}

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

    use crate::ingest::record_from_path;

    use omry_service::archiving::Record;
    use rstest::rstest;
    use std::env;
    use std::fs::{self};
    use std::path::PathBuf;

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

    macro_rules! set_snapshot_suffix {
        ($($expr:expr),*) => {
            let mut settings = insta::Settings::clone_current();
            settings.set_snapshot_suffix(format!($($expr,)*));
            let _guard = settings.bind_to_scope();
        }
    }

    /// Gets the path of the test page ("example.html")
    fn get_test_page_path() -> Result<PathBuf> {
        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)?;
        Ok(workspace_dir_path.join(TEST_WEB_PAGE_PATH))
    }

    fn test_record() -> Result<Record> {
        let path = get_test_page_path()?;
        let record = Record::new(record_from_path(&path, None)?);
        Ok(record)
    }

    #[test]
    fn can_extract_text_from_example_html() -> Result<()> {
        let record = test_record()?;
        let article = extract_document_text(record.document(), record.url(), None)?;
        insta::assert_debug_snapshot!(article);
        Ok(())
    }

    #[rstest]
    #[case::single(
        "This domain is for use in illustrative <mark>example</mark>s in documents",
        0
    )]
    #[case::multiple(
        "Horizon Forbidden West and <mark>The</mark> <mark>Last</mark> <mark>of</mark> <mark>Us</mark> Part 2",
        1
    )]
    fn can_find_highlight_start_end(
        #[case] text: &str,
        #[case] snapshot_index: usize,
    ) -> Result<()> {
        set_snapshot_suffix!("{}", snapshot_index);

        let highlights = find_highlight(text)?;
        insta::assert_debug_snapshot!(highlights);
        Ok(())
    }

    #[test]
    fn text_without_highlight_returns_empty_vec() -> Result<()> {
        let text = "This domain is for use in illustrative";
        let result = find_highlight(text)?;
        insta::assert_debug_snapshot!(result);
        Ok(())
    }

    #[rstest]
    #[case("<mark>Example</mark> Domain", 0)]
    #[case(
        "This domain is for use in illustrative <mark>example</mark>s in documents\\.",
        1
    )]
    #[case(
        "Horizon Forbidden West and <mark>The</mark> <mark>Last</mark> <mark>of</mark> <mark>Us</mark> Part 2",
        2
    )]
    fn can_colorize_highlights(#[case] text: &str, #[case] snapshot_index: usize) -> Result<()> {
        set_snapshot_suffix!("{}", snapshot_index);

        let highlight = colorize_highlight(text)?;
        insta::assert_snapshot!(highlight);
        Ok(())
    }

    #[test]
    fn text_without_highlights_returns_same_text() {
        let text = "This domain is for use in illustrative";
        let result = colorize_highlight(text);
        insta::assert_debug_snapshot!(result);
    }

    #[test]
    fn can_rewrite_wsl_urls() {
        let url = "file:///some/path/to/file.html";
        let new_url = rewrite_url(url);

        if is_wsl() {
            insta::assert_snapshot!(new_url);
        } else {
            assert_eq!(new_url, url);
        }
    }
}
