//! Shared mobile authentication state; Swift only supplies input and presents results.

use std::{collections::BTreeMap, error::Error, fmt, sync::Mutex};

use crate::{
    authentication::{
        AuthenticationError, NativeAuthenticationHandle, NativeAuthenticationSession,
    },
    config::{Config, ConfigError},
    crypto::{CryptoError, KeyInfo, KeyStore, SecretProvider, SecretProviderError},
    document::{DocumentError, EntryDocument, EntryDocumentService, EntryFieldId},
    git::{AutomaticEntryCommitter, AutomaticTreeCommitter, GitError, GitIdentity},
    mobile_entry::{
        MobileEntryDraft, MobileEntryEditorError, MobileEntryEditorFieldKind,
        MobileEntryEditorInput, MobileEntryEditorPage, MobileEntryEditorSession, MobileEntryPage,
        MobileEntryValueError, field_value,
    },
    mobile_mutation::{
        MobileMutationAction, MobileMutationError, MobileMutationOutcome, MobileMutationPlan,
        MobileMutationRequest, MobileMutationService,
    },
    mobile_totp::{
        MobileTotpDetail, MobileTotpError, MobileTotpOperation, MobileTotpPage, MobileTotpService,
    },
    otp::OtpError,
    recipient::RecipientPolicyManager,
    repository::{
        DirectoryPath, EncryptedEntry, EntryPath, Repository, RepositoryError, SecretBytes,
    },
    secret_store::{SecretProtectionPolicy, SecretStoreError},
    write::{VaultWriter, WriteError},
};

#[derive(Clone, Copy, Debug, Eq, PartialEq)]
pub enum MobileAuthenticationErrorKind {
    PassphraseRequired,
    InvalidPassphrase,
    Cancelled,
    BiometryUnavailable,
    Configuration,
    KeyMaterial,
    Entry,
    SecureStorage,
    Expired,
    Conflict,
}

#[derive(Clone, Debug, Eq, PartialEq)]
pub struct MobileAuthenticationError {
    kind: MobileAuthenticationErrorKind,
    title: String,
    detail: String,
}

impl MobileAuthenticationError {
    pub fn kind(&self) -> MobileAuthenticationErrorKind {
        self.kind
    }

    pub fn title(&self) -> &str {
        &self.title
    }

    pub fn detail(&self) -> &str {
        &self.detail
    }

    fn new(kind: MobileAuthenticationErrorKind, title: &str, detail: impl Into<String>) -> Self {
        Self {
            kind,
            title: title.to_owned(),
            detail: detail.into(),
        }
    }

    fn passphrase_required(detail: impl Into<String>) -> Self {
        Self::new(
            MobileAuthenticationErrorKind::PassphraseRequired,
            "Passphrase Required",
            detail,
        )
    }

    fn authentication(error: AuthenticationError) -> Self {
        match error {
            AuthenticationError::Cancelled => Self::new(
                MobileAuthenticationErrorKind::Cancelled,
                "Authentication Cancelled",
                "No protected content was unlocked.",
            ),
            AuthenticationError::Expired => Self::new(
                MobileAuthenticationErrorKind::Expired,
                "IronStorage Locked",
                "The authentication lease expired.",
            ),
            AuthenticationError::SecretStore(SecretStoreError::Missing) => {
                Self::passphrase_required("Enter the GPG key passphrase to continue.")
            }
            AuthenticationError::SecretStore(
                SecretStoreError::Denied
                | SecretStoreError::UnsupportedProtection
                | SecretStoreError::Unavailable,
            ) => Self::new(
                MobileAuthenticationErrorKind::BiometryUnavailable,
                "Biometric Unlock Unavailable",
                "Enter the GPG key passphrase to recover or continue without biometric unlock.",
            ),
            error => Self::new(
                MobileAuthenticationErrorKind::SecureStorage,
                "Secure Unlock Failed",
                error.to_string(),
            ),
        }
    }
}

impl fmt::Display for MobileAuthenticationError {
    fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result {
        write!(formatter, "{}: {}", self.title, self.detail)
    }
}

impl Error for MobileAuthenticationError {}

#[derive(Clone, Copy, Debug, Eq, PartialEq)]
pub struct MobileAuthenticationState {
    unlocked: bool,
    biometric_unlock_enabled: bool,
    remaining_seconds: u64,
}

#[derive(Clone, Debug, Eq, PartialEq)]
pub struct MobileEntryCopy {
    value: String,
    timeout_seconds: u64,
}

impl MobileEntryCopy {
    pub fn value(&self) -> &str {
        &self.value
    }

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

pub struct MobileEntryPresentation {
    page: MobileEntryPage,
    totp: Option<MobileTotpDetail>,
    cache_notice: Option<String>,
}

impl MobileEntryPresentation {
    pub fn page(&self) -> &MobileEntryPage {
        &self.page
    }

