#![allow(
    clippy::disallowed_types,
    reason = "this module is the documented CLI-only configured-editor process boundary"
)]

use std::{
    error::Error,
    ffi::OsString,
    fmt, fs,
    io::{self, Read as _, Seek as _, SeekFrom, Write as _},
    path::{Path, PathBuf},
    process::Command,
};

use ironstorage::{config::ResolvedEditor, repository::SecretBytes};

const MAX_EDIT_BYTES: u64 = 16 * 1024 * 1024;

#[derive(Clone, Debug, Eq, PartialEq)]
pub(crate) struct EditorInvocation {
    program: String,
    arguments: Vec<OsString>,
    plaintext_path: PathBuf,
}

impl EditorInvocation {
    pub(crate) fn program(&self) -> &str {
        &self.program
    }

    pub(crate) fn arguments(&self) -> &[OsString] {
        &self.arguments
    }

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

#[derive(Clone, Copy, Debug, Eq, PartialEq)]
pub(crate) enum EditorStatus {
    Saved,
    Cancelled,
    Failed(i32),
}

/// Host boundary for the one CLI-only editor exception. Storage and other applications never
/// receive an executable and tests can exercise the complete session without spawning a process.
pub(crate) trait EditorHost {
    fn edit(&mut self, invocation: &EditorInvocation) -> Result<EditorStatus, EditorHostError>;
}

pub(crate) struct NativeEditorHost;

impl EditorHost for NativeEditorHost {
    fn edit(&mut self, invocation: &EditorInvocation) -> Result<EditorStatus, EditorHostError> {
        let status = Command::new(invocation.program())
            .args(invocation.arguments())
            .status()
            .map_err(|_| EditorHostError(invocation.program().to_owned()))?;
        if status.success() {
            Ok(EditorStatus::Saved)
        } else {
            Ok(EditorStatus::Failed(status.code().unwrap_or(1)))
        }
    }
}

#[derive(Clone, Debug, Eq, PartialEq)]
pub(crate) struct EditorHostError(String);

impl fmt::Display for EditorHostError {
    fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result {
        write!(
            formatter,
            "editor executable could not be launched: {}",
            self.0
        )
    }
}

impl Error for EditorHostError {}

pub(crate) fn edit_replacement(
    plaintext: &SecretBytes,
    editor: &ResolvedEditor,
    host: &mut impl EditorHost,
) -> Result<SecretBytes, CliEditorError> {
    let temporary = create_secure_directory()?;
    let path = temporary.path().join("entry.txt");
    let mut file = create_private_file(&path)?;
    let prepared = file
        .write_all(plaintext.expose())
        .and_then(|()| file.sync_all());
    drop(file);
    if prepared.is_err() {
        let _ = wipe_and_remove(&path);
        return Err(CliEditorError::TemporaryFile);
    }

    let command = editor.command();
    let mut arguments = command
        .arguments()
        .iter()
        .map(OsString::from)
        .collect::<Vec<_>>();
    arguments.push(path.clone().into_os_string());
    let invocation = EditorInvocation {
        program: command.program().to_owned(),
        arguments,
        plaintext_path: path.clone(),
    };
    let result = match host.edit(&invocation) {
        Ok(EditorStatus::Saved) => {
            restrict_file_permissions(&path).and_then(|()| read_replacement(&path))
        }
        Ok(EditorStatus::Cancelled) => Err(CliEditorError::Cancelled),
        Ok(EditorStatus::Failed(code)) => Err(CliEditorError::Failed(code)),
        Err(error) => Err(CliEditorError::Host(error)),
    };
    wipe_and_remove(&path)?;
    result
}

fn create_secure_directory() -> Result<tempfile::TempDir, CliEditorError> {
    #[cfg(target_os = "linux")]
    if Path::new("/dev/shm").is_dir()
        && let Ok(directory) = tempfile::Builder::new()
            .prefix("ironstorage-edit-")
            .tempdir_in("/dev/shm")
    {
        return Ok(directory);
    }
    tempfile::Builder::new()
        .prefix("ironstorage-edit-")
        .tempdir()
        .map_err(|_| CliEditorError::TemporaryDirectory)
}

#[cfg(unix)]
fn create_private_file(path: &Path) -> Result<fs::File, CliEditorError> {
    use std::os::unix::fs::OpenOptionsExt as _;

    fs::OpenOptions::new()
        .create_new(true)
        .read(true)
        .write(true)
        .mode(0o600)
        .open(path)
        .map_err(|_| CliEditorError::TemporaryFile)
}

#[cfg(not(unix))]
fn create_private_file(path: &Path) -> Result<fs::File, CliEditorError> {
    fs::OpenOptions::new()
        .create_new(true)
        .read(true)
        .write(true)
        .open(path)
        .map_err(|_| CliEditorError::TemporaryFile)
}

fn read_replacement(path: &Path) -> Result<SecretBytes, CliEditorError> {
    let metadata = fs::metadata(path).map_err(|_| CliEditorError::TemporaryFile)?;
    if !metadata.is_file() || metadata.len() > MAX_EDIT_BYTES {
        return Err(CliEditorError::ReplacementTooLarge);
    }
    let mut file = fs::File::open(path).map_err(|_| CliEditorError::TemporaryFile)?;
    let mut bytes = Vec::with_capacity(metadata.len() as usize);
    file.read_to_end(&mut bytes)
        .map_err(|_| CliEditorError::TemporaryFile)?;
    Ok(SecretBytes::new(bytes))
}

#[cfg(unix)]
fn restrict_file_permissions(path: &Path) -> Result<(), CliEditorError> {
    use std::os::unix::fs::PermissionsExt as _;

    fs::set_permissions(path, fs::Permissions::from_mode(0o600))
        .map_err(|_| CliEditorError::TemporaryFile)
}

#[cfg(not(unix))]
fn restrict_file_permissions(_path: &Path) -> Result<(), CliEditorError> {
    Ok(())
}

fn wipe_and_remove(path: &Path) -> Result<(), CliEditorError> {
    let mut file = match fs::OpenOptions::new().read(true).write(true).open(path) {
        Ok(file) => file,
        Err(error) if error.kind() == io::ErrorKind::NotFound => return Ok(()),
        Err(_) => return Err(CliEditorError::Cleanup),
    };
    let length = file.metadata().map_err(|_| CliEditorError::Cleanup)?.len();
    file.seek(SeekFrom::Start(0))
        .map_err(|_| CliEditorError::Cleanup)?;
    let zeros = [0_u8; 8192];
    let mut remaining = length;
    while remaining > 0 {
        let count = remaining.min(zeros.len() as u64) as usize;
        file.write_all(&zeros[..count])
            .map_err(|_| CliEditorError::Cleanup)?;
        remaining -= count as u64;
    }
    file.set_len(0).map_err(|_| CliEditorError::Cleanup)?;
    file.sync_all().map_err(|_| CliEditorError::Cleanup)?;
    drop(file);
    fs::remove_file(path).map_err(|_| CliEditorError::Cleanup)
}

#[derive(Debug)]
pub(crate) enum CliEditorError {
    TemporaryDirectory,
    TemporaryFile,
    ReplacementTooLarge,
    Host(EditorHostError),
    Cancelled,
    Failed(i32),
    Cleanup,
}

impl fmt::Display for CliEditorError {
    fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result {
        match self {
            Self::TemporaryDirectory => formatter.write_str("cannot create secure edit directory"),
            Self::TemporaryFile => formatter.write_str("cannot use secure edit file"),
            Self::ReplacementTooLarge => formatter.write_str("edited entry is too large"),
            Self::Host(error) => error.fmt(formatter),
            Self::Cancelled => formatter.write_str("editor cancelled"),
            Self::Failed(code) => write!(formatter, "editor exited unsuccessfully ({code})"),
            Self::Cleanup => formatter.write_str("cannot securely clean up edit file"),
        }
    }
}

impl Error for CliEditorError {}

#[cfg(test)]
mod tests {
    use super::*;
    use ironstorage::{config::ConfigLoader, repository::SecretBytes};

