use std::ffi::OsString;
use std::io::IsTerminal;
use std::io::Write;
use std::path::{Path, PathBuf};
use std::process;
use std::{env, fs, io};

/// Allows for text input in the configured editor.
pub struct Editor {
    path: PathBuf,
    truncate: bool,
    cleanup: bool,
}

impl Default for Editor {
    fn default() -> Self {
        Self::comment()
    }
}

impl Drop for Editor {
    fn drop(&mut self) {
        if self.cleanup {
            fs::remove_file(&self.path).ok();
        }
    }
}

impl Editor {
    /// Create a new editor.
    pub fn new(path: impl AsRef<Path>) -> io::Result<Self> {
        let path = path.as_ref();
        if path.try_exists()? {
            let meta = fs::metadata(path)?;
            if !meta.is_file() {
                return Err(io::Error::new(
                    io::ErrorKind::InvalidInput,
                    "must be used to edit a file",
                ));
            }
        }
        Ok(Self {
            path: path.to_path_buf(),
            truncate: false,
            cleanup: false,
        })
    }

    pub fn comment() -> Self {
        const COMMENT_FILE: &str = "RAD_COMMENT";

        let path = env::temp_dir().join(COMMENT_FILE);

        Self {
            path,
            truncate: true,
            cleanup: true,
        }
    }

    /// Set the file extension.
    pub fn extension(mut self, ext: &str) -> Self {
        let ext = ext.trim_start_matches('.');

        self.path.set_extension(ext);
        self
    }

    /// Truncate the file to length 0 when opening
    pub fn truncate(mut self, truncate: bool) -> Self {
        self.truncate = truncate;
        self
    }

    /// Clean up the file after the [`Editor`] is dropped.
    pub fn cleanup(mut self, cleanup: bool) -> Self {
        self.cleanup = cleanup;
        self
    }

    /// Initialize the file with the provided `content`, as long as the file
    /// does not already contain anything.
    #[allow(clippy::byte_char_slices)]
    pub fn initial(self, content: impl AsRef<[u8]>) -> io::Result<Self> {
        let content = content.as_ref();
        let mut file = fs::OpenOptions::new()
            .write(true)
            .create(true)
            .truncate(self.truncate)
            .open(&self.path)?;

        if file.metadata()?.len() == 0 {
            file.write_all(content)?;
            if !content.ends_with(&[b'\n']) {
                file.write_all(b"\n")?;
            }
            file.flush()?;
        }
        Ok(self)
    }

    /// Open the editor and return the edited text.
    ///
    /// If the text hasn't changed from the initial contents of the editor,
    /// return `None`.
    pub fn edit(&mut self) -> io::Result<Option<String>> {
        let Some(cmd) = self::default_editor() else {
            return Err(io::Error::new(
                io::ErrorKind::NotFound,
                "editor not configured: the `EDITOR` environment variable is not set",
            ));
        };

        let lossy = cmd.to_string_lossy();

        #[cfg(unix)]
        let Some(parts) = shlex::split(&lossy) else {
            return Err(io::Error::new(
                io::ErrorKind::InvalidInput,
                format!("invalid editor command {cmd:?}"),
            ));
        };

        #[cfg(windows)]
        let parts = winsplit::split(&lossy);

        let Some((program, args)) = parts.split_first() else {
            return Err(io::Error::new(
                io::ErrorKind::InvalidInput,
                format!("invalid editor command {cmd:?}"),
            ));
        };

        let stdout: process::Stdio = {
            #[cfg(unix)]
            {
                use std::os::fd::{AsRawFd as _, FromRawFd as _};

                // We duplicate the stderr file descriptor to pass it to the child process; otherwise, if
                // we simply pass the `RawFd` of our stderr, `Command` will close our stderr when the
                // child exits.

                let stderr = io::stderr().as_raw_fd();
                unsafe { process::Stdio::from_raw_fd(libc::dup(stderr)) }
            }
            #[cfg(not(unix))]
            {
                // No duplication of the file handle for stderr on Windows.
                // This might not always work, but is better than not being able to build for
                // Windows.
                io::stderr().into()
            }
        };

        let stdin = if io::stdin().is_terminal() {
            process::Stdio::inherit()
        } else if cfg!(unix) {
            // If standard input is not a terminal device, the editor won't work correctly.
            // In that case, we use the terminal device, eg. `/dev/tty` as standard input.
            let tty = fs::OpenOptions::new()
                .read(true)
                .write(true)
                .open("/dev/tty")?;
            process::Stdio::from(tty)
        } else if cfg!(windows) {
            let tty = fs::OpenOptions::new().read(true).open("CONIN$")?;
            process::Stdio::from(tty)
        } else {
            return Err(io::Error::new(
                io::ErrorKind::Unsupported,
                format!("standard input is not a terminal, refusing to execute editor {cmd:?}"),
            ));
        };

        process::Command::new(program)
            .stdout(stdout)
            .stderr(process::Stdio::inherit())
            .stdin(stdin)
            .args(args)
            .arg(&self.path)
            .spawn()
            .map_err(|e| {
                io::Error::new(
                    e.kind(),
                    format!("failed to spawn editor command {cmd:?}: {e}"),
                )
            })?
            .wait()
            .map_err(|e| {
                io::Error::new(
                    e.kind(),
                    format!("editor command {cmd:?} didn't spawn: {e}"),
                )
            })?;

        let text = fs::read_to_string(&self.path)?;
        if text.trim().is_empty() {
            return Ok(None);
        }
        Ok(Some(text))
    }
}