    pub fn totp(&self) -> Option<&MobileTotpDetail> {
        self.totp.as_ref()
    }

    pub fn cache_notice(&self) -> Option<&str> {
        self.cache_notice.as_deref()
    }

    pub fn into_parts(self) -> (MobileEntryPage, Option<MobileTotpDetail>, Option<String>) {
        (self.page, self.totp, self.cache_notice)
    }
}

impl MobileAuthenticationState {
    pub fn unlocked(self) -> bool {
        self.unlocked
    }

    pub fn biometric_unlock_enabled(self) -> bool {
        self.biometric_unlock_enabled
    }

    pub fn remaining_seconds(self) -> u64 {
        self.remaining_seconds
    }
}

struct ActiveMobileLease {
    handle: NativeAuthenticationHandle,
    key: KeyInfo,
}

struct MobileAuthenticationStatus {
    biometric_unlock_enabled: bool,
    git_identity: GitIdentity,
    active: Option<ActiveMobileLease>,
    next_editor_id: u64,
    editors: BTreeMap<u64, MobileEntryDraft>,
    mutation_active: bool,
    watch_shared_totp_entries: std::collections::BTreeSet<EntryPath>,
}

/// One process-wide mobile authentication lease shared by every tab and viewer.
pub struct MobileAuthentication {
    config: Config,
    repository: Repository,
    keys: KeyStore,
    session: NativeAuthenticationSession,
    status: Mutex<MobileAuthenticationStatus>,
    totp_discovery: Mutex<()>,
}

impl MobileAuthentication {
    pub fn load() -> Result<Self, MobileAuthenticationError> {
        let config = Config::load(None).map_err(|error| {
            MobileAuthenticationError::new(
                MobileAuthenticationErrorKind::Configuration,
                "Authentication Is Unavailable",
                error.to_string(),
            )
        })?;
        let repository = Repository::open(config.vault()).map_err(entry_error)?;
        let keys = KeyStore::load(config.key_material()).map_err(key_error)?;
        let session = NativeAuthenticationSession::system(
            SecretProtectionPolicy::current_biometry_for_openpgp(),
            config.authentication_timeout(),
        )
        .map_err(MobileAuthenticationError::authentication)?;
        Ok(Self {
            status: Mutex::new(MobileAuthenticationStatus {
                biometric_unlock_enabled: config.biometric_unlock_enabled(),
                git_identity: config.git_identity().clone(),
                active: None,
                next_editor_id: 0,
                editors: BTreeMap::new(),
                mutation_active: false,
                watch_shared_totp_entries: config.watch_shared_totp_entries().clone(),
            }),
            config,
            repository,
            keys,
            session,
            totp_discovery: Mutex::new(()),
        })
    }

    /// Validate an entry unlock and retain only the key passphrase for the shared lease.
    pub fn unlock_entry(
        &self,
        path: &str,
        passphrase: Option<SecretBytes>,
    ) -> Result<MobileAuthenticationState, MobileAuthenticationError> {
        let entry = EntryPath::parse(path).map_err(entry_error)?;
        let ciphertext = self.repository.read_entry(&entry).map_err(entry_error)?;
        self.unlock_ciphertext(&ciphertext, passphrase)
    }

    fn unlock_ciphertext(
        &self,
        ciphertext: &EncryptedEntry,
        passphrase: Option<SecretBytes>,
    ) -> Result<MobileAuthenticationState, MobileAuthenticationError> {
        let candidates = self.keys.decrypting_keys(ciphertext).map_err(key_error)?;

        if let Some(active) = self.take_active()? {
            let mut provider = KeyOnlyProvider::new(active.handle.clone(), &active.key);
            if self.keys.decrypt(ciphertext, &mut provider).is_ok() {
                self.restore_active(active)?;
                return self.state();
            }
            self.session
                .manual_lock()
                .map_err(MobileAuthenticationError::authentication)?;
            self.status()?.editors.clear();
        }

        let biometric_enabled = self.status()?.biometric_unlock_enabled;
        match passphrase {
            Some(passphrase) => {
                for key in &candidates {
                    let candidate = SecretBytes::new(passphrase.expose().to_vec());
                    let handle = self
                        .session
                        .authenticate_with_passphrase(key, candidate)
                        .map_err(MobileAuthenticationError::authentication)?;
                    let mut provider = KeyOnlyProvider::new(handle.clone(), key);
                    match self.keys.decrypt(ciphertext, &mut provider) {
                        Ok(plaintext) => {
                            drop(plaintext);
                            if biometric_enabled {
                                handle
                                    .persist_passphrase(key)
                                    .map_err(MobileAuthenticationError::authentication)?;
                            }
                            self.set_active(handle, key.clone())?;
                            return self.state();
                        }
                        Err(CryptoError::DecryptionFailed) => {
                            self.session
                                .manual_lock()
                                .map_err(MobileAuthenticationError::authentication)?;
                        }
                        Err(error) => return Err(key_error(error)),
                    }
                }
                Err(MobileAuthenticationError::new(
                    MobileAuthenticationErrorKind::InvalidPassphrase,
                    "Incorrect Passphrase",
                    "The GPG key could not be unlocked.",
                ))
            }
            None if biometric_enabled => {
                for key in &candidates {
                    let handle = match self.session.authenticate(key) {
                        Ok(handle) => handle,
                        Err(AuthenticationError::Cancelled) => {
                            return Err(MobileAuthenticationError::authentication(
                                AuthenticationError::Cancelled,
                            ));
                        }
                        Err(_) => continue,
                    };
                    let mut provider = KeyOnlyProvider::new(handle.clone(), key);
                    if let Ok(plaintext) = self.keys.decrypt(ciphertext, &mut provider) {
                        drop(plaintext);
                        self.set_active(handle, key.clone())?;
                        return self.state();
                    }
                    let _ = self.session.delete_key_passphrase(key);
                    let _ = self.session.manual_lock();
                }
                Err(MobileAuthenticationError::passphrase_required(
                    "Biometric unlock could not restore this key. Enter its GPG passphrase to recover.",
                ))
            }
            None => Err(MobileAuthenticationError::passphrase_required(
                "Enter the GPG key passphrase to continue.",
            )),
        }
    }

