use std::ffi::OsStr;
use std::path::Path;
use std::process::Command;

use serde::Serialize;
use sysinfo::{Process, System, get_current_pid};
use thiserror::Error;

#[derive(PartialEq, Eq, derive_more::Display)]
pub enum Shell {
    #[display("sh")]
    Sh,
    #[display("bash")]
    Bash,
    #[display("fish")]
    Fish,
    #[display("zsh")]
    Zsh,
    #[display("xonsh")]
    Xonsh,
    #[display("nu")]
    Nu,
    #[display("powershell")]
    Powershell,
    #[display("unknown")]
    Unknown,
}

#[derive(Debug, Error, Serialize)]
pub enum ShellError {
    #[error("shell not supported")]
    NotSupported,

    #[error("failed to execute shell command: {0}")]
    ExecError(String),
}

impl Shell {
    #[must_use]
    pub fn current() -> Self {
        let sys = System::new_all();

        let process = sys
            .process(get_current_pid().expect("Failed to get current PID"))
            .expect("Process with current pid does not exist");

        let parent = sys
            .process(process.parent().expect("Atuin running with no parent!"))
            .expect("Process with parent pid does not exist");

        let shell = parent.name().trim().to_lowercase();
        let shell = shell.strip_prefix('-').unwrap_or(&shell);

        Self::from_string(shell)
    }

    #[must_use]
    pub fn from_env() -> Self {
        std::env::var("ATUIN_SHELL")
            .map_or(Self::Unknown, |shell| Self::from_string(&shell.trim().to_lowercase()))
    }

    #[must_use]
    pub fn config_file(&self) -> Option<std::path::PathBuf> {
        let mut path = directories::BaseDirs::new()?.home_dir().to_owned();

        // TODO: handle all shells
        match self {
            Self::Bash => path.push(".bashrc"),
            Self::Zsh => path.push(".zshrc"),
            Self::Fish => path.push(".config/fish/config.fish"),

            _ => return None,
        };

        Some(path)
    }

    /// Best-effort attempt to determine the default shell
    /// This implementation will be different across different platforms
    /// Caller should ensure to handle Shell::Unknown correctly
    pub fn default_shell() -> Result<Self, ShellError> {
        let sys = System::name().unwrap_or("".to_string()).to_lowercase();

        // TODO: Support Linux
        // I'm pretty sure we can use /etc/passwd there, though there will probably be some issues
        let path = if sys.contains("darwin") {
            // This works in my testing so far
            Self::Sh.run_interactive([
                "dscl localhost -read \"/Local/Default/Users/$USER\" shell | awk '{print $2}'"
            ])?
        } else if cfg!(windows) {
            return Ok(Self::Powershell);
        } else {
            Self::Sh.run_interactive(["getent passwd $LOGNAME | cut -d: -f7"])?
        };

        let path = Path::new(path.trim());
        let shell = path.file_name();

        if shell.is_none() {
            return Err(ShellError::NotSupported);
        }

        Ok(Self::from_string(&shell.unwrap().to_string_lossy()))
    }

    #[must_use]
    pub fn from_string(name: &str) -> Self {
        match name {
            "bash" => Self::Bash,
            "fish" => Self::Fish,
            "zsh" => Self::Zsh,
            "xonsh" => Self::Xonsh,
            "nu" => Self::Nu,
            "sh" => Self::Sh,
            "powershell" => Self::Powershell,

            _ => Self::Unknown,
        }
    }

    /// Returns true if the shell is posix-like
    /// Note that while fish is not posix compliant, it behaves well enough for our current
    /// featureset that this does not matter.
    #[must_use]
    pub fn is_posixish(&self) -> bool {
        matches!(self, Self::Bash | Self::Fish | Self::Zsh)
    }

    pub fn run_interactive<I, S>(&self, args: I) -> Result<String, ShellError>
    where
        I: IntoIterator<Item = S>,
        S: AsRef<OsStr>,
    {
        let shell = self.to_string();
        let output = if self == &Self::Powershell {
            Command::new(shell)
                .args(args)
                .output()
                .map_err(|e| ShellError::ExecError(e.to_string()))?
        } else {
            Command::new(shell)
                .arg("-ic")
                .args(args)
                .output()
                .map_err(|e| ShellError::ExecError(e.to_string()))?
        };

        Ok(String::from_utf8(output.stdout).unwrap())
    }
}

#[must_use]
pub fn shell_name(parent: Option<&Process>) -> String {
    let sys = System::new_all();

    let parent = if let Some(parent) = parent {
        parent
    } else {
        let process = sys
            .process(get_current_pid().expect("Failed to get current PID"))
            .expect("Process with current pid does not exist");

        sys.process(process.parent().expect("Atuin running with no parent!"))
            .expect("Process with parent pid does not exist")
    };

    let shell = parent.name().trim().to_lowercase();
    let shell = shell.strip_prefix('-').unwrap_or(&shell);

    shell.to_string()
}
