// bibiman - a TUI for managing BibLaTeX databases
// Copyright (C) 2024  lukeflo
//
// This program is free software: you can redistribute it and/or modify
// it under the terms of the GNU General Public License as published by
// the Free Software Foundation, either version 3 of the License, or
// (at your option) any later version.
//
// This program is distributed in the hope that it will be useful,
// but WITHOUT ANY WARRANTY; without even the implied warranty of
// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the
// GNU General Public License for more details.
//
// You should have received a copy of the GNU General Public License
// along with this program.  If not, see <https://www.gnu.org/licenses/>.
/////

use biblatex::{self, Bibliography, Entry};
use biblatex::{ChunksExt, Type};
use color_eyre::owo_colors::OwoColorize;
use itertools::Itertools;
use log::{error, info};
use serde::{Deserialize, Serialize};
use std::collections::HashMap;
use std::ffi::{OsStr, OsString};
use std::path::Path;
use std::{fs, path::PathBuf};
use walkdir::WalkDir;

use crate::app;
use crate::bibiman::sanitize::sanitize_one_bibidata;
use crate::cliargs::{self};
use crate::config::BibiConfig;

/// Custom field used for the fourth column of the `EntryTable`
#[derive(Debug, Default, Clone, PartialEq, Eq, PartialOrd, Ord, Deserialize, Serialize)]
pub enum CustomField {
    #[serde(alias = "journaltitle")]
    Journaltitle,
    #[serde(alias = "organization")]
    Organization,
    #[serde(alias = "institution")]
    Institution,
    #[serde(alias = "series")]
    Series,
    #[serde(alias = "publisher")]
    Publisher,
    #[serde(alias = "pubtype")]
    #[default]
    Pubtype,
}

// Set necessary fields
// TODO: can surely be made more efficient/simpler
#[derive(Debug)]
pub struct BibiSetup {
    // pub bibfile: PathBuf,           // path to bibfile
    // pub bibfilestring: Vec<String>, // content of bibfile as string
    pub bibliography: Bibliography, // parsed bibliography
    pub citekeys: Vec<String>,      // list of all citekeys
    pub keyword_list: Vec<String>,  // list of all available keywords
    pub entry_list: Vec<BibiData>,  // List of all entries
}

#[derive(Debug, Clone, PartialEq, Eq, PartialOrd, Ord)]
pub struct BibiData {
    pub id: u32,
    pub authors: String,
    pub short_author: String,
    pub title: String,
    pub year: String,
    pub custom_field: (CustomField, String),
    pub keywords: Vec<String>,
    pub citekey: String,
    pub abstract_text: String,
    pub doi_url: Option<String>,
    pub filepath: Option<Vec<OsString>>,
    pub file_field: bool,
    pub subtitle: Option<String>,
    pub notes: Option<Vec<OsString>>,
    pub symbols: [Option<String>; 3],
    /// This field should be set to None when initially creating a BibiData instance.
    /// It then can be generated from the constructed BibiData Object using
    /// `BibiData::gen_sanitized()`
    pub sanitized_bibi_data: Option<SanitizedBibiData>,
}

/// Struct that holds sanitized bibidata data.
#[derive(Debug, Clone, PartialEq, Eq, PartialOrd, Ord)]
pub struct SanitizedBibiData {
    pub title: String,
    pub subtitle: Option<String>,
    pub abstract_text: String,
}

#[derive(Debug, Clone, PartialEq)]
pub struct BibiRow<'a> {
    pub authors: &'a str,
    pub title: &'a str,
    pub year: &'a str,
    pub custom_field_value: &'a str,
    pub symbols: &'a [Option<String>; 3],
}