    pub fn set_biometric_unlock(
        &self,
        enabled: bool,
    ) -> Result<MobileAuthenticationState, MobileAuthenticationError> {
        if enabled {
            let status = self.status()?;
            if status.biometric_unlock_enabled {
                drop(status);
                return self.state();
            }
            let active = status.active.as_ref().ok_or_else(|| {
                MobileAuthenticationError::passphrase_required(
                    "Unlock a password entry before enabling biometric unlock.",
                )
            })?;
            active
                .handle
                .persist_passphrase(&active.key)
                .map_err(MobileAuthenticationError::authentication)?;
            drop(status);
            if let Err(error) = self.config.update_biometric_unlock(true) {
                let _ = self.delete_all_key_passphrases();
                let _ = self.manual_lock();
                return Err(config_error(error));
            }
            self.status()?.biometric_unlock_enabled = true;
        } else {
            self.delete_all_key_passphrases()?;
            self.config
                .update_biometric_unlock(false)
                .map_err(config_error)?;
            self.session
                .manual_lock()
                .map_err(MobileAuthenticationError::authentication)?;
            let mut status = self.status()?;
            status.biometric_unlock_enabled = false;
            status.active = None;
            status.editors.clear();
        }
        self.state()
    }

    pub fn state(&self) -> Result<MobileAuthenticationState, MobileAuthenticationError> {
        let remaining = self
            .session
            .remaining_time()
            .map_err(MobileAuthenticationError::authentication)?;
        let mut status = self.status()?;
        if remaining.is_none() {
            status.active = None;
            status.editors.clear();
        }
        Ok(MobileAuthenticationState {
            unlocked: status.active.is_some(),
            biometric_unlock_enabled: status.biometric_unlock_enabled,
            remaining_seconds: remaining.map_or(0, |duration| duration.as_secs()),
        })
    }

    pub fn git_identity(&self) -> Result<GitIdentity, MobileAuthenticationError> {
        Ok(self.status()?.git_identity.clone())
    }

    pub fn set_git_identity(
        &self,
        name: String,
        email: String,
    ) -> Result<GitIdentity, MobileAuthenticationError> {
        let identity = GitIdentity::new(name, email).map_err(|_| {
            MobileAuthenticationError::new(
                MobileAuthenticationErrorKind::Configuration,
                "Git Identity Is Invalid",
                "Enter a non-empty name and email without line breaks or angle brackets.",
            )
        })?;
        self.config
            .update_git_identity(&identity)
            .map_err(config_error)?;
        self.status()?.git_identity = identity.clone();
        Ok(identity)
    }

    pub fn touch_user_activity(&self) -> Result<(), MobileAuthenticationError> {
        let status = self.status()?;
        let active = status.active.as_ref().ok_or_else(|| {
            MobileAuthenticationError::new(
                MobileAuthenticationErrorKind::Expired,
                "IronStorage Locked",
                "Authenticate before using protected content.",
            )
        })?;
        active
            .handle
            .touch_user_activity()
            .map_err(MobileAuthenticationError::authentication)
    }

    pub fn entry_page(
        &self,
        path: &str,
        unix_seconds: u64,
    ) -> Result<MobileEntryPresentation, MobileAuthenticationError> {
        let document = self.open_active_document(path)?;
        let ciphertext = document
            .original_ciphertext()
            .ok_or_else(|| entry_detail("Password Entry Is Unavailable", "entry does not exist"))?;
        let shared = self.status()?.watch_shared_totp_entries.clone();
        let cache_path = self.config.source().with_file_name("totp-catalog.toml");
        let reconciliation = MobileTotpService::new(&self.repository, &self.keys)
            .reconcile_document(
                &document,
                ciphertext,
                Some(unix_seconds),
                &shared,
                &cache_path,
            )
            .map_err(totp_error)?;
        let (totp, cache_notice) = reconciliation.into_parts();
        Ok(MobileEntryPresentation {
            page: MobileEntryPage::from_document(&document),
            totp,
            cache_notice,
        })
    }

