//! Different kinds of CI plans.

#![allow(clippy::result_large_err)]

use std::{
    collections::HashMap,
    fmt::Debug,
    path::{Path, PathBuf},
};

use serde::{Deserialize, Serialize};

use crate::{
    action::{Context, PostPlanAction, PrePlanAction, RunnableAction, UnsafeAction},
    action_impl::{NetworkdDiagnostics, WaitOnline},
    config::Config,
    project::Project,
    qemu,
    runlog::RunLogSource,
};

/// A complete plan, as used by users. This can only contain
/// unsafe actions that are to be executed in a virtual machine.
#[derive(Debug, Clone, Default, Serialize, Deserialize)]
pub struct Plan {
    steps: Vec<UnsafeAction>,
}

impl Plan {
    /// Load plan from a file.
    pub fn from_file(filename: &Path) -> Result<Self, PlanError> {
        let plan = std::fs::read(filename).map_err(|e| PlanError::PlanOpen(filename.into(), e))?;
        let plan = serde_norway::from_slice(&plan)
            .map_err(|e| PlanError::PlanParse(filename.into(), e))?;
        Ok(plan)
    }

    /// Write plan to a file.
    pub fn to_file(&self, filename: &Path) -> Result<(), PlanError> {
        let plan = serde_norway::to_string(&self).map_err(PlanError::PlanSerialize)?;
        std::fs::write(filename, plan).map_err(|e| PlanError::PlanWrite(filename.into(), e))?;
        Ok(())
    }

    /// Append another action to the plan.
    pub fn push(&mut self, action: UnsafeAction) {
        self.steps.push(action);
    }

    /// Iterator over actions in a plan.
    pub fn iter(&self) -> impl Iterator<Item = &UnsafeAction> {
        self.steps.iter()
    }
}

/// A runnable plan. This contains actual actions that can be executed.
/// It does not guard against safe vs unsafe actions, anything goes.
#[derive(Debug, Default, Clone, Serialize, Deserialize, Eq, PartialEq)]
#[serde(deny_unknown_fields)]
pub struct RunnablePlan {
    steps: Vec<RunnableAction>,
    executor_drive: Option<String>,
    source_drive: Option<String>,
    artifact_drive: Option<String>,
    cache_drive: Option<String>,
    deps_drive: Option<String>,
    secrets_drive: Option<String>,

    workspace_dir: Option<String>,
    source_dir: Option<String>,
    deps_dir: Option<String>,
    cache_dir: Option<String>,
    artifacts_dir: Option<String>,
    secrets_dir: Option<String>,

    #[serde(default)]
    envs: HashMap<String, Vec<u8>>,
}

impl RunnablePlan {
    #[cfg(test)]
    fn parse_str(yaml: &str) -> Result<Self, PlanError> {
        serde_norway::from_str(yaml).map_err(PlanError::PlanParseStr)
    }

    /// Load plan from a file.
    pub fn from_file(filename: &Path) -> Result<Self, PlanError> {
        let plan = std::fs::read(filename).map_err(|e| PlanError::PlanOpen(filename.into(), e))?;
        let plan = String::from_utf8_lossy(&plan);
        let plan: Self =
            serde_norway::from_str(&plan).map_err(|e| PlanError::PlanParse(filename.into(), e))?;

        for step in plan.steps.iter() {
            if let RunnableAction::HttpGet(x) = step {
                for item in x.items() {
                    if item
                        .filename()
                        .as_os_str()
                        .as_encoded_bytes()
                        .contains(&b'/')
                    {
                        return Err(PlanError::FilenameIsNotBasename(filename.to_path_buf()));
                    }
                }
            }
        }

        Ok(plan)
    }

    /// Convert plan to a YAML string.
    pub fn to_string(&self) -> Result<String, PlanError> {
        serde_norway::to_string(self).map_err(PlanError::PlanSerialize)
    }

    /// Write plan to a file as YAML.
    pub fn to_file(&self, filename: &Path) -> Result<(), PlanError> {
        let plan = serde_norway::to_string(&self).map_err(PlanError::PlanSerialize)?;
        std::fs::write(filename, plan).map_err(|e| PlanError::PlanWrite(filename.into(), e))?;
        Ok(())
    }

    /// Environment variables to set in context.
    pub fn envs(&self) -> &HashMap<String, Vec<u8>> {
        &self.envs
    }

