//! Paths and Unix shells
//!
//! MacOS, Linux, FreeBSD, and many other OS model their design on Unix,
//! so handling them is relatively consistent. But only relatively.
//! POSIX postdates Unix by 20 years, and each "Unix-like" shell develops
//! unique quirks over time.
//!
//!
//! Windowing Managers, Desktop Environments, GUI Terminals, and PATHs
//!
//! Duplicating paths in PATH can cause performance issues when the OS searches
//! the same place multiple times. Traditionally, Unix configurations have
//! resolved this by setting up PATHs in the shell's login profile.
//!
//! This has its own issues. Login profiles are only intended to run once, but
//! changing the PATH is common enough that people may run it twice. Desktop
//! environments often choose to NOT start login shells in GUI terminals. Thus,
//! a trend has emerged to place PATH updates in other run-commands (rc) files,
//! leaving Rustup with few assumptions to build on for fulfilling its promise
//! to set up PATH appropriately.
//!
//! Rustup addresses this by:
//! 1) using a shell script that updates PATH if the path is not in PATH
//! 2) sourcing this script (`. /path/to/script`) in any appropriate rc file

use std::borrow::Cow;
use std::path::PathBuf;

use anyhow::{Result, bail};

use super::utils;
use crate::process::Process;

pub(crate) type Shell = Box<dyn UnixShell>;

#[derive(Debug, PartialEq)]
pub(crate) struct ShellScript {
    content: &'static str,
    name: &'static str,
}

// TODO: Update into a bytestring.
fn cargo_home_str_with_home(home: &str, process: &Process) -> Result<Cow<'static, str>> {
    let path = process.cargo_home()?;

    let default_cargo_home = process
        .home_dir()
        .unwrap_or_else(|| PathBuf::from("."))
        .join(".cargo");
    Ok(if default_cargo_home == path {
        Cow::Owned(format!("{home}/.cargo"))
    } else {
        match path.to_str() {
            Some(p) => p.to_owned().into(),
            None => bail!("Non-Unicode path!"),
        }
    })
}

// TODO: Tcsh (BSD)
// TODO?: Make a decision on Ion Shell
// Cross-platform non-POSIX shells have not been assessed for integration yet
fn enumerate_shells() -> Vec<Shell> {
    vec![
        Box::new(Posix),
        Box::new(Bash),
        Box::new(Zsh),
        Box::new(Fish),
        Box::new(Nu),
        Box::new(Tcsh),
        Box::new(Pwsh),
        Box::new(Xonsh),
    ]
}

pub(crate) fn get_available_shells(process: &Process) -> impl Iterator<Item = Shell> + '_ {
    enumerate_shells()
        .into_iter()
        .filter(|sh| sh.does_exist(process))
}

pub(crate) trait UnixShell {
    // Detects if a shell "exists". Users have multiple shells, so an "eager"
    // heuristic should be used, assuming shells exist if any traces do.
    fn does_exist(&self, process: &Process) -> bool;

    // Gives all rcfiles of a given shell that Rustup is concerned with.
    // Used primarily in checking rcfiles for cleanup.
    fn rcfiles(&self, process: &Process) -> Vec<PathBuf>;

    // Gives rcs that should be written to.
    fn update_rcs(&self, process: &Process) -> Vec<PathBuf>;

    // Writes the relevant env file.
    fn env_script(&self) -> ShellScript {
        ShellScript {
            name: "env",
            content: include_str!("env.sh"),
        }
    }

