use std::{
    collections::{HashMap, HashSet},
    sync::Arc,
};

use camino::Utf8PathBuf;
use petgraph::{
    graph::{NodeIndex, UnGraph},
    visit::EdgeRef as _,
};
use walkdir::{DirEntry, WalkDir};

use crate::{
    ItemPathBuf, VaultPathBuf,
    file::File,
    link::LinkKind,
    note::{Note, NoteError},
};

/// Errors that could oocur while loading a [`Vault`].
#[allow(missing_docs)]
#[derive(Debug, miette::Diagnostic, thiserror::Error)]
pub enum VaultError {
    #[error("failed to walk source directory")]
    Walk(#[source] walkdir::Error),
    #[error("path `{1}` is not valid utf-8")]
    PathNotUtf8(#[source] camino::FromPathBufError, std::path::PathBuf),
    #[error("failed to create relative path")]
    StripPrefix(#[source] std::path::StripPrefixError),
    #[error("failed to load note")]
    Note(#[source] NoteError),
}

/// Structured view of a Obsidian Vault
#[derive(Debug)]
pub struct Vault {
    /// The path to the root of the Vault.
    pub path: VaultPathBuf,

    /// The (non Obsidian) files of the Vault.
    pub files: HashMap<ItemPathBuf, File>,

    /// The parsed notes of the Vault.
    pub notes: HashMap<ItemPathBuf, Note>,

    /// All the tags used in the Vault and their locations.
    pub tags: HashMap<String, HashSet<ItemPathBuf>>,

    graph: UnGraph<ItemPathBuf, ()>,
}

impl Vault {
    /// Create a [`Vault`] from a [`VaultPathBuf`], reading the vault's contents from the disk.
    ///
    /// # Errors
    ///
    /// This will fail if its unable to do IO
    /// or is unable to parse any of the supported Obsidian types.
    pub fn from_path(path: VaultPathBuf) -> Result<Self, VaultError> {
        let mut this = Self {
            path,

            files: HashMap::new(),
            notes: HashMap::new(),

            tags: HashMap::new(),

            graph: UnGraph::default(),
        };

        this.reload()?;

        Ok(this)
    }

    /// Clears the currrent Vault and reloads it from disk.
    ///
    /// # Errors
    ///
    /// This will fail if its unable to do IO
    /// or is unable to parse any of the supported Obsidian types.
    pub fn reload(&mut self) -> Result<(), VaultError> {
        self.files.clear();
        self.notes.clear();
        self.tags.clear();
        self.graph.clear();

        let obsidian_path = self.path.0.join(".obsidian");

        for entry in WalkDir::new(&self.path.0) {
            let entry = entry.map_err(VaultError::Walk)?;

            if entry.file_name().to_string_lossy().starts_with('.') {
                continue;
            }

            if entry.path().starts_with(&obsidian_path) {
                continue;
            }

            if !entry.file_type().is_file() {
                continue;
            }

            self.handle_entry(&entry)?;
        }

        self.map_tags();
        self.map_links();

        Ok(())
    }

    fn handle_entry(&mut self, entry: &DirEntry) -> Result<(), VaultError> {
        let full = entry.path().to_path_buf();
        let full = Utf8PathBuf::try_from(full.clone())
            .map_err(|err| VaultError::PathNotUtf8(err, full))?;
        let relative = full
            .strip_prefix(&self.path.0)
            .map_err(VaultError::StripPrefix)?;

        let path = self.path.as_item(&full, relative);

        let file = File::from_path(path);

        tracing::debug!(path=%file.path.full, kind=?file.kind, "found vault file");

        if file.kind.is_note() {
            let index = self.graph.add_node(file.path.clone());

            let _ = self.notes.insert(
                file.path.clone(),
                file.into_note(index).map_err(VaultError::Note)?,
            );
        } else {
            let _ = self.files.insert(file.path.clone(), file);
        }

        Ok(())
    }

    fn map_tags(&mut self) {
        for note in self.notes.values() {
            let Some(tags) = note.tags() else {
                continue;
            };

            for tag in tags {
                self.tags
                    .entry(tag.clone())
                    .or_default()
                    .insert(note.path.clone());
            }
        }
    }

    fn map_links(&mut self) {
        for note in self.notes.values() {
            for link in &note.links {
                if link.kind != LinkKind::Wiki {
                    continue;
                }

                if let Some((_, other)) = self.find_note_by_title(&link.href) {
                    self.graph.add_edge(note.index, other.index, ());
                }
            }
        }
    }

    /// Locate a note by its filename/original name.
    #[must_use]
    pub fn find_note_by_name(&self, name: &str) -> Option<(ItemPathBuf, &Note)> {
        for (path, note) in &self.notes {
            if note.name == name {
                return Some((path.clone(), note));
            }
        }

        None
    }

    /// Locate a note by its title.
    #[must_use]
    pub fn find_note_by_title(&self, title: &str) -> Option<(ItemPathBuf, &Note)> {
        for (path, note) in &self.notes {
            if note.title() == title {
                return Some((path.clone(), note));
            }
        }

        None
    }

    /// Returns a iterator of all the [`Note`]'s that link to a given [`Note`].
    pub fn get_note_references(&self, index: NodeIndex) -> impl Iterator<Item = &Note> {
        self.graph
            .edges(index)
            .map(|edge| edge.target())
            .filter_map(|id| self.graph.node_weight(id))
            .filter_map(|path| self.notes.get(path))
    }
}

/// Utility wrapper of [`Vault`] (basically [`Arc`] `<` [`RwLock`] `<` [`Vault`] `>>`)
#[derive(Debug)]
pub struct SharedVault {
    vault: Arc<parking_lot::RwLock<Vault>>,
}

impl SharedVault {
    /// Create a [`SharedVault`] from a [`VaultPathBuf`], reading the vault's contents from the disk.
    ///
    /// # Errors
    ///
    /// This will fail if its unable to do IO
    /// or is unable to parse any of the supported Obsidian types.
    pub fn from_path(path: VaultPathBuf) -> Result<Self, VaultError> {
        Ok(Self {
            vault: Arc::new(parking_lot::RwLock::new(Vault::from_path(path)?)),
        })
    }

    /// Take a read reference to the innter [`Vault`].
    pub fn read(&self) -> parking_lot::RwLockReadGuard<'_, Vault> {
        self.vault.read()
    }

    /// Clears the currrent Vault and reloads it from disk.
    ///
    /// # Errors
    ///
    /// This will fail if its unable to do IO
    /// or is unable to parse any of the supported Obsidian types.
    pub fn reload(&mut self) -> Result<(), VaultError> {
        self.vault.write().reload()
    }

    /// Locate a note by its filename/original name.
    #[must_use]
    pub fn find_note_by_name(&self, name: &str) -> Option<(ItemPathBuf, Note)> {
        self.read()
            .find_note_by_name(name)
            .map(|(path, note)| (path, note.clone()))
    }

    /// Locate a note by its title.
    #[must_use]
    pub fn find_note_by_title(&self, title: &str) -> Option<(ItemPathBuf, Note)> {
        self.read()
            .find_note_by_title(title)
            .map(|(path, note)| (path, note.clone()))
    }
}

impl Clone for SharedVault {
    fn clone(&self) -> Self {
        Self {
            vault: self.vault.clone(),
        }
    }
}
