//! Capability-scoped password-store repository discovery and atomic file access.

use std::{
    collections::BTreeMap,
    error::Error,
    ffi::{OsStr, OsString},
    fmt, fs,
    io::{self, Read as _, Write as _},
    path::{Component, Path, PathBuf},
};

#[cfg(test)]
use std::collections::BTreeSet;

use cap_std::{ambient_authority, fs::Dir};
use cap_tempfile::TempFile;
use zeroize::Zeroize;

const ENTRY_EXTENSION: &str = "gpg";
const RECIPIENT_FILE: &str = ".gpg-id";
const RECIPIENT_SIGNATURE_FILE: &str = ".gpg-id.sig";
const GIT_DIRECTORY: &str = ".git";

/// A validated logical password-store entry path, without the `.gpg` suffix.
#[derive(Clone, Debug, Eq, Ord, PartialEq, PartialOrd)]
pub struct EntryPath(PathBuf);

impl EntryPath {
    pub fn parse(path: impl AsRef<Path>) -> Result<Self, RepositoryError> {
        let path = path.as_ref();
        if has_trailing_separator(path) {
            return Err(RepositoryError::InvalidPath {
                path: path.to_owned(),
            });
        }
        Ok(Self(normalize_relative_path(path, false)?))
    }

    pub fn as_path(&self) -> &Path {
        &self.0
    }

    pub fn parent_directory(&self) -> DirectoryPath {
        self.parent()
    }

    fn parent(&self) -> DirectoryPath {
        DirectoryPath(self.0.parent().map_or_else(PathBuf::new, Path::to_path_buf))
    }

    pub fn encrypted_relative_path(&self) -> PathBuf {
        let mut path = self.0.clone();
        let mut file_name = path
            .file_name()
            .expect("validated entry path has a file name")
            .to_os_string();
        file_name.push(".gpg");
        path.set_file_name(file_name);
        path
    }
}

impl fmt::Display for EntryPath {
    fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result {
        self.0.display().fmt(formatter)
    }
}

/// A validated logical directory path. The empty path denotes the store root.
#[derive(Clone, Debug, Eq, Ord, PartialEq, PartialOrd)]
pub struct DirectoryPath(PathBuf);

impl DirectoryPath {
    pub fn root() -> Self {
        Self(PathBuf::new())
    }

    pub fn parse(path: impl AsRef<Path>) -> Result<Self, RepositoryError> {
        let path = path.as_ref();
        Ok(Self(normalize_relative_path(path, true)?))
    }

    pub fn as_path(&self) -> &Path {
        &self.0
    }

    fn parent(&self) -> Option<Self> {
        if self.0.as_os_str().is_empty() {
            None
        } else {
            Some(Self(
                self.0.parent().map_or_else(PathBuf::new, Path::to_path_buf),
            ))
        }
    }
}

impl fmt::Display for DirectoryPath {
    fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result {
        if self.0.as_os_str().is_empty() {
            formatter.write_str(".")
        } else {
            self.0.display().fmt(formatter)
        }
    }
}

/// Encrypted bytes read from or ready to be written to a `.gpg` entry.
#[derive(Clone, Eq, PartialEq)]
pub struct EncryptedEntry(Vec<u8>);

impl EncryptedEntry {
    pub fn new(bytes: Vec<u8>) -> Self {
        Self(bytes)
    }

    pub fn as_bytes(&self) -> &[u8] {
        &self.0
    }

    pub fn into_bytes(self) -> Vec<u8> {
        self.0
    }
}

impl fmt::Debug for EncryptedEntry {
    fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result {
        formatter
            .debug_struct("EncryptedEntry")
            .field("length", &self.0.len())
            .finish()
    }
}

/// Decrypted bytes that are redacted in diagnostics and zeroed when dropped.
pub struct SecretBytes(Vec<u8>);

impl SecretBytes {
    pub fn new(bytes: Vec<u8>) -> Self {
        Self(bytes)
    }

    pub fn expose(&self) -> &[u8] {
        &self.0
    }

    pub fn expose_mut(&mut self) -> &mut [u8] {
        &mut self.0
    }
}

impl fmt::Debug for SecretBytes {
    fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result {
        formatter.write_str("SecretBytes([REDACTED])")
    }
}

impl Drop for SecretBytes {
    fn drop(&mut self) {
        self.0.zeroize();
    }
}

#[derive(Clone, Debug, Eq, PartialEq)]
pub struct EntryRecord {
    path: EntryPath,
    ciphertext_length: u64,
}

impl EntryRecord {
    pub fn path(&self) -> &EntryPath {
        &self.path
    }

    pub fn ciphertext_length(&self) -> u64 {
        self.ciphertext_length
    }
}

#[derive(Clone, Debug, Eq, PartialEq)]
pub struct DirectoryRecord {
    path: DirectoryPath,
    has_recipient_file: bool,
    has_recipient_signature: bool,
}

impl DirectoryRecord {
    pub fn path(&self) -> &DirectoryPath {
        &self.path
    }

    pub fn has_recipient_file(&self) -> bool {
        self.has_recipient_file
    }

    pub fn has_recipient_signature(&self) -> bool {
        self.has_recipient_signature
    }
}

