//! ## Auto update
//!
//! Automatic update module. This module is used to upgrade the current version of termscp to the latest available on Github

use std::net::ToSocketAddrs as _;

use self_update::backends::github::Update as GithubUpdater;
pub use self_update::errors::Error as UpdateError;
use self_update::update::Release as UpdRelease;
use self_update::{Status, cargo_crate_version};

use crate::utils::parser::parse_semver;

/// The status of the update in case of success
#[derive(Debug, Eq, PartialEq)]
pub enum UpdateStatus {
    /// Termscp is already up to date
    AlreadyUptodate,
    /// The update has been correctly installed
    UpdateInstalled(String),
}

/// Info related to a github release
#[derive(Debug)]
pub struct Release {
    /// Release version string returned by GitHub.
    pub version: String,
    /// Release notes body returned by GitHub.
    pub body: String,
}

/// The update structure defines the options used to install the update.
/// Once you're fine with the options, just call the `upgrade()` method to upgrade termscp.
#[derive(Debug, Default)]
pub struct Update {
    ask_confirm: bool,
    progress: bool,
}

impl Update {
    /// Set whether to show or not the progress bar
    pub fn show_progress(mut self, opt: bool) -> Self {
        self.progress = opt;
        self
    }

    /// Set whether to ask for confirm when updating
    pub fn ask_confirm(mut self, opt: bool) -> Self {
        self.ask_confirm = opt;
        self
    }

    /// Maps a build target triple onto the target triple used to name the
    /// official release assets.
    ///
    /// Official Linux binaries are statically linked against musl, so a
    /// termscp built against glibc (for example via `cargo install`) must
    /// still download the `-musl` asset.
    fn map_release_target(target: &str) -> String {
        if target.contains("-linux-") {
            target.replace("-gnu", "-musl")
        } else {
            target.to_string()
        }
    }

    /// Installs the latest available release using the configured update options.
    pub fn upgrade(self) -> Result<UpdateStatus, UpdateError> {
        info!("Updating termscp...");
        GithubUpdater::configure()
            // Set default options
            .repo_owner("veeso")
            .repo_name("termscp")
            .bin_name("termscp")
            .target(&Self::map_release_target(self_update::get_target()))
            .current_version(cargo_crate_version!())
            .no_confirm(!self.ask_confirm)
            .show_download_progress(self.progress)
            .show_output(self.progress)
            .build()?
            .update()
            .map(UpdateStatus::from)
    }

    /// Returns whether a new version of termscp is available
    /// In case of success returns `Ok(Option<Release>)`, where the option is
    /// `Some(new_version)`;
    /// otherwise if no version is available, return None
    /// In case of error returns Error with the error description
    pub fn is_new_version_available() -> Result<Option<Release>, UpdateError> {
        // check if api.github.com is reachable before doing anything
        Self::check_github_api_reachable()?;

        info!("Checking whether a new version is available...");
        GithubUpdater::configure()
            // Set default options
            .repo_owner("veeso")
            .repo_name("termscp")
            .bin_name("termscp")
            .current_version(cargo_crate_version!())
            .no_confirm(true)
            .show_download_progress(false)
            .show_output(false)
            .build()?
            .get_latest_release()
            .map(Release::from)
            .map(Self::check_version)
    }

    /// Check if api.github.com is reachable
    /// This is useful to avoid long timeouts when the network is down
    /// or the DNS is not working
    fn check_github_api_reachable() -> Result<(), UpdateError> {
        let Some(socket_addr) = ("api.github.com", 443)
            .to_socket_addrs()
            .ok()
            .and_then(|mut i| i.next())
        else {
            error!("Could not resolve api.github.com");
            return Err(UpdateError::Network(
                "Could not resolve api.github.com".into(),
            ));
        };

        // just try to open a connection to api.github.com with a timeout of 5 seconds with tcp
        std::net::TcpStream::connect_timeout(&socket_addr, std::time::Duration::from_secs(5))
            .map(|_| ())
            .map_err(|e| UpdateError::Network(format!("Could not reach api.github.com: {e}")))
    }

    /// In case received version is newer than current one, version as Some is returned; otherwise None
    fn check_version(r: Release) -> Option<Release> {
        debug!("got version from GitHub: {}", r.version);
        match parse_semver(r.version.as_str()) {
            Some(new_version) => {
                // Check if version is different
                debug!(
                    "New version: {}; current version: {}",
                    new_version,
                    cargo_crate_version!()
                );
                if Self::is_new_version_higher(new_version.as_str(), cargo_crate_version!()) {
                    Some(r) // New version is available
                } else {
                    None // No new version
                }
            }
            None => None,
        }
    }

    /// Check wether new version is higher than new version
    fn is_new_version_higher(new_version: &str, current_version: &str) -> bool {
        match (
            semver::Version::parse(new_version),
            semver::Version::parse(current_version),
        ) {
            (Ok(new), Ok(current)) => new > current,
            _ => false,
        }
    }
}
impl From<Status> for UpdateStatus {
    fn from(s: Status) -> Self {
        match s {
            Status::UpToDate(_) => Self::AlreadyUptodate,
            Status::Updated(v) => Self::UpdateInstalled(v),
        }
    }
}