    /// Append an action to the plan.
    pub fn push(&mut self, action: RunnableAction) {
        self.steps.push(action);
    }

    /// Append any number of actions to a plan.
    pub fn push_unsafe_actions<'a>(&mut self, actions: impl Iterator<Item = &'a UnsafeAction>) {
        for action in actions {
            self.push(RunnableAction::from(action));
        }
    }

    /// Set environment for later actions.
    pub fn set_env(&mut self, name: &str, value: &[u8]) {
        self.envs.insert(name.to_string(), value.into());
    }

    /// Iterator over actions in a plan.
    pub fn iter(&self) -> impl Iterator<Item = &RunnableAction> {
        self.steps.iter()
    }

    /// Execute actions in the plan.
    pub fn execute(&self, source: RunLogSource, context: &mut Context) -> Result<(), PlanError> {
        context
            .set_envs_from_plan(self)
            .map_err(PlanError::Context)?;
        for action in self.steps.iter() {
            context.runlog().execute_action(source, action);
            let result = action.execute(context);
            match &result {
                Ok(()) => context.runlog().action_succeeded(source, action),
                Err(_) => {
                    context.runlog().action_failed(source, action);
                    // FIXME: this needs to be passed into the runlog, but that will
                    // FIXME: error to be serde::Deserialize
                    // let mut source = err.source();
                    // while let Some(src) = source {
                    //     error!("caused by: {src}");
                    //     source = src.source();
                    // }
                    result?;
                }
            }
        }

        if !self.steps.is_empty() {
            context.runlog().plan_succeeded(source);
        }
        Ok(())
    }

    /// Return executor drive, as set in the plan.
    pub fn executor_drive(&self) -> Option<&String> {
        self.executor_drive.as_ref()
    }

    /// Return source drive, as set in the plan.
    pub fn source_drive(&self) -> Option<&String> {
        self.source_drive.as_ref()
    }

    /// Return artifact drive, as set in the plan.
    pub fn run_artifact_drive(&self) -> Option<&String> {
        self.artifact_drive.as_ref()
    }

    /// Return cache drive, as set in the plan.
    pub fn cache_drive(&self) -> Option<&String> {
        self.cache_drive.as_ref()
    }

    /// Return dependencies drive, as set in the plan.
    pub fn deps_drive(&self) -> Option<&String> {
        self.deps_drive.as_ref()
    }

    /// Return root directory of workspace.
    pub fn workspace_dir(&self) -> Option<&String> {
        self.workspace_dir.as_ref()
    }

    /// Return source directory.
    pub fn source_dir(&self) -> Option<&String> {
        self.source_dir.as_ref()
    }

    /// Return dependencies directory.
    pub fn deps_dir(&self) -> Option<&String> {
        self.deps_dir.as_ref()
    }

    /// Return cache directory.
    pub fn cache_dir(&self) -> Option<&String> {
        self.cache_dir.as_ref()
    }

    /// Return artifacts directory.
    pub fn artifacts_dir(&self) -> Option<&String> {
        self.artifacts_dir.as_ref()
    }

    /// Set executor drive.
    pub fn set_executor_drive(&mut self, path: &str) {
        self.executor_drive = Some(path.into());
    }

    /// Set source drive.
    pub fn set_source_drive(&mut self, path: &str) {
        self.source_drive = Some(path.into());
    }

    /// Set artifacts drive.
    pub fn set_artifact_drive(&mut self, path: &str) {
        self.artifact_drive = Some(path.into());
    }

    /// Set cache drive.
    pub fn set_cache_drive(&mut self, path: &str) {
        self.cache_drive = Some(path.into());
    }

    /// Set dependencies drive.
    pub fn set_deps_drive(&mut self, path: &str) {
        self.deps_drive = Some(path.into());
    }

    /// Set root directory of workspace.
    pub fn set_workspace_dir(&mut self, path: &str) {
        self.workspace_dir = Some(path.into());
    }

    /// Set source directory.
    pub fn set_source_dir(&mut self, path: &str) {
        self.source_dir = Some(path.into());
    }

    /// Set depencencies directory.
    pub fn set_deps_dir(&mut self, path: &str) {
        self.deps_dir = Some(path.into());
    }

    /// Set cache directory.
    pub fn set_cache_dir(&mut self, path: &str) {
        self.cache_dir = Some(path.into());
    }

    /// Set artifacts directory.
    pub fn set_artifacts_dir(&mut self, path: &str) {
        self.artifacts_dir = Some(path.into());
    }

    /// Set directories that have not been set yet.
    pub fn set_unset_dirs(&mut self, path: &str) {
        fn set(s: &mut Option<String>, path: &str) {
            if s.is_none() {
                *s = Some(path.to_string());
            }
        }

        set(&mut self.workspace_dir, path);
        set(&mut self.source_dir, path);
        set(&mut self.deps_dir, path);
        set(&mut self.cache_dir, path);
        set(&mut self.artifacts_dir, path);
    }
}