#[derive(Clone, Debug, Eq, PartialEq)]
pub struct RecipientPolicy {
    directory: DirectoryPath,
    recipients: PathBuf,
    signature: Option<PathBuf>,
}

impl RecipientPolicy {
    pub fn directory(&self) -> &DirectoryPath {
        &self.directory
    }

    pub fn recipients_path(&self) -> &Path {
        &self.recipients
    }

    pub fn signature_path(&self) -> Option<&Path> {
        self.signature.as_deref()
    }
}

#[derive(Clone, Debug, Eq, PartialEq)]
pub struct GitRepository {
    work_tree: DirectoryPath,
    git_directory: PathBuf,
}

impl GitRepository {
    pub fn work_tree(&self) -> &DirectoryPath {
        &self.work_tree
    }

    pub fn git_directory(&self) -> &Path {
        &self.git_directory
    }
}

#[derive(Clone, Debug, Eq, PartialEq)]
pub struct AuxiliaryFile {
    path: PathBuf,
    length: u64,
}

impl AuxiliaryFile {
    pub fn path(&self) -> &Path {
        &self.path
    }

    pub fn length(&self) -> u64 {
        self.length
    }
}

#[derive(Clone, Copy, Debug, Eq, PartialEq)]
pub enum ResolvedObject<'a> {
    Entry(&'a EntryRecord),
    Directory(&'a DirectoryRecord),
}

/// A deterministic inventory of the ordinary pass tree.
#[derive(Clone, Debug, Default, Eq, PartialEq)]
pub struct RepositorySnapshot {
    entries: BTreeMap<EntryPath, EntryRecord>,
    directories: BTreeMap<DirectoryPath, DirectoryRecord>,
    recipients: BTreeMap<DirectoryPath, RecipientPolicy>,
    git_repositories: BTreeMap<DirectoryPath, GitRepository>,
    auxiliary_files: BTreeMap<PathBuf, AuxiliaryFile>,
}

impl RepositorySnapshot {
    pub fn entries(&self) -> impl ExactSizeIterator<Item = &EntryRecord> {
        self.entries.values()
    }

    pub fn directories(&self) -> impl ExactSizeIterator<Item = &DirectoryRecord> {
        self.directories.values()
    }

    pub fn recipient_policies(&self) -> impl ExactSizeIterator<Item = &RecipientPolicy> {
        self.recipients.values()
    }

    pub fn git_repositories(&self) -> impl ExactSizeIterator<Item = &GitRepository> {
        self.git_repositories.values()
    }

    pub fn auxiliary_files(&self) -> impl ExactSizeIterator<Item = &AuxiliaryFile> {
        self.auxiliary_files.values()
    }

    /// Resolve an upstream-style display path. A trailing slash explicitly selects a directory.
    pub fn resolve(&self, input: &str) -> Result<ResolvedObject<'_>, RepositoryError> {
        let directory_only = input.ends_with('/') || (cfg!(windows) && input.ends_with('\\'));
        let trimmed = if directory_only {
            input.trim_end_matches(['/', '\\'])
        } else {
            input
        };
        let directory = DirectoryPath::parse(trimmed)?;
        let directory_record = self.directories.get(&directory);
        if directory_only || trimmed.is_empty() {
            return directory_record
                .map(ResolvedObject::Directory)
                .ok_or_else(|| RepositoryError::NotFound {
                    path: directory.0.clone(),
                });
        }
        let entry = EntryPath::parse(trimmed)?;
        let entry_record = self.entries.get(&entry);
        match (entry_record, directory_record) {
            (Some(entry), _) => Ok(ResolvedObject::Entry(entry)),
            (None, Some(directory)) => Ok(ResolvedObject::Directory(directory)),
            (None, None) => Err(RepositoryError::NotFound {
                path: entry.0.clone(),
            }),
        }
    }

    pub fn recipient_policy(&self, entry: &EntryPath) -> Option<&RecipientPolicy> {
        let mut directory = entry.parent();
        loop {
            if let Some(policy) = self.recipients.get(&directory) {
                return Some(policy);
            }
            directory = directory.parent()?;
        }
    }

    pub fn recipient_policy_at(&self, directory: &DirectoryPath) -> Option<&RecipientPolicy> {
        self.recipients.get(directory)
    }

    pub fn git_repository(&self, entry: &EntryPath) -> Option<&GitRepository> {
        let mut directory = entry.parent();
        loop {
            if let Some(repository) = self.git_repositories.get(&directory) {
                return Some(repository);
            }
            directory = directory.parent()?;
        }
    }
}

/// An open repository whose filesystem access remains scoped to its root handle.
pub struct Repository {
    root_path: PathBuf,
    root: Dir,
}

impl fmt::Debug for Repository {
    fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result {
        formatter
            .debug_struct("Repository")
            .field("root_path", &self.root_path)
            .finish_non_exhaustive()
    }
}

impl Repository {
    pub fn open(root: impl AsRef<Path>) -> Result<Self, RepositoryError> {
        let requested = root.as_ref();
        let metadata = fs::symlink_metadata(requested)
            .map_err(|error| io_error("inspect repository root", requested, error))?;
        if metadata.file_type().is_symlink() {
            return Err(RepositoryError::Symlink {
                path: requested.to_owned(),
            });
        }
        if !metadata.is_dir() {
            return Err(RepositoryError::InvalidRoot {
                path: requested.to_owned(),
            });
        }
        let root_path = fs::canonicalize(requested)
            .map_err(|error| io_error("canonicalize repository root", requested, error))?;
        let root = Dir::open_ambient_dir(&root_path, ambient_authority())
            .map_err(|error| io_error("open repository root", &root_path, error))?;
        Ok(Self { root_path, root })
    }