impl BibiData {
    // This functions decides which fields are rendered in the entry table
    // Fields which should be usable but not visible can be left out
    pub fn ref_vec(&self) -> BibiRow<'_> {
        let author_ref = if self.short_author.is_empty() {
            self.authors()
        } else {
            &self.short_author
        };
        if let Some(sanidata) = &self.sanitized_bibi_data {
            BibiRow {
                authors: author_ref,
                title: &sanidata.title,
                year: self.year(),
                custom_field_value: self.custom_field_value(),
                symbols: &self.symbols,
            }
        } else {
            BibiRow {
                authors: author_ref,
                title: self.title(),
                year: self.year(),
                custom_field_value: self.custom_field_value(),
                symbols: &self.symbols,
            }
        }
    }

    /// Generates the SanitizedBibiData for the BibiData.
    ///
    /// Consumes self and returns a new BibiData struct.
    pub fn gen_sanitized(mut self) -> Self {
        self.sanitized_bibi_data = Some(sanitize_one_bibidata(&self));
        self
    }

    pub fn entry_id(&self) -> &u32 {
        &self.id
    }

    pub fn authors(&self) -> &str {
        &self.authors
    }

    pub fn title(&self) -> &str {
        if let Some(sani_data) = &self.sanitized_bibi_data {
            &sani_data.title
        } else {
            &self.title
        }
    }

    pub fn year(&self) -> &str {
        &self.year
    }

    pub fn custom_field_value(&self) -> &str {
        &self.custom_field.1
    }

    pub fn citekey(&self) -> &str {
        &self.citekey
    }

    pub fn doi_url(&self) -> &str {
        self.doi_url.as_ref().unwrap()
    }

    // Gets the path of the associated file for a bib entry. If one is set explicitly
    // as a field, use that. If not, try to match it to one of the files found in the
    // pdf_files dir.
    pub fn filepath(&mut self) -> Vec<&OsStr> {
        self.filepath
            .as_mut()
            .unwrap()
            .iter_mut()
            .map(|p| p.as_os_str())
            .collect_vec()
    }

    pub fn subtitle(&self) -> Option<&str> {
        if let Some(sani_data) = &self.sanitized_bibi_data {
            sani_data.subtitle.as_ref().map(|s| s.as_str())
        } else {
            self.subtitle.as_ref().map(|s| s.as_str())
        }
    }

    pub fn get_abstract(&self) -> &str {
        if let Some(sani_data) = &self.sanitized_bibi_data {
            &sani_data.abstract_text
        } else {
            &self.abstract_text
        }
    }

    fn set_symbols(mut self, cfg: &BibiConfig) -> Self {
        self.symbols = [
            if self.file_field || self.filepath.is_some() {
                Some(cfg.general.file_symbol.clone())
            } else {
                None
            },
            if self.doi_url.is_some() {
                Some(cfg.general.link_symbol.clone())
            } else {
                None
            },
            if self.notes.is_some() {
                Some(cfg.general.note_symbol.clone())
            } else {
                None
            },
        ];
        self
    }
}

impl BibiSetup {
    /// Setup the TUI:
    /// * Getting files
    /// * Parse files into `biblatex::Bibliography` struct
    /// * If wanted, format citekeys
    /// * Get citekey vector
    /// * Collect all keywords
    /// * Build the entry list to be displayed
    pub fn new(main_bibfiles: &[PathBuf], cfg: &BibiConfig) -> Self {
        Self::check_files(main_bibfiles);
        let bibliography = Self::parse_bibfiles(main_bibfiles);
        let citekeys = Self::get_citekeys(&bibliography);
        let (entry_list, keyword_list) = Self::create_entry_list(&citekeys, &bibliography, cfg);
        Self {
            // bibfile,
            // bibfilestring,
            bibliography,
            citekeys,
            keyword_list,
            entry_list,
        }
    }

    /// Check which file format the passed file has
    fn check_files(main_bibfiles: &[PathBuf]) {
        if main_bibfiles.is_empty() {
            println!(
                "{}",
                "No bibfile passed as argument or through config file. Please select a valid file."
                    .red()
                    .bold()
            );
            println!();
            println!("{}", cliargs::help_func());
            std::process::exit(1)
        } else {
            // Loop over all files and check for the correct extension
            main_bibfiles.iter().for_each(|f| {
                if f.extension().is_some() && f.extension().unwrap() != "bib" {
                    println!(
                        "{}\n{}",
                        "The passed file has no valid extension. You need a \'.bib\' file:"
                            .red()
                            .bold(),
                        f.as_os_str().to_string_lossy().bright_red().italic()
                    );
                    println!();
                    println!("{}", cliargs::help_func());
                    std::process::exit(1)
                }
            });
        }
    }

    fn parse_bibfiles(main_bibfiles: &[PathBuf]) -> Bibliography {
        let mut bib_vec: Vec<Entry> = Vec::new();

        for bibfile in main_bibfiles.iter() {
            match fs::read_to_string(bibfile) {
                Ok(bibstring) => match Bibliography::parse(&bibstring) {
                    Ok(bib) => bib_vec.append(&mut bib.into_vec()),
                    Err(e) => error!(
                        "Couldn't parse bibstring from file {} to bibliography: {}",
                        bibfile.display(),
                        e.to_string()
                    ),
                },
                Err(e) => {
                    error!(
                        "Couldn't read bibfile {} to string due to the following error: {}",
                        bibfile.display(),
                        e.to_string()
                    );
                }
            }
        }

        let mut bibliography = Bibliography::new();
        for e in bib_vec {
            bibliography.insert(e);
        }
        bibliography
    }