/// Construct runnable plans for pre-plan, plan, and post-plan.
pub fn construct_all_plans(
    config: &Config,
    name: &str,
    project: &Project,
) -> Result<(RunnablePlan, RunnablePlan, RunnablePlan), PlanError> {
    // eprintln!("CONSTRUCT: {config:#?} {project:#?}");
    let pre_plan = runnable_plan_from_pre_plan_actions(project.pre_plan())?;
    let plan = construct_runnable_plan(project.plan())?;
    let post_plan = runnable_plan_from_post_plan_actions(config, name, project.post_plan())?;
    Ok((pre_plan, plan, post_plan))
}

/// Construct a [`RunnablePlan`] from unsafe actions.
pub fn construct_runnable_plan(actions: &[UnsafeAction]) -> Result<RunnablePlan, PlanError> {
    VmPlanFactory::default()
        .push(actions.iter().map(RunnableAction::from))
        .build()
}

/// Construct a [`RunnablePlan`] from a pre-plan actions.
pub fn runnable_plan_from_pre_plan_actions(
    actions: &[PrePlanAction],
) -> Result<RunnablePlan, PlanError> {
    VmPlanFactory::default()
        .save_deps()
        .push_one(RunnableAction::WaitOnline(WaitOnline::default()))
        .push_one(RunnableAction::NetworkDiagnostics(
            NetworkdDiagnostics::default(),
        ))
        .push(actions.iter().map(RunnableAction::from))
        .build()
}

/// Construct a [`RunnablePlan`] from post-plan actions.
pub fn runnable_plan_from_post_plan_actions(
    config: &Config,
    name: &str,
    actions: &[PostPlanAction],
) -> Result<RunnablePlan, PlanError> {
    VmPlanFactory::default()
        .push_one(RunnableAction::WaitOnline(WaitOnline::default()))
        .push_one(RunnableAction::NetworkDiagnostics(
            NetworkdDiagnostics::default(),
        ))
        .push(actions.iter().map(|action| {
            RunnableAction::from((
                action,
                config.rsync_target_for_project(name),
                config.dput_target(),
            ))
        }))
        .build()
}

/// Construct different kinds of runnable plans.
pub struct VmPlanFactory {
    plan: RunnablePlan,
    save_deps: bool,
}

impl VmPlanFactory {
    /// Build the plan.
    pub fn build(mut self) -> Result<RunnablePlan, PlanError> {
        let mut epilogue = vec![
            UnsafeAction::tar_create(Path::new(qemu::CACHE_DRIVE), Path::new(qemu::CACHE_DIR)),
            UnsafeAction::tar_create(
                Path::new(qemu::ARTIFACT_DRIVE),
                Path::new(qemu::ARTIFACTS_DIR),
            ),
        ];
        if self.save_deps {
            epilogue.push(UnsafeAction::tar_create(
                Path::new(qemu::DEPS_DRIVE),
                Path::new(qemu::DEPS_DIR),
            ));
        };
        self.plan.push_unsafe_actions(epilogue.iter());

        Ok(self.plan)
    }

    /// At end of run, save dependencies directory to corresponding drive.
    pub fn save_deps(mut self) -> Self {
        self.save_deps = true;
        self
    }

    /// Append runnable actions.
    pub fn push(mut self, actions: impl Iterator<Item = RunnableAction>) -> Self {
        for action in actions {
            self.plan.push(action);
        }
        self
    }

    /// Append one runnable action.
    pub fn push_one(mut self, action: RunnableAction) -> Self {
        self.plan.push(action);
        self
    }
}