    pub fn root_path(&self) -> &Path {
        &self.root_path
    }

    pub fn snapshot(&self) -> Result<RepositorySnapshot, RepositoryError> {
        let mut snapshot = RepositorySnapshot::default();
        scan_directory(&self.root, &DirectoryPath::root(), &mut snapshot)?;
        Ok(snapshot)
    }

    pub fn read_entry(&self, path: &EntryPath) -> Result<EncryptedEntry, RepositoryError> {
        let (parent, file_name) = self.open_entry_parent(path)?;
        let metadata = child_metadata(&parent, &file_name, &path.encrypted_relative_path())?
            .ok_or_else(|| RepositoryError::NotFound {
                path: path.0.clone(),
            })?;
        require_regular_file(metadata, &path.encrypted_relative_path())?;
        let mut file = parent
            .open(&file_name)
            .map_err(|error| io_error("open encrypted entry", &path.0, error))?;
        let mut bytes = Vec::new();
        file.read_to_end(&mut bytes)
            .map_err(|error| io_error("read encrypted entry", &path.0, error))?;
        Ok(EncryptedEntry(bytes))
    }

    pub(crate) fn read_policy_file(
        &self,
        directory: &DirectoryPath,
        signature: bool,
    ) -> Result<Option<Vec<u8>>, RepositoryError> {
        let opened = match self.open_directory(&directory.0) {
            Ok(opened) => opened,
            Err(RepositoryError::NotFound { .. }) => return Ok(None),
            Err(error) => return Err(error),
        };
        let name = if signature {
            RECIPIENT_SIGNATURE_FILE
        } else {
            RECIPIENT_FILE
        };
        let path = directory.0.join(name);
        let Some(metadata) = child_metadata(&opened, name, &path)? else {
            return Ok(None);
        };
        require_regular_file(metadata, &path)?;
        let mut file = opened
            .open(name)
            .map_err(|error| io_error("open recipient policy", &path, error))?;
        let mut bytes = Vec::new();
        file.read_to_end(&mut bytes)
            .map_err(|error| io_error("read recipient policy", &path, error))?;
        Ok(Some(bytes))
    }

    pub(crate) fn replace_policy_file(
        &self,
        directory: &DirectoryPath,
        signature: bool,
        contents: Option<&[u8]>,
    ) -> Result<(), RepositoryError> {
        let name = if signature {
            RECIPIENT_SIGNATURE_FILE
        } else {
            RECIPIENT_FILE
        };
        let path = directory.0.join(name);
        let (opened, created) = if contents.is_some() {
            self.create_directory_path(directory)?
        } else {
            match self.open_directory(&directory.0) {
                Ok(opened) => (opened, Vec::new()),
                Err(RepositoryError::NotFound { .. }) => return Ok(()),
                Err(error) => return Err(error),
            }
        };
        let result = (|| {
            if let Some(contents) = contents {
                if let Some(metadata) = child_metadata(&opened, name, &path)? {
                    require_regular_file(metadata, &path)?;
                }
                let mut temporary = TempFile::new(&opened)
                    .map_err(|error| io_error("create atomic recipient policy", &path, error))?;
                set_private_permissions(&temporary, &path)?;
                temporary
                    .write_all(contents)
                    .map_err(|error| io_error("write atomic recipient policy", &path, error))?;
                temporary
                    .as_file()
                    .sync_all()
                    .map_err(|error| io_error("sync atomic recipient policy", &path, error))?;
                temporary
                    .replace(name)
                    .map_err(|error| io_error("replace recipient policy", &path, error))?;
                sync_directory(&opened, &directory.0)
                    .map_err(|_| RepositoryError::DurabilityUncertain { path: path.clone() })
            } else {
                let Some(metadata) = child_metadata(&opened, name, &path)? else {
                    return Ok(());
                };
                require_regular_file(metadata, &path)?;
                opened
                    .remove_file(name)
                    .map_err(|error| io_error("remove recipient policy", &path, error))?;
                sync_directory(&opened, &directory.0)
                    .map_err(|_| RepositoryError::DurabilityUncertain { path: path.clone() })
            }
        })();
        match result {
            Ok(()) => Ok(()),
            Err(error) if error.committed() => Err(error),
            Err(error) => self.rollback_created(created, error),
        }
    }

    /// Atomically replace an encrypted entry and durably commit its containing directory.
    pub fn write_entry(
        &self,
        path: &EntryPath,
        ciphertext: &EncryptedEntry,
    ) -> Result<(), RepositoryError> {
        self.write_entry_with_checkpoint(path, ciphertext, |_| Ok(()))
    }

