use std::{collections::HashMap, fmt};

use camino::Utf8PathBuf;
use serde::{Deserialize, Serialize};
use thiserror::Error as ThisError;
use tokio::process;

use super::{Status, command, file};
use crate::installer;

#[derive(Debug, Deserialize, Eq, PartialEq, Serialize)]
#[serde(rename_all = "lowercase")]
pub(crate) enum DesiredState {
    Absent,
    Latest,
    Present,
}

// TODO: wait for https://github.com/rust-lang/rust/issues/35121
// TODO: drop this and use the std::never::Never type instead
#[derive(Debug, ThisError)]
pub(crate) enum Error {
    #[error(transparent)]
    CommandJob {
        #[from]
        source: command::Error,
    },
    #[error(transparent)]
    FileJob {
        #[from]
        source: file::Error,
    },
    #[allow(dead_code)]
    #[error("never")]
    Never,
}
impl PartialEq for Error {
    fn eq(&self, other: &Error) -> bool {
        format!("{self:?}") == format!("{other:?}")
    }
}

#[derive(Debug, Deserialize, Eq, PartialEq, Serialize)]
#[serde(default, rename_all = "lowercase", tag = "type")]
pub(crate) struct InstallerNpm {
    /// Packages to target?
    pub packages: Vec<String>,
    /// Custom path for local mode, otherwise run against global prefix (default), see [`npm-prefix`](https://docs.npmjs.com/cli/v7/commands/npm-prefix)
    pub local: Option<Utf8PathBuf>,
    /// Action to perform?
    /// - [`Absent`](DesiredDate::Absent) to uninstall target [`packages`](InstallerNpm::packages)
    /// - [`Latest`](DesiredDate::Latest) to update all currently-installed packages
    /// - [`Present`](DesiredDate::Present) to install target [`packages`](InstallerNpm::packages)
    pub state: DesiredState,
}
impl Default for InstallerNpm {
    fn default() -> Self {
        Self {
            local: None,
            packages: vec![],
            state: DesiredState::Latest,
        }
    }
}
impl fmt::Display for InstallerNpm {
    fn fmt(&self, f: &mut fmt::Formatter) -> fmt::Result {
        write!(
            f,
            "npm install {} {:?} packages",
            &self.packages.len(),
            &self.state
        )
    }
}
impl InstallerNpm {
    pub async fn execute(&self) -> Result {
        match self.state {
            DesiredState::Absent => {
                if self.packages.is_empty() {
                    return Ok(Status::no_change(String::from("0 packages to uninstall")));
                }

                let found: Vec<String> = self
                    .found_versions()
                    .await
                    .keys()
                    .map(String::from)
                    .collect();
                let surplus: Vec<String> = self
                    .packages
                    .iter()
                    .filter(|c| !c.trim().is_empty() && found.contains(c))
                    .map(String::from)
                    .collect();
                if surplus.is_empty() {
                    return Ok(Status::no_change(format!(
                        "{} packages absent",
                        self.packages.len()
                    )));
                }

                let before = format!(
                    "{} of {} packages to install",
                    surplus.len(),
                    self.packages.len()
                );

                let mut cmd = self.npm_command();
                cmd.argv.push(String::from("uninstall"));
                cmd.argv.extend(surplus);
                cmd.execute().await?;

                Ok(Status::changed(
                    before,
                    format!("{} packages uninstalled", self.packages.len()),
                ))
            }
            DesiredState::Latest => {
                let found_before = self.found_versions().await;

                let mut cmd = self.npm_command();
                cmd.argv.push(String::from("update"));
                cmd.execute().await?;

                let found_after = self.found_versions().await;

                Ok(installer::calculate_status(&found_before, &found_after))
            }
            DesiredState::Present => {
                if self.packages.is_empty() {
                    return Ok(Status::no_change(String::from("0 packages to install")));
                }

                let found: Vec<String> = self
                    .found_versions()
                    .await
                    .keys()
                    .map(String::from)
                    .collect();
                let missing: Vec<String> = self
                    .packages
                    .iter()
                    .filter(|c| !c.trim().is_empty() && !found.contains(c))
                    .map(String::from)
                    .collect();
                if missing.is_empty() {
                    return Ok(Status::no_change(format!(
                        "{} packages present",
                        self.packages.len()
                    )));
                }

                let before = format!(
                    "{} of {} packages to install",
                    missing.len(),
                    self.packages.len()
                );

                let mut cmd = self.npm_command();
                cmd.argv.push(String::from("install"));
                cmd.argv.extend(missing);
                cmd.execute().await?;

                Ok(Status::changed(
                    before,
                    format!("{} packages installed", self.packages.len()),
                ))
            }
        }
    }

