//! Types and functions for version control system (VCS) support.
//!
//! VCS support is used to ensure temporary artifacts and test output is not
//! persisted by the VCS. When Tytanic is used within a Git repository for
//! example, the temporary artifacts should most likely not be tracked by Git.
//!
//! Tytanic only supports VCS which support `.gitignore`-like ignore files. This
//! means that mercurial is not supported, because its `.hgignore` is only read
//! at the root of the repository. Thus, it can't use the self ignore pattern,
//! in which a directory is ignored by placing the `.gitignore` file within it
//! containing `*`.
//!
//! Note that the VCS kind only exists to allow support for VCS which expect a
//! differently named ignore file.
//!
//! # Examples
//! To ignore a directory in a git repository all that is needed is a [`Vcs`]
//! instance with [`Kind::Git`] and a path to the directory from the root of the
//! repository.
//! ```no_run
//! # use tytanic_core::project::vcs::Kind;
//! # use tytanic_core::project::vcs::Vcs;
//! let root = "/home/user/src/my-package";
//!
//! // creates /home/user/src/my-package/.gitignore
//! Vcs::new(root, Kind::Git).ignore_directory("build")?;
//! # Ok::<_, Box<dyn std::error::Error>>(())
//! ```

use std::fmt::Display;
use std::fs;
use std::io;

use camino::Utf8Path;
use camino::Utf8PathBuf;

use thiserror::Error;

/// The content of the generated inner git ignore file.
const INNER_GITIGNORE_CONTENT: &str = "# generated by tytanic, do not edit\n*\n";

/// The supported set of version control systems.
#[derive(Debug, Default, Clone, Copy, PartialEq, Eq, Hash)]
pub enum Kind {
    /// The git VCS.
    #[default]
    Git,

    /// A git-compatible VCS developed at Meta.
    Sapling,

    /// A git-compatible VCS.
    Jujutsu,
}

impl Display for Kind {
    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
        match self {
            Kind::Git => f.write_str("git"),
            Kind::Sapling => f.write_str("sapling"),
            Kind::Jujutsu => f.write_str("jujutsu"),
        }
    }
}

impl Kind {
    /// The ignore filename used by this VCS.
    ///
    /// # Examples
    /// ```
    /// # use tytanic_core::project::vcs::Kind;
    /// assert_eq!(Kind::Git.ignore_filename(), ".gitignore");
    /// ```
    pub fn ignore_filename(&self) -> &'static str {
        match self {
            Self::Git | Self::Sapling | Self::Jujutsu => ".gitignore",
        }
    }
}

/// A version control system, this is used to ignore temporary artifacts.
#[derive(Debug, Clone, PartialEq, Eq, Hash)]
pub struct Vcs {
    root: Option<Utf8PathBuf>,
    kind: Kind,
}

impl Vcs {
    /// Creates a new VCS with the given root directory and kind.
    ///
    /// The root directory is that of the VCS repository, which may match that
    /// of the associated project.
    pub fn new<I>(root: I, kind: Kind) -> Self
    where
        I: Into<Utf8PathBuf>,
    {
        let root = root.into();

        debug_assert!(root.is_absolute(), "root must be absolute");

        Self {
            root: Some(root),
            kind,
        }
    }

    /// Creates a new VCS with the given kind and no root.
    pub fn new_rootless(kind: Kind) -> Self {
        Self { root: None, kind }
    }

    /// Tries to infer the VCS for the given VCS root directory.
    ///
    /// Returns `None` if there is no known hidden VCS directory (such as
    /// `.git`, `.hg`, etc.).
    ///
    /// # Errors
    /// May return errors if `root` or its sub directories are inaccessible.
    ///
    /// # Examples
    /// ```no_run
    /// # use tytanic_core::project::vcs::Kind;
    /// # use tytanic_core::project::vcs::Vcs;
    /// let vcs = Vcs::try_infer_kind("/home/user/src/my-package")?;
    /// assert_eq!(vcs, Some(Kind::Git));
    ///
    /// let vcs = Vcs::try_infer_kind("/home/user/src")?;
    /// assert_eq!(vcs, None);
    /// # Ok::<_, Box<dyn std::error::Error>>(())
    /// ```
    pub fn try_infer_kind<P>(root: P) -> io::Result<Option<Kind>>
    where
        P: AsRef<Utf8Path>,
    {
        fn inner(root: &Utf8Path) -> io::Result<Option<Kind>> {
            let mut root = root.to_path_buf();

            // Sapling and Jujutsu are both git compatible and may exist
            // alongside a `.git` directory, so we need to look for them first.

            let mut inferred = None;
            for (dir, kind) in [
                (".jj", Kind::Jujutsu),
                (".sl", Kind::Sapling),
                (".git", Kind::Git),
            ] {
                root.push(dir);
                if root.try_exists()? {
                    inferred = Some(kind);
                    root.pop();
                    break;
                }
                root.pop();
            }

            Ok(inferred)
        }

        inner(root.as_ref())
    }
}

