// 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 std::{
    convert::Infallible,
    fs::{File, create_dir_all},
    io::{Write, stdin},
    marker::PhantomData,
    path::PathBuf,
    str::FromStr,
    sync::LazyLock,
};

use color_eyre::{eyre::Result, owo_colors::OwoColorize};
use figment::{
    Figment,
    providers::{Format, Serialized, Toml},
};
use ratatui::style::Color;
use serde::{
    Deserialize, Deserializer, Serialize,
    de::{MapAccess, Visitor},
};

use crate::{
    bibiman::{bibisetup::CustomField, citekeys::CitekeyCase, clipboard::ClipboardProvider},
    cliargs::CLIArgs,
};

pub const IGNORED_SPECIAL_CHARS: [char; 39] = [
    '?', '!', '\\', '\'', '.', '-', '–', ':', ',', '[', ']', '(', ')', '{', '}', '§', '$', '%',
    '&', '/', '`', '´', '#', '+', '*', '=', '|', '<', '>', '^', '°', '_', '"', '»', '«', '‘', '’',
    '“', '”',
];

pub static IGNORED_WORDS: LazyLock<Vec<String>> = LazyLock::new(|| {
    vec![
        "the".into(),
        "a".into(),
        "an".into(),
        "of".into(),
        "for".into(),
        "in".into(),
        "at".into(),
        "to".into(),
        "and".into(),
        "him".into(),
        "her".into(),
        "his".into(),
        "he".into(),
        "she".into(),
        "it".into(),
        "hers".into(),
        "der".into(),
        "die".into(),
        "das".into(),
        "ein".into(),
        "eine".into(),
        "eines".into(),
        "des".into(),
        "auf".into(),
        "und".into(),
        "für".into(),
        "vor".into(),
        "er".into(),
        "sie".into(),
        "es".into(),
        "ihm".into(),
        "ihr".into(),
        "ihnen".into(),
        "zum".into(),
        "dem".into(),
    ]
});

const DEFAULT_CONFIG: &str = r##"
# [general]
## Default files/dirs which are loaded on startup
## Use absolute paths (~ for HOME works). Otherwise, loading might not work.
# bibfiles = [ "/path/to/bibfile", "path/to/dir/with/bibfiles" ]

## Default editor to use when editing files. It can be set as plain string
## only representing the command, or with arguments array.
# editor = { command = "vim", args = [ "-y", "-R" ] }
# OR
# editor = "vim"

## Default app to open PDFs/Epubs
# pdf_opener = "xdg-open"

## Default app to open URLs/DOIs
# url_opener = "xdg-open"

## Prefix which is prepended to the filepath from the `file` field
## Use absolute paths (~ for HOME works). Otherwise, loading might not work.
# file_prefix = "/some/path/prefix"

## Path to folder (with subfolders) containing PDF files with the basename
## of the format "citekey.pdf". Other PDF basenames are not accepted.
## Use absolute paths (~ for HOME works). Otherwise, loading might not work.
# pdf_path = "/path/to/pdf/folder"

## Path to folder (with subfolders) containing note files with the basename of
## the format "citekey.extension". Other basenames are not accepted. The possible
## extensions can be set through the "note_extensions" array.
# note_path = "/path/to/notes/folder"
# note_extensions = [ "md", "txt", "org" ]

## Symbols/chars to show if not has specific attachement
# note_symbol = "N"
# file_symbol = "F"
# link_symbol = "L"

## Select a custom column beside standard "author", "title" and "year"
## Possible values are "journaltitle", "organization", "instituion", "publisher"
## and "pubtype" (which is the default)
# custom_column = "pubtype"

## Define a custom formatter to format entries added via DOI. The formatter command
## must be able to read from stdin and print to stdout. Check the docs of
## your preffered formatter if specific options are needed to accomplish that.
# formatter = { command = "tex-fmt", args = ["--stdin", "--tabsize=8", "--wraplen=30"] }

## Set the clipboard program to be used. If not set, bibiman will choose a default
## depending on OS, envrionment vars and available binaries.
## Its possible to explicitly set one of the standard providers:
# clipboard = "wayland" # other possible values: 'x-clip', 'x-sel', 'win32-yank', 'tmux'
## Its also possible to set a custom command:
# [global.clipboard.custom]
# paste = { command = "wl-copy", args = [ "--type", "text/plain" ] } # "paste" is meant as pasting to the clipboard, thus, the copy-command is needed