/// Get the default editor command.
fn default_editor() -> Option<OsString> {
    // First check the standard environment variables.
    if let Ok(visual) = env::var("VISUAL")
        && !visual.is_empty()
    {
        return Some(visual.into());
    }
    if let Ok(editor) = env::var("EDITOR")
        && !editor.is_empty()
    {
        return Some(editor.into());
    }

    // Check Git. The user might have configured their editor there.
    // On Windows, custom editors configured via Git are not supported,
    // because of the complexity surrounding how the editor command is
    // parsed and executed. See also <https://stackoverflow.com/a/773973/1835188>.
    #[cfg(all(feature = "git2", not(windows)))]
    if let Ok(path) = git2::Config::open_default().and_then(|cfg| cfg.get_path("core.editor")) {
        return Some(path.into_os_string());
    }

    // On macOS, `nano` is installed by default and it's what most users are used to
    // in the terminal.
    #[cfg(target_os = "macos")]
    if exists("nano") {
        return Some("nano".into());
    }

    // On Windows, `edit` is available by default, see <https://learn.microsoft.com/windows/edit>.
    #[cfg(windows)]
    if exists("edit.exe") {
        return Some("edit.exe".into());
    }

    // On Windows, `notepad` is commonly available for decades, see <https://apps.microsoft.com/detail/9msmlrh6lzf3>.
    #[cfg(windows)]
    if exists("notepad.exe") {
        return Some("notepad.exe".into());
    }

    // If all else fails, we try `vi`. It's usually installed on most unix-based systems.
    if exists("vi") {
        return Some("vi".into());
    }

    None
}

/// Check whether a binary can be found in the most common paths on Unix-like systems.
/// We don't bother checking the `$PATH` variable, as we're only looking for very standard tools
/// and prefer not to make this too complex.
#[cfg(unix)]
fn exists(cmd: &str) -> bool {
    // Some common paths where system-installed binaries are found.
    const PATHS: &[&str] = &["/usr/local/bin", "/usr/bin", "/bin"];

    for dir in PATHS {
        if Path::new(dir).join(cmd).exists() {
            return true;
        }
    }
    false
}

/// Check whether a binary can be found on `$PATH`.
/// See:
///  - <https://devblogs.microsoft.com/scripting/weekend-scripter-where-exethe-what-why-and-how/>
///  - <https://learn.microsoft.com/windows-server/administration/windows-commands/where>
#[cfg(windows)]
fn exists(cmd: &str) -> bool {
    std::process::Command::new("where.exe")
        .arg("/q")
        .arg("$PATH:".to_owned() + cmd)
        .output()
        .map(|output| output.status.success())
        .unwrap_or_default()
}