    pub fn reveal_entry_field(
        &self,
        path: &str,
        field: u64,
    ) -> Result<String, MobileAuthenticationError> {
        let document = self.open_active_document(path)?;
        field_value(&document, field).map_err(value_error)
    }

    pub fn copy_entry_field(
        &self,
        path: &str,
        field: u64,
    ) -> Result<MobileEntryCopy, MobileAuthenticationError> {
        Ok(MobileEntryCopy {
            value: self.reveal_entry_field(path, field)?,
            timeout_seconds: self.config.clipboard_timeout().duration().as_secs(),
        })
    }

    pub fn prepare_entry_mutation(
        &self,
        path: &str,
        action: MobileMutationAction,
    ) -> Result<MobileMutationPlan, MobileAuthenticationError> {
        let (has_open_editor, has_dirty_editor) = self.editor_state(path)?;
        MobileMutationService::new(&self.repository)
            .prepare(path, action, has_open_editor, has_dirty_editor)
            .map_err(mutation_error)
    }

    pub fn perform_entry_mutation(
        &self,
        request: MobileMutationRequest,
    ) -> Result<MobileMutationOutcome, MobileAuthenticationError> {
        self.ensure_active()?;
        self.reserve_entry_mutation()?;
        let result = self.perform_reserved_entry_mutation(request);
        self.release_entry_mutation();
        result
    }

    fn perform_reserved_entry_mutation(
        &self,
        request: MobileMutationRequest,
    ) -> Result<MobileMutationOutcome, MobileAuthenticationError> {
        let (handle, key, identity) = {
            let status = self.status()?;
            let active = status.active.as_ref().ok_or_else(locked_error)?;
            (
                active.handle.clone(),
                active.key.clone(),
                status.git_identity.clone(),
            )
        };
        let mut committer =
            AutomaticTreeCommitter::for_source(&self.repository, &request.source, identity)
                .map_err(git_mutation_error)?;
        let has_open_editor = self.editor_state(&request.source)?.0;
        let editors = if has_open_editor && request.discard_editor {
            self.take_entry_editors(&request.source)?
        } else {
            Vec::new()
        };
        let mut provider = KeyOnlyProvider::new(handle, &key);
        let result = MobileMutationService::new(&self.repository).perform(
            &request,
            has_open_editor && editors.is_empty(),
            &self.keys,
            &mut provider,
            &mut committer,
        );
        match result {
            Ok(outcome) => Ok(outcome),
            Err(error) => {
                self.restore_entry_editors(editors)?;
                Err(mutation_error(error))
            }
        }
    }

    pub fn unlock_totp(
        &self,
        passphrase: Option<SecretBytes>,
    ) -> Result<MobileAuthenticationState, MobileAuthenticationError> {
        let path = self
            .repository
            .snapshot()
            .map_err(entry_error)?
            .entries()
            .next()
            .map(|entry| entry.path().clone())
            .ok_or_else(|| entry_detail("TOTP Is Unavailable", "the password store is empty"))?;
        let ciphertext = self.repository.read_entry(&path).map_err(entry_error)?;
        self.unlock_ciphertext(&ciphertext, passphrase)
    }

    pub fn totp_page(
        &self,
        operation: &MobileTotpOperation,
    ) -> Result<MobileTotpPage, MobileAuthenticationError> {
        self.ensure_active()?;
        let _discovery = self.totp_discovery.lock().map_err(|_| {
            entry_detail(
                "TOTP Discovery Is Unavailable",
                "the TOTP discovery lock is unavailable",
            )
        })?;
        let (handle, key, shared) = {
            let status = self.status()?;
            let active = status.active.as_ref().ok_or_else(locked_error)?;
            (
                active.handle.clone(),
                active.key.clone(),
                status.watch_shared_totp_entries.clone(),
            )
        };
        let cache_path = self.config.source().with_file_name("totp-catalog.toml");
        let mut provider = KeyOnlyProvider::new(handle, &key);
        MobileTotpService::new(&self.repository, &self.keys)
            .discover(&shared, &mut provider, &cache_path, operation)
            .map_err(totp_error)
    }

    pub fn cached_totp_page(&self) -> Result<Option<MobileTotpPage>, MobileAuthenticationError> {
        self.search_cached_totp_page("")
    }

    pub fn search_cached_totp_page(
        &self,
        query: &str,
    ) -> Result<Option<MobileTotpPage>, MobileAuthenticationError> {
        let shared = self.status()?.watch_shared_totp_entries.clone();
        let cache_path = self.config.source().with_file_name("totp-catalog.toml");
        Ok(
            MobileTotpService::new(&self.repository, &self.keys).search_cached_page(
                &shared,
                &cache_path,
                query,
            ),
        )
    }