# [colors]
## Default values for dark-themed terminal
## Possible values are:
## ANSI color names. E.g. "bright-black"
## 256-colors indices. E.g. "250"
## Hex color codes. E.g. "#3a3a3a"
# main_text_color = "250"
# highlight_text_color = "254"
# entry_color = "36"
# keyword_color = "101"
# info_color = "99"
# confirm_color = "47"
# warn_color = "124"
# bar_bg_color = "234"
# popup_fg_color = "43"
# popup_bg_color = "234"
# selected_row_bg_color = "237"
# note_color = "123"
# file_color = "209"
# link_color = "39"
# author_color = "38"
# title_color = "37"
# year_color = "135"

# [citekey_formatter]
## Define the patterns for creating citekeys. Every item of the array consists of
## five components separated by semicolons. Despite the field name every component
## can be left blank:
## - name of the biblatex field ("author", "title"...)
## - number of max words from the given field
## - number of chars used from each word
## - delimiter to separate words of the same field
## - trailing delimiter separating the current field from the following
# fields = [ "author;2;;-;_", "title;3;6;_;_", "year" ]

## Convert chars to specified case. Possible values:
## "upper", "uppercase", "lower", "lowercase"
# case = "lowercase"

## Map all unicode chars to their pure ascii equivalent
# ascii_only = true

## List of special chars that'll be ignored when building citekeys.
## A custom list will overwrite the default list. Thus, to add to the list,
## uncomment it and add the additional chars.
# ignored_chars = [
#   '?', '!', '\\', '\'', '.', '-', '–', ':', ',', '[', ']', '(', ')', '{', '}', '§', '$', '%', '&', '/', '`', '´', '#', '+', '*', '=', '|', '<', '>', '^', '°', '_', '"', '»', '«', '‘', '’', '“', '”',

## List of words that'll be ignored when building citekeys.
## A custom list will overwrite the default list. Thus, to add to the list,
## uncomment it and add the additional words.
# ignored_words = [
#   "the"
#   "a"
#   "an"
#   "of"
#   "for"
#   "in"
#   "at"
#   "to"
#   "and"
#   "him"
#   "her"
#   "his"
#   "he"
#   "she"
#   "it"
#   "hers"
#   "der"
#   "die"
#   "das"
#   "ein"
#   "eine"
#   "eines"
#   "des"
#   "auf"
#   "und"
#   "für"
#   "vor"
#   "er"
#   "sie"
#   "es"
#   "ihm"
#   "ihr"
#   "ihnen"
#   "zum"
#   "dem"
# ]
"##;

/// Main struct of the config file. Contains substructs/headings in toml
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
pub struct BibiConfig {
    pub general: General,
    pub colors: Colors,
    pub citekey_formatter: CitekeyFormatter,
}

/// Substruct [general] in config.toml
#[derive(Debug, Clone, Deserialize, Serialize, PartialEq)]
pub struct General {
    pub bibfiles: Option<Vec<PathBuf>>,
    #[serde(deserialize_with = "opt_string_or_struct")]
    editor: Option<ExtCmd>,
    pub pdf_opener: String,
    pub url_opener: String,
    pub file_prefix: Option<PathBuf>,
    pub pdf_path: Option<PathBuf>,
    pub note_path: Option<PathBuf>,
    pub note_extensions: Option<Vec<String>>,
    pub note_symbol: String,
    pub file_symbol: String,
    pub link_symbol: String,
    pub custom_column: CustomField,
    #[serde(deserialize_with = "opt_string_or_struct")]
    formatter: Option<ExtCmd>,
    clipboard: ClipboardProvider,
}

#[derive(Debug, Clone, Deserialize, Serialize, PartialEq)]
struct ExtCmd {
    command: String,
    args: Option<Vec<String>>,
}

impl FromStr for ExtCmd {
    // This implementation of `from_str` can never fail, so use the impossible
    // `Infallible` type as the error type.
    type Err = Infallible;