impl From<UpdRelease> for Release {
    fn from(r: UpdRelease) -> Self {
        Self {
            version: r.version,
            body: r.body.unwrap_or_default(),
        }
    }
}

#[cfg(test)]
mod test {

    use pretty_assertions::assert_eq;

    use super::*;

    #[test]
    fn auto_update_default() {
        let upd: Update = Update::default();
        assert_eq!(upd.ask_confirm, false);
        assert_eq!(upd.progress, false);
        let upd = upd.ask_confirm(true).show_progress(true);
        assert_eq!(upd.ask_confirm, true);
        assert_eq!(upd.progress, true);
    }

    #[test]
    fn should_map_linux_release_target_to_musl() {
        assert_eq!(
            Update::map_release_target("x86_64-unknown-linux-gnu"),
            "x86_64-unknown-linux-musl".to_string()
        );
        assert_eq!(
            Update::map_release_target("aarch64-unknown-linux-gnu"),
            "aarch64-unknown-linux-musl".to_string()
        );
    }

    #[test]
    fn should_leave_non_gnu_linux_release_target_unchanged() {
        assert_eq!(
            Update::map_release_target("x86_64-unknown-linux-musl"),
            "x86_64-unknown-linux-musl".to_string()
        );
    }

    #[test]
    fn should_leave_other_platform_release_targets_unchanged() {
        assert_eq!(
            Update::map_release_target("aarch64-apple-darwin"),
            "aarch64-apple-darwin".to_string()
        );
        assert_eq!(
            Update::map_release_target("x86_64-pc-windows-msvc"),
            "x86_64-pc-windows-msvc".to_string()
        );
    }

    #[test]
    #[cfg(all(
        not(all(
            any(target_os = "macos", target_os = "freebsd"),
            feature = "github-actions"
        )),
        not(feature = "isolated-tests")
    ))]
    fn auto_update() {
        // Wno version
        assert_eq!(
            Update::default()
                .show_progress(true)
                .upgrade()
                .ok()
                .unwrap(),
            UpdateStatus::AlreadyUptodate,
        );
    }

    #[test]
    #[cfg(all(
        not(all(
            any(target_os = "macos", target_os = "freebsd"),
            feature = "github-actions"
        )),
        not(feature = "isolated-tests")
    ))]
    fn check_for_updates() {
        println!("{:?}", Update::is_new_version_available());
        assert!(Update::is_new_version_available().is_ok());
    }

    #[test]
    fn update_status() {
        assert_eq!(
            UpdateStatus::from(Status::Updated(String::from("0.6.0"))),
            UpdateStatus::UpdateInstalled(String::from("0.6.0"))
        );
        assert_eq!(
            UpdateStatus::from(Status::UpToDate(String::from("0.6.0"))),
            UpdateStatus::AlreadyUptodate
        );
    }

    #[test]
    fn release() {
        let release: UpdRelease = UpdRelease {
            name: String::from("termscp 0.7.0"),
            version: String::from("0.7.0"),
            date: String::from("2021-09-12T00:00:00Z"),
            body: Some(String::from("fixed everything")),
            assets: vec![],
        };
        let release: Release = Release::from(release);
        assert_eq!(release.body.as_str(), "fixed everything");
        assert_eq!(release.version.as_str(), "0.7.0");
    }

    #[test]
    fn should_default_release_body_when_missing() {
        let release: UpdRelease = UpdRelease {
            name: String::from("termscp 0.7.0"),
            version: String::from("0.7.0"),
            date: String::from("2021-09-12T00:00:00Z"),
            body: None,
            assets: vec![],
        };
        let release: Release = Release::from(release);

        assert!(release.body.is_empty());
        assert_eq!(release.version.as_str(), "0.7.0");
    }

    #[test]
    fn should_tell_that_version_is_higher() {
        assert!(Update::is_new_version_higher("0.10.0", "0.9.0"));
        assert!(Update::is_new_version_higher("0.20.0", "0.19.1"));
        assert!(Update::is_new_version_higher("1.0.0", "0.19.1"));
        assert!(!Update::is_new_version_higher("0.9.0", "0.10.0"));
        assert!(!Update::is_new_version_higher("0.9.9", "0.10.1"));
        assert!(!Update::is_new_version_higher("0.10.9", "0.11.0"));
    }

    #[test]
    fn should_ignore_release_without_semver() {
        let release = Release {
            version: String::from("latest"),
            body: String::from("notes"),
        };

        assert!(Update::check_version(release).is_none());
    }

    #[test]
    fn should_ignore_release_when_version_is_not_newer() {
        let release = Release {
            version: cargo_crate_version!().to_string(),
            body: String::from("notes"),
        };

        assert!(Update::check_version(release).is_none());
    }

    #[test]
    fn should_accept_release_when_version_is_newer() {
        let release = Release {
            version: String::from("termscp-999.0.0"),
            body: String::from("notes"),
        };

        let release = Update::check_version(release).unwrap();

        assert_eq!(release.version.as_str(), "termscp-999.0.0");
        assert_eq!(release.body.as_str(), "notes");
    }

    #[test]
    fn test_should_check_whether_github_api_is_reachable() {
        assert!(Update::check_github_api_reachable().is_ok());
    }
}