    pub fn remove_entry(&self, path: &EntryPath) -> Result<EncryptedEntry, RepositoryError> {
        let original = self.read_entry(path)?;
        let (parent, file_name) = self.open_entry_parent(path)?;
        parent
            .remove_file(&file_name)
            .map_err(|error| io_error("remove encrypted entry", &path.0, error))?;
        sync_directory(&parent, &path.parent().0).map_err(|_| {
            RepositoryError::DurabilityUncertain {
                path: path.0.clone(),
            }
        })?;
        Ok(original)
    }

    fn write_entry_with_checkpoint<F>(
        &self,
        path: &EntryPath,
        ciphertext: &EncryptedEntry,
        mut checkpoint: F,
    ) -> Result<(), RepositoryError>
    where
        F: FnMut(WriteStage) -> Result<(), RepositoryError>,
    {
        let (parent, file_name, created) = self.create_entry_parent(path)?;
        let encrypted_path = path.encrypted_relative_path();
        if let Err(error) = validate_write_target(&parent, &file_name, &encrypted_path) {
            return self.rollback_created(created, error);
        }

        let result = (|| {
            let mut temporary = TempFile::new(&parent)
                .map_err(|error| io_error("create atomic entry", &path.0, error))?;
            set_private_permissions(&temporary, &path.0)?;
            temporary
                .write_all(ciphertext.as_bytes())
                .map_err(|error| io_error("write atomic entry", &path.0, error))?;
            checkpoint(WriteStage::BeforeTemporarySync)?;
            temporary
                .as_file()
                .sync_all()
                .map_err(|error| io_error("sync atomic entry", &path.0, error))?;
            checkpoint(WriteStage::AfterTemporarySync)?;
            temporary
                .replace(&file_name)
                .map_err(|error| io_error("replace encrypted entry", &path.0, error))?;
            if checkpoint(WriteStage::AfterRename).is_err() {
                return Err(RepositoryError::DurabilityUncertain {
                    path: path.0.clone(),
                });
            }
            sync_directory(&parent, &path.0).map_err(|_| RepositoryError::DurabilityUncertain {
                path: path.0.clone(),
            })?;
            Ok(())
        })();

        match result {
            Ok(()) => Ok(()),
            Err(error) if error.committed() => Err(error),
            Err(error) => self.rollback_created(created, error),
        }
    }

    /// Remove a directory and then its empty ancestors, stopping at the root or first nonempty
    /// directory. Recipient policy files and all other ordinary files naturally preserve a
    /// directory because removal is attempted only with the filesystem's empty-directory
    /// operation.
    pub fn cleanup_empty_directories(
        &self,
        start: &DirectoryPath,
    ) -> Result<Vec<DirectoryPath>, RepositoryError> {
        let mut current = Some(start.clone());
        let mut removed = Vec::new();
        while let Some(directory) = current {
            if directory.0.as_os_str().is_empty() {
                break;
            }
            let parent = directory.parent().unwrap_or_else(DirectoryPath::root);
            let parent_handle = self.open_directory(&parent.0)?;
            let name = directory
                .0
                .file_name()
                .expect("non-root directory has a file name");
            let metadata = child_metadata(&parent_handle, name, &directory.0)?;
            let Some(metadata) = metadata else {
                current = directory.parent();
                continue;
            };
            require_directory(metadata, &directory.0)?;
            match parent_handle.remove_dir(name) {
                Ok(()) => {
                    sync_directory(&parent_handle, &parent.0).map_err(|_| {
                        RepositoryError::DurabilityUncertain {
                            path: directory.0.clone(),
                        }
                    })?;
                    removed.push(directory.clone());
                    current = Some(parent);
                }
                Err(error) if error.kind() == io::ErrorKind::DirectoryNotEmpty => break,
                Err(error) if error.kind() == io::ErrorKind::NotFound => {
                    current = directory.parent()
                }
                Err(error) => {
                    return Err(io_error(
                        "remove empty repository directory",
                        &directory.0,
                        error,
                    ));
                }
            }
        }
        Ok(removed)
    }

    pub(crate) fn ensure_directory(
        &self,
        path: &DirectoryPath,
    ) -> Result<Vec<DirectoryPath>, RepositoryError> {
        let (_, created) = self.create_directory_path(path)?;
        Ok(created.into_iter().map(DirectoryPath).collect())
    }

    pub(crate) fn remove_empty_directory(
        &self,
        directory: &DirectoryPath,
    ) -> Result<bool, RepositoryError> {
        let Some(parent) = directory.parent() else {
            return Ok(false);
        };
        let parent_handle = self.open_directory(parent.as_path())?;
        let name = directory
            .as_path()
            .file_name()
            .expect("non-root directory has a file name");
        let Some(metadata) = child_metadata(&parent_handle, name, directory.as_path())? else {
            return Ok(false);
        };
        require_directory(metadata, directory.as_path())?;
        match parent_handle.remove_dir(name) {
            Ok(()) => {
                sync_directory(&parent_handle, parent.as_path()).map_err(|_| {
                    RepositoryError::DurabilityUncertain {
                        path: directory.as_path().to_owned(),
                    }
                })?;
                Ok(true)
            }
            Err(error) if error.kind() == io::ErrorKind::DirectoryNotEmpty => Ok(false),
            Err(error) if error.kind() == io::ErrorKind::NotFound => Ok(false),
            Err(error) => Err(io_error(
                "remove empty repository directory",
                directory.as_path(),
                error,
            )),
        }
    }