    async fn found_versions(&self) -> HashMap<String, String> {
        let out = match self
            .npm_exec()
            .args(["ls", "--depth=0", "--json"])
            .output()
            .await
        {
            Ok(o) => String::from_utf8_lossy(&o.stdout).into_owned(),
            Err(_) => String::new(),
        };
        let ls: NpmLs = match serde_json::from_str(&out) {
            Ok(ls) => ls,
            Err(e) => {
                eprintln!("error: unable to parse JSON from `npm ls`: {e:?}");
                return HashMap::new();
            }
        };
        ls.dependencies
            .iter()
            .map(|(name, dep)| (name.clone(), dep.version.clone()))
            .collect()
    }

    fn npm_command(&self) -> command::Command {
        let mut cmd = command::Command {
            argv: vec![],
            command: String::from("npm"),
            ..Default::default()
        };
        match &self.local {
            Some(l) => cmd.chdir = Some(l.clone()),
            None => cmd.argv.push(String::from("--global")),
        }
        cmd
    }

    fn npm_exec(&self) -> process::Command {
        let mut p = process::Command::new("npm");
        match &self.local {
            Some(l) => p.current_dir(l),
            None => p.arg("--global"),
        };
        p
    }
}

pub(crate) type Result = std::result::Result<Status, Error>;

#[derive(Debug, Deserialize)]
struct Dependency {
    #[serde(default)]
    version: String,
}

#[derive(Debug, Deserialize)]
struct NpmLs {
    #[serde(default)]
    dependencies: HashMap<String, Dependency>,
}

#[cfg(test)]
mod tests {
    use std::fs::{create_dir_all, metadata};

    use super::super::file::tests::temp_dir;

    use super::*;

    const SAMPLE_PACKAGE: &str = "open-cli";

    #[tokio::test]
    async fn present_then_absent() -> std::result::Result<(), Error> {
        let tmp = temp_dir()?;
        let tmp_bin = tmp.join("node_modules/.bin");
        create_dir_all(&tmp_bin).expect("unable to create temporary bin path");
        assert!(metadata(tmp_bin.join(SAMPLE_PACKAGE)).is_err());

        let present = InstallerNpm {
            local: Some(tmp.clone()),
            packages: vec![String::from(SAMPLE_PACKAGE)],
            state: DesiredState::Present,
        };

        let initial_versions = present.found_versions().await;
        assert_eq!(initial_versions.len(), 0);

        present.execute().await?;

        assert!(metadata(tmp_bin.join(SAMPLE_PACKAGE)).is_ok());

        let installed_versions = present.found_versions().await;
        assert_ne!(installed_versions.len(), 0);
        assert!(installed_versions.contains_key(SAMPLE_PACKAGE));

        let absent = InstallerNpm {
            local: Some(tmp),
            packages: vec![String::from(SAMPLE_PACKAGE)],
            state: DesiredState::Absent,
        };
        absent.execute().await?;

        assert!(metadata(tmp_bin.join(SAMPLE_PACKAGE)).is_err());

        let absent_versions = absent.found_versions().await;
        assert_eq!(absent_versions.len(), 0);

        Ok(())
    }
}
