use std::env::consts::EXE_EXTENSION;
use std::fmt::Display;
use std::path::{Path, PathBuf};
use std::str::FromStr;
use std::sync::LazyLock;

use anyhow::{Context, Result};
use itertools::Itertools;
use prek_consts::env_vars::{EnvVars, EnvVarsRead};
use serde::Deserialize;
use target_lexicon::{Architecture, HOST, OperatingSystem};
use tracing::{debug, trace, warn};

use crate::archive;
use crate::checksum::Sha256Digest;
use crate::fs::LockedFile;
use crate::git;
use crate::http::{REQWEST_CLIENT, download_artifact};
use crate::languages::golang::GoRequest;
use crate::languages::golang::golang::bin_dir;
use crate::languages::golang::version::GoVersion;
use crate::process::Cmd;
use crate::store::Store;

pub(crate) struct GoResult {
    path: PathBuf,
    version: GoVersion,
    from_system: bool,
}

impl Display for GoResult {
    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
        write!(f, "{}@{}", self.path.display(), self.version)?;
        Ok(())
    }
}

/// Override the Go binary name for testing.
static GO_BINARY_NAME: LazyLock<String> = LazyLock::new(|| {
    if let Ok(name) = EnvVars.var(EnvVars::PREK_INTERNAL__GO_BINARY_NAME) {
        name
    } else {
        "go".to_string()
    }
});

#[derive(Deserialize)]
struct GoRelease {
    version: String,
    files: Vec<GoFile>,
}

#[derive(Deserialize)]
struct GoFile {
    filename: String,
    sha256: String,
}

impl GoResult {
    pub(crate) fn from_dir(dir: &Path, version: GoVersion) -> Self {
        let go = bin_dir(dir).join("go").with_extension(EXE_EXTENSION);
        Self {
            path: go,
            version,
            from_system: false,
        }
    }

    pub(crate) fn bin(&self) -> &Path {
        &self.path
    }

    pub(crate) fn version(&self) -> &GoVersion {
        &self.version
    }

    pub(crate) fn is_from_system(&self) -> bool {
        self.from_system
    }

    pub(crate) fn cmd(&self) -> Cmd {
        Cmd::new(&self.path)
    }

    async fn from_system_executable(path: PathBuf) -> Result<Self> {
        let output = Cmd::new(&path)
            .arg("version")
            .env(EnvVars::GOTOOLCHAIN, "local")
            .check(true)
            .output()
            .await?;
        // e.g. "go version go1.24.5 darwin/arm64"
        let version_str = String::from_utf8(output.stdout)?;
        let version_str = version_str
            .split_ascii_whitespace()
            .nth(2)
            .with_context(|| format!("Failed to parse Go version from output: {version_str}"))?;

        let version = GoVersion::from_str(version_str)?;

        Ok(Self {
            path,
            version,
            from_system: true,
        })
    }
}

pub(crate) struct GoInstaller {
    root: PathBuf,
}

impl GoInstaller {
    pub(crate) fn new(root: PathBuf) -> Self {
        Self { root }
    }

    pub(crate) async fn install(
        &self,
        store: &Store,
        request: &GoRequest,
        allows_download: bool,
    ) -> Result<GoResult> {
        fs_err::tokio::create_dir_all(&self.root).await?;

        let _lock = LockedFile::acquire(self.root.join(".lock"), "go").await?;

        if let Ok(go) = self.find_installed(request) {
            trace!(%go, "Found installed go");
            return Ok(go);
        }

        if let Some(go) = self.find_system_go(request).await? {
            trace!(%go, "Using system go");
            return Ok(go);
        }

        if !allows_download {
            anyhow::bail!("No suitable system Go version found and downloads are disabled");
        }

        let resolved_version = self
            .resolve_version(request)
            .await
            .with_context(|| format!("Failed to resolve go version `{request}`"))?;
        trace!(version = %resolved_version, "Installing go");

        self.download(store, &resolved_version).await
    }