    pub fn totp_detail(
        &self,
        path: &str,
        unix_seconds: u64,
    ) -> Result<MobileTotpDetail, MobileAuthenticationError> {
        self.ensure_active()?;
        let (handle, key, shared) = {
            let status = self.status()?;
            let active = status.active.as_ref().ok_or_else(locked_error)?;
            (
                active.handle.clone(),
                active.key.clone(),
                status.watch_shared_totp_entries.clone(),
            )
        };
        let mut provider = KeyOnlyProvider::new(handle, &key);
        MobileTotpService::new(&self.repository, &self.keys)
            .detail(path, unix_seconds, &shared, &mut provider)
            .map_err(totp_error)
    }

    pub fn copy_totp_code(
        &self,
        path: &str,
        unix_seconds: u64,
    ) -> Result<MobileEntryCopy, MobileAuthenticationError> {
        let detail = self.totp_detail(path, unix_seconds)?;
        let value = String::from_utf8(detail.code().expose().to_vec())
            .map_err(|_| entry_detail("TOTP Code Is Unavailable", "the code is not UTF-8"))?;
        Ok(MobileEntryCopy {
            value,
            timeout_seconds: self.config.clipboard_timeout().duration().as_secs(),
        })
    }

    pub fn set_totp_watch_shared(
        &self,
        path: &str,
        shared: bool,
        unix_seconds: u64,
    ) -> Result<MobileTotpDetail, MobileAuthenticationError> {
        self.totp_detail(path, unix_seconds)?;
        let entry = EntryPath::parse(path).map_err(entry_error)?;
        let mut selected = self.status()?.watch_shared_totp_entries.clone();
        if shared {
            selected.insert(entry);
        } else {
            selected.remove(&entry);
        }
        self.config
            .update_watch_shared_totp_entries(&selected)
            .map_err(config_error)?;
        self.status()?.watch_shared_totp_entries = selected;
        self.totp_detail(path, unix_seconds)
    }

    pub fn replace_entry_field(
        &self,
        path: &str,
        field: u64,
        value: String,
    ) -> Result<MobileEntryPage, MobileAuthenticationError> {
        let mut document = self.open_active_document(path)?;
        document
            .replace_field_value(EntryFieldId::from_value(field), value.into_bytes())
            .map_err(document_error)?;
        let identity = self.git_identity()?;
        let mut committer = AutomaticEntryCommitter::for_entry(&self.repository, path, identity)
            .map_err(|error| entry_detail("Password Entry Could Not Be Saved", error))?;
        EntryDocumentService::new(&self.repository, &self.keys)
            .save_recoverable(&document, None, &mut committer)
            .map_err(document_error)?;
        self.reconcile_saved_document(&document);
        Ok(MobileEntryPage::from_document(&document))
    }

    pub fn begin_entry_editor(
        &self,
        path: &str,
    ) -> Result<MobileEntryEditorSession, MobileAuthenticationError> {
        let document = self.open_active_document(path)?;
        self.store_editor(MobileEntryDraft::new(document, false))
    }

    pub fn begin_create_entry(
        &self,
        directory: &str,
        name: &str,
        passphrase: Option<SecretBytes>,
    ) -> Result<MobileEntryEditorSession, MobileAuthenticationError> {
        let directory = DirectoryPath::parse(directory).map_err(entry_error)?;
        let leaf = EntryPath::parse(name).map_err(entry_error)?;
        if leaf
            .as_path()
            .parent()
            .is_some_and(|parent| !parent.as_os_str().is_empty())
        {
            return Err(entry_detail(
                "Entry Name Is Invalid",
                "enter a name without a folder separator",
            ));
        }
        let path =
            EntryPath::parse(directory.as_path().join(leaf.as_path())).map_err(entry_error)?;
        let path_text = path.to_string();
        if VaultWriter::new(&self.repository, &self.keys)
            .entry_exists(&path_text)
            .map_err(|error| entry_detail("Password Entry Is Unavailable", error))?
        {
            return Err(entry_detail(
                "Password Already Exists",
                "Choose a different entry name.",
            ));
        }
        let recipients = RecipientPolicyManager::new(&self.repository, &self.keys)
            .resolve_for_entry(&path, None)
            .map_err(|error| entry_detail("Recipients Are Unavailable", error))?;
        let probe = self
            .keys
            .encrypt(
                SecretBytes::new(b"IronStorage mobile entry creation".to_vec()),
                recipients.recipients(),
            )
            .map_err(key_error)?;
        self.unlock_ciphertext(&probe, passphrase)?;
        let mut document = self.open_active_document(&path_text)?;
        document
            .add(
                0,
                crate::document::EntryFieldDraft::line(Vec::new()).map_err(document_error)?,
            )
            .map_err(document_error)?;
        self.store_editor(MobileEntryDraft::new(document, true))
    }