    fn create_entry_list(
        citekeys: &[String],
        bibliography: &Bibliography,
        cfg: &BibiConfig,
    ) -> (Vec<BibiData>, Vec<String>) {
        let mut pdf_files = if let Some(pdf_path) = &cfg.general.pdf_path {
            collect_file_paths(pdf_path, Some(vec!["pdf".into()].as_slice()))
        } else {
            None
        };
        let ext = if let Some(ext) = &cfg.general.note_extensions
            && cfg.general.note_path.is_some()
        {
            Some(ext.as_slice())
        } else {
            None
        };
        let mut note_files = if let Some(note_path) = &cfg.general.note_path
            && cfg.general.note_extensions.is_some()
        {
            collect_file_paths(note_path, ext.clone())
        } else {
            None
        };
        //
        //
        // bibiman will sanitize some fields at this point,
        // this may cause longer startup-load-times.
        //
        //
        let mut bibidata_vec: Vec<BibiData> = Vec::new();
        let mut keyword_vec: Vec<String> = Vec::new();

        for (i, k) in citekeys.iter().enumerate() {
            if let Some(entry) = bibliography.get(k) {
                let filepaths: (Option<Vec<OsString>>, bool) =
                    { Self::get_filepath(entry, &mut pdf_files) };

                let keywords = Self::get_keywords(entry);

                if !keywords.is_empty() {
                    for kw in keywords.iter() {
                        keyword_vec.push(kw.clone());
                    }
                }

                let authors = Self::get_authors(entry);

                let short_author = match authors.split_once(",") {
                    Some((first, _rest)) => {
                        if authors.contains("(ed.)") {
                            let first_author = format!("{} et al. (ed.)", first);
                            first_author
                        } else {
                            let first_author = format!("{} et al.", first);
                            first_author
                        }
                    }
                    None => String::from(""),
                };

                bibidata_vec.push(
                    BibiData {
                        id: i as u32,
                        authors,
                        short_author,
                        title: Self::get_title(entry),
                        year: Self::get_year(entry),
                        custom_field: (
                            cfg.general.custom_column.clone(),
                            Self::get_custom_field(entry, &cfg.general.custom_column),
                        ),
                        keywords: Self::get_keywords(entry),
                        citekey: k.to_owned(),
                        abstract_text: Self::get_abstract(entry),
                        doi_url: Self::get_weblink(entry),
                        filepath: filepaths.0,
                        file_field: filepaths.1,
                        subtitle: Self::get_subtitle(entry),
                        notes: if note_files.is_some() {
                            Self::get_notepath(k, &mut note_files, ext)
                        } else {
                            None
                        },
                        symbols: [None, None, None],
                        sanitized_bibi_data: None,
                    }
                    .set_symbols(cfg)
                    .gen_sanitized(),
                );
            } else {
                info!("No entry found for citekey {k}.");
                continue;
            }
        }

        // Sort the vector and remove duplicates
        keyword_vec.sort_by_key(|a| a.to_lowercase());
        keyword_vec.dedup();

        (bibidata_vec, keyword_vec)
    }

    /// get list of citekeys from the given bibfile
    /// this list is the base for further operations on the bibentries
    /// since it is the entry point of the biblatex crate.
    pub fn get_citekeys(bibstring: &Bibliography) -> Vec<String> {
        let citekeys: Vec<String> = bibstring.keys().map(|k| k.to_owned()).collect();
        citekeys
    }

    pub fn get_authors(entry: &Entry) -> String {
        if let Ok(authors) = entry.author() {
            if authors.len() > 1 {
                authors.iter().map(|a| &a.name).join(", ")
            } else if authors.len() == 1 {
                authors[0].name.to_string()
            } else {
                "empty".to_string()
            }
        } else if let Ok(editors) = entry.editors()
            && !editors.is_empty()
        {
            if editors[0].0.len() > 1 {
                format!("{} (ed.)", editors[0].0.iter().map(|e| &e.name).join(", "))
            } else if editors[0].0.len() == 1 {
                format!("{} (ed.)", editors[0].0[0].name)
            } else {
                "empty".to_string()
            }
        } else {
            "empty".to_string()
        }
    }

