//! Directories used by Sequoia.
//!
//! This crate is primarily for use by Sequoia libraries, and
//! applications to locate their configuration, data, and cache
//! directories.
//!
//! By default, Sequoia uses the system's standard locations for user
//! data, configuration files, and cache data.  On Linux, for
//! instance, this means following [the XDG base directory
//! specification].
//!
//! [the XDG base directory specification]: https://specifications.freedesktop.org/basedir-spec/basedir-spec-latest.html
//!
//! Sequoia programs also support using an alternate home directory.
//! In this case, the user data, configuration files, and cache data
//! are placed under a single, unified directory.  This is a
//! lightweight way to partially isolate a program.
//!
//! An alternate home location can be specified when instantiating a
//! `Home` object.  The user is also able to override the default by
//! setting the `SEQUOIA_HOME` environment variable.  The default home
//! location is the user's home directory.  On Linux, this usually
//! looks like `/home/USER`.
//!
//! In a few cases, a program might need to use multiple home
//! directories.  For instance, a program might not want to use the
//! user's default configuration, or certificate store, but does want
//! to use the default key store.  This is possible by instantiating
//! multiple [`Home`] objects, and querying the appropriate home
//! directory.
//!
//! When a program specifies an alternate home directory, we check if
//! it aliases the default home directory.  This check is performed at
//! the file system level, and not just by comparing the paths.  If
//! the specified directory aliases the default directory, we treat it
//! as the default and use the default layout.
//!
//! Some programs may act differently depending on whether the home
//! location is the default or not.  For instance, the keystore uses
//! the user's default gpg-agent when using the default home location.
use std::borrow::Borrow;
use std::io;
use std::path::Path;
use std::path::PathBuf;
use std::sync::Arc;
use std::sync::OnceLock;

use directories::BaseDirs;
use directories::ProjectDirs;

#[macro_use]
mod macros;

const TRACE: bool = false;

/// Make sure the example in the README works.
#[cfg(doctest)]
mod doctest {
    #[doc=include_str!("../README.md")]
    struct ReadMe;
}

/// Errors used in this crate.
///
/// Note: This enum cannot be exhaustively matched to allow future
/// extensions.
#[non_exhaustive]
#[derive(thiserror::Error, Debug)]
pub enum Error {
    #[error("The set of standard directories is not known on this platform")]
    NoStandardDirectories,