    fn cargo_home_str(&self, process: &Process) -> Result<Cow<'static, str>> {
        #[cfg(windows)]
        let home = "%USERPROFILE%";
        #[cfg(not(windows))]
        let home = "$HOME";
        cargo_home_str_with_home(home, process)
    }

    fn source_string(&self, process: &Process) -> Result<String> {
        Ok(format!(r#". "{}/env""#, self.cargo_home_str(process)?))
    }

    fn write_script(&self, script: &ShellScript, process: &Process) -> Result<()> {
        let home = process.cargo_home()?;
        let cargo_bin = format!("{}/bin", self.cargo_home_str(process)?);
        let env_name = home.join(script.name);
        let env_file = script.content.replace("{cargo_bin}", &cargo_bin);
        utils::write_file(script.name, &env_name, &env_file)?;
        Ok(())
    }
}

pub(super) struct Posix;

impl UnixShell for Posix {
    fn does_exist(&self, _: &Process) -> bool {
        true
    }

    fn rcfiles(&self, process: &Process) -> Vec<PathBuf> {
        match process.home_dir() {
            Some(dir) => vec![dir.join(".profile")],
            _ => vec![],
        }
    }

    fn update_rcs(&self, process: &Process) -> Vec<PathBuf> {
        // Write to .profile even if it doesn't exist. It's the only rc in the
        // POSIX spec so it should always be set up.
        self.rcfiles(process)
    }
}

struct Bash;

impl UnixShell for Bash {
    fn does_exist(&self, process: &Process) -> bool {
        !self.update_rcs(process).is_empty()
    }

    fn rcfiles(&self, process: &Process) -> Vec<PathBuf> {
        // Bash also may read .profile, however Rustup already includes handling
        // .profile as part of POSIX and always does setup for POSIX shells.
        [".bash_profile", ".bash_login", ".bashrc"]
            .iter()
            .filter_map(|rc| process.home_dir().map(|dir| dir.join(rc)))
            .collect()
    }

    fn update_rcs(&self, process: &Process) -> Vec<PathBuf> {
        self.rcfiles(process)
            .into_iter()
            .filter(|rc| rc.is_file())
            .collect()
    }
}

struct Zsh;

impl Zsh {
    fn zdotdir(process: &Process) -> Result<PathBuf> {
        use std::ffi::OsStr;
        use std::os::unix::ffi::OsStrExt;

        if matches!(process.var("SHELL"), Ok(sh) if sh.contains("zsh")) {
            match process.var("ZDOTDIR") {
                Ok(dir) if !dir.is_empty() => Ok(PathBuf::from(dir)),
                _ => bail!("Zsh setup failed."),
            }
        } else {
            match std::process::Command::new("zsh")
                .args(["-c", "echo -n $ZDOTDIR"])
                .output()
            {
                Ok(io) if !io.stdout.is_empty() => Ok(PathBuf::from(OsStr::from_bytes(&io.stdout))),
                _ => bail!("Zsh setup failed."),
            }
        }
    }
}

impl UnixShell for Zsh {
    fn does_exist(&self, process: &Process) -> bool {
        // zsh has to either be the shell or be callable for zsh setup.
        matches!(process.var("SHELL"), Ok(sh) if sh.contains("zsh"))
            || utils::find_cmd(&["zsh"], process).is_some()
    }

    fn rcfiles(&self, process: &Process) -> Vec<PathBuf> {
        [Zsh::zdotdir(process).ok(), process.home_dir()]
            .iter()
            .filter_map(|dir| dir.as_ref().map(|p| p.join(".zshenv")))
            .collect()
    }

    fn update_rcs(&self, process: &Process) -> Vec<PathBuf> {
        // zsh can change $ZDOTDIR both _before_ AND _during_ reading .zshenv,
        // so we: write to $ZDOTDIR/.zshenv if-exists ($ZDOTDIR changes before)
        // OR write to $HOME/.zshenv if it exists (change-during)
        // if neither exist, we create it ourselves, but using the same logic,
        // because we must still respond to whether $ZDOTDIR is set or unset.
        // In any case we only write once.
        self.rcfiles(process)
            .into_iter()
            .filter(|env| env.is_file())
            .chain(self.rcfiles(process))
            .take(1)
            .collect()
    }
}

struct Fish;

impl UnixShell for Fish {
    fn does_exist(&self, process: &Process) -> bool {
        // fish has to either be the shell or be callable for fish setup.
        matches!(process.var("SHELL"), Ok(sh) if sh.contains("fish"))
            || utils::find_cmd(&["fish"], process).is_some()
    }

    // > "$XDG_CONFIG_HOME/fish/conf.d" (or "~/.config/fish/conf.d" if that variable is unset) for the user
    // from <https://github.com/fish-shell/fish-shell/issues/3170#issuecomment-228311857>
    fn rcfiles(&self, process: &Process) -> Vec<PathBuf> {
        let p0 = process.var("XDG_CONFIG_HOME").ok().map(|p| {
            let mut path = PathBuf::from(p);
            path.push("fish/conf.d/rustup.fish");
            path
        });

        let p1 = process.home_dir().map(|mut path| {
            path.push(".config/fish/conf.d/rustup.fish");
            path
        });

        p0.into_iter().chain(p1).collect()
    }

    fn update_rcs(&self, process: &Process) -> Vec<PathBuf> {
        // The first rcfile takes precedence.
        match self.rcfiles(process).into_iter().next() {
            Some(path) => vec![path],
            None => vec![],
        }
    }

    fn env_script(&self) -> ShellScript {
        ShellScript {
            name: "env.fish",
            content: include_str!("env.fish"),
        }
    }

    fn source_string(&self, process: &Process) -> Result<String> {
        Ok(format!(
            r#"source "{}/env.fish""#,
            self.cargo_home_str(process)?
        ))
    }
}

pub(super) struct Nu;

impl UnixShell for Nu {
    fn does_exist(&self, process: &Process) -> bool {
        // nu has to either be the shell or be callable for nu setup.
        matches!(process.var("SHELL"), Ok(sh) if sh.contains("nu"))
            || utils::find_cmd(&["nu"], process).is_some()
    }

    fn rcfiles(&self, process: &Process) -> Vec<PathBuf> {
        let mut paths = vec![];

        if let Ok(p) = process.var("XDG_CONFIG_HOME") {
            let mut p = PathBuf::from(p);
            p.extend(["nushell", "config.nu"]);
            paths.push(p)
        }

        if let Some(mut p) = process.home_dir() {
            p.extend([".config", "nushell", "config.nu"]);
            paths.push(p)
        }
        paths
    }

    fn update_rcs(&self, process: &Process) -> Vec<PathBuf> {
        // The first rcfile in XDG_CONFIG_HOME takes precedence.
        match self.rcfiles(process).into_iter().next() {
            Some(path) => vec![path],
            None => vec![],
        }
    }

    fn env_script(&self) -> ShellScript {
        ShellScript {
            name: "env.nu",
            content: include_str!("env.nu"),
        }
    }

    fn source_string(&self, process: &Process) -> Result<String> {
        Ok(format!(
            r#"source "{}/env.nu""#,
            self.cargo_home_str(process)?
        ))
    }

    fn cargo_home_str(&self, process: &Process) -> Result<Cow<'static, str>> {
        cargo_home_str_with_home("~", process)
    }
}