    pub fn get_title(entry: &Entry) -> String {
        if let Ok(title) = entry.title() {
            title.format_verbatim()
        } else {
            "no title".to_string()
        }
    }

    pub fn get_year(entry: &Entry) -> String {
        if let Ok(date) = entry.date() {
            let year = date.to_chunks().format_verbatim();
            year[..4].to_string()
        } else {
            "n.d.".to_string()
        }
    }

    pub fn get_custom_field(entry: &Entry, custom_field: &CustomField) -> String {
        match custom_field {
            CustomField::Journaltitle => {
                if let Ok(val) = entry.journal_title() {
                    val.format_verbatim()
                } else {
                    "empty".to_string()
                }
            }
            CustomField::Organization => {
                if let Ok(val) = entry.organization() {
                    let values: Vec<String> = val
                        .iter()
                        .map(|i| i.get(0).unwrap().v.get().to_string())
                        .collect();
                    values.concat()
                } else {
                    "empty".to_string()
                }
            }
            CustomField::Institution => {
                if let Ok(val) = entry.institution() {
                    let values = val
                        .into_iter()
                        .map(|i| i.v.get().to_owned())
                        .collect::<Vec<_>>();
                    values.concat()
                } else {
                    "empty".to_string()
                }
            }
            CustomField::Series => {
                if let Ok(val) = entry.series() {
                    let values = val
                        .into_iter()
                        .map(|i| i.v.get().to_owned())
                        .collect::<Vec<_>>();
                    values.concat()
                } else {
                    "empty".to_string()
                }
            }
            CustomField::Publisher => {
                if let Ok(val) = entry.publisher() {
                    let values: Vec<String> = val
                        .iter()
                        .map(|i| i.get(0).unwrap().v.get().to_string())
                        .collect();
                    values.concat()
                } else {
                    "empty".to_string()
                }
            }
            CustomField::Pubtype => entry.entry_type.to_string(),
        }
    }

    pub fn get_keywords(entry: &Entry) -> Vec<String> {
        if let Ok(keywords) = entry.keywords() {
            let keyword_vec = keywords
                .format_verbatim()
                .split(',')
                .map(|s| s.trim().to_string())
                .filter(|s| !s.is_empty())
                .collect();
            keyword_vec
        } else {
            Vec::new()
        }
    }

    pub fn get_abstract(entry: &Entry) -> String {
        if let Ok(abstract_) = entry.abstract_() {
            abstract_.format_verbatim()
        } else {
            "no abstract".to_string()
        }
    }

    pub fn get_weblink(entry: &Entry) -> Option<String> {
        if let Ok(doi) = entry.doi() {
            Some(doi.trim().into())
        } else if let Ok(url) = entry.url() {
            Some(url.trim().into())
        } else {
            None
        }
    }

    pub fn get_filepath(
        entry: &Entry,
        pdf_files: &mut Option<HashMap<String, Vec<PathBuf>>>,
    ) -> (Option<Vec<OsString>>, bool) {
        if let Ok(pdffile) = entry.file() {
            (Some(vec![pdffile.trim().into()]), true)
        } else if pdf_files.is_some() {
            (
                Self::merge_filepath_or_none_two(
                    &entry.key,
                    pdf_files,
                    vec!["pdf".into()].as_slice(),
                ),
                false,
            )
        } else {
            (None, false)
        }
    }

    pub fn get_notepath(
        citekey: &str,
        note_files: &mut Option<HashMap<String, Vec<PathBuf>>>,
        ext: Option<&[String]>,
    ) -> Option<Vec<OsString>> {
        if let Some(e) = ext {
            Self::merge_filepath_or_none_two(citekey, note_files, e)
        } else {
            None
        }
    }

    pub fn get_subtitle(entry: &Entry) -> Option<String> {
        if let Ok(subtitle) = entry.subtitle() {
            Some(subtitle.format_verbatim())
        } else {
            None
        }
    }