    #[error(transparent)]
    Io(#[from] std::io::Error),

    #[error(transparent)]
    Other(#[from] anyhow::Error),
}

/// This crate's `Result` type.
pub type Result<R, E=Error> = anyhow::Result<R, E>;

/// A Sequoia component.
#[derive(Debug, Clone)]
pub enum Component {
    /// The cert-d component.
    ///
    /// See the [Shared OpenPGP Certificate Directory] specification.
    CertD,

    /// Key store component.
    Keystore,

    /// `sq`.
    Sq,

    /// An as-yet unspecified component.
    Other(String),
}

impl Component {
    /// Returns whether the component is a Sequoia component, and its
    /// sub-directory.
    fn dir(&self) -> (bool, &str) {
        match self {
            Component::CertD => (false, "pgp.cert.d"),
            Component::Keystore => (true, "keystore"),
            Component::Sq => (true, "sq"),
            Component::Other(c) => (true, &**c),
        }
    }
}

#[derive(Debug, Clone)]
struct Dirs {
    base: BaseDirs,
    project: ProjectDirs,
}

/// A Sequoia home directory.
///
/// This is used to determine the location of various components'
/// configuration, data, and cache directories.
#[derive(Debug, Clone)]
pub struct Home {
    /// The home location.
    location: PathBuf,

    // If Some, then then we're using the default location.
    dirs: Option<Dirs>,

    // If this is an ephmeral home directory.  An ephemeral home
    // directory when the last copy of this structure is dropped.
    ephemeral: Option<Arc<tempfile::TempDir>>,
}

impl std::fmt::Display for Home {
    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result
    {
        if self.is_default_location() {
            write!(f, "default ({})", self.location.display())
        } else if self.ephemeral.is_some() {
            write!(f, "ephemeral ({})", self.location.display())
        } else {
            write!(f, "{}", self.location.display())
        }
    }
}

impl Home {
    /// The environment variable that overrides the default home
    /// location.
    pub const ENV: &'static str = "SEQUOIA_HOME";

    /// Returns the default home location.
    ///
    /// The default home location is the user's home directory, e.g.,
    /// `/home/user`.  When using the home directory, the actual files
    /// are placed according to the operating system's conventions.
    /// On systems that support the XDG guidelines, for instance, data
    /// is placed under `$XDG_DATA_HOME/sequoia`, which usually looks
    /// like `/home/user/.local/share/sequoia`.
    ///
    /// Returns an error if the default location can't be determined.
    pub fn default_location() -> Result<PathBuf> {
        static DEFAULT_LOCATION: OnceLock<Option<PathBuf>> = OnceLock::new();

        let dirs = DEFAULT_LOCATION.get_or_init(|| {
            let dirs = BaseDirs::new()?;
            Some(dirs.home_dir().to_path_buf())
        });

        if let Some(dirs) = dirs {
            Ok(dirs.clone())
        } else {
            Err(Error::NoStandardDirectories)
        }
    }

    /// Returns an instance of `Home`.
    ///
    /// Returns an instance of `Home`.  If `home` is not `None`, the
    /// specified location is used.  Otherwise, if the environment
    /// variable `SEQUOIA_HOME` is set, that is used.  Otherwise, the
    /// default location as returned by [`Home::default_location`] is
    /// used.
    pub fn new<P>(location: P) -> Result<Self>
        where P: Into<Option<PathBuf>>
    {
        tracer!(TRACE, "Home::new");

        let mut location = location.into();

        if let Some(path) = location.as_ref() {
            t!("location: {}", path.display());

            if path.is_relative() {
                // XXX: Switch to std::path::absolute when that
                // stabilizes.
                //
                // https://doc.rust-lang.org/std/path/fn.absolute.html

                t!("Canonicalizing relative path");

                std::fs::create_dir_all(path)?;
                let path = path.canonicalize()?;
                t!(" -> {:?}", path.display());
                location = Some(path);
            }
        } else {
            t!("location: use the default");
        }

        let (location, default) = if let Some(location) = location {
            (location, None)
        } else if let Ok(location) = std::env::var(Home::ENV) {
            t!("{} set to {}", Home::ENV, location);
            (PathBuf::from(location), None)
        } else {
            t!("{} is unset", Home::ENV);
            (Self::default_location()?, Some(true))
        };

        let default = default.unwrap_or_else(|| {
            // If the platform doesn't have a home directory, just
            // return false.
            Self::aliases_default(&location).unwrap_or(false)
        });

        t!("Location is {}the default location",
           if default { "" } else { "not " });

        let dirs = if default {
            let (Some(base), Some(project)) =
                (BaseDirs::new(),
                 ProjectDirs::from("org", "Sequoia-PGP", "sequoia"))
            else { return Err(Error::NoStandardDirectories) };

            Some(Dirs { base, project })
        } else {
            None
        };

        Ok(Self {
            location,
            dirs: dirs,
            ephemeral: None,
        })
    }

    /// Returns a default instance of `Home`.
    ///
    /// Returns a reference to an instance of the default.  This
    /// unconditionally uses [`Home::default_location`]; it ignores
    /// the environment variable `SEQUOIA_HOME`.
    ///
    /// Returns `None` if the platform does not have any known default
    /// directories.
    pub fn default() -> Option<&'static Self>
    {
        static DEFAULT: OnceLock<Option<Home>> = OnceLock::new();

        let default = DEFAULT
            .get_or_init(|| {
                let path = Self::default_location().ok()?;
                Some(Self::from_path(path))
            });

        default.as_ref()
    }

    /// Returns an instance of `Home`.
    ///
    /// The specified location is used as the home location.  The
    /// environment variable `SEQUOIA_HOME` is ignored.
    pub fn from_path<P>(location: P) -> Self
        where P: ToOwned<Owned=PathBuf>
    {
        let location = location.to_owned();
        Self::new(Some(location))
            .expect("infallible when specifying a location")
    }

    /// Returns an ephemeral instance of `Home`.
    ///
    /// A temporary directory is created and used as the home
    /// location.  The environment variable `SEQUOIA_HOME` is ignored.
    ///
    /// When the last clone of this structure is dropped, the
    /// ephemeral directory is removed.
    pub fn ephemeral() -> Result<Self> {
        let ephemeral = tempfile::Builder::new().prefix("sequoia-").tempdir()?;
        let dir = ephemeral.path().to_path_buf();

        let mut home = Self::from_path(dir);
        home.ephemeral = Some(Arc::new(ephemeral));

        Ok(home)
    }

    /// Returns whether the location is ephemeral.
    ///
    /// A home location is ephemeral when it is created with
    /// [`Home::ephemeral`].
    pub fn is_ephemeral(&self) -> bool {
        self.ephemeral.is_some()
    }

    /// Returns whether two directories are aliases.
    ///
    /// `a` and `b` do not need to exist.  `a` may be created.
    ///
    /// If `a` and `b` are bit-for-bit identical, this returns true.
    ///
    /// Otherwise, this function tries to figure out if `a` designates
    /// the same directory as `b`.  To do this, the function first
    /// ensures that the two directories exist.  If `a` doesn't exist,
    /// and cannot be created, this function returns an error.  If `b`
    /// doesn't exist, and cannot be created, this function returns
    /// `Ok(false)`.
    ///
    /// This function then compares the files' identities.  On
    /// Unix-like systems, this is done by comparing the files' device
    /// ids and their inode numbers.  On Windows, the equivalent is
    /// used (cf. `nFileIndexLow`, etc.).
    /// Returns whether two directories are aliases.
    ///
    /// This is similar to [`Home::aliases_default`], but sometimes
    /// only the location of a component is known, not the home
    /// location.
    ///
    /// # Examples
    ///
    /// ```
    /// # use sequoia_directories::Home;
    /// # use sequoia_directories::Component;
    ///
    /// let default = Home::default().expect("have defaults");
    /// let ephemeral = Home::ephemeral()
    ///     .expect("can create temporary directories");
    ///
    /// let default_keystore = default.data_dir(Component::Keystore);
    /// let other_keystore = ephemeral.data_dir(Component::Keystore);
    ///
    /// assert!(! Home::aliases(&default_keystore, &other_keystore).expect("ok"));
    ///
    /// # #[cfg(unix)]
    /// # {
    /// let other_keystore_alias = ephemeral.location().join("symlink");
    /// std::os::unix::fs::symlink(&other_keystore, &other_keystore_alias).expect("ok");
    ///
    /// // The paths aren't identical.
    /// assert!(other_keystore != other_keystore_alias);
    /// // But they alias.
    /// assert!(Home::aliases(&other_keystore, &other_keystore_alias).expect("ok"));
    /// # }
    /// ```
    pub fn aliases(a: &Path, b: &Path) -> Result<bool> {
        if a == b {
            // They are identical.
            return Ok(true);
        }

        // See if they alias.

        // Make sure `a` exists.  If not, return an error.
        std::fs::create_dir_all(a)?;

        // Get `same-file` `Handle`s for `a` and
        // `b`.  Comparing two handles returns whether
        // they alias.

        let a_handle = same_file::Handle::from_path(a)?;

        let b_handle
            = match same_file::Handle::from_path(b)
        {
            Ok(handle) => handle,
            Err(err) => match err.kind() {
                // If `b` doesn't exist, but `a` does, they can't
                // possible be aliases.
                io::ErrorKind::NotFound => return Ok(false),

                // We can't access `b`, but we can access `a`.  This
                // doesn't mean they don't alias: one could be behind
                // a symlink.  There's not much much more that we can
                // do.  We conservatively assume that they don't
                // alias.
                io::ErrorKind::PermissionDenied => return Ok(false),

                _ => return Err(err.into()),
            }
        };

        // The specified location and the default location exist.  See
        // if they are the same file.
        Ok(a_handle == b_handle)
    }

    /// Returns whether the specified directory is the default home
    /// directory.
    ///
    /// If `dir` is bit-for-bit identical to the default, this returns
    /// true.
    ///
    /// Otherwise, this function tries to figure out if `dir`
    /// designates the `default` directory.  To do this, the function
    /// first ensures that the two directories exist.  If `dir`
    /// doesn't exist, and cannot be created, this function returns an
    /// error.  If the default directory doesn't exist, and cannot be
    /// created, this function returns `Ok(false)`.
    ///
    /// This function then compares the file's identity.  On Unix-like
    /// systems, this is done by comparing the files' device ids and
    /// their inode numbers.  On Windows, the equivalent is used
    /// (cf. `nFileIndexLow`, etc.).
    pub fn aliases_default(location: &Path) -> Result<bool> {
        let default_location = Self::default_location()?;

        Self::aliases(location, &default_location)
    }

    /// Returns whether this home location is the default home
    /// location.
    ///
    /// This is computed at instantiation time using
    /// [`Home::aliases_default`].
    pub fn is_default_location(&self) -> bool {
        self.dirs.is_some()
    }

    /// Returns the home location.
    pub fn location(&self) -> &Path {
        &self.location
    }

    /// Returns the Sequoia-global configuration directory.
    pub fn base_config_dir(&self) -> PathBuf
    {
        if let Some(ref dirs) = self.dirs {
            dirs.base.config_dir().to_path_buf()
        } else {
            self.location.to_path_buf()
        }
    }

    /// Returns a component's configuration directory.
    pub fn config_dir<C>(&self, component: C) -> PathBuf
        where C: Borrow<Component>
    {
        let component = component.borrow();
        let (sequoia, component) = component.dir();

        if let Some(ref dirs) = self.dirs {
            if sequoia {
                dirs.project.config_dir().join(component)
            } else {
                dirs.base.config_dir().join(component)
            }
        } else {
            self.location.join("config").join(component)
        }
    }

    /// Returns a component's data directory.
    pub fn data_dir<C>(&self, component: C) -> PathBuf
        where C: Borrow<Component>
    {
        let component = component.borrow();
        let (sequoia, component) = component.dir();

        if let Some(ref dirs) = self.dirs {
            if sequoia {
                dirs.project.data_dir().join(component)
            } else {
                dirs.base.data_dir().join(component)
            }
        } else {
            self.location.join("data").join(component)
        }
    }

    /// Returns a component's cache directory.
    pub fn cache_dir<C>(&self, component: C) -> PathBuf
        where C: Borrow<Component>
    {
        let component = component.borrow();
        let (sequoia, component) = component.dir();

        if let Some(ref dirs) = self.dirs {
            if sequoia {
                dirs.project.cache_dir().join(component)
            } else {
                dirs.base.cache_dir().join(component)
            }
        } else {
            self.location.join("cache").join(component)
        }
    }
}

#[cfg(test)]
mod tests {
    use std::sync::Mutex;

    use super::*;

    static REMOVED_SEQUOIA_HOME: Mutex<bool> = Mutex::new(false);

    /// Sets up the test environment.
    fn setup() {
        let mut removed = REMOVED_SEQUOIA_HOME.lock().unwrap();
        if ! *removed {
            std::env::remove_var(Home::ENV);
            *removed = true;
        }
    }

    #[test]
    fn is_default() -> Result<()> {
        setup();

        let default_location
            = Home::default_location().expect("supported platform");
        eprintln!("default_location: {:?}", default_location);

        let tmpdir = tempfile::tempdir()?;
        let tmpdir = tmpdir.path().to_path_buf();
        eprintln!("tmpdir: {:?}", tmpdir);

        std::env::set_current_dir(&tmpdir).expect("can cd to tmpdir");
        let mut tmpdir_rel = PathBuf::from(".");
        for _ in tmpdir.components().skip(1) {
            tmpdir_rel.push("..");
        }
        for c in tmpdir.components().skip(1) {
            tmpdir_rel.push(c);
        }
        eprintln!("tmpdir_rel: {:?}", tmpdir_rel);

        // The default should be the default.
        let home = Home::new(None).expect("supported platform");
        assert!(home.is_default_location());

        // The default path should be the default (they are
        // bit-for-bit identical).
        let home = Home::new(default_location.clone()).unwrap();
        assert!(home.is_default_location());

        // Append "/." to the end of the default location.  The names
        // are now different, but they still reference the same
        // directory.
        let period = default_location.join(".");
        let home = Home::new(period).unwrap();
        assert!(home.is_default_location());

        // Assert that a completely different location, which exists,
        // isn't the default.
        let home = Home::new(tmpdir.clone()).unwrap();
        assert!(! home.is_default_location());

        let home = Home::new(tmpdir_rel.clone()).unwrap();
        assert!(! home.is_default_location());

        // Assert that a completely different location, which does not
        // exist, isn't considered the default.
        let non_existant = tmpdir.join("adsflkj");
        let home = Home::new(non_existant).unwrap();
        assert!(! home.is_default_location());

        let non_existant = tmpdir_rel.join("adsflkj");
        let home = Home::new(non_existant).unwrap();
        assert!(! home.is_default_location());

        // Assert that a completely different path that is a link to
        // the default, is also considered the default.
        #[cfg(unix)]
        {
            let link = tmpdir.join("link");
            let link_rel = tmpdir_rel.join("link");

            std::os::unix::fs::symlink(default_location, &link)
                .expect("can create symbolic link in temp dir");

            let home = Home::new(link.clone()).unwrap();
            assert!(home.is_default_location());

            let home = Home::new(link_rel.clone()).unwrap();
            assert!(home.is_default_location());

            // And add a dot.
            let home = Home::new(link.join(".")).unwrap();
            assert!(home.is_default_location());

            let home = Home::new(link_rel.join(".")).unwrap();
            assert!(home.is_default_location());
        }

        Ok(())
    }
}