    fn find_installed(&self, request: &GoRequest) -> Result<GoResult> {
        let mut installed = fs_err::read_dir(&self.root)
            .ok()
            .into_iter()
            .flatten()
            .filter_map(|entry| match entry {
                Ok(entry) => Some(entry),
                Err(e) => {
                    warn!(?e, "Failed to read entry");
                    None
                }
            })
            .filter(|entry| entry.file_type().is_ok_and(|f| f.is_dir()))
            .filter_map(|entry| {
                let dir_name = entry.file_name();
                let version = GoVersion::from_str(&dir_name.to_string_lossy()).ok()?;
                Some((version, entry.path()))
            })
            .sorted_unstable_by(|(a, _), (b, _)| a.cmp(b))
            .rev();

        installed
            .find_map(|(version, path)| {
                if request.matches(&version) {
                    trace!(%version, "Found matching installed go");
                    Some(GoResult::from_dir(&path, version))
                } else {
                    trace!(%version, "Installed go does not match request");
                    None
                }
            })
            .context("No installed go version matches the request")
    }

    async fn resolve_version(&self, req: &GoRequest) -> Result<GoVersion> {
        let output = git::git_cmd()?
            .arg("ls-remote")
            .arg("--tags")
            .arg("https://github.com/golang/go")
            .output()
            .await?
            .stdout;
        let output_str = str::from_utf8(&output)?;
        let version = output_str
            .lines()
            .filter_map(|line| {
                let tag = line.split('\t').nth(1)?;
                let tag = tag.strip_prefix("refs/tags/go")?;
                GoVersion::from_str(tag).ok()
            })
            .sorted_unstable_by(|a, b| b.cmp(a))
            .find(|version| req.matches(version))
            .with_context(|| format!("Version `{req}` not found on remote"))?;
        Ok(version)
    }

    async fn download(&self, store: &Store, version: &GoVersion) -> Result<GoResult> {
        let arch = match HOST.architecture {
            Architecture::X86_32(_) => "386",
            Architecture::X86_64 => "amd64",
            Architecture::Aarch64(_) => "arm64",
            Architecture::S390x => "s390x",
            Architecture::Powerpc => "ppc64",
            Architecture::Powerpc64le => "ppc64le",
            _ => anyhow::bail!("Unsupported architecture"),
        };
        let os = match HOST.operating_system {
            OperatingSystem::Darwin(_) => "darwin",
            OperatingSystem::Linux => "linux",
            OperatingSystem::Windows => "windows",
            OperatingSystem::Aix => "aix",
            OperatingSystem::Netbsd => "netbsd",
            OperatingSystem::Openbsd => "openbsd",
            OperatingSystem::Solaris => "solaris",
            OperatingSystem::Dragonfly => "dragonfly",
            OperatingSystem::Illumos => "illumos",
            _ => anyhow::bail!("Unsupported OS"),
        };

        let ext = if cfg!(windows) { "zip" } else { "tar.gz" };
        let filename = format!("go{version}.{os}-{arch}.{ext}");
        let url = format!("https://go.dev/dl/{filename}");
        let checksum_version = version.to_string();
        let target = self.root.join(version.to_string());

        let download = download_artifact(&url, &filename, store, async || {
            Self::fetch_checksum(&checksum_version, &filename).await
        })
        .await
        .context("Failed to download go")?;
        let extracted = archive::extract_archive(download.path())
            .await
            .context("Failed to extract go")?;
        if target.exists() {
            debug!(target = %target.display(), "Removing existing go");
            fs_err::tokio::remove_dir_all(&target).await?;
        }

        debug!(?extracted, target = %target.display(), "Moving go to target");
        // TODO: retry on Windows
        fs_err::tokio::rename(&extracted, &target).await?;

        Ok(GoResult::from_dir(&target, version.clone()))
    }

    async fn fetch_checksum(version: &str, filename: &str) -> Result<Option<Sha256Digest>> {
        let url = "https://go.dev/dl/?mode=json&include=all";
        let response = REQWEST_CLIENT
            .get(url)
            .send()
            .await
            .context("Failed to fetch Go release metadata")?;
        if response.status() == reqwest::StatusCode::NOT_FOUND {
            return Ok(None);
        }

        let releases: Vec<GoRelease> = response
            .error_for_status()
            .context("Failed to fetch Go release metadata")?
            .json()
            .await
            .context("Failed to parse Go release metadata")?;
        digest_from_go_releases(&releases, version, filename)
    }