    pub fn entry_editor(
        &self,
        editor: u64,
    ) -> Result<MobileEntryEditorPage, MobileAuthenticationError> {
        self.ensure_active()?;
        self.status()?
            .editors
            .get(&editor)
            .map(MobileEntryDraft::page)
            .ok_or_else(editor_missing)
    }

    pub fn update_entry_editor(
        &self,
        editor: u64,
        fields: Vec<MobileEntryEditorInput>,
    ) -> Result<MobileEntryEditorPage, MobileAuthenticationError> {
        self.ensure_active()?;
        let mut status = self.status()?;
        let draft = status.editors.get_mut(&editor).ok_or_else(editor_missing)?;
        draft.apply(fields).map_err(editor_error)?;
        Ok(draft.page())
    }

    pub fn add_entry_editor_field(
        &self,
        editor: u64,
        kind: MobileEntryEditorFieldKind,
        name: Option<String>,
        value: String,
    ) -> Result<MobileEntryEditorPage, MobileAuthenticationError> {
        self.ensure_active()?;
        let mut status = self.status()?;
        let draft = status.editors.get_mut(&editor).ok_or_else(editor_missing)?;
        draft.add(kind, name, value).map_err(editor_error)?;
        Ok(draft.page())
    }

    pub fn remove_entry_editor_field(
        &self,
        editor: u64,
        field: u64,
    ) -> Result<MobileEntryEditorPage, MobileAuthenticationError> {
        self.ensure_active()?;
        let mut status = self.status()?;
        let draft = status.editors.get_mut(&editor).ok_or_else(editor_missing)?;
        draft.remove(field).map_err(editor_error)?;
        Ok(draft.page())
    }

    pub fn reorder_entry_editor_field(
        &self,
        editor: u64,
        field: u64,
        index: usize,
    ) -> Result<MobileEntryEditorPage, MobileAuthenticationError> {
        self.ensure_active()?;
        let mut status = self.status()?;
        let draft = status.editors.get_mut(&editor).ok_or_else(editor_missing)?;
        draft.reorder(field, index).map_err(editor_error)?;
        Ok(draft.page())
    }

    pub fn generate_entry_editor_password(
        &self,
        editor: u64,
        length: Option<u32>,
        no_symbols: bool,
    ) -> Result<MobileEntryEditorPage, MobileAuthenticationError> {
        self.ensure_active()?;
        let mut status = self.status()?;
        let draft = status.editors.get_mut(&editor).ok_or_else(editor_missing)?;
        draft
            .generate_password(length, no_symbols)
            .map_err(editor_error)?;
        Ok(draft.page())
    }

    pub fn save_entry_editor(
        &self,
        editor: u64,
        fields: Vec<MobileEntryEditorInput>,
    ) -> Result<MobileEntryPage, MobileAuthenticationError> {
        self.ensure_active()?;
        let mut draft = self
            .status()?
            .editors
            .remove(&editor)
            .ok_or_else(editor_missing)?;
        if let Err(error) = draft.apply(fields) {
            self.restore_editor(editor, draft)?;
            return Err(editor_error(error));
        }
        let path = draft.document().path().to_string();
        let identity = self.git_identity()?;
        let mut committer =
            match AutomaticEntryCommitter::for_entry(&self.repository, &path, identity) {
                Ok(committer) => committer,
                Err(error) => {
                    self.restore_editor(editor, draft)?;
                    return Err(entry_detail("Password Entry Could Not Be Saved", error));
                }
            };
        if let Err(error) = EntryDocumentService::new(&self.repository, &self.keys)
            .save_recoverable(draft.document(), None, &mut committer)
        {
            self.restore_editor(editor, draft)?;
            return Err(document_error(error));
        }
        self.reconcile_saved_document(draft.document());
        Ok(MobileEntryPage::from_document(draft.document()))
    }

    pub fn discard_entry_editor(&self, editor: u64) -> Result<(), MobileAuthenticationError> {
        self.status()?.editors.remove(&editor);
        Ok(())
    }

    pub fn manual_lock(&self) -> Result<(), MobileAuthenticationError> {
        self.session
            .manual_lock()
            .map_err(MobileAuthenticationError::authentication)?;
        let mut status = self.status()?;
        status.active = None;
        status.editors.clear();
        Ok(())
    }

    pub fn cancel(&self) -> Result<(), MobileAuthenticationError> {
        self.session
            .cancel()
            .map_err(MobileAuthenticationError::authentication)?;
        let mut status = self.status()?;
        status.active = None;
        status.editors.clear();
        Ok(())
    }

    fn delete_all_key_passphrases(&self) -> Result<(), MobileAuthenticationError> {
        for key in self.keys.infos().filter(KeyInfo::has_secret) {
            self.session
                .delete_key_passphrase(&key)
                .map_err(MobileAuthenticationError::authentication)?;
        }
        Ok(())
    }

