//! Project specification.

use std::{
    collections::HashMap,
    fs::{read, write},
    io::Write,
    path::{Path, PathBuf},
};

use clingwrap::tildepathbuf::TildePathBuf;
use serde::{Deserialize, Serialize};

use crate::{
    action::{PostPlanAction, PrePlanAction, UnsafeAction},
    util::mkdir,
};

/// A list of projects.
#[derive(Debug, Deserialize, Clone)]
#[serde(deny_unknown_fields)]
pub struct Projects {
    projects: HashMap<String, Project>,
}

impl Projects {
    /// Load from a file.
    pub fn from_file(filename: &Path) -> Result<Self, ProjectError> {
        let dirname = if let Some(parent) = filename.parent() {
            parent.to_path_buf()
        } else {
            return Err(ProjectError::Parent(filename.into()));
        };

        let yaml = read(filename).map_err(|e| ProjectError::Read(filename.into(), e))?;
        let mut projects: Self =
            serde_norway::from_slice(&yaml).map_err(|e| ProjectError::Yaml(filename.into(), e))?;

        for (name, p) in projects.projects.iter_mut() {
            p.expand_tilde(&dirname)?;
            if !p.expanded_source.is_dir() {
                return Err(ProjectError::NotADirectory(
                    name.into(),
                    p.expanded_source.clone(),
                ));
            }
        }

        Ok(projects)
    }

    /// Look up project by name.
    pub fn get(&self, name: &str) -> Option<&Project> {
        self.projects.get(name)
    }

    /// Iterator over projects.
    pub fn iter(&self) -> impl Iterator<Item = (&str, &Project)> {
        self.projects.iter().map(|(k, v)| (k.as_str(), v))
    }
}

/// Specification of one CI project.
#[derive(Debug, Deserialize, Clone)]
#[serde(deny_unknown_fields)]
pub struct Project {
    /// Source directory.
    pub source: TildePathBuf,

    #[serde(skip)]
    expanded_source: PathBuf,

    /// Virtual machine image to use.
    pub image: TildePathBuf,

    #[serde(skip)]
    expanded_image: PathBuf,

    /// Pre-plan actions.
    pub pre_plan: Option<Vec<PrePlanAction>>,

    /// Plan actions.
    pub plan: Option<Vec<UnsafeAction>>,

    /// Post-plan actions.
    pub post_plan: Option<Vec<PostPlanAction>>,

    /// Maximum size of artifacts directory for this project, in bytes.
    pub artifact_max_size: Option<u64>,

    /// Maximum size of cache directory for this project, in bytes.
    pub cache_max_size: Option<u64>,
}

impl Project {
    fn expand_tilde(&mut self, basedir: &Path) -> Result<(), ProjectError> {
        self.expanded_source = Self::abspath(basedir.join(self.source.path()))?;
        self.expanded_image = Self::abspath(basedir.join(self.image.path()))?;
        Ok(())
    }

    /// Load from file.
    pub fn from_file(filename: &Path) -> Result<Self, ProjectError> {
        let dirname = if let Some(parent) = filename.parent() {
            parent.to_path_buf()
        } else {
            return Err(ProjectError::Parent(filename.into()));
        };

        let yaml = read(filename).map_err(|e| ProjectError::Read(filename.into(), e))?;
        let mut project: Project =
            serde_norway::from_slice(&yaml).map_err(|e| ProjectError::Yaml(filename.into(), e))?;

        project.expand_tilde(&dirname)?;
        if !project.expanded_source.is_dir() {
            return Err(ProjectError::NotADirectory(
                filename.to_string_lossy().to_string(),
                project.expanded_source,
            ));
        }

        Ok(project)
    }

    fn abspath(path: PathBuf) -> Result<PathBuf, ProjectError> {
        path.canonicalize()
            .map_err(|e| ProjectError::Canonicalize(path, e))
    }

    /// Source directory.
    pub fn source(&self) -> &Path {
        &self.expanded_source
    }

    /// Virtual machine image file.
    pub fn image(&self) -> &Path {
        &self.expanded_image
    }

    /// Maximum size of artifacts directory, in bytes.
    pub fn artifact_max_size(&self) -> Option<u64> {
        self.artifact_max_size
    }