impl Default for VmPlanFactory {
    /// Create a new factory to be executed in a VM, not on the host.
    ///
    /// Add the standard prologue for all kinds of plans, for extracting
    /// virtual drives, creating the workspace, etc.
    fn default() -> Self {
        let prologue = [
            UnsafeAction::mkdir(Path::new(qemu::WORKSPACE_DIR)),
            UnsafeAction::mkdir(Path::new(qemu::ARTIFACTS_DIR)),
            UnsafeAction::mkdir(Path::new(qemu::ARTIFACTS_DIR).join("debian")),
            UnsafeAction::mkdir(Path::new(qemu::ARTIFACTS_DIR).join("rsync")),
            UnsafeAction::mkdir(Path::new(qemu::SECRETS_DIR)),
            UnsafeAction::mkdir(Path::new(qemu::DEPS_DIR)),
            UnsafeAction::tar_extract(Path::new(qemu::SOURCE_DRIVE), Path::new(qemu::SOURCE_DIR)),
            UnsafeAction::tar_extract(Path::new(qemu::DEPS_DRIVE), Path::new(qemu::DEPS_DIR)),
            UnsafeAction::tar_extract(Path::new(qemu::CACHE_DRIVE), Path::new(qemu::CACHE_DIR)),
            UnsafeAction::tar_extract(
                Path::new(qemu::ARTIFACT_DRIVE),
                Path::new(qemu::ARTIFACTS_DIR),
            ),
            UnsafeAction::tar_extract(Path::new(qemu::SECRETS_DRIVE), Path::new(qemu::SECRETS_DIR)),
            UnsafeAction::shell("ln -sf /ci /workspace"),
            UnsafeAction::create_file(
                "/root/.ssh/config",
                r#"Host *
StrictHostKeyChecking accept-new
IdentityFile /ci/secrets/ssh_key
                "#,
                true,
            ),
        ];

        let mut plan = RunnablePlan::default();

        // This plan will be executed in a VM, with the standard workspace. Thus,
        // set directories to the VM workspace directories (`/ci/something`).
        // Also set virtual drives.
        plan.set_executor_drive(qemu::EXECUTOR_DRIVE);
        plan.set_source_drive(qemu::SOURCE_DRIVE);
        plan.set_artifact_drive(qemu::ARTIFACT_DRIVE);
        plan.set_cache_drive(qemu::CACHE_DRIVE);
        plan.set_deps_drive(qemu::DEPS_DRIVE);
        plan.set_workspace_dir(qemu::WORKSPACE_DIR);
        plan.set_source_dir(qemu::SOURCE_DIR);
        plan.set_artifacts_dir(qemu::ARTIFACTS_DIR);
        plan.set_deps_dir(qemu::DEPS_DIR);
        plan.set_cache_dir(qemu::CACHE_DIR);

        plan.push_unsafe_actions(prologue.iter());

        Self {
            plan,
            save_deps: false,
        }
    }
}

/// Errors from handling plans.
#[derive(Debug, thiserror::Error)]
pub enum PlanError {
    /// Can't open plan file.
    #[error("failed to read CI plan file: {0}")]
    PlanOpen(PathBuf, #[source] std::io::Error),

    /// Can't parse plan as YAML from a file.
    #[error("failed to parse CI plan file as YAML: {0}")]
    PlanParse(PathBuf, #[source] serde_norway::Error),

    /// Can't parse CI plan as YAML from string.
    #[error("failed to parse CI plan")]
    PlanParseStr(#[source] serde_norway::Error),

    /// Can't serialize a plan as YAML.
    #[error("failed to serialize CI plan as YAML")]
    PlanSerialize(#[source] serde_norway::Error),

    /// Can't write plan to file.
    #[error("failed to write CI plan file: {0}")]
    PlanWrite(PathBuf, #[source] std::io::Error),

    /// Forwarded from action.
    #[error(transparent)]
    Action(#[from] crate::action::ActionError),

    /// The `http-get` filename contains a directory.
    #[error("the filename in a URL/filename pair contains a directory")]
    FilenameIsNotBasename(PathBuf),

    /// Can't create a [`Context`].
    #[error("failed to create a context for executing actions")]
    Context(#[source] crate::action::ActionError),
}

#[cfg(test)]
mod test {
    use super::*;

    #[test]
    fn round_trip() -> Result<(), Box<dyn std::error::Error>> {
        let mut plan = RunnablePlan::default();
        plan.set_source_dir("/src");

        let s = plan.to_string()?;
        let des = RunnablePlan::parse_str(&s)?;

        assert_eq!(plan, des);
        Ok(())
    }
}