    fn from_str(s: &str) -> Result<Self, Self::Err> {
        Ok(ExtCmd {
            command: s.to_string(),
            args: None,
        })
    }
}

/// Deserializeses `struct_or_struct` to `Option<T>`
/// S. https://www.reddit.com/r/rust/comments/uhpabe/serde_string_or_struct_for_option/?utm_source=share&utm_medium=web3x&utm_name=web3xcss&utm_term=1&utm_content=share_button
fn opt_string_or_struct<'de, T, D>(deserializer: D) -> Result<Option<T>, D::Error>
where
    T: Deserialize<'de> + FromStr<Err = Infallible>,
    D: Deserializer<'de>,
{
    struct OptStringOrStruct<T>(PhantomData<T>);

    impl<'de, T> Visitor<'de> for OptStringOrStruct<T>
    where
        T: Deserialize<'de> + FromStr<Err = Infallible>,
    {
        type Value = Option<T>;

        fn expecting(&self, formatter: &mut std::fmt::Formatter) -> std::fmt::Result {
            formatter.write_str("a nul, a string or map")
        }

        fn visit_none<E>(self) -> Result<Self::Value, E>
        where
            E: serde::de::Error,
        {
            Ok(None)
        }

        fn visit_some<D>(self, deserializer: D) -> Result<Self::Value, D::Error>
        where
            D: Deserializer<'de>,
        {
            string_or_struct(deserializer).map(Some)
        }
    }

    deserializer.deserialize_option(OptStringOrStruct(PhantomData))
}

/// Makes it possible to deserialize a plain string to an inner struct
/// with a default value. S. https://serde.rs/string-or-struct.html
fn string_or_struct<'de, T, D>(deserializer: D) -> Result<T, D::Error>
where
    T: Deserialize<'de> + FromStr<Err = Infallible>,
    D: Deserializer<'de>,
{
    // This is a Visitor that forwards string types to T's `FromStr` impl and
    // forwards map types to T's `Deserialize` impl. The `PhantomData` is to
    // keep the compiler from complaining about T being an unused generic type
    // parameter. We need T in order to know the Value type for the Visitor
    // impl.
    struct StringOrStruct<T>(PhantomData<fn() -> T>);

    impl<'de, T> Visitor<'de> for StringOrStruct<T>
    where
        T: Deserialize<'de> + FromStr<Err = Infallible>,
    {
        type Value = T;

        fn expecting(&self, formatter: &mut std::fmt::Formatter) -> std::fmt::Result {
            formatter.write_str("string or map")
        }

        fn visit_str<E>(self, value: &str) -> Result<T, E>
        where
            E: serde::de::Error,
        {
            Ok(FromStr::from_str(value).unwrap())
        }

        fn visit_map<M>(self, map: M) -> Result<T, M::Error>
        where
            M: MapAccess<'de>,
        {
            // `MapAccessDeserializer` is a wrapper that turns a `MapAccess`
            // into a `Deserializer`, allowing it to be used as the input to T's
            // `Deserialize` implementation. T then deserializes itself using
            // the entries from the map visitor.
            Deserialize::deserialize(serde::de::value::MapAccessDeserializer::new(map))
        }
    }

    deserializer.deserialize_any(StringOrStruct(PhantomData))
}

/// Substruct [colors] in config.toml
#[derive(Debug, Clone, Deserialize, Serialize, PartialEq)]
pub struct Colors {
    pub main_text_color: Color,
    pub highlight_text_color: Color,
    pub entry_color: Color,
    pub keyword_color: Color,
    pub info_color: Color,
    pub confirm_color: Color,
    pub warn_color: Color,
    pub bar_bg_color: Color,
    pub popup_fg_color: Color,
    pub popup_bg_color: Color,
    pub selected_row_bg_color: Color,
    pub note_color: Color,
    pub file_color: Color,
    pub link_color: Color,
    pub author_color: Color,
    pub title_color: Color,
    pub year_color: Color,
}

#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
pub struct CitekeyFormatter {
    pub fields: Option<Vec<String>>,
    pub case: Option<CitekeyCase>,
    pub ascii_only: bool,
    pub ignored_chars: Option<Vec<char>>,
    pub ignored_words: Option<Vec<String>>,
}