    fn status(
        &self,
    ) -> Result<std::sync::MutexGuard<'_, MobileAuthenticationStatus>, MobileAuthenticationError>
    {
        self.status.lock().map_err(|_| {
            MobileAuthenticationError::new(
                MobileAuthenticationErrorKind::SecureStorage,
                "Authentication Is Unavailable",
                "The shared authentication state is unavailable.",
            )
        })
    }

    fn take_active(&self) -> Result<Option<ActiveMobileLease>, MobileAuthenticationError> {
        Ok(self.status()?.active.take())
    }

    fn restore_active(&self, active: ActiveMobileLease) -> Result<(), MobileAuthenticationError> {
        self.status()?.active = Some(active);
        Ok(())
    }

    fn set_active(
        &self,
        handle: NativeAuthenticationHandle,
        key: KeyInfo,
    ) -> Result<(), MobileAuthenticationError> {
        self.status()?.active = Some(ActiveMobileLease { handle, key });
        Ok(())
    }

    fn ensure_active(&self) -> Result<(), MobileAuthenticationError> {
        let handle = self
            .status()?
            .active
            .as_ref()
            .map(|active| active.handle.clone())
            .ok_or_else(locked_error)?;
        if let Err(error) = handle.ensure_active() {
            let mut status = self.status()?;
            status.active = None;
            status.editors.clear();
            return Err(MobileAuthenticationError::authentication(error));
        }
        Ok(())
    }

    fn store_editor(
        &self,
        draft: MobileEntryDraft,
    ) -> Result<MobileEntryEditorSession, MobileAuthenticationError> {
        let mut status = self.status()?;
        if status.mutation_active {
            return Err(entry_detail(
                "Password Action In Progress",
                "wait for the current move, copy, or delete action to finish",
            ));
        }
        let id = status.next_editor_id;
        status.next_editor_id = status.next_editor_id.checked_add(1).ok_or_else(|| {
            entry_detail(
                "Entry Editor Is Unavailable",
                "entry editor identifiers are exhausted",
            )
        })?;
        let page = draft.page();
        status.editors.insert(id, draft);
        Ok(MobileEntryEditorSession::new(id, page))
    }

    fn reserve_entry_mutation(&self) -> Result<(), MobileAuthenticationError> {
        let mut status = self.status()?;
        // ponytail: serialize mobile mutations; use per-path reservations if concurrent UI needs it.
        if status.mutation_active {
            return Err(MobileAuthenticationError::new(
                MobileAuthenticationErrorKind::Conflict,
                "Password Action In Progress",
                "Wait for the current move, copy, or delete action to finish.",
            ));
        }
        status.mutation_active = true;
        Ok(())
    }

    fn release_entry_mutation(&self) {
        if let Ok(mut status) = self.status.lock() {
            status.mutation_active = false;
        }
    }

    fn editor_state(&self, path: &str) -> Result<(bool, bool), MobileAuthenticationError> {
        let status = self.status()?;
        let mut matching = status
            .editors
            .values()
            .filter(|draft| draft.document().path().to_string() == path);
        let Some(first) = matching.next() else {
            return Ok((false, false));
        };
        Ok((
            true,
            first.document().is_modified() || matching.any(|draft| draft.document().is_modified()),
        ))
    }

    fn take_entry_editors(
        &self,
        path: &str,
    ) -> Result<Vec<(u64, MobileEntryDraft)>, MobileAuthenticationError> {
        let mut status = self.status()?;
        let ids = status
            .editors
            .iter()
            .filter(|(_, draft)| draft.document().path().to_string() == path)
            .map(|(id, _)| *id)
            .collect::<Vec<_>>();
        Ok(ids
            .into_iter()
            .filter_map(|id| status.editors.remove(&id).map(|draft| (id, draft)))
            .collect())
    }

    fn restore_entry_editors(
        &self,
        editors: Vec<(u64, MobileEntryDraft)>,
    ) -> Result<(), MobileAuthenticationError> {
        self.status()?.editors.extend(editors);
        Ok(())
    }

    fn restore_editor(
        &self,
        editor: u64,
        draft: MobileEntryDraft,
    ) -> Result<(), MobileAuthenticationError> {
        self.status()?.editors.insert(editor, draft);
        Ok(())
    }

    fn open_active_document(&self, path: &str) -> Result<EntryDocument, MobileAuthenticationError> {
        let (handle, key) = {
            let status = self.status()?;
            let active = status.active.as_ref().ok_or_else(locked_error)?;
            (active.handle.clone(), active.key.clone())
        };
        handle
            .ensure_active()
            .map_err(MobileAuthenticationError::authentication)?;
        let mut provider = KeyOnlyProvider::new(handle, &key);
        EntryDocumentService::new(&self.repository, &self.keys)
            .open(path, &mut provider)
            .map_err(document_error)
    }

