//! Prepare [cloud-init][] configuration.
//!
//! The configuration is provided to cloud-init via the "NoCloud"
//! route, using an ISO image file.
//!
//! [cloud-init]: https://cloud-init.io/

use crate::spec::{Specification, SpecificationError};
use crate::sshkeys::{CaKey, KeyError, KeyKind, KeyPair};
use log::debug;
use serde::{Deserialize, Serialize};
use shell_words::quote;
use std::default::Default;
use std::fs::write;
// use std::os::unix::fs::PermissionsExt;
use std::path::{Path, PathBuf};
use std::process::Command;
use tempfile::tempdir;

const SCRIPT: &str = include!(concat!(env!("OUT_DIR"), "/cloud-init.rs"));

/// Errors from this module.
#[derive(Debug, thiserror::Error)]
pub enum CloudInitError {
    /// CA key is not specified.
    #[error("Host certificate requested, but no CA key specified")]
    NoCAKey,

    /// Something went wrong creating ISO image with configuration.
    #[error("failed to create ISO image with xorriso: {0}")]
    IsoFailed(String),

    /// Error in the specification.
    #[error(transparent)]
    SpecificationError(#[from] SpecificationError),

    /// Error in generating SSH host key or certificate.
    #[error(transparent)]
    KeyError(#[from] KeyError),

    /// Something went wrong reading a file.
    #[error("could not read {0}: {1}")]
    ReadError(PathBuf, #[source] std::io::Error),

    /// Something went wrong doing I/O.
    #[error("could not write to {0}: {1}")]
    WriteError(PathBuf, #[source] std::io::Error),

    /// Something went wrong parsing a string as UTF8.
    #[error(transparent)]
    StringError(#[from] std::string::FromUtf8Error),

    /// Something went wrong parsing or serializing to YAML.
    #[error(transparent)]
    SerdeError(#[from] serde_yaml::Error),

    /// Could not create a temporary directory.
    #[error("couldn't create a temporary directory: {0}")]
    TempDirError(#[source] std::io::Error),

    /// Could not execute command.
    #[error("couldn't execute {0}: {1}")]
    CommandError(String, #[source] std::io::Error),
}

#[derive(Clone, Debug, Serialize)]
#[serde(rename_all = "kebab-case")]
struct Metadata {
    local_hostname: String,
}

impl Metadata {
    fn from(spec: &Specification) -> Self {
        Self {
            local_hostname: spec.name.to_string(),
        }
    }

    fn as_yaml(&self) -> Result<String, serde_yaml::Error> {
        let yaml = serde_yaml::to_string(self)?;
        let yaml = cleanup_document(&yaml);
        Ok(format!("# Amazon EC2 style metadata\n{}", &yaml))
    }
}

fn cleanup_document(doc: &str) -> String {
    let doc = doc.strip_prefix("---\n").unwrap_or(doc);
    let doc = doc.strip_suffix('\n').unwrap_or(doc);
    doc.to_string()
}

#[derive(Clone, Debug, Serialize)]
struct Userdata {
    ssh_authorized_keys: Vec<String>,

    #[serde(skip_serializing_if = "Option::is_none")]
    ssh_keys: Option<Hostkeys>,

    #[serde(skip_serializing_if = "Option::is_none")]
    user_ca_pubkey: Option<String>,

    allow_authorized_keys: bool,

    runcmd: Vec<String>,
}

impl Userdata {
    fn from(spec: &Specification) -> Result<Self, CloudInitError> {
        let user_ca_pubkey = if let Some(filename) = &spec.user_ca_pubkey {
            let data = std::fs::read(filename)
                .map_err(|err| CloudInitError::ReadError(filename.to_path_buf(), err))?;
            Some(String::from_utf8(data)?)
        } else {
            None
        };
        Ok(Self {
            ssh_authorized_keys: spec.ssh_keys.clone(),
            ssh_keys: Hostkeys::from(spec)?,
            user_ca_pubkey,
            allow_authorized_keys: spec.allow_authorized_keys,
            runcmd: vec![
                format!("python3 -c {}", quote(SCRIPT)),
                "systemctl reload ssh".to_string(),
            ],
        })
    }

    fn as_yaml(&self) -> Result<String, serde_yaml::Error> {
        let yaml = serde_yaml::to_string(self)?;
        let yaml = cleanup_document(&yaml);
        debug!("userdata self:\n{:#?}", self);
        debug!("userdata yaml:\n{}", yaml);
        Ok(format!("#cloud-config\n{}", &yaml))
    }
}

#[derive(Clone, Debug, Deserialize, Serialize, Default)]
struct Hostkeys {
    #[serde(skip_serializing_if = "Option::is_none")]
    rsa_private: Option<String>,

    #[serde(skip_serializing_if = "Option::is_none")]
    rsa_certificate: Option<String>,

    #[serde(skip_serializing_if = "Option::is_none")]
    dsa_private: Option<String>,

    #[serde(skip_serializing_if = "Option::is_none")]
    dsa_certificate: Option<String>,

    #[serde(skip_serializing_if = "Option::is_none")]
    ecdsa_private: Option<String>,

    #[serde(skip_serializing_if = "Option::is_none")]
    ecdsa_certificate: Option<String>,

    #[serde(skip_serializing_if = "Option::is_none")]
    ed25519_private: Option<String>,

    #[serde(skip_serializing_if = "Option::is_none")]
    ed25519_certificate: Option<String>,
}

impl Hostkeys {
    fn from(spec: &Specification) -> Result<Option<Self>, CloudInitError> {
        let rsa = spec.rsa_host_key.clone();
        let rsa_cert = spec.rsa_host_cert.clone();

        let dsa = spec.dsa_host_key.clone();
        let dsa_cert = spec.dsa_host_cert.clone();

        let ecdsa = spec.ecdsa_host_key.clone();
        let ecdsa_cert = spec.ecdsa_host_cert.clone();

        let ed25519 = spec.ed25519_host_key.clone();
        let ed25519_cert = spec.ed25519_host_cert.clone();

        if rsa.is_some() || dsa.is_some() || ecdsa.is_some() || ed25519.is_some() {
            debug!("At least one host key specified");
            Ok(Some(Self {
                rsa_private: rsa,
                rsa_certificate: rsa_cert,
                dsa_private: dsa,
                dsa_certificate: dsa_cert,
                ecdsa_private: ecdsa,
                ecdsa_certificate: ecdsa_cert,
                ed25519_private: ed25519,
                ed25519_certificate: ed25519_cert,
            }))
        } else if spec.generate_host_certificate {
            if spec.ca_key.is_none() {
                debug!("No CA key specified");
                return Err(CloudInitError::NoCAKey);
            }
            if let Some(filename) = &spec.ca_key {
                debug!("Generating host key and certificate");
                let ca = CaKey::from_file(filename)?;
                let pair = KeyPair::generate(KeyKind::Ed25519)?;
                let cert = ca.certify_host(&pair, &spec.name)?;
                debug!("generated Ed25519 host certificate {:?}", cert);
                Ok(Some(Self {
                    ed25519_private: Some(pair.private().to_string()),
                    ed25519_certificate: Some(cert),
                    ..Self::default()
                }))
            } else {
                Ok(None)
            }
        } else {
            debug!("No host keys specified, no host certificate wanted");
            Ok(None)
        }
    }
}

/// Full cloud-init configuration.
#[derive(Clone, Debug)]
pub struct CloudInitConfig {
    metadata: Metadata,
    userdata: Userdata,
}

impl CloudInitConfig {
    /// Create from a specification.
    pub fn from(spec: &Specification) -> Result<Self, CloudInitError> {
        let metadata = Metadata::from(spec);
        let userdata = Userdata::from(spec)?;
        Ok(CloudInitConfig { metadata, userdata })
    }

    /// Debugging aid: dump cloud-init to stdout.
    pub fn dump(&self) {
        println!("==== meta-data:\n{}", self.metadata().unwrap());
        println!("==== user-data:\n{}", self.userdata().unwrap());
    }

    fn metadata(&self) -> Result<String, serde_yaml::Error> {
        self.metadata.as_yaml()
    }

    fn userdata(&self) -> Result<String, serde_yaml::Error> {
        self.userdata.as_yaml()
    }

    /// Put cloud-init configuration into a named directory.
    ///
    /// The files `meta-data` and `user-data` will be stored in the
    /// directory. cloud-init reads them on first boot and makes
    /// appropriate changes the host's configuration.
    pub fn create_dir(&self, path: &Path) -> Result<(), CloudInitError> {
        let metadata = path.join("meta-data");
        debug!("writing metadata to {}", metadata.display());
        write(&metadata, self.metadata()?)
            .map_err(|err| CloudInitError::WriteError(metadata, err))?;

        let userdata = path.join("user-data");
        debug!("writing userdata to {}", userdata.display());
        write(&userdata, self.userdata()?)
            .map_err(|err| CloudInitError::WriteError(userdata, err))?;

        // let scriptsdir = path.join("scripts/per-once");
        // let script = scriptsdir.join("vmadm-configure-sshd");
        // std::fs::create_dir_all(&scriptsdir)?;
        // debug!("writing script to {}", script.display());
        // write(&script, SCRIPT)?;
        // let mut permissions = std::fs::metadata(&script)?.permissions();
        // permissions.set_mode(0o755);
        // debug!("setting permissions on {:?} to {:?}", script, permissions);
        // std::fs::set_permissions(&script, permissions)?;

        Ok(())
    }

    /// Create an ISO disk image file with the cloud-init configuration.
    ///
    /// The image will be attached to the VM when it starts.
    /// cloud-init finds it via the volume ID (file system label).
    pub fn create_iso(&self, filename: &Path) -> Result<(), CloudInitError> {
        let dir = match tempdir() {
            Ok(path) => path,
            Err(err) => return Err(CloudInitError::TempDirError(err)),
        };
        self.create_dir(dir.path())?;

        let r = Command::new("xorrisofs")
            .arg("-quiet")
            .arg("-volid")
            .arg("CIDATA")
            .arg("-joliet")
            .arg("-rock")
            .arg("-output")
            .arg(filename)
            .arg(dir.path())
            .output()
            .map_err(|err| CloudInitError::CommandError("xorrisofs".into(), err))?;

        if !r.status.success() {
            let stderr = String::from_utf8_lossy(&r.stderr).to_string();
            return Err(CloudInitError::IsoFailed(stderr));
        }
        Ok(())
    }
}