    /// Maximum size of cache directory, in bytes.
    pub fn cache_max_size(&self) -> Option<u64> {
        self.cache_max_size
    }

    /// List of pre-plan actions.
    pub fn pre_plan(&self) -> &[PrePlanAction] {
        if let Some(plan) = &self.pre_plan {
            plan.as_slice()
        } else {
            &[]
        }
    }

    /// List of plan actions.
    pub fn plan(&self) -> &[UnsafeAction] {
        if let Some(plan) = &self.plan {
            plan.as_slice()
        } else {
            &[]
        }
    }

    /// List of post-plan actions.
    pub fn post_plan(&self) -> &[PostPlanAction] {
        if let Some(plan) = &self.post_plan {
            plan.as_slice()
        } else {
            &[]
        }
    }
}

/// Persistent project state.
#[derive(Debug, Clone, Deserialize, Serialize)]
#[allow(dead_code)]
pub struct State {
    // File where this state is stored, if it's stored.
    #[serde(skip)]
    filename: PathBuf,

    // Where persistent state is stored for this project.
    #[serde(skip)]
    statedir: PathBuf,

    /// Latest commit that CI has run on, if any.
    pub latest_commit: Option<String>,
}

impl State {
    /// Load state for a project from a file, if it's present. If it's
    /// not present, return an empty state.
    pub fn from_file(statedir: &Path, project: &str) -> Result<Self, ProjectError> {
        let statedir = statedir.join(project);
        let filename = statedir.join("meta.yaml");
        let state = if filename.exists() {
            let yaml = read(&filename).map_err(|e| ProjectError::ReadState(filename.clone(), e))?;
            let mut state: Self = serde_norway::from_slice(&yaml)
                .map_err(|e| ProjectError::ParseState(filename.clone(), e))?;
            state.filename = filename;
            state.statedir = statedir;
            state
        } else {
            Self {
                filename,
                statedir,
                latest_commit: None,
            }
        };

        mkdir(&state.artifactsdir())?;
        mkdir(&state.cachedir())?;
        mkdir(&state.dependenciesdir())?;

        Ok(state)
    }

    /// Write project state.
    pub fn write_to_file(&self) -> Result<(), ProjectError> {
        let yaml = serde_norway::to_string(&self)
            .map_err(|e| ProjectError::SerializeState(self.clone(), e))?;
        if !self.statedir.exists() {
            std::fs::create_dir(&self.statedir)
                .map_err(|e| ProjectError::CreateState(self.statedir.clone(), e))?;
        }
        write(&self.filename, yaml)
            .map_err(|e| ProjectError::WriteState(self.filename.clone(), e))?;
        Ok(())
    }

    /// Return state directory.
    pub fn statedir(&self) -> &Path {
        &self.statedir
    }

    /// Return artifacts directory for project.
    pub fn artifactsdir(&self) -> PathBuf {
        self.statedir.join("artifacts")
    }

    /// Return cache directory for project.
    pub fn cachedir(&self) -> PathBuf {
        self.statedir.join("cache")
    }

    /// Return dependencies directory for a project.
    pub fn dependenciesdir(&self) -> PathBuf {
        self.statedir.join("dependencies")
    }

    /// Return latest commit that CI has run on.
    pub fn latest_commit(&self) -> Option<&str> {
        self.latest_commit.as_deref()
    }

    /// Set latest commit.
    pub fn set_latest_commot(&mut self, commit: Option<&str>) {
        self.latest_commit = commit.map(|s| s.into());
    }

    /// Path to run log.
    pub fn run_log_filename(&self) -> PathBuf {
        self.statedir.join("run.log")
    }

    /// Remove any existing run log.
    pub fn remove_run_log(&self) -> Result<(), ProjectError> {
        let filename = self.run_log_filename();
        if filename.exists() {
            std::fs::remove_file(&filename)
                .map_err(|err| ProjectError::RemoveRunLog(filename, err))?;
        }
        Ok(())
    }

    /// Create empty run log file. Return its filename.
    pub fn create_run_log(&self) -> Result<PathBuf, ProjectError> {
        let filename = self.run_log_filename();
        std::fs::OpenOptions::new()
            .create(true)
            .write(true)
            .truncate(true)
            .open(&filename)
            .map_err(|err| ProjectError::CreateRunLog(filename.clone(), err))?;
        Ok(filename)
    }