    fn open_entry_parent(&self, path: &EntryPath) -> Result<(Dir, OsString), RepositoryError> {
        let mut directory = self
            .root
            .try_clone()
            .map_err(|error| io_error("clone repository root", Path::new("."), error))?;
        let mut relative = PathBuf::new();
        let mut components = path.0.components().peekable();
        while let Some(component) = components.next() {
            let Component::Normal(name) = component else {
                unreachable!("EntryPath is validated")
            };
            if components.peek().is_none() {
                let mut file_name = name.to_os_string();
                file_name.push(".gpg");
                return Ok((directory, file_name));
            }
            relative.push(name);
            let metadata = child_metadata(&directory, name, &relative)?;
            let Some(metadata) = metadata else {
                return Err(RepositoryError::NotFound { path: relative });
            };
            require_directory(metadata, &relative)?;
            directory = directory
                .open_dir(name)
                .map_err(|error| io_error("open entry parent", &relative, error))?;
        }
        unreachable!("EntryPath has at least one component")
    }

    fn create_entry_parent(
        &self,
        path: &EntryPath,
    ) -> Result<(Dir, OsString, Vec<PathBuf>), RepositoryError> {
        let mut directory = self
            .root
            .try_clone()
            .map_err(|error| io_error("clone repository root", Path::new("."), error))?;
        let mut relative = PathBuf::new();
        let mut created = Vec::new();
        let mut components = path.0.components().peekable();
        while let Some(component) = components.next() {
            let Component::Normal(name) = component else {
                unreachable!("EntryPath is validated")
            };
            if components.peek().is_none() {
                let mut file_name = name.to_os_string();
                file_name.push(".gpg");
                return Ok((directory, file_name, created));
            }
            relative.push(name);
            match child_metadata(&directory, name, &relative) {
                Ok(Some(metadata)) => {
                    if let Err(error) = require_directory(metadata, &relative) {
                        return self.rollback_created(created, error);
                    }
                }
                Ok(None) => {
                    if let Err(error) = create_private_directory(&directory, name, &relative) {
                        return self.rollback_created(created, error);
                    }
                    created.push(relative.clone());
                    if let Err(error) = sync_directory(&directory, &relative) {
                        return self.rollback_created(created, error);
                    }
                }
                Err(error) => return self.rollback_created(created, error),
            }
            match directory.open_dir(name) {
                Ok(opened) => directory = opened,
                Err(error) => {
                    return self.rollback_created(
                        created,
                        io_error("open entry directory", &relative, error),
                    );
                }
            }
        }
        unreachable!("EntryPath has at least one component")
    }

    fn create_directory_path(
        &self,
        path: &DirectoryPath,
    ) -> Result<(Dir, Vec<PathBuf>), RepositoryError> {
        let mut directory = self
            .root
            .try_clone()
            .map_err(|error| io_error("clone repository root", Path::new("."), error))?;
        let mut relative = PathBuf::new();
        let mut created = Vec::new();
        for component in path.0.components() {
            let Component::Normal(name) = component else {
                unreachable!("DirectoryPath is validated")
            };
            relative.push(name);
            match child_metadata(&directory, name, &relative) {
                Ok(Some(metadata)) => {
                    if let Err(error) = require_directory(metadata, &relative) {
                        return self.rollback_created(created, error);
                    }
                }
                Ok(None) => {
                    if let Err(error) = create_private_directory(&directory, name, &relative) {
                        return self.rollback_created(created, error);
                    }
                    created.push(relative.clone());
                    if let Err(error) = sync_directory(&directory, &relative) {
                        return self.rollback_created(created, error);
                    }
                }
                Err(error) => return self.rollback_created(created, error),
            }
            match directory.open_dir(name) {
                Ok(opened) => directory = opened,
                Err(error) => {
                    return self.rollback_created(
                        created,
                        io_error("open recipient policy directory", &relative, error),
                    );
                }
            }
        }
        Ok((directory, created))
    }

    fn rollback_created<T>(
        &self,
        mut created: Vec<PathBuf>,
        original: RepositoryError,
    ) -> Result<T, RepositoryError> {
        while let Some(path) = created.pop() {
            let parent = path.parent().unwrap_or_else(|| Path::new(""));
            let parent_dir = self.open_directory(parent)?;
            let name = path
                .file_name()
                .expect("created directory path has a file name");
            match parent_dir.remove_dir(name) {
                Ok(()) => {
                    sync_directory(&parent_dir, parent)?;
                }
                Err(error) if error.kind() == io::ErrorKind::NotFound => {}
                Err(error) => {
                    return Err(RepositoryError::RollbackFailed {
                        path,
                        source: error.kind(),
                    });
                }
            }
        }
        Err(original)
    }

    fn open_directory(&self, path: &Path) -> Result<Dir, RepositoryError> {
        let path = normalize_relative_path(path, true)?;
        let mut directory = self
            .root
            .try_clone()
            .map_err(|error| io_error("clone repository directory", &path, error))?;
        let mut relative = PathBuf::new();
        for component in path.components() {
            let Component::Normal(name) = component else {
                unreachable!("normalized directory path has only normal components")
            };
            relative.push(name);
            let metadata = child_metadata(&directory, name, &relative)?.ok_or_else(|| {
                RepositoryError::NotFound {
                    path: relative.clone(),
                }
            })?;
            require_directory(metadata, &relative)?;
            directory = directory
                .open_dir(name)
                .map_err(|error| io_error("open repository directory", &relative, error))?;
        }
        Ok(directory)
    }
}