struct Tcsh;

impl UnixShell for Tcsh {
    fn does_exist(&self, process: &Process) -> bool {
        matches!(process.var("SHELL"), Ok(sh) if sh.contains("tcsh"))
            || utils::find_cmd(&["tcsh"], process).is_some()
    }

    fn rcfiles(&self, process: &Process) -> Vec<PathBuf> {
        let mut paths = vec![];

        if let Some(home) = process.home_dir() {
            paths.push(home.join(".tcshrc"));
            paths.push(home.join(".cshrc"));
        }

        paths
    }

    fn update_rcs(&self, process: &Process) -> Vec<PathBuf> {
        for f in self.rcfiles(process) {
            if f.is_file() {
                return vec![f];
            }
        }

        // If neither exists, default to ~/.tcshrc
        if let Some(home) = process.home_dir() {
            return vec![home.join(".tcshrc")];
        }

        vec![]
    }

    fn env_script(&self) -> ShellScript {
        ShellScript {
            name: "env.tcsh",
            content: include_str!("env.tcsh"),
        }
    }

    fn source_string(&self, process: &Process) -> Result<String> {
        Ok(format!(
            r#"source "{}/env.tcsh""#,
            self.cargo_home_str(process)?
        ))
    }
}

struct Pwsh;

impl UnixShell for Pwsh {
    fn does_exist(&self, process: &Process) -> bool {
        matches!(process.var("SHELL"), Ok(sh) if sh.contains("pwsh"))
            || utils::find_cmd(&["pwsh"], process).is_some()
    }