    async fn find_system_go(&self, go_request: &GoRequest) -> Result<Option<GoResult>> {
        let go_paths = match which::which_all(&*GO_BINARY_NAME) {
            Ok(paths) => paths,
            Err(e) => {
                debug!("No go executables found in PATH: {}", e);
                return Ok(None);
            }
        };

        for go_path in go_paths {
            match GoResult::from_system_executable(go_path).await {
                Ok(go) => {
                    // Check if this version matches the request
                    if go_request.matches(&go.version) {
                        trace!(
                            %go,
                            "Found matching system go"
                        );
                        return Ok(Some(go));
                    }
                    trace!(
                        %go,
                        "System go does not match requested version"
                    );
                }
                Err(e) => {
                    warn!(?e, "Failed to get version for system go");
                }
            }
        }

        debug!(?go_request, "No system go matches the requested version");
        Ok(None)
    }
}

fn digest_from_go_releases(
    releases: &[GoRelease],
    version: &str,
    filename: &str,
) -> Result<Option<Sha256Digest>> {
    let release_name = format!("go{version}");
    let Some(file) = releases
        .iter()
        .find(|release| release.version == release_name)
        .and_then(|release| release.files.iter().find(|file| file.filename == filename))
    else {
        return Ok(None);
    };
    file.sha256.parse().map(Some)
}

#[cfg(test)]
mod tests {
    use super::*;

    const EMPTY_SHA256: &str = "e3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b855";

    #[test]
    fn finds_go_checksum_for_release_file() -> Result<()> {
        let releases = vec![
            go_release(
                "go1.23.0",
                vec![go_file(
                    "go1.23.0.linux-amd64.tar.gz",
                    "0000000000000000000000000000000000000000000000000000000000000000",
                )],
            ),
            go_release(
                "go1.24.1",
                vec![
                    go_file("go1.24.1.darwin-arm64.tar.gz", EMPTY_SHA256),
                    go_file(
                        "go1.24.1.linux-amd64.tar.gz",
                        "ffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffff",
                    ),
                ],
            ),
        ];

        let digest = digest_from_go_releases(&releases, "1.24.1", "go1.24.1.darwin-arm64.tar.gz")?
            .expect("expected checksum");

        assert_eq!(digest.to_string(), EMPTY_SHA256);
        Ok(())
    }

    #[test]
    fn returns_none_when_go_release_file_is_missing() -> Result<()> {
        let releases = vec![go_release(
            "go1.24.1",
            vec![go_file("go1.24.1.linux-amd64.tar.gz", EMPTY_SHA256)],
        )];

        let digest = digest_from_go_releases(&releases, "1.24.1", "go1.24.1.darwin-arm64.tar.gz")?;

        assert!(digest.is_none());
        Ok(())
    }

    #[test]
    fn returns_none_when_go_release_is_missing() -> Result<()> {
        let releases = vec![go_release(
            "go1.23.0",
            vec![go_file("go1.23.0.linux-amd64.tar.gz", EMPTY_SHA256)],
        )];

        let digest = digest_from_go_releases(&releases, "1.24.1", "go1.24.1.linux-amd64.tar.gz")?;

        assert!(digest.is_none());
        Ok(())
    }

    #[tokio::test]
    #[cfg(unix)]
    async fn from_system_executable_uses_local_gotoolchain() -> anyhow::Result<()> {
        let temp_dir = tempfile::tempdir()?;
        let fake_go = temp_dir.path().join("go");
        fs_err::write(
            &fake_go,
            indoc::indoc! {r#"#!/bin/sh
                if [ "$1" = "version" ]; then
                  if [ "${GOTOOLCHAIN:-}" = "local" ]; then
                    printf 'go version go1.24.13 linux/amd64\n'
                  else
                    printf 'go version go1.26.0 linux/amd64\n'
                  fi
                  exit 0
                fi

                printf 'unexpected args: %s\n' "$*" >&2
                exit 1
            "#},
        )?;

        crate::fs::make_executable(&fake_go)?;

        let go = GoResult::from_system_executable(fake_go).await?;

        assert_eq!(go.version().to_string(), "1.24.13");
        Ok(())
    }

    fn go_release(version: &str, files: Vec<GoFile>) -> GoRelease {
        GoRelease {
            version: version.to_string(),
            files,
        }
    }

    fn go_file(filename: &str, sha256: &str) -> GoFile {
        GoFile {
            filename: filename.to_string(),
            sha256: sha256.to_string(),
        }
    }
}