#[derive(Clone, Copy, Debug, Eq, PartialEq)]
enum WriteStage {
    BeforeTemporarySync,
    AfterTemporarySync,
    AfterRename,
}

#[cfg(test)]
impl WriteStage {
    fn fixture_id(self) -> &'static str {
        match self {
            Self::BeforeTemporarySync => "before-temp-fsync",
            Self::AfterTemporarySync => "after-temp-fsync-before-rename",
            Self::AfterRename => "after-rename-before-directory-fsync",
        }
    }
}

#[derive(Clone, Debug, Eq, PartialEq)]
pub enum RepositoryError {
    InvalidRoot {
        path: PathBuf,
    },
    InvalidPath {
        path: PathBuf,
    },
    Symlink {
        path: PathBuf,
    },
    UnsupportedFileType {
        path: PathBuf,
    },
    InvalidGitBoundary {
        path: PathBuf,
    },
    Collision {
        path: PathBuf,
    },
    NotFound {
        path: PathBuf,
    },
    Io {
        operation: &'static str,
        path: PathBuf,
        source: io::ErrorKind,
    },
    RollbackFailed {
        path: PathBuf,
        source: io::ErrorKind,
    },
    DurabilityUncertain {
        path: PathBuf,
    },
}

impl RepositoryError {
    fn committed(&self) -> bool {
        matches!(self, Self::DurabilityUncertain { .. })
    }
}

impl fmt::Display for RepositoryError {
    fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result {
        match self {
            Self::InvalidRoot { path } => {
                write!(
                    formatter,
                    "password-store root is not a directory: {}",
                    path.display()
                )
            }
            Self::InvalidPath { path } => write!(
                formatter,
                "password-store path must be non-absolute and contain no traversal: {}",
                path.display()
            ),
            Self::Symlink { path } => {
                write!(
                    formatter,
                    "symbolic links are forbidden in a password store: {}",
                    path.display()
                )
            }
            Self::UnsupportedFileType { path } => write!(
                formatter,
                "unsupported password-store file type: {}",
                path.display()
            ),
            Self::InvalidGitBoundary { path } => write!(
                formatter,
                "password-store Git metadata must be a directory: {}",
                path.display()
            ),
            Self::Collision { path } => write!(
                formatter,
                "password-store entry collides with a directory: {}",
                path.display()
            ),
            Self::NotFound { path } => {
                write!(
                    formatter,
                    "password-store object not found: {}",
                    path.display()
                )
            }
            Self::Io {
                operation,
                path,
                source,
            } => write!(formatter, "cannot {operation} {}: {source}", path.display()),
            Self::RollbackFailed { path, source } => write!(
                formatter,
                "failed to roll back empty password-store directory {}: {source}",
                path.display()
            ),
            Self::DurabilityUncertain { path } => write!(
                formatter,
                "repository mutation completed but its directory sync was interrupted: {}",
                path.display()
            ),
        }
    }
}

impl Error for RepositoryError {}

fn normalize_relative_path(path: &Path, allow_empty: bool) -> Result<PathBuf, RepositoryError> {
    if (!allow_empty && path.as_os_str().is_empty()) || path.is_absolute() {
        return Err(RepositoryError::InvalidPath {
            path: path.to_owned(),
        });
    }
    let mut normalized = PathBuf::new();
    for component in path.components() {
        match component {
            Component::Normal(name) if !name.is_empty() => normalized.push(name),
            _ => {
                return Err(RepositoryError::InvalidPath {
                    path: path.to_owned(),
                });
            }
        }
    }
    if !allow_empty && normalized.as_os_str().is_empty() {
        return Err(RepositoryError::InvalidPath {
            path: path.to_owned(),
        });
    }
    Ok(normalized)
}

fn has_trailing_separator(path: &Path) -> bool {
    path.as_os_str()
        .as_encoded_bytes()
        .last()
        .is_some_and(|byte| *byte == b'/' || (cfg!(windows) && *byte == b'\\'))
}