    /// Check if there exists files with the basename of the format
    /// "citekey.extension" in the passed hashmap. If so, return all matches
    /// as `Option<Vec>`, otherwise return `None`
    fn merge_filepath_or_none_two(
        citekey: &str,
        files: &mut Option<HashMap<String, Vec<PathBuf>>>,
        extensions: &[String],
    ) -> Option<Vec<OsString>> {
        let mut file = Vec::new();

        for e in extensions.iter() {
            let basename = citekey.to_owned().to_ascii_lowercase() + "." + e;
            if files.as_ref().unwrap().contains_key(&basename) {
                let _ = files
                    .as_ref()
                    .unwrap()
                    .get(&basename)
                    .unwrap()
                    .to_owned()
                    .into_iter()
                    .for_each(|p| file.push(p.into_os_string()));
            }
        }

        if file.is_empty() { None } else { Some(file) }
    }
}

/// This function walks the given dir and collects all files matching one of the
/// passed extensions into a `HashMap` of the format `[String, Vec<PathBuf>]`,
/// where `String` represents the basename of the file and the `Vec<PathBuf>` holds
/// all filepaths ending with this basename.
///
/// In most cases the latter is only a single path, but there might be some concepts
/// with subdirs were some entries have multiple files associated with them.
///
/// Passing [`None`] as argument for extensions will result in collecting all files
/// from the given directory and its subdirectories!
pub fn collect_file_paths<P: AsRef<Path>>(
    file_dir: P,
    extensions: Option<&[String]>,
) -> Option<HashMap<String, Vec<PathBuf>>> {
    let mut files: HashMap<String, Vec<PathBuf>> = HashMap::new();

    let file_dir = file_dir.as_ref();

    // Expand tilde to /home/user
    let file_dir = if file_dir.starts_with("~") {
        &app::expand_home(&file_dir.to_path_buf())
    } else {
        file_dir
    };

    // Walk the passed dir and collect all pdf files into hashmap
    if file_dir.is_dir() {
        for file in WalkDir::new(file_dir).into_iter().filter_map(|e| e.ok()) {
            let f = file.into_path();
            if f.is_file()
                && let Some(ext) = f.extension()
                && extensions.is_some_and(|v| {
                    v.contains(&ext.to_ascii_lowercase().to_string_lossy().to_string())
                })
            {
                let filename = if let Some(filename) = f.file_name() {
                    filename
                        .to_ascii_lowercase()
                        .into_string()
                        .unwrap_or_else(|os| os.to_string_lossy().to_string())
                } else {
                    continue;
                };

                if let Some(paths) = files.get_mut(&filename) {
                    paths.push(f);
                } else {
                    files.insert(filename, vec![f]);
                }
            } else if f.is_file() && extensions.is_none() {
                let filename = if let Some(filename) = f.file_name() {
                    filename
                        .to_ascii_lowercase()
                        .into_string()
                        .unwrap_or_else(|os| os.to_string_lossy().to_string())
                } else {
                    continue;
                };

                if let Some(paths) = files.get_mut(&filename) {
                    paths.push(f);
                } else {
                    files.insert(filename, vec![f]);
                }
            }
        }
    }

    if files.is_empty() { None } else { Some(files) }
}

#[cfg(test)]
mod tests {
    use std::{collections::HashMap, ffi::OsString, path::PathBuf};

    use super::BibiSetup;

    #[test]
    fn check_file_matching() {
        let mut files: HashMap<String, Vec<PathBuf>> = HashMap::new();
        files.insert(
            "citekey.md".to_string(),
            vec![
                PathBuf::from("/one/note/citekey.md"),
                PathBuf::from("/one/other/citekey.md"),
            ],
        );
        files.insert(
            "citekey.pdf".to_string(),
            vec![
                PathBuf::from("/one/note/citekey.pdf"),
                PathBuf::from("/one/other/citekey.pdf"),
            ],
        );
        files.insert(
            "citekey2.pdf".to_string(),
            vec![
                PathBuf::from("/one/note/citekey2.pdf"),
                PathBuf::from("/one/other/citekey2.pdf"),
            ],
        );

        let matches = BibiSetup::merge_filepath_or_none_two(
            "citekey",
            &mut Some(files),
            vec!["md".into(), "pdf".into()].as_slice(),
        );

        assert_eq!(
            matches.clone().unwrap().iter().next().unwrap().to_owned(),
            OsString::from("/one/note/citekey.md")
        );
        assert_eq!(
            matches.clone().unwrap().last().unwrap().to_owned(),
            OsString::from("/one/other/citekey.pdf")
        );
        assert!(
            !matches
                .clone()
                .unwrap()
                .contains(&OsString::from("/one/other/citekey2.pdf"))
        );
    }
}