impl Default for BibiConfig {
    fn default() -> Self {
        Self {
            general: General {
                bibfiles: None,
                editor: None,
                pdf_opener: select_opener(),
                url_opener: select_opener(),
                file_prefix: None,
                pdf_path: None,
                note_path: None,
                note_extensions: None,
                note_symbol: String::from("N"),
                file_symbol: String::from("F"),
                link_symbol: String::from("L"),
                custom_column: CustomField::Pubtype,
                formatter: None,
                clipboard: ClipboardProvider::default(),
            },
            colors: Self::dark_colors(),
            citekey_formatter: CitekeyFormatter {
                fields: None,
                case: None,
                ascii_only: true,
                ignored_chars: None,
                ignored_words: None,
            },
        }
    }
}

impl BibiConfig {
    pub fn new(args: &CLIArgs) -> Self {
        Self {
            general: General {
                bibfiles: None,
                editor: None,
                pdf_opener: select_opener(),
                url_opener: select_opener(),
                file_prefix: None,
                pdf_path: None,
                note_path: None,
                note_extensions: None,
                note_symbol: String::from("N"),
                file_symbol: String::from("F"),
                link_symbol: String::from("L"),
                custom_column: CustomField::Pubtype,
                formatter: None,
                clipboard: ClipboardProvider::default(),
            },
            colors: if args.light_theme {
                Self::light_colors()
            } else {
                Self::dark_colors()
            },
            citekey_formatter: CitekeyFormatter {
                fields: None,
                case: None,
                ascii_only: true,
                ignored_chars: None,
                ignored_words: None,
            },
        }
    }

    pub fn parse_config(args: &CLIArgs) -> Result<BibiConfig> {
        let cfg_file: BibiConfig = if args.cfg_path.as_ref().unwrap().is_file() {
            Figment::from(Serialized::defaults(BibiConfig::new(args)))
                .merge(Toml::file(&args.cfg_path.as_ref().unwrap()))
                .extract()?
        } else {
            BibiConfig::new(args)
        };

        Ok(cfg_file)
    }

    /// overwright config values with values set explicitly through the
    /// command line interface
    pub fn cli_overwrite(&mut self, args: &CLIArgs) {
        if args.pdf_path.is_some() {
            self.general.pdf_path = args.pdf_path.clone();
        }
    }

    /// Standard color scheme for terminals with dark background (default)
    pub fn dark_colors() -> Colors {
        Colors {
            main_text_color: Color::Indexed(250),
            highlight_text_color: Color::Indexed(254),
            entry_color: Color::Indexed(36),
            keyword_color: Color::Indexed(101),
            info_color: Color::Indexed(99),
            confirm_color: Color::Indexed(47),
            warn_color: Color::Indexed(124),
            bar_bg_color: Color::Indexed(235),
            popup_fg_color: Color::Indexed(43),
            popup_bg_color: Color::Indexed(234),
            selected_row_bg_color: Color::Indexed(237),
            note_color: Color::Indexed(123),
            file_color: Color::Indexed(209),
            link_color: Color::Indexed(39),
            author_color: Color::Indexed(38),
            title_color: Color::Indexed(37),
            year_color: Color::Indexed(135),
        }
    }

    /// Activates the default color scheme for light background terminals
    pub fn light_colors() -> Colors {
        Colors {
            main_text_color: Color::Indexed(235),
            highlight_text_color: Color::Indexed(232),
            entry_color: Color::Indexed(23),
            keyword_color: Color::Indexed(58),
            info_color: Color::Indexed(57),
            bar_bg_color: Color::Indexed(144),
            popup_fg_color: Color::Indexed(43),
            popup_bg_color: Color::Indexed(187),
            confirm_color: Color::Indexed(22),
            warn_color: Color::Indexed(124),
            selected_row_bg_color: Color::Indexed(107),
            note_color: Color::Indexed(123),
            file_color: Color::Indexed(209),
            link_color: Color::Indexed(27),
            author_color: Color::Indexed(38),
            title_color: Color::Indexed(37),
            year_color: Color::Indexed(135),
        }
    }