fn scan_directory(
    directory: &Dir,
    relative: &DirectoryPath,
    snapshot: &mut RepositorySnapshot,
) -> Result<(), RepositoryError> {
    snapshot
        .directories
        .entry(relative.clone())
        .or_insert_with(|| DirectoryRecord {
            path: relative.clone(),
            has_recipient_file: false,
            has_recipient_signature: false,
        });
    let mut entries = directory
        .read_dir(".")
        .map_err(|error| io_error("read repository directory", &relative.0, error))?
        .collect::<Result<Vec<_>, _>>()
        .map_err(|error| io_error("read repository entry", &relative.0, error))?;
    entries.sort_by_key(cap_std::fs::DirEntry::file_name);

    for entry in entries {
        let name = entry.file_name();
        let path = relative.0.join(&name);
        let file_type = entry
            .file_type()
            .map_err(|error| io_error("inspect repository entry", &path, error))?;
        if file_type.is_symlink() {
            return Err(RepositoryError::Symlink { path });
        }
        if file_type.is_dir() {
            if name == OsStr::new(GIT_DIRECTORY) {
                snapshot.git_repositories.insert(
                    relative.clone(),
                    GitRepository {
                        work_tree: relative.clone(),
                        git_directory: path,
                    },
                );
                continue;
            }
            let opened = entry
                .open_dir()
                .map_err(|error| io_error("open repository directory", &path, error))?;
            scan_directory(&opened, &DirectoryPath(path), snapshot)?;
            continue;
        }
        if !file_type.is_file() {
            return Err(RepositoryError::UnsupportedFileType { path });
        }
        let length = entry
            .metadata()
            .map_err(|error| io_error("inspect repository file", &path, error))?
            .len();
        if name == OsStr::new(GIT_DIRECTORY) {
            return Err(RepositoryError::InvalidGitBoundary { path });
        }
        if name == OsStr::new(RECIPIENT_FILE) {
            let directory_record = snapshot
                .directories
                .get_mut(relative)
                .expect("current directory record exists");
            directory_record.has_recipient_file = true;
            snapshot.recipients.insert(
                relative.clone(),
                RecipientPolicy {
                    directory: relative.clone(),
                    recipients: path,
                    signature: None,
                },
            );
            continue;
        }
        if name == OsStr::new(RECIPIENT_SIGNATURE_FILE) {
            let directory_record = snapshot
                .directories
                .get_mut(relative)
                .expect("current directory record exists");
            directory_record.has_recipient_signature = true;
            if let Some(policy) = snapshot.recipients.get_mut(relative) {
                policy.signature = Some(path);
            } else {
                snapshot
                    .auxiliary_files
                    .insert(path.clone(), AuxiliaryFile { path, length });
            }
            continue;
        }
        if Path::new(&name).extension() == Some(OsStr::new(ENTRY_EXTENSION)) {
            let Some(stem) = Path::new(&name).file_stem().filter(|stem| !stem.is_empty()) else {
                return Err(RepositoryError::InvalidPath { path });
            };
            let entry_path = EntryPath(relative.0.join(stem));
            snapshot.entries.insert(
                entry_path.clone(),
                EntryRecord {
                    path: entry_path,
                    ciphertext_length: length,
                },
            );
        } else {
            snapshot
                .auxiliary_files
                .insert(path.clone(), AuxiliaryFile { path, length });
        }
    }

    if let Some(policy) = snapshot.recipients.get_mut(relative) {
        let signature = relative.0.join(RECIPIENT_SIGNATURE_FILE);
        if snapshot
            .directories
            .get(relative)
            .is_some_and(DirectoryRecord::has_recipient_signature)
        {
            policy.signature = Some(signature.clone());
            snapshot.auxiliary_files.remove(&signature);
        }
    }
    Ok(())
}

fn child_metadata(
    directory: &Dir,
    name: impl AsRef<Path>,
    path: &Path,
) -> Result<Option<cap_std::fs::Metadata>, RepositoryError> {
    match directory.symlink_metadata(name) {
        Ok(metadata) => {
            if metadata.file_type().is_symlink() {
                Err(RepositoryError::Symlink {
                    path: path.to_owned(),
                })
            } else {
                Ok(Some(metadata))
            }
        }
        Err(error) if error.kind() == io::ErrorKind::NotFound => Ok(None),
        Err(error) => Err(io_error("inspect repository object", path, error)),
    }
}

fn require_directory(metadata: cap_std::fs::Metadata, path: &Path) -> Result<(), RepositoryError> {
    if metadata.is_dir() {
        Ok(())
    } else if metadata.is_file() {
        Err(RepositoryError::Collision {
            path: path.to_owned(),
        })
    } else {
        Err(RepositoryError::UnsupportedFileType {
            path: path.to_owned(),
        })
    }
}

fn require_regular_file(
    metadata: cap_std::fs::Metadata,
    path: &Path,
) -> Result<(), RepositoryError> {
    if metadata.is_file() {
        Ok(())
    } else if metadata.is_dir() {
        Err(RepositoryError::Collision {
            path: path.to_owned(),
        })
    } else {
        Err(RepositoryError::UnsupportedFileType {
            path: path.to_owned(),
        })
    }
}

fn validate_write_target(
    parent: &Dir,
    file_name: &OsStr,
    encrypted: &Path,
) -> Result<(), RepositoryError> {
    if let Some(metadata) = child_metadata(parent, file_name, encrypted)? {
        require_regular_file(metadata, encrypted)?;
    }
    Ok(())
}

#[cfg(unix)]
fn create_private_directory(
    parent: &Dir,
    name: &OsStr,
    path: &Path,
) -> Result<(), RepositoryError> {
    use cap_std::fs::{DirBuilder, DirBuilderExt as _};

    let mut builder = DirBuilder::new();
    builder.mode(0o700);
    parent
        .create_dir_with(name, &builder)
        .map_err(|error| io_error("create entry directory", path, error))
}

#[cfg(not(unix))]
fn create_private_directory(
    parent: &Dir,
    name: &OsStr,
    path: &Path,
) -> Result<(), RepositoryError> {
    parent
        .create_dir(name)
        .map_err(|error| io_error("create entry directory", path, error))
}