    /// Create empty raw log file. Return its filename.
    pub fn create_raw_log(&self) -> Result<PathBuf, ProjectError> {
        let filename = self.raw_log_filename();
        std::fs::OpenOptions::new()
            .create(true)
            .write(true)
            .truncate(true)
            .open(&filename)
            .map_err(|err| ProjectError::CreateRunLog(filename.clone(), err))?;
        Ok(filename)
    }

    /// Remove any existing raw log.
    pub fn remove_raw_log(&self) -> Result<(), ProjectError> {
        let filename = self.raw_log_filename();
        if filename.exists() {
            std::fs::remove_file(&filename)
                .map_err(|err| ProjectError::RemoveRawLog(filename, err))?;
        }
        Ok(())
    }

    /// Path to raw log. This is where the output from virtual machine goes.
    pub fn raw_log_filename(&self) -> PathBuf {
        self.statedir.join("raw.log")
    }

    /// Append data to run log. The file must already exist.
    pub fn append_to_run_log(&self, data: &[u8]) -> Result<(), ProjectError> {
        let filename = self.run_log_filename();
        let mut file = std::fs::OpenOptions::new()
            .append(true)
            .open(&filename)
            .map_err(|err| ProjectError::CreateRunLog(filename.clone(), err))?;

        file.write_all(data)
            .map_err(|err| ProjectError::AppendToRunLog(filename, err))?;

        Ok(())
    }

    /// Return contents of run log.
    pub fn read_run_log(&self) -> Result<Vec<u8>, ProjectError> {
        let filename = self.run_log_filename();
        let data =
            std::fs::read(&filename).map_err(|err| ProjectError::ReadRunLog(filename, err))?;
        Ok(data)
    }
}

/// Errors from handling project specifications.
#[derive(Debug, thiserror::Error)]
pub enum ProjectError {
    /// Can't find parent directory.
    #[error("failed to determine directory containing project file {0}")]
    Parent(PathBuf),

    /// Can't make filename absolute.
    #[error("failed to make filename absolute: {0}")]
    Canonicalize(PathBuf, #[source] std::io::Error),

    /// Can't read projects file.
    #[error("failed top read project file {0}")]
    Read(PathBuf, #[source] std::io::Error),

    /// Can't parse projects file as YAML.
    #[error("failed to parse project file as YAML: {0}")]
    Yaml(PathBuf, #[source] serde_norway::Error),

    /// Can't serialize project state as YAML.
    #[error("failed to serialize project state as YAML: {0:#?}")]
    SerializeState(State, #[source] serde_norway::Error),

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

    /// Can't read project state from file.
    #[error("failed to read project state from file {0}")]
    ReadState(PathBuf, #[source] std::io::Error),

    /// Can't parse project state as YAML.
    #[error("failed to parse project state file as YAML: {0}")]
    ParseState(PathBuf, #[source] serde_norway::Error),

    /// Can't create project state directory.
    #[error("failed to create project state directory {0}")]
    CreateState(PathBuf, #[source] std::io::Error),

    /// Can't remove run log file.
    #[error("failed to remove run log file {0}")]
    RemoveRunLog(PathBuf, #[source] std::io::Error),

    /// Can't remove raw log file.
    #[error("failed to remove raw log file {0}")]
    RemoveRawLog(PathBuf, #[source] std::io::Error),

    /// Can't create run log file.
    #[error("failed to create run log file {0}")]
    CreateRunLog(PathBuf, #[source] std::io::Error),

    /// Can't append to run log file.
    #[error("failed to append to run log file {0}")]
    AppendToRunLog(PathBuf, #[source] std::io::Error),

    /// Can't read run log file.
    #[error("failed to read run log file {0}")]
    ReadRunLog(PathBuf, #[source] std::io::Error),

    /// Can't create directory.
    #[error(transparent)]
    MKdir(#[from] crate::util::UtilError),

    /// Source directory isn't a directory.
    #[error("project {0} source directory is not a directory: {1}")]
    NotADirectory(String, PathBuf),
}
