use std::{collections::BTreeMap, sync::LazyLock};

use camino::Utf8PathBuf;
use convert_case::{Case, Casing as _};
use petgraph::graph::NodeIndex;
use regex::Regex;
use serde::Deserialize as _;

use crate::{
    ItemPath, ItemPathBuf,
    link::Link,
    utils::{FileMetadata, Heading, extract_headings, make_table_of_contents},
};

/// Errors that could occur while parsing an Obsidian [`Note`].
#[allow(missing_docs)]
#[derive(Debug, miette::Diagnostic, thiserror::Error)]
pub enum NoteError {
    #[error("failed to read frontmatter of note `{0}`")]
    FrontmatterParse(Utf8PathBuf, #[source] sable_frontmatter::MetadataError),
    #[error("failed to deserialize recognized obsidian properties of note `{0}`")]
    PropertiesParse(Utf8PathBuf, #[source] serde_json::Error),
    #[error("failed to read note at `{0}`")]
    IoRead(Utf8PathBuf, #[source] std::io::Error),
    #[error("failed to get metadata of note `{0}`")]
    GetMetadata(Utf8PathBuf, #[source] crate::utils::Error),
}

/// Recognized [`Note`] frontmatter properties.
#[allow(missing_docs)]
#[derive(Debug, Clone, Default, Hash, PartialEq, Eq, serde::Serialize, serde::Deserialize)]
pub struct NoteProperties {
    pub aliases: Option<Vec<String>>,
    pub css_classes: Option<Vec<String>>,
    pub tags: Option<Vec<String>>,

    pub template: Option<String>,
    pub title: Option<String>,

    pub draft: Option<bool>,

    #[serde(flatten)]
    other: BTreeMap<String, sable_frontmatter::Metadata>,
}

impl NoteProperties {
    pub fn get(&self, key: &str) -> Option<&sable_frontmatter::Metadata> {
        self.other.get(key)
    }
}

/// Predescribed [`Note`] context for [`tera`] templates or related engines.
///
/// [`tera`]: https://docs.rs/tera/
#[derive(Debug, serde::Serialize)]
pub struct NoteContext<'n> {
    /// The path of the [`Note`].
    pub path: ItemPath<'n>,
    /// The graph index of the [`Note`].
    pub index: NodeIndex,

    /// The filename of the [`Note`].
    pub name: &'n str,
    /// The title of the [`Note`].
    pub title: &'n str,

    /// The file metadata of the [`Note`].
    pub metadata: &'n FileMetadata,
    /// The frontmatter properties of the [`Note`].
    pub properties: &'n NoteProperties,

    /// Structured table of contents of the [`Note`].
    pub toc: &'n [Heading],

    /// The raw markdown of the [`Note`].
    pub contents: &'n str,
}

/// Predescribed [`Note`] context for [`tera`] templates or related engines.
///
/// [`tera`]: https://docs.rs/tera/
#[derive(Debug, serde::Deserialize)]
pub struct NoteContextOwned {
    /// The path of the [`Note`].
    pub path: ItemPathBuf,
    /// The graph index of the [`Note`].
    pub index: NodeIndex,

    /// The filename of the [`Note`].
    pub name: String,
    /// The title of the [`Note`].
    pub title: String,

    /// The file metadata of the [`Note`].
    pub metadata: FileMetadata,
    /// The frontmatter properties of the [`Note`].
    pub properties: NoteProperties,

    /// Structured table of contents of the [`Note`].
    pub toc: Vec<Heading>,

    /// The raw markdown of the [`Note`].
    pub contents: String,
}

/// A parsed Obsidian note.
#[derive(Debug, Clone, Hash, PartialEq, Eq)]
pub struct Note {
    /// The path of the [`Note`].
    pub path: ItemPathBuf,
    /// The graph index of the [`Note`].
    pub index: NodeIndex,

    /// The filename of the [`Note`].
    pub name: String,

    /// The file metadata of the [`Note`].
    pub metadata: FileMetadata,
    /// The frontmatter properties of the [`Note`].
    pub properties: NoteProperties,

    /// Structured table of contents of the [`Note`].
    pub toc: Vec<Heading>,
    /// Extracted links from the [`Note`]'s markdown.
    pub links: Vec<Link>,

    /// The raw markdown of the [`Note`].
    pub contents: String,
}

impl Note {
    /// Parse a Obsidian note from a [`ItemPathBuf`] and the notes raw content.
    ///
    /// # Errors
    ///
    /// This will error if its unable to deserialize the frontmatter,
    /// or it was unable to get file system metadata of the note.
    pub fn from_str(path: ItemPathBuf, index: NodeIndex, raw: &str) -> Result<Self, NoteError> {
        let (frontmatter, contents) = sable_frontmatter::parse(raw)
            .map(|(frontmatter, contents)| (frontmatter, contents.to_string()))
            .map_err(|err| NoteError::FrontmatterParse(path.relative.to_path_buf(), err))?;

        let mut properties = if let Some(frontmatter) = frontmatter.as_ref() {
            NoteProperties::deserialize(frontmatter)
                .map_err(|err| NoteError::PropertiesParse(path.relative.to_path_buf(), err))?
        } else {
            NoteProperties::default()
        };

        let metadata = FileMetadata::new(&path.full)
            .map_err(|err| NoteError::GetMetadata(path.relative.to_path_buf(), err))?;

        let name = path.full.file_stem().map_or_else(
            || {
                tracing::warn!(path=%path.relative, "unable to convert note file name into title");

                String::new()
            },
            |t| t.to_case(Case::Title),
        );

        // TODO: use [`Path::file_prefix`] when it stablizes
        // TODO: check contents for `h1` header to use as the title
        properties.title.get_or_insert_with(|| name.clone());

        if let Some(mut tags) = extract_tags(contents.as_str()) {
            properties.tags.get_or_insert_default().append(&mut tags);
        }

        let headings = extract_headings(&contents);
        let toc = make_table_of_contents(headings);

        let links = Link::collect(&contents);

        Ok(Self {
            path,
            index,

            name,

            metadata,
            properties,

            toc,
            links,

            contents,
        })
    }

    /// Load and parse a Obsidian note from a [`ItemPathBuf`].
    ///
    /// # Errors
    ///
    /// This will error if it failed to read the note from the file system,
    ///  its unable to deserialize the frontmatter,
    /// or it was unable to get file system metadata of the note.
    pub fn from_path(path: ItemPathBuf, index: NodeIndex) -> Result<Self, NoteError> {
        let contents = std::fs::read_to_string(&path.full)
            .map_err(|err| NoteError::IoRead(path.full.clone(), err))?;

        Self::from_str(path, index, &contents)
    }

    /// Returns the template property of this [`Note`]'s frontmatter.
    #[must_use]
    pub fn template(&self) -> Option<&str> {
        self.properties.template.as_deref()
    }

    /// Returns the tags property of this [`Note`]'s frontmatter.
    #[must_use]
    pub fn tags(&self) -> Option<&[String]> {
        self.properties.tags.as_deref()
    }

    /// Returns a reference to the [`Note`]'s path information.
    #[must_use]
    pub fn path(&self) -> ItemPath<'_> {
        self.path.as_ref()
    }

    /// Returns a reference to the [`Note`]'s filename.
    #[must_use]
    pub const fn name(&self) -> &str {
        self.name.as_str()
    }

    /// Returns a reference to the [`Note`]'s title.
    #[must_use]
    pub fn title(&self) -> &str {
        self.properties.title.as_deref().unwrap_or_default()
    }

    /// Create a template engine compatible render context reference.
    #[must_use]
    pub fn as_context(&self) -> NoteContext<'_> {
        NoteContext {
            path: self.path(),
            index: self.index,

            name: self.name(),
            title: self.title(),

            metadata: &self.metadata,
            properties: &self.properties,

            toc: &self.toc,

            contents: &self.contents,
        }
    }
}

/// Finds Obsidian `#tags`, and converts them into a list without preceding `#`.
pub fn extract_tags(content: &str) -> Option<Vec<String>> {
    static REGEX: LazyLock<Regex> = LazyLock::new(|| {
        Regex::new(r"(?:#([^\s#]+))").expect("failed to construct obsidian tag regex")
    });

    fn handle(captures: &regex::Captures<'_>) -> Option<String> {
        let mut iter = captures.iter();

        let _match = iter.next()??;
        let group = iter.next()??;

        let rest = group.as_str().to_string();

        Some(rest)
    }

    if !REGEX.is_match(content.as_ref()) {
        return None;
    }

    let captures_list = REGEX.captures_iter(content.as_ref()).collect::<Vec<_>>();

    let mut tags = Vec::with_capacity(captures_list.len());

    for captures in captures_list.into_iter().rev() {
        if let Some(tag) = handle(&captures) {
            tags.push(tag);
        }
    }

    Some(tags)
}

// pub fn extract_tags(content: Cow<'_, str>) -> (Cow<'_, str>, Option<Vec<String>>) {
//     static REGEX: LazyLock<Regex> = LazyLock::new(|| {
//         Regex::new(r#"(?:#([^\s#]+))"#).expect("failed to construct obsidian tag regex")
//     });

//     fn handle(new: &str, captures: &regex::Captures<'_>) -> Option<(String, String)> {
//         let mut iter = captures.iter();

//         let r#match = iter.next()??;
//         let group = iter.next()??;

//         let rest = group.as_str().to_string();

//         let span = r#match.range();
//         let updated = [&new[0..span.start], "", &new[span.end..]].concat();

//         Some((updated, rest))
//     }

//     if !REGEX.is_match(content.as_ref()) {
//         return (content, None);
//     }

//     let mut new = content.as_ref().to_string();

//     let captures = REGEX.captures_iter(content.as_ref()).collect::<Vec<_>>();

//     let mut tags = Vec::with_capacity(captures.len());

//     for captures in captures.into_iter().rev() {
//         if let Some((updated, tag)) = handle(&new, &captures) {
//             new = updated;
//             tags.push(tag);
//         }
//     }

//     (Cow::from(new), Some(tags))
// }