#[cfg(unix)]
fn set_private_permissions(temporary: &TempFile<'_>, path: &Path) -> Result<(), RepositoryError> {
    use cap_std::fs::{Permissions, PermissionsExt as _};

    temporary
        .as_file()
        .set_permissions(Permissions::from_mode(0o600))
        .map_err(|error| io_error("set encrypted entry permissions", path, error))
}

#[cfg(not(unix))]
fn set_private_permissions(_temporary: &TempFile<'_>, _path: &Path) -> Result<(), RepositoryError> {
    Ok(())
}

fn sync_directory(directory: &Dir, path: &Path) -> Result<(), RepositoryError> {
    directory
        .open(".")
        .and_then(|file| file.sync_all())
        .map_err(|error| io_error("sync repository directory", path, error))
}

fn io_error(operation: &'static str, path: &Path, error: io::Error) -> RepositoryError {
    RepositoryError::Io {
        operation,
        path: path.to_owned(),
        source: error.kind(),
    }
}

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

    #[test]
    fn interruption_boundaries_never_expose_partial_entries() -> Result<(), Box<dyn Error>> {
        let stages = [
            WriteStage::BeforeTemporarySync,
            WriteStage::AfterTemporarySync,
            WriteStage::AfterRename,
        ];
        let layouts_source = fs::read_to_string(
            Path::new(env!("CARGO_MANIFEST_DIR")).join("tests/fixtures/compatibility/layouts.toml"),
        )?;
        let layouts = toml::from_str::<toml::Value>(&layouts_source)?;
        let fixture_ids = layouts["interruption"]
            .as_array()
            .expect("interruption recipes")
            .iter()
            .filter_map(|recipe| recipe["id"].as_str())
            .collect::<BTreeSet<_>>();
        assert_eq!(
            fixture_ids,
            stages.iter().map(|stage| stage.fixture_id()).collect()
        );

        for stage in stages {
            let temporary = tempfile::tempdir()?;
            fs::create_dir(temporary.path().join("vault"))?;
            let repository = Repository::open(temporary.path().join("vault"))?;
            let path = EntryPath::parse("nested/entry")?;
            repository.write_entry(&path, &EncryptedEntry::new(b"original".to_vec()))?;

            let result = repository.write_entry_with_checkpoint(
                &path,
                &EncryptedEntry::new(b"replacement".to_vec()),
                |current| {
                    if current == stage {
                        Err(RepositoryError::Io {
                            operation: "simulated interruption",
                            path: path.0.clone(),
                            source: io::ErrorKind::Interrupted,
                        })
                    } else {
                        Ok(())
                    }
                },
            );
            if stage == WriteStage::AfterRename {
                assert!(matches!(
                    result,
                    Err(RepositoryError::DurabilityUncertain { .. })
                ));
            } else {
                assert!(matches!(result, Err(RepositoryError::Io { .. })));
            }
            let actual = repository.read_entry(&path)?;
            let expected = if stage == WriteStage::AfterRename {
                b"replacement".as_slice()
            } else {
                b"original".as_slice()
            };
            assert_eq!(actual.as_bytes(), expected);
            let files = fs::read_dir(temporary.path().join("vault/nested"))?
                .collect::<Result<Vec<_>, _>>()?;
            assert_eq!(files.len(), 1, "temporary file leaked at {stage:?}");
        }
        Ok(())
    }

    #[test]
    fn post_rename_interruption_preserves_a_complete_new_tree() -> Result<(), Box<dyn Error>> {
        let temporary = tempfile::tempdir()?;
        fs::create_dir(temporary.path().join("vault"))?;
        let repository = Repository::open(temporary.path().join("vault"))?;
        let path = EntryPath::parse("one/two/entry")?;
        let error = repository
            .write_entry_with_checkpoint(
                &path,
                &EncryptedEntry::new(b"complete ciphertext".to_vec()),
                |stage| {
                    if stage == WriteStage::AfterRename {
                        Err(RepositoryError::Io {
                            operation: "simulated interruption",
                            path: path.0.clone(),
                            source: io::ErrorKind::Interrupted,
                        })
                    } else {
                        Ok(())
                    }
                },
            )
            .expect_err("directory sync interruption");
        assert!(matches!(error, RepositoryError::DurabilityUncertain { .. }));
        assert_eq!(
            repository.read_entry(&path)?.as_bytes(),
            b"complete ciphertext"
        );
        assert!(temporary.path().join("vault/one/two").is_dir());
        Ok(())
    }

    #[test]
    fn failed_new_entry_removes_created_empty_directories() -> Result<(), Box<dyn Error>> {
        let temporary = tempfile::tempdir()?;
        fs::create_dir(temporary.path().join("vault"))?;
        let repository = Repository::open(temporary.path().join("vault"))?;
        let path = EntryPath::parse("one/two/entry")?;
        let error = repository
            .write_entry_with_checkpoint(
                &path,
                &EncryptedEntry::new(b"ciphertext".to_vec()),
                |_| {
                    Err(RepositoryError::Io {
                        operation: "simulated interruption",
                        path: path.0.clone(),
                        source: io::ErrorKind::Interrupted,
                    })
                },
            )
            .expect_err("interrupted write");
        assert!(matches!(error, RepositoryError::Io { .. }));
        assert!(!temporary.path().join("vault/one").exists());
        Ok(())
    }
}