    fn rcfiles(&self, process: &Process) -> Vec<PathBuf> {
        let mut paths = vec![];

        let Some(mut config_dir) = process.home_dir() else {
            return paths;
        };
        config_dir.extend([".config", "powershell"]);

        // PowerShell provides many kinds of user-specific and host-specific
        // profile files. When the system has multiple profiles, PowerShell
        // executes them in a defined order.
        //
        // For more details, please refer to:
        // https://learn.microsoft.com/en-us/powershell/module/microsoft.powershell.core/about/about_profiles
        //
        // `~/.config/powershell/profile.ps1` is the "Current User, All Hosts"
        // profile file. It affects all PowerShell hosts of the current user.
        paths.push(config_dir.join("profile.ps1"));

        let Ok(config_dir) = config_dir.read_dir() else {
            return paths;
        };

        // Some editors like Visual Studio Code or PowerShell ISE use their
        // own dedicated profile files, whose file names are
        // `<Host Profile ID>_profile.ps1`. Such customization may use
        // PowerShell Editor Services for IDE integration.
        // https://github.com/PowerShell/PowerShellEditorServices
        for host_profile in config_dir {
            let Ok(host_profile) = host_profile else {
                continue;
            };
            let host_profile_path = host_profile.path();
            if !host_profile_path.is_file() {
                continue;
            }
            if host_profile_path.ends_with("_profile.ps1") {
                paths.push(host_profile_path);
            }
        }

        paths
    }

    fn update_rcs(&self, process: &Process) -> Vec<PathBuf> {
        let mut paths = vec![];
        // Always modify the "Current User, All Hosts" profile.
        let Some(mut profile) = process.home_dir() else {
            return paths;
        };

        profile.extend([".config", "powershell", "profile.ps1"]);
        paths.push(profile);
        paths
    }

    fn env_script(&self) -> ShellScript {
        ShellScript {
            name: "env.ps1",
            content: include_str!("env.ps1"),
        }
    }

    fn source_string(&self, process: &Process) -> Result<String> {
        Ok(format!(r#". "{}/env.ps1""#, self.cargo_home_str(process)?))
    }
}

struct Xonsh;

impl UnixShell for Xonsh {
    fn does_exist(&self, process: &Process) -> bool {
        process.var("XONSHRC").is_ok() || utils::find_cmd(&["xonsh"], process).is_some()
    }

    fn rcfiles(&self, process: &Process) -> Vec<PathBuf> {
        let mut paths = vec![];

        if let Ok(p) = process.var("XDG_CONFIG_HOME") {
            let mut p = PathBuf::from(p);
            p.extend(["xonsh", "rc.xsh"]);
            paths.push(p);
        }

        if let Some(mut p) = process.home_dir() {
            p.extend([".config", "xonsh", "rc.xsh"]);
            paths.push(p);
        }

        if let Some(home) = process.home_dir() {
            paths.push(home.join(".xonshrc"));
        }

        paths
    }

    fn update_rcs(&self, process: &Process) -> Vec<PathBuf> {
        // The first rcfile in XDG_CONFIG_HOME takes precedence.
        match self.rcfiles(process).into_iter().next() {
            Some(path) => vec![path],
            None => vec![],
        }
    }

    fn env_script(&self) -> ShellScript {
        ShellScript {
            name: "env.xsh",
            content: include_str!("env.xsh"),
        }
    }

    fn source_string(&self, process: &Process) -> Result<String> {
        Ok(format!(
            r#"source "{}/env.xsh""#,
            self.cargo_home_str(process)?
        ))
    }

    fn cargo_home_str(&self, process: &Process) -> Result<Cow<'static, str>> {
        cargo_home_str_with_home("$HOME", process)
    }
}

pub(crate) fn legacy_paths(process: &Process) -> impl Iterator<Item = PathBuf> + '_ {
    let zprofiles = Zsh::zdotdir(process)
        .into_iter()
        .chain(process.home_dir())
        .map(|d| d.join(".zprofile"));
    let profiles = [".bash_profile", ".profile"]
        .iter()
        .filter_map(|rc| process.home_dir().map(|d| d.join(rc)));

    profiles.chain(zprofiles)
}
