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

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

use crate::status::{Satisfying, Status, count_statuses};

use super::{command, file};

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

// 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 InstallerGo {
    /// Set the GOBIN environment variable for `go`,
    /// where `go install` will put executable files
    pub gobin: Option<Utf8PathBuf>,
    /// Set the GOPATH environment variable for `go`,
    /// where `go install` will put package source code
    pub gopath: Option<Utf8PathBuf>,
    pub packages: Vec<String>,
    pub state: DesiredState,
}
impl Default for InstallerGo {
    fn default() -> Self {
        Self {
            gobin: None,
            gopath: None,
            packages: vec![],
            state: DesiredState::Latest,
        }
    }
}
impl fmt::Display for InstallerGo {
    fn fmt(&self, f: &mut fmt::Formatter) -> fmt::Result {
        write!(
            f,
            "go install {} {:?} packages",
            &self.packages.len(),
            &self.state
        )
    }
}
impl InstallerGo {
    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 gobin = self.gobin_dir().await;

                let mut statuses: Vec<Status> = vec![];
                for pkg in &self.packages {
                    let exe = executable_name(pkg);
                    let f = file::File {
                        force: Some(true),
                        path: gobin.join(exe),
                        src: None,
                        state: file::FileState::Absent,
                    };
                    let status = f.execute().await?;
                    // TODO: remove related directories from GOPATH/pkg/mod and GOPATH/src
                    statuses.push(status);
                }
                let summary = count_statuses(statuses.iter());
                Ok(Status::Satisfying(Satisfying::Changed(
                    String::from("unknown"),
                    format!("{summary:?}"),
                )))
            }
            DesiredState::Latest => {
                if self.packages.is_empty() {
                    return Ok(Status::no_change(String::from("0 packages to install")));
                }

                let mut env: HashMap<String, String> = HashMap::new();
                if let Some(gobin) = &self.gobin {
                    env.insert(String::from("GOBIN"), String::from(gobin.as_str()));
                }
                if let Some(gopath) = &self.gopath {
                    env.insert(String::from("GOPATH"), String::from(gopath.as_str()));
                }
                let mut statuses: Vec<Status> = vec![];
                for pkg in &self.packages {
                    let target = if pkg.contains('@') {
                        pkg.clone()
                    } else {
                        format!("{pkg}@latest")
                    };
                    let cmd = command::Command {
                        argv: vec![String::from("install"), target],
                        env: env.clone(),
                        command: String::from("go"),
                        ..Default::default()
                    };
                    let status = cmd.execute().await?;
                    // TODO: determine how many changes/nochanges there actually are
                    statuses.push(status);
                }
                let summary = count_statuses(statuses.iter());
                Ok(Status::Satisfying(Satisfying::Changed(
                    String::from("unknown"),
                    format!("{summary:?}"),
                )))
            }
        }
    }

    async fn gobin_dir(&self) -> Utf8PathBuf {
        if let Some(gobin) = &self.gobin {
            return gobin.clone();
        }
        let out = match process::Command::new("go")
            .args(["env", "GOBIN"])
            .output()
            .await
        {
            Ok(o) => String::from_utf8_lossy(&o.stdout).into_owned(),
            Err(_) => String::new(),
        };
        if !out.trim().is_empty() {
            return Utf8PathBuf::from(out);
        }
        self.gopath_dir().await.join("bin")
    }

    async fn gopath_dir(&self) -> Utf8PathBuf {
        if let Some(gopath) = &self.gopath {
            return gopath.clone();
        }
        let out = match process::Command::new("go")
            .args(["env", "GOPATH"])
            .output()
            .await
        {
            Ok(o) => String::from_utf8_lossy(&o.stdout).into_owned(),
            Err(_) => String::new(),
        };
        if !out.trim().is_empty() {
            return Utf8PathBuf::from(out);
        }
        Utf8PathBuf::try_from(dirs::home_dir().expect("unable to determine home directory"))
            .expect("home directory path has invalid characters")
    }
}

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

const GO_EXT: &str = ".go";

/// attempt to guess the executable binary's name from the package/target
fn executable_name<S>(target: S) -> String
where
    S: AsRef<str>,
{
    let p = Utf8PathBuf::from(target.as_ref().trim_end_matches('/').trim_end_matches('.'));
    let name = String::from(
        p.file_name()
            // expect: shouldn't panic because of the previous trims
            .expect("no file name, even though we trimmed slashes and dots"),
    );

    // checking to see if there's a version segment we need to trim
    let mut chars = name.chars();
    if chars.next() == Some('v')
        && let Some(second) = chars.next()
        && second.is_ascii_digit()
        && (Version::parse(&name[1..]).is_ok() || VersionReq::parse(&name[1..]).is_ok())
    {
        // TODO: properly pre-validate / handle errors without expect/panic
        let p = p
            .parent()
            .expect("package needs to be more than just the version-suffix");
        return executable_name(p.as_str());
    }

    // not using Utf8PathBuf::file_stem here, because that would impact more than ".go"
    if name.ends_with(GO_EXT) {
        return String::from(&name[..name.len() - GO_EXT.len()]);
    }

    name
}

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

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

    use super::*;

    const SAMPLE_BIN: &str = "hello-world-go";
    const SAMPLE_PKG: &str = "gitlab.com/jokeyrhyme/hello-world-go";

    #[tokio::test]
    async fn gobin_values() {
        let installer = InstallerGo {
            gobin: Some(Utf8PathBuf::from("gobin/dir")),
            ..Default::default()
        };
        assert_eq!(installer.gobin_dir().await, Utf8PathBuf::from("gobin/dir"));

        // TODO: consider testing fallback to `go env`
    }

    #[tokio::test]
    async fn gopath_values() {
        let installer = InstallerGo {
            gopath: Some(Utf8PathBuf::from("gopath/dir")),
            ..Default::default()
        };
        assert_eq!(
            installer.gopath_dir().await,
            Utf8PathBuf::from("gopath/dir")
        );

        // TODO: consider testing fallback to `go env`
    }

    #[test]
    fn executable_name_cases() {
        let cases = vec![
            (SAMPLE_PKG, SAMPLE_BIN),                                 // directory
            ("github.com/praetorian-inc/gokart/cmf/scan.go", "scan"), // file
            ("github.com/zricethezav/gitleaks/v7", "gitleaks"),       // versioned
            ("mvdan.cc/gofumpt/...", "gofumpt"),                      // /...
        ];
        for (input, want) in cases {
            let got = executable_name(input);
            assert_eq!(want, got);
        }
    }

    #[tokio::test]
    async fn latest_then_absent() -> std::result::Result<(), Error> {
        let tmp = temp_dir()?;
        assert!(metadata(tmp.join("bin").join(SAMPLE_BIN)).is_err());

        let latest = InstallerGo {
            gobin: Some(tmp.join("bin")),
            gopath: Some(tmp.to_path_buf()),
            packages: vec![String::from(SAMPLE_PKG)],
            state: DesiredState::Latest,
        };
        latest.execute().await?;
        assert!(metadata(tmp.join("bin").join(SAMPLE_BIN)).is_ok());

        let absent = InstallerGo {
            gobin: Some(tmp.join("bin")),
            gopath: Some(tmp.to_path_buf()),
            packages: vec![String::from(SAMPLE_PKG)],
            state: DesiredState::Absent,
        };
        absent.execute().await?;
        assert!(metadata(tmp.join("bin").join(SAMPLE_BIN)).is_err());

        Ok(())
    }
}