impl Vcs {
    /// The root of this VCS' repository.
    pub fn root(&self) -> Option<&Utf8Path> {
        self.root.as_deref()
    }

    /// The kind of this repository.
    pub fn kind(&self) -> Kind {
        self.kind
    }
}

impl Vcs {
    /// Ignores the given directory without adding any new files to the VCS'
    /// file tree.
    ///
    /// The appropriate ignore file is created in the directory with the `*`
    /// pattern. This way the ignore file need not be persisted for it to take
    /// effect.
    ///
    /// # Panics
    /// Panics if:
    /// - the directory cannot be converted into UTF-8.
    /// - it isn't an absolute path.
    /// - it isn't inside the VCS root.
    ///
    /// # Errors
    /// Returns an error if the VCS has no root set.
    ///
    /// # Examples
    /// ```no_run
    /// # use tytanic_core::project::vcs::Kind;
    /// # use tytanic_core::project::vcs::Vcs;
    /// let root = "/home/user/src/my-package";
    ///
    /// // creates `/home/user/src/my-package/.gitignore` containg `*`
    /// Vcs::new(root, Kind::Git).ignore_directory("build")?;
    /// # Ok::<_, Box<dyn std::error::Error>>(())
    /// ```
    pub fn ignore_directory<P>(&self, dir: P) -> Result<(), IgnoreDirectoryError>
    where
        P: AsRef<Utf8Path>,
    {
        fn inner(vcs: &Vcs, dir: &Utf8Path) -> Result<(), IgnoreDirectoryError> {
            assert!(dir.is_absolute());

            let name = vcs.kind().ignore_filename();
            fs::write(dir.join(name), INNER_GITIGNORE_CONTENT)?;

            Ok(())
        }

        inner(self, dir.as_ref())
    }
}

/// Returned by [`Vcs::ignore_directory`].
#[derive(Debug, Error)]
pub enum IgnoreDirectoryError {
    /// An IO error occurred.
    #[error("an IO error occurred")]
    Io(#[from] io::Error),
}

#[cfg(test)]
mod tests {
    use tytanic_utils::fs::TempTestEnv;

    use super::*;

    #[test]
    fn test_sapling_detect_before_git() {
        TempTestEnv::run_no_check(
            |root| root.setup_dir(".sl").setup_dir(".git"),
            |root| {
                let kind = Vcs::try_infer_kind(root).unwrap().unwrap();
                assert_eq!(kind, Kind::Sapling);
            },
        );
    }

    #[test]
    fn test_jujutsu_detect_before_git() {
        TempTestEnv::run_no_check(
            |root| root.setup_dir(".jj").setup_dir(".git"),
            |root| {
                let kind = Vcs::try_infer_kind(root).unwrap().unwrap();
                assert_eq!(kind, Kind::Jujutsu);
            },
        );
    }

    #[test]
    fn test_git_detect() {
        TempTestEnv::run_no_check(
            |root| root.setup_dir(".git"),
            |root| {
                let kind = Vcs::try_infer_kind(root).unwrap().unwrap();
                assert_eq!(kind, Kind::Git);
            },
        );
    }

    #[test]
    fn test_git_ignore_create() {
        TempTestEnv::run(
            |root| root.setup_dir("tests/fancy/out"),
            |root| {
                let vcs = Vcs::new(root, Kind::Git);
                vcs.ignore_directory(root.join("tests/fancy/out")).unwrap();
            },
            |root| root.expect_file_content("tests/fancy/out/.gitignore", INNER_GITIGNORE_CONTENT),
        );
    }

    #[test]
    fn test_git_ignore_truncate() {
        TempTestEnv::run(
            |root| root.setup_file("tests/fancy/out/.gitignore", "foo"),
            |root| {
                let vcs = Vcs::new(root, Kind::Git);
                vcs.ignore_directory(root.join("tests/fancy/out")).unwrap();
            },
            |root| root.expect_file_content("tests/fancy/out/.gitignore", INNER_GITIGNORE_CONTENT),
        );
    }
}