    /// Function which offers the user to create a default config
    /// if no exists at the standard config path.
    pub fn create_default_config(args: &CLIArgs) {
        let path = args.cfg_path.as_ref().unwrap().to_str();
        let mut input_str = String::new();

        match path {
            Some(p) => {
                println!("It seems no config file {} exists.", p.bold());
            }
            None => {
                println!(
                    "Can't parse config file path. Running {} without any config file.",
                    "bibiman".bold()
                );
                return;
            }
        }

        loop {
            println!(
                "\nDo you want to create a default config? {}",
                "[Y|N]".bold()
            );

            stdin()
                .read_line(&mut input_str)
                .expect("Couldn't read input");

            match input_str.trim().to_lowercase().as_str() {
                "y" | "yes" => {
                    break;
                }
                "n" | "no" => {
                    println!("\nNo config file will be created.");
                    return;
                }
                v => {
                    println!("\nInvalid value {}.", v.red());
                    println!("Please type {} or {}.", "[Y]es".bold(), "[N]o".bold());
                    input_str.clear();
                    continue;
                }
            }
        }

        {
            // Ignore any errors of this function, if something goes wrong creating a file will fail too.
            let mut dirpath = PathBuf::from_str(path.unwrap()).unwrap_or_else(|_| PathBuf::new());
            dirpath.pop();
            let _ = create_dir_all(dirpath);
        }

        let cfg_file = File::create_new(path.unwrap());

        match cfg_file {
            Ok(mut file) => {
                file.write_all(DEFAULT_CONFIG.as_bytes()).unwrap();
                println!("\nCreated default config file {}", path.unwrap().bold());
                println!(
                    "Check {} for explanations how to configure it.",
                    "https://codeberg.org/lukeflo/bibiman#configuration".bright_yellow()
                )
            }
            Err(e) => {
                println!(
                    "\nCouldn't create default config due to the following error:\n{}",
                    e.red()
                )
            }
        }
    }

    /// Get the external formatter command
    pub(crate) fn get_ext_formatter(&self) -> Option<(&str, Option<&[String]>)> {
        if let Some(cmd) = &self.general.formatter {
            Some((&cmd.command, cmd.args.as_ref().map(|vec| vec.as_slice())))
        } else {
            None
        }
    }

    /// Get the external editor command
    pub(crate) fn get_ext_editor(&self) -> Option<(&str, Option<&[String]>)> {
        if let Some(cmd) = &self.general.editor {
            Some((&cmd.command, cmd.args.as_ref().map(|vec| vec.as_slice())))
        } else {
            None
        }
    }

    pub(crate) fn get_clipboard(&self) -> &ClipboardProvider {
        &self.general.clipboard
    }
}

fn select_opener() -> String {
    match std::env::consts::OS {
        "linux" => String::from("xdg-open"),
        "macos" | "apple" => String::from("open"),
        "windows" => String::from("start"),
        "freebsd" => String::from("xdg-open"),
        "openbsd" | "netbsd" | "solaris" | "redox" => String::from("open"),
        _ => panic!("Couldn't detect OS for setting correct opener"),
    }
}

#[cfg(test)]
mod tests {
    use figment::{
        Figment,
        providers::{Format, Toml},
    };
    use serde::Deserialize;

    use crate::config::ExtCmd;

    use super::BibiConfig;

    #[test]
    fn parse_default_config() {
        let default_config: BibiConfig = BibiConfig::default();

        assert_eq!(default_config.general.bibfiles, None);
        assert_eq!(default_config.general.note_symbol, "N");
        assert_eq!(default_config.general.editor, None);
    }

    #[test]
    fn nested_struct() {
        figment::Jail::expect_with(|jail| {
            jail.create_file(
                "bibiman.toml",
                r#"
                    ext_cmd = { command = "bibformat", args = ["--dry", "--quite"] }
                "#,
            )?;

            #[derive(Deserialize)]
            struct TopLevel {
                ext_cmd: ExtCmd,
            }

            let config: TopLevel = Figment::new().merge(Toml::file("bibiman.toml")).extract()?;

            assert_eq!(config.ext_cmd.command, "bibformat");
            assert_eq!(
                config.ext_cmd.args,
                Some(vec!["--dry".into(), "--quite".into()])
            );

            Ok(())
        });
    }
}