    fn reconcile_saved_document(&self, document: &EntryDocument) {
        let Ok(ciphertext) = self.repository.read_entry(document.path()) else {
            return;
        };
        let Ok(status) = self.status() else {
            return;
        };
        let shared = status.watch_shared_totp_entries.clone();
        drop(status);
        let cache_path = self.config.source().with_file_name("totp-catalog.toml");
        let _ = MobileTotpService::new(&self.repository, &self.keys).reconcile_document(
            document,
            &ciphertext,
            None,
            &shared,
            &cache_path,
        );
    }
}

struct KeyOnlyProvider<'a> {
    handle: NativeAuthenticationHandle,
    fingerprint: &'a str,
}

impl<'a> KeyOnlyProvider<'a> {
    fn new(handle: NativeAuthenticationHandle, key: &'a KeyInfo) -> Self {
        Self {
            handle,
            fingerprint: key.fingerprint().as_str(),
        }
    }
}

impl SecretProvider for KeyOnlyProvider<'_> {
    fn secret_for(&mut self, key: &KeyInfo) -> Result<SecretBytes, SecretProviderError> {
        if key.fingerprint().as_str() != self.fingerprint {
            return Err(SecretProviderError::Missing);
        }
        self.handle.secret_for(key)
    }
}

fn config_error(error: ConfigError) -> MobileAuthenticationError {
    MobileAuthenticationError::new(
        MobileAuthenticationErrorKind::Configuration,
        "Preference Was Not Saved",
        error.to_string(),
    )
}

fn entry_error(error: RepositoryError) -> MobileAuthenticationError {
    MobileAuthenticationError::new(
        MobileAuthenticationErrorKind::Entry,
        "Password Entry Is Unavailable",
        error.to_string(),
    )
}

fn key_error(error: CryptoError) -> MobileAuthenticationError {
    MobileAuthenticationError::new(
        MobileAuthenticationErrorKind::KeyMaterial,
        "GPG Key Unlock Failed",
        error.to_string(),
    )
}

fn locked_error() -> MobileAuthenticationError {
    MobileAuthenticationError::new(
        MobileAuthenticationErrorKind::Expired,
        "IronStorage Locked",
        "Authenticate before using protected content.",
    )
}

fn document_error(error: DocumentError) -> MobileAuthenticationError {
    let kind = if matches!(
        &error,
        DocumentError::Write(WriteError::ConcurrentModification { .. })
    ) {
        MobileAuthenticationErrorKind::Conflict
    } else {
        MobileAuthenticationErrorKind::Entry
    };
    MobileAuthenticationError::new(kind, "Password Entry Could Not Be Saved", error.to_string())
}

fn mutation_error(error: MobileMutationError) -> MobileAuthenticationError {
    MobileAuthenticationError::new(
        if error.is_conflict() {
            MobileAuthenticationErrorKind::Conflict
        } else {
            MobileAuthenticationErrorKind::Entry
        },
        error.title(),
        error.to_string(),
    )
}

fn git_mutation_error(error: GitError) -> MobileAuthenticationError {
    let conflict = matches!(
        &error,
        GitError::DirtyWorktree | GitError::MergeConflicts { .. }
    );
    MobileAuthenticationError::new(
        if conflict {
            MobileAuthenticationErrorKind::Conflict
        } else {
            MobileAuthenticationErrorKind::Entry
        },
        if conflict {
            "Password Action Blocked by Git"
        } else {
            "Password Action Failed"
        },
        error.to_string(),
    )
}

fn value_error(error: MobileEntryValueError) -> MobileAuthenticationError {
    entry_detail("Field Value Is Unavailable", error)
}

fn editor_error(error: MobileEntryEditorError) -> MobileAuthenticationError {
    entry_detail("Entry Draft Is Invalid", error)
}

fn totp_error(error: MobileTotpError) -> MobileAuthenticationError {
    match error {
        MobileTotpError::Otp(OtpError::Cancelled) => MobileAuthenticationError::new(
            MobileAuthenticationErrorKind::Cancelled,
            "TOTP Discovery Cancelled",
            "TOTP discovery was cancelled",
        ),
        MobileTotpError::ConcurrentModification => MobileAuthenticationError::new(
            MobileAuthenticationErrorKind::Conflict,
            "TOTP Discovery Needs Refreshing",
            "the password store changed while TOTP discovery was in progress",
        ),
        error => entry_detail("TOTP Is Unavailable", error),
    }
}

fn editor_missing() -> MobileAuthenticationError {
    entry_detail(
        "Entry Draft Is Unavailable",
        "the entry draft was discarded or locked",
    )
}

fn entry_detail(title: &str, error: impl fmt::Display) -> MobileAuthenticationError {
    MobileAuthenticationError::new(
        MobileAuthenticationErrorKind::Entry,
        title,
        error.to_string(),
    )
}