    #[derive(Default)]
    struct RecordingHost {
        replacement: Vec<u8>,
        status: Option<EditorStatus>,
        invocation: Option<EditorInvocation>,
        mode: Option<u32>,
    }

    impl EditorHost for RecordingHost {
        fn edit(&mut self, invocation: &EditorInvocation) -> Result<EditorStatus, EditorHostError> {
            self.invocation = Some(invocation.clone());
            #[cfg(unix)]
            {
                use std::os::unix::fs::PermissionsExt as _;
                self.mode = Some(
                    fs::metadata(invocation.plaintext_path())
                        .unwrap()
                        .permissions()
                        .mode()
                        & 0o777,
                );
            }
            if self.status.unwrap_or(EditorStatus::Saved) == EditorStatus::Saved {
                fs::write(invocation.plaintext_path(), &self.replacement).unwrap();
            }
            Ok(self.status.unwrap_or(EditorStatus::Saved))
        }
    }

    #[test]
    fn secure_editor_file_uses_resolved_arguments_permissions_and_cleanup()
    -> Result<(), Box<dyn Error>> {
        let temporary = tempfile::tempdir()?;
        fs::create_dir(temporary.path().join("vault"))?;
        fs::create_dir(temporary.path().join("keys"))?;
        let config_path = temporary.path().join("config.toml");
        fs::write(
            &config_path,
            "vault='vault'\ndefault_key='alice'\nkey_material='keys'\neditor=['fixture-editor','--wait','--clean']\n",
        )?;
        let config = ConfigLoader::new(temporary.path().to_owned(), temporary.path().to_owned())
            .load(Some(&config_path))?;
        let editor = config.resolve_editor_from(None, None)?;

        let plaintext = SecretBytes::new(b"original\n".to_vec());
        let mut host = RecordingHost {
            replacement: b"replacement\n".to_vec(),
            ..RecordingHost::default()
        };
        let replacement = edit_replacement(&plaintext, &editor, &mut host)?;
        assert_eq!(replacement.expose(), b"replacement\n");
        let invocation = host.invocation.expect("invocation");
        assert_eq!(invocation.program(), "fixture-editor");
        assert_eq!(invocation.arguments()[0].to_str(), Some("--wait"));
        assert_eq!(invocation.arguments()[1].to_str(), Some("--clean"));
        assert_eq!(
            invocation.arguments().last().unwrap(),
            invocation.plaintext_path().as_os_str()
        );
        #[cfg(unix)]
        assert_eq!(host.mode, Some(0o600));
        assert!(!invocation.plaintext_path().exists());
        Ok(())
    }

    #[test]
    fn failure_and_cancellation_clean_up_without_returning_plaintext() -> Result<(), Box<dyn Error>>
    {
        let temporary = tempfile::tempdir()?;
        fs::create_dir(temporary.path().join("vault"))?;
        fs::create_dir(temporary.path().join("keys"))?;
        let config_path = temporary.path().join("config.toml");
        fs::write(
            &config_path,
            "vault='vault'\ndefault_key='alice'\nkey_material='keys'\n",
        )?;
        let config = ConfigLoader::new(temporary.path().to_owned(), temporary.path().to_owned())
            .load(Some(&config_path))?;
        let editor =
            config.resolve_editor_from(Some(std::ffi::OsStr::new("visual --flag")), None)?;
        let plaintext = SecretBytes::new(b"secret".to_vec());
        for status in [EditorStatus::Failed(42), EditorStatus::Cancelled] {
            let mut host = RecordingHost {
                status: Some(status),
                ..RecordingHost::default()
            };
            assert!(edit_replacement(&plaintext, &editor, &mut host).is_err());
            assert!(!host.invocation.unwrap().plaintext_path().exists());
        }
        Ok(())
    }

    #[test]
    fn missing_editor_executable_has_a_clear_error() {
        let invocation = EditorInvocation {
            program: "ironstorage-editor-does-not-exist".to_owned(),
            arguments: Vec::new(),
            plaintext_path: PathBuf::from("unused"),
        };
        let error = NativeEditorHost
            .edit(&invocation)
            .expect_err("the intentionally absent editor must fail");
        assert_eq!(
            error.to_string(),
            "editor executable could not be launched: ironstorage-editor-does-not-exist"
        );
    }
}
