//! Strict, secret-free configuration shared by every frontend.

use std::{
    collections::BTreeSet,
    env,
    error::Error,
    fmt, fs,
    fs::OpenOptions,
    io::Write,
    path::{Component, Path, PathBuf},
    time::Duration,
};

use cap_std::{ambient_authority, fs::Dir};
use cap_tempfile::TempFile;
use serde::Deserialize;
use url::Url;

use crate::{
    authentication::{AuthenticationTimeout, DEFAULT_AUTHENTICATION_TIMEOUT},
    git::GitIdentity,
    mobile::MobileTab,
    presentation::{ClipboardTimeout, DEFAULT_CLIPBOARD_TIMEOUT},
    repository::EntryPath,
};

const APPLICATION_DIRECTORY: &str = "ironstorage";
const CONFIG_FILE: &str = "config.toml";
const MAX_CONFIG_BYTES: u64 = 1024 * 1024;

/// Validated application configuration.
#[derive(Clone, Debug, PartialEq)]
pub struct Config {
    source: PathBuf,
    document: toml::Value,
    vault: PathBuf,
    default_key: KeyIdentity,
    key_material: PathBuf,
    editor: Option<EditorCommand>,
    clipboard_timeout: ClipboardTimeout,
    authentication_timeout: AuthenticationTimeout,
    biometric_unlock_enabled: bool,
    mobile_tab: MobileTab,
    mobile_home_refreshed_at: Option<i64>,
    watch_shared_totp_entries: BTreeSet<EntryPath>,
    git_identity: GitIdentity,
    git_remotes: Vec<GitRemote>,
}

// Accepted fields contain no floating-point TOML values, so validated documents
// preserve the equivalence relation required by `Eq`.
impl Eq for Config {}

/// Editable, secret-free values shared by every frontend.
#[derive(Clone, Debug, Eq, PartialEq)]
pub struct ConfigSettings {
    vault: PathBuf,
    default_key: String,
    editor: Option<Vec<String>>,
    authentication_timeout: Duration,
}

impl ConfigSettings {
    pub fn vault(&self) -> &Path {
        &self.vault
    }

    pub fn set_vault(&mut self, vault: PathBuf) {
        self.vault = vault;
    }

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

    pub fn set_default_key(&mut self, default_key: String) {
        self.default_key = default_key;
    }

    pub fn editor(&self) -> Option<&[String]> {
        self.editor.as_deref()
    }

    pub fn set_editor(&mut self, editor: Option<Vec<String>>) {
        self.editor = editor;
    }

    pub fn authentication_timeout(&self) -> Duration {
        self.authentication_timeout
    }

    pub fn set_authentication_timeout(&mut self, timeout: Duration) {
        self.authentication_timeout = timeout;
    }
}

impl Config {
    /// Load an explicit configuration file, or the native per-user default.
    pub fn load(explicit: Option<&Path>) -> Result<Self, ConfigError> {
        ConfigLoader::system()?.load(explicit)
    }

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

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

    pub fn default_key(&self) -> &KeyIdentity {
        &self.default_key
    }

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

    pub fn configured_editor(&self) -> Option<&EditorCommand> {
        self.editor.as_ref()
    }

    pub fn clipboard_timeout(&self) -> ClipboardTimeout {
        self.clipboard_timeout
    }

    /// Shared inactivity lease used by every interactive frontend.
    pub fn authentication_timeout(&self) -> AuthenticationTimeout {
        self.authentication_timeout
    }

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

    pub fn mobile_tab(&self) -> MobileTab {
        self.mobile_tab
    }

    pub fn mobile_home_refreshed_at(&self) -> Option<i64> {
        self.mobile_home_refreshed_at
    }

    pub fn watch_shared_totp_entries(&self) -> &BTreeSet<EntryPath> {
        &self.watch_shared_totp_entries
    }

    pub fn git_remotes(&self) -> &[GitRemote] {
        &self.git_remotes
    }

    pub fn git_identity(&self) -> &GitIdentity {
        &self.git_identity
    }

    pub fn settings(&self) -> ConfigSettings {
        let editor = self.editor.as_ref().map(|editor| {
            std::iter::once(editor.program.clone())
                .chain(editor.arguments.iter().cloned())
                .collect()
        });
        ConfigSettings {
            vault: self.vault.clone(),
            default_key: self.default_key.0.clone(),
            editor,
            authentication_timeout: self.authentication_timeout.duration(),
        }
    }

    pub fn update_mobile_tab(&self, tab: MobileTab) -> Result<(), ConfigError> {
        let mut document = self.current_document()?;
        let root = document
            .as_table_mut()
            .ok_or_else(|| ConfigError::Malformed {
                path: self.source.clone(),
            })?;
        let ui = root
            .entry("ui")
            .or_insert_with(|| toml::Value::Table(toml::Table::new()))
            .as_table_mut()
            .ok_or(ConfigError::InvalidField { field: "ui" })?;
        ui.insert(
            "selected_mobile_tab".to_owned(),
            toml::Value::String(tab.config_value().to_owned()),
        );
        let raw = document
            .clone()
            .try_into::<RawConfig>()
            .map_err(|_| ConfigError::Malformed {
                path: self.source.clone(),
            })?;
        validate_config(self.source.clone(), document, raw)?.persist()
    }

    pub fn update_watch_shared_totp_entries(
        &self,
        entries: &BTreeSet<EntryPath>,
    ) -> Result<(), ConfigError> {
        let mut document = self.current_document()?;
        let root = document
            .as_table_mut()
            .ok_or_else(|| ConfigError::Malformed {
                path: self.source.clone(),
            })?;
        let ui = root
            .entry("ui")
            .or_insert_with(|| toml::Value::Table(toml::Table::new()))
            .as_table_mut()
            .ok_or(ConfigError::InvalidField { field: "ui" })?;
        ui.insert(
            "watch_shared_totp_entries".to_owned(),
            toml::Value::Array(
                entries
                    .iter()
                    .map(|entry| toml::Value::String(entry.to_string()))
                    .collect(),
            ),
        );
        let raw = document
            .clone()
            .try_into::<RawConfig>()
            .map_err(|_| ConfigError::Malformed {
                path: self.source.clone(),
            })?;
        validate_config(self.source.clone(), document, raw)?.persist()
    }

    pub(crate) fn update_mobile_home_refresh(&self, unix_seconds: i64) -> Result<(), ConfigError> {
        if unix_seconds <= 0 {
            return Err(ConfigError::InvalidField {
                field: "ui.home_remote_refreshed_at_unix_seconds",
            });
        }
        let mut document = self.current_document()?;
        let root = document
            .as_table_mut()
            .ok_or_else(|| ConfigError::Malformed {
                path: self.source.clone(),
            })?;
        let ui = root
            .entry("ui")
            .or_insert_with(|| toml::Value::Table(toml::Table::new()))
            .as_table_mut()
            .ok_or(ConfigError::InvalidField { field: "ui" })?;
        ui.insert(
            "home_remote_refreshed_at_unix_seconds".to_owned(),
            toml::Value::Integer(unix_seconds),
        );
        let raw = document
            .clone()
            .try_into::<RawConfig>()
            .map_err(|_| ConfigError::Malformed {
                path: self.source.clone(),
            })?;
        validate_config(self.source.clone(), document, raw)?.persist()
    }

    pub fn update_biometric_unlock(&self, enabled: bool) -> Result<(), ConfigError> {
        let mut document = self.current_document()?;
        let root = document
            .as_table_mut()
            .ok_or_else(|| ConfigError::Malformed {
                path: self.source.clone(),
            })?;
        let security = root
            .entry("security")
            .or_insert_with(|| toml::Value::Table(toml::Table::new()))
            .as_table_mut()
            .ok_or(ConfigError::InvalidField { field: "security" })?;
        security.insert(
            "biometric_unlock_enabled".to_owned(),
            toml::Value::Boolean(enabled),
        );
        let raw = document
            .clone()
            .try_into::<RawConfig>()
            .map_err(|_| ConfigError::Malformed {
                path: self.source.clone(),
            })?;
        validate_config(self.source.clone(), document, raw)?.persist()
    }

    pub fn update_git_identity(&self, identity: &GitIdentity) -> Result<(), ConfigError> {
        let mut document = self.current_document()?;
        let root = document
            .as_table_mut()
            .ok_or_else(|| ConfigError::Malformed {
                path: self.source.clone(),
            })?;
        let git = root
            .entry("git")
            .or_insert_with(|| toml::Value::Table(toml::Table::new()))
            .as_table_mut()
            .ok_or(ConfigError::InvalidField { field: "git" })?;
        git.insert(
            "user_name".to_owned(),
            toml::Value::String(identity.name().to_owned()),
        );
        git.insert(
            "user_email".to_owned(),
            toml::Value::String(identity.email().to_owned()),
        );
        let raw = document
            .clone()
            .try_into::<RawConfig>()
            .map_err(|_| ConfigError::Malformed {
                path: self.source.clone(),
            })?;
        validate_config(self.source.clone(), document, raw)?.persist()
    }

    fn current_document(&self) -> Result<toml::Value, ConfigError> {
        Self::load(Some(&self.source)).map(|config| config.document)
    }

    pub(crate) fn create_mobile_clone(
        source: PathBuf,
        vault: &Path,
        key_material: &Path,
        default_key: &str,
        remote: &GitRemote,
    ) -> Result<Self, ConfigError> {
        let base = source
            .parent()
            .ok_or(ConfigError::InvalidField { field: "source" })?;
        let vault = vault
            .strip_prefix(base)
            .map_err(|_| ConfigError::InvalidField { field: "vault" })?;
        let key_material =
            key_material
                .strip_prefix(base)
                .map_err(|_| ConfigError::InvalidField {
                    field: "key_material",
                })?;
        let mut root = toml::Table::new();
        root.insert(
            "vault".to_owned(),
            toml::Value::String(path_text(vault, "vault")?),
        );
        root.insert(
            "default_key".to_owned(),
            toml::Value::String(default_key.to_owned()),
        );
        root.insert(
            "key_material".to_owned(),
            toml::Value::String(path_text(key_material, "key_material")?),
        );
        let mut configured = toml::Table::new();
        configured.insert(
            "name".to_owned(),
            toml::Value::String(remote.name().as_str().to_owned()),
        );
        configured.insert(
            "url".to_owned(),
            toml::Value::String(remote.url().to_string()),
        );
        configured.insert(
            "server_id".to_owned(),
            toml::Value::String(remote.server_id().as_str().to_owned()),
        );
        configured.insert(
            "application_id".to_owned(),
            toml::Value::String(remote.application_id().as_str().to_owned()),
        );
        let mut git = toml::Table::new();
        git.insert(
            "remotes".to_owned(),
            toml::Value::Array(vec![toml::Value::Table(configured)]),
        );
        root.insert("git".to_owned(), toml::Value::Table(git));
        let document = toml::Value::Table(root);
        let raw = document
            .clone()
            .try_into::<RawConfig>()
            .map_err(|_| ConfigError::Malformed {
                path: source.clone(),
            })?;
        let config = validate_config(source, document, raw)?;
        config.persist_new()?;
        Ok(config)
    }

    /// Select a configured remote by name, or the configured default (first
    /// remote) when no name was requested.
    pub fn git_remote(&self, requested: Option<&str>) -> Option<&GitRemote> {
        match requested {
            Some(name) => self
                .git_remotes
                .iter()
                .find(|remote| remote.name().as_str() == name),
            None => self.git_remotes.first(),
        }
    }

    /// Resolve the configured editor, then `$VISUAL`, `$EDITOR`, and finally `vim`.
    pub fn resolve_editor(&self) -> Result<ResolvedEditor, EditorError> {
        self.resolve_editor_from(
            env::var_os("VISUAL").as_deref(),
            env::var_os("EDITOR").as_deref(),
        )
    }

    /// Deterministic editor resolution used by adapters and tests.
    pub fn resolve_editor_from(
        &self,
        visual: Option<&std::ffi::OsStr>,
        editor: Option<&std::ffi::OsStr>,
    ) -> Result<ResolvedEditor, EditorError> {
        if let Some(command) = &self.editor {
            return Ok(ResolvedEditor {
                command: command.clone(),
                source: EditorSource::Configuration,
            });
        }
        if let Some(command) = parse_environment_editor("VISUAL", visual)? {
            return Ok(ResolvedEditor {
                command,
                source: EditorSource::VisualEnvironment,
            });
        }
        if let Some(command) = parse_environment_editor("EDITOR", editor)? {
            return Ok(ResolvedEditor {
                command,
                source: EditorSource::EditorEnvironment,
            });
        }
        Ok(ResolvedEditor {
            command: EditorCommand {
                program: "vim".to_owned(),
                arguments: Vec::new(),
            },
            source: EditorSource::Fallback,
        })
    }

    pub(crate) fn with_settings(&self, settings: ConfigSettings) -> Result<Self, ConfigError> {
        let vault =
            fs::canonicalize(&settings.vault).map_err(|_| ConfigError::VaultUnavailable {
                path: settings.vault.clone(),
            })?;
        if !vault.is_dir() {
            return Err(ConfigError::VaultIsNotDirectory { path: vault });
        }
        let vault = vault
            .to_str()
            .ok_or(ConfigError::InvalidField { field: "vault" })?;
        let mut document = self.document.clone();
        let root = document
            .as_table_mut()
            .ok_or_else(|| ConfigError::Malformed {
                path: self.source.clone(),
            })?;
        root.insert("vault".to_owned(), toml::Value::String(vault.to_owned()));
        root.insert(
            "default_key".to_owned(),
            toml::Value::String(settings.default_key),
        );
        match settings.editor {
            Some(editor) => {
                root.insert(
                    "editor".to_owned(),
                    toml::Value::Array(editor.into_iter().map(toml::Value::String).collect()),
                );
            }
            None => {
                root.remove("editor");
            }
        }
        if settings.authentication_timeout.subsec_nanos() != 0 {
            return Err(ConfigError::InvalidField {
                field: "security.inactivity_timeout_seconds",
            });
        }
        let authentication_timeout = i64::try_from(settings.authentication_timeout.as_secs())
            .map_err(|_| ConfigError::InvalidField {
                field: "security.inactivity_timeout_seconds",
            })?;
        let security = root
            .entry("security")
            .or_insert_with(|| toml::Value::Table(toml::Table::new()))
            .as_table_mut()
            .ok_or(ConfigError::InvalidField { field: "security" })?;
        security.insert(
            "inactivity_timeout_seconds".to_owned(),
            toml::Value::Integer(authentication_timeout),
        );
        let raw = document
            .clone()
            .try_into::<RawConfig>()
            .map_err(|_| ConfigError::Malformed {
                path: self.source.clone(),
            })?;
        validate_config(self.source.clone(), document, raw)
    }

    pub(crate) fn persist(&self) -> Result<(), ConfigError> {
        let parent = self.source.parent().ok_or_else(|| ConfigError::Write {
            path: self.source.clone(),
        })?;
        let name = self.source.file_name().ok_or_else(|| ConfigError::Write {
            path: self.source.clone(),
        })?;
        let directory =
            Dir::open_ambient_dir(parent, ambient_authority()).map_err(|_| ConfigError::Write {
                path: self.source.clone(),
            })?;
        let permissions = directory
            .metadata(name)
            .map_err(|_| ConfigError::Write {
                path: self.source.clone(),
            })?
            .permissions();
        let contents = toml::to_string_pretty(&self.document).map_err(|_| ConfigError::Write {
            path: self.source.clone(),
        })?;
        let mut temporary = TempFile::new(&directory).map_err(|_| ConfigError::Write {
            path: self.source.clone(),
        })?;
        temporary
            .as_file()
            .set_permissions(permissions)
            .and_then(|()| temporary.write_all(contents.as_bytes()))
            .and_then(|()| temporary.as_file().sync_all())
            .and_then(|()| temporary.replace(name))
            .map_err(|_| ConfigError::Write {
                path: self.source.clone(),
            })
    }

    fn persist_new(&self) -> Result<(), ConfigError> {
        let parent = self.source.parent().ok_or_else(|| ConfigError::Write {
            path: self.source.clone(),
        })?;
        let name = self.source.file_name().ok_or_else(|| ConfigError::Write {
            path: self.source.clone(),
        })?;
        if self.source.exists() {
            return Err(ConfigError::AlreadyConfigured {
                path: self.source.clone(),
            });
        }
        fs::create_dir_all(parent).map_err(|_| ConfigError::Write {
            path: self.source.clone(),
        })?;
        set_private_directory(parent).map_err(|_| ConfigError::Write {
            path: self.source.clone(),
        })?;
        let contents = toml::to_string_pretty(&self.document).map_err(|_| ConfigError::Write {
            path: self.source.clone(),
        })?;
        let temporary = (0..128_u8)
            .find_map(|attempt| {
                let path = parent.join(format!(
                    ".ironstorage-config-{}-{attempt}",
                    rand::random::<u64>()
                ));
                let mut options = OpenOptions::new();
                options.write(true).create_new(true);
                #[cfg(unix)]
                {
                    use std::os::unix::fs::OpenOptionsExt as _;
                    options.mode(0o600);
                }
                match options.open(&path) {
                    Ok(file) => Some(Ok((path, file))),
                    Err(error) if error.kind() == std::io::ErrorKind::AlreadyExists => None,
                    Err(_) => Some(Err(ConfigError::Write {
                        path: self.source.clone(),
                    })),
                }
            })
            .transpose()?
            .ok_or_else(|| ConfigError::Write {
                path: self.source.clone(),
            })?;
        let (temporary_path, mut temporary_file) = temporary;
        let installed = temporary_file
            .write_all(contents.as_bytes())
            .and_then(|()| temporary_file.sync_all())
            .and_then(|()| fs::hard_link(&temporary_path, parent.join(name)));
        drop(temporary_file);
        let _ = fs::remove_file(&temporary_path);
        match installed {
            Ok(()) => Ok(()),
            Err(error) if error.kind() == std::io::ErrorKind::AlreadyExists => {
                Err(ConfigError::AlreadyConfigured {
                    path: self.source.clone(),
                })
            }
            Err(_) => Err(ConfigError::Write {
                path: self.source.clone(),
            }),
        }
    }
}

/// Deterministic path context for configuration loading.
#[derive(Clone, Debug, Eq, PartialEq)]
pub struct ConfigLoader {
    current_directory: PathBuf,
    native_config_directory: PathBuf,
}

impl ConfigLoader {
    pub fn system() -> Result<Self, ConfigError> {
        let current_directory = env::current_dir().map_err(|_| ConfigError::CurrentDirectory)?;
        let native_config_directory =
            native_config_directory().ok_or(ConfigError::NativeConfigDirectory)?;
        Ok(Self::new(current_directory, native_config_directory))
    }

    pub fn new(current_directory: PathBuf, native_config_directory: PathBuf) -> Self {
        Self {
            current_directory: normalize_absolute(current_directory),
            native_config_directory: normalize_absolute(native_config_directory),
        }
    }

    pub fn default_path(&self) -> PathBuf {
        self.native_config_directory
            .join(APPLICATION_DIRECTORY)
            .join(CONFIG_FILE)
    }

    pub fn load(&self, explicit: Option<&Path>) -> Result<Config, ConfigError> {
        let requested = explicit
            .map(|path| resolve_path(&self.current_directory, path))
            .unwrap_or_else(|| self.default_path());
        let metadata = fs::metadata(&requested).map_err(|error| {
            if error.kind() == std::io::ErrorKind::NotFound {
                ConfigError::NotFound {
                    path: requested.clone(),
                }
            } else {
                ConfigError::Read {
                    path: requested.clone(),
                }
            }
        })?;
        if !metadata.is_file() || metadata.len() > MAX_CONFIG_BYTES {
            return Err(ConfigError::InvalidFile {
                path: requested.clone(),
            });
        }
        let source = fs::canonicalize(&requested).map_err(|_| ConfigError::Read {
            path: requested.clone(),
        })?;
        let contents = fs::read_to_string(&source).map_err(|_| ConfigError::Read {
            path: source.clone(),
        })?;
        let value =
            toml::from_str::<toml::Value>(&contents).map_err(|_| ConfigError::Malformed {
                path: source.clone(),
            })?;
        reject_insecure_fields(&value, "")?;
        validate_known_fields(&value, &source)?;
        let raw = value
            .clone()
            .try_into::<RawConfig>()
            .map_err(|_| ConfigError::Malformed {
                path: source.clone(),
            })?;
        validate_config(source, value, raw)
    }
}

#[derive(Clone, Debug, Eq, Ord, PartialEq, PartialOrd)]
pub struct KeyIdentity(String);

impl KeyIdentity {
    pub fn as_str(&self) -> &str {
        &self.0
    }
}

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

macro_rules! identifier_type {
    ($name:ident) => {
        #[derive(Clone, Debug, Eq, Ord, PartialEq, PartialOrd)]
        pub struct $name(String);

        impl $name {
            pub fn as_str(&self) -> &str {
                &self.0
            }
        }

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

identifier_type!(RemoteName);
identifier_type!(ServerId);
identifier_type!(ApplicationId);

#[derive(Clone, Debug, Eq, PartialEq)]
pub struct GitRemote {
    name: RemoteName,
    url: Url,
    server_id: ServerId,
    application_id: ApplicationId,
}

impl GitRemote {
    pub fn https(
        name: impl Into<String>,
        url: impl Into<String>,
        server_id: impl Into<String>,
        application_id: impl Into<String>,
    ) -> Result<Self, ConfigError> {
        let mut remotes = validate_remotes(vec![RawGitRemote {
            name: name.into(),
            url: url.into(),
            server_id: server_id.into(),
            application_id: application_id.into(),
        }])?;
        Ok(remotes.remove(0))
    }

    pub fn name(&self) -> &RemoteName {
        &self.name
    }

    pub fn url(&self) -> &Url {
        &self.url
    }

    pub fn server_id(&self) -> &ServerId {
        &self.server_id
    }

    pub fn application_id(&self) -> &ApplicationId {
        &self.application_id
    }
}

/// An executable and arguments. It is never interpreted by a shell.
#[derive(Clone, Debug, Eq, PartialEq)]
pub struct EditorCommand {
    program: String,
    arguments: Vec<String>,
}

impl EditorCommand {
    pub fn program(&self) -> &str {
        &self.program
    }

    pub fn arguments(&self) -> &[String] {
        &self.arguments
    }
}

#[derive(Clone, Copy, Debug, Eq, PartialEq)]
pub enum EditorSource {
    Configuration,
    VisualEnvironment,
    EditorEnvironment,
    Fallback,
}

#[derive(Clone, Debug, Eq, PartialEq)]
pub struct ResolvedEditor {
    command: EditorCommand,
    source: EditorSource,
}

impl ResolvedEditor {
    pub fn command(&self) -> &EditorCommand {
        &self.command
    }

    pub fn source(&self) -> EditorSource {
        self.source
    }
}

#[derive(Clone, Debug, Eq, PartialEq)]
pub enum ConfigError {
    CurrentDirectory,
    NativeConfigDirectory,
    NotFound { path: PathBuf },
    Read { path: PathBuf },
    InvalidFile { path: PathBuf },
    Malformed { path: PathBuf },
    UnknownField { field: String },
    InsecureField { field: String },
    MissingField { field: &'static str },
    InvalidField { field: &'static str },
    VaultUnavailable { path: PathBuf },
    VaultIsNotDirectory { path: PathBuf },
    Write { path: PathBuf },
    AlreadyConfigured { path: PathBuf },
    KeyMaterialNotFound { path: PathBuf },
    InvalidKeyMaterial { path: PathBuf },
    DuplicateRemote { name: String },
    DuplicateCredentialReference,
    InvalidRemoteUrl { name: String },
}

impl fmt::Display for ConfigError {
    fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result {
        match self {
            Self::CurrentDirectory => formatter.write_str("cannot determine the current directory"),
            Self::NativeConfigDirectory => {
                formatter.write_str("cannot determine the native user configuration directory")
            }
            Self::NotFound { path } => {
                write!(
                    formatter,
                    "configuration file not found: {}",
                    path.display()
                )
            }
            Self::Read { path } => {
                write!(
                    formatter,
                    "cannot read configuration file: {}",
                    path.display()
                )
            }
            Self::InvalidFile { path } => write!(
                formatter,
                "configuration path is not a small regular file: {}",
                path.display()
            ),
            Self::Malformed { path } => write!(
                formatter,
                "configuration is malformed; its contents were redacted: {}",
                path.display()
            ),
            Self::UnknownField { field } => {
                write!(formatter, "unknown configuration field: {field}")
            }
            Self::InsecureField { field } => write!(
                formatter,
                "secret values are forbidden in configuration field: {field}"
            ),
            Self::MissingField { field } => {
                write!(formatter, "missing required configuration field: {field}")
            }
            Self::InvalidField { field } => {
                write!(formatter, "invalid configuration field: {field}")
            }
            Self::VaultUnavailable { path } => write!(
                formatter,
                "vault folder is missing or inaccessible: {}",
                path.display()
            ),
            Self::VaultIsNotDirectory { path } => {
                write!(
                    formatter,
                    "vault path is not a directory: {}",
                    path.display()
                )
            }
            Self::Write { path } => {
                write!(
                    formatter,
                    "cannot update configuration file: {}",
                    path.display()
                )
            }
            Self::AlreadyConfigured { path } => write!(
                formatter,
                "configuration already exists and was not replaced: {}",
                path.display()
            ),
            Self::KeyMaterialNotFound { path } => write!(
                formatter,
                "exported key material does not exist: {}",
                path.display()
            ),
            Self::InvalidKeyMaterial { path } => write!(
                formatter,
                "exported key material is not a file or directory: {}",
                path.display()
            ),
            Self::DuplicateRemote { name } => {
                write!(formatter, "duplicate Git remote name: {name}")
            }
            Self::DuplicateCredentialReference => {
                formatter.write_str("duplicate Git server/application credential reference")
            }
            Self::InvalidRemoteUrl { name } => write!(
                formatter,
                "Git remote {name} must be an HTTPS URL without embedded credentials, query, or fragment"
            ),
        }
    }
}

impl Error for ConfigError {}

#[derive(Clone, Debug, Eq, PartialEq)]
pub enum EditorError {
    NonUnicode { variable: &'static str },
    InvalidCommand { source: &'static str },
}

impl fmt::Display for EditorError {
    fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result {
        match self {
            Self::NonUnicode { variable } => {
                write!(formatter, "{variable} is not valid Unicode")
            }
            Self::InvalidCommand { source } => {
                write!(
                    formatter,
                    "{source} does not contain a valid editor command"
                )
            }
        }
    }
}

impl Error for EditorError {}

#[derive(Deserialize)]
#[serde(deny_unknown_fields)]
struct RawConfig {
    vault: Option<PathBuf>,
    default_key: Option<String>,
    key_material: Option<PathBuf>,
    editor: Option<RawEditor>,
    clipboard_timeout_seconds: Option<u64>,
    #[serde(default)]
    security: RawSecurity,
    #[serde(default)]
    ui: RawUi,
    #[serde(default)]
    git: RawGit,
}

#[derive(Default, Deserialize)]
#[serde(deny_unknown_fields)]
struct RawSecurity {
    inactivity_timeout_seconds: Option<u64>,
    biometric_unlock_enabled: Option<bool>,
}

#[derive(Default, Deserialize)]
#[serde(deny_unknown_fields)]
struct RawUi {
    selected_mobile_tab: Option<String>,
    home_remote_refreshed_at_unix_seconds: Option<i64>,
    #[serde(default)]
    watch_shared_totp_entries: Vec<String>,
}

#[derive(Deserialize)]
#[serde(untagged)]
enum RawEditor {
    CommandLine(String),
    Arguments(Vec<String>),
}

#[derive(Default, Deserialize)]
#[serde(deny_unknown_fields)]
struct RawGit {
    user_name: Option<String>,
    user_email: Option<String>,
    #[serde(default)]
    remotes: Vec<RawGitRemote>,
}

#[derive(Deserialize)]
#[serde(deny_unknown_fields)]
struct RawGitRemote {
    name: String,
    url: String,
    server_id: String,
    application_id: String,
}

fn validate_config(
    source: PathBuf,
    document: toml::Value,
    raw: RawConfig,
) -> Result<Config, ConfigError> {
    let base = source
        .parent()
        .ok_or(ConfigError::InvalidField { field: "source" })?;
    let vault = resolve_required_path(base, raw.vault, "vault")?;
    if vault.exists() && !vault.is_dir() {
        return Err(ConfigError::VaultIsNotDirectory { path: vault });
    }

    let key_material = resolve_required_path(base, raw.key_material, "key_material")?;
    if !key_material.exists() {
        return Err(ConfigError::KeyMaterialNotFound { path: key_material });
    }
    let key_metadata =
        fs::metadata(&key_material).map_err(|_| ConfigError::KeyMaterialNotFound {
            path: key_material.clone(),
        })?;
    if !key_metadata.is_file() && !key_metadata.is_dir() {
        return Err(ConfigError::InvalidKeyMaterial { path: key_material });
    }
    let key_material = fs::canonicalize(&key_material)
        .map_err(|_| ConfigError::InvalidKeyMaterial { path: key_material })?;

    let default_key = raw
        .default_key
        .ok_or(ConfigError::MissingField {
            field: "default_key",
        })
        .and_then(validate_key_identity)?;
    let editor = raw.editor.map(validate_editor).transpose()?;
    let clipboard_timeout = ClipboardTimeout::new(Duration::from_secs(
        raw.clipboard_timeout_seconds
            .unwrap_or(DEFAULT_CLIPBOARD_TIMEOUT.as_secs()),
    ))
    .map_err(|_| ConfigError::InvalidField {
        field: "clipboard_timeout_seconds",
    })?;
    let authentication_timeout = AuthenticationTimeout::new(Duration::from_secs(
        raw.security
            .inactivity_timeout_seconds
            .unwrap_or(DEFAULT_AUTHENTICATION_TIMEOUT.as_secs()),
    ))
    .map_err(|_| ConfigError::InvalidField {
        field: "security.inactivity_timeout_seconds",
    })?;
    let biometric_unlock_enabled = raw.security.biometric_unlock_enabled.unwrap_or(false);
    let mobile_tab = raw
        .ui
        .selected_mobile_tab
        .as_deref()
        .map(MobileTab::from_config)
        .transpose()?
        .unwrap_or_default();
    let mobile_home_refreshed_at = match raw.ui.home_remote_refreshed_at_unix_seconds {
        Some(value) if value > 0 => Some(value),
        Some(_) => {
            return Err(ConfigError::InvalidField {
                field: "ui.home_remote_refreshed_at_unix_seconds",
            });
        }
        None => None,
    };
    let watch_shared_totp_entries = raw
        .ui
        .watch_shared_totp_entries
        .into_iter()
        .map(|entry| {
            EntryPath::parse(&entry).map_err(|_| ConfigError::InvalidField {
                field: "ui.watch_shared_totp_entries",
            })
        })
        .collect::<Result<BTreeSet<_>, _>>()?;
    let git_identity = match (raw.git.user_name, raw.git.user_email) {
        (None, None) => GitIdentity::ironstorage(),
        (Some(name), Some(email)) => {
            GitIdentity::new(name, email).map_err(|_| ConfigError::InvalidField {
                field: "git.user_identity",
            })?
        }
        _ => {
            return Err(ConfigError::InvalidField {
                field: "git.user_identity",
            });
        }
    };
    let git_remotes = validate_remotes(raw.git.remotes)?;

    Ok(Config {
        source,
        document,
        vault,
        default_key,
        key_material,
        editor,
        clipboard_timeout,
        authentication_timeout,
        biometric_unlock_enabled,
        mobile_tab,
        mobile_home_refreshed_at,
        watch_shared_totp_entries,
        git_identity,
        git_remotes,
    })
}

fn resolve_required_path(
    base: &Path,
    value: Option<PathBuf>,
    field: &'static str,
) -> Result<PathBuf, ConfigError> {
    let value = value.ok_or(ConfigError::MissingField { field })?;
    if value.as_os_str().is_empty() {
        return Err(ConfigError::InvalidField { field });
    }
    Ok(resolve_path(base, &value))
}

fn path_text(path: &Path, field: &'static str) -> Result<String, ConfigError> {
    path.to_str()
        .map(str::to_owned)
        .ok_or(ConfigError::InvalidField { field })
}

fn set_private_directory(path: &Path) -> std::io::Result<()> {
    #[cfg(unix)]
    {
        use std::os::unix::fs::PermissionsExt as _;
        fs::set_permissions(path, fs::Permissions::from_mode(0o700))?;
    }
    #[cfg(not(unix))]
    let _ = path;
    Ok(())
}

fn validate_key_identity(value: String) -> Result<KeyIdentity, ConfigError> {
    let trimmed = value.trim();
    if trimmed.is_empty()
        || trimmed.len() > 512
        || trimmed.chars().any(char::is_control)
        || trimmed != value
    {
        return Err(ConfigError::InvalidField {
            field: "default_key",
        });
    }
    Ok(KeyIdentity(value))
}

fn validate_editor(raw: RawEditor) -> Result<EditorCommand, ConfigError> {
    let words = match raw {
        RawEditor::CommandLine(command) => {
            shlex::split(&command).ok_or(ConfigError::InvalidField { field: "editor" })?
        }
        RawEditor::Arguments(arguments) => arguments,
    };
    editor_from_words(words).map_err(|_| ConfigError::InvalidField { field: "editor" })
}

fn editor_from_words(mut words: Vec<String>) -> Result<EditorCommand, ()> {
    if words.is_empty() || words.iter().any(|word| word.contains('\0')) {
        return Err(());
    }
    let program = words.remove(0);
    if program.is_empty() {
        return Err(());
    }
    Ok(EditorCommand {
        program,
        arguments: words,
    })
}

fn parse_environment_editor(
    variable: &'static str,
    value: Option<&std::ffi::OsStr>,
) -> Result<Option<EditorCommand>, EditorError> {
    let Some(value) = value else {
        return Ok(None);
    };
    if value.is_empty() {
        return Ok(None);
    }
    let value = value.to_str().ok_or(EditorError::NonUnicode { variable })?;
    let words = shlex::split(value).ok_or(EditorError::InvalidCommand { source: variable })?;
    editor_from_words(words)
        .map(Some)
        .map_err(|()| EditorError::InvalidCommand { source: variable })
}

fn validate_remotes(raw: Vec<RawGitRemote>) -> Result<Vec<GitRemote>, ConfigError> {
    let mut names = BTreeSet::new();
    let mut references = BTreeSet::new();
    let mut remotes = Vec::with_capacity(raw.len());
    for remote in raw {
        let name = RemoteName(validate_identifier("git.remotes.name", remote.name)?);
        let server_id = ServerId(validate_identifier(
            "git.remotes.server_id",
            remote.server_id,
        )?);
        let application_id = ApplicationId(validate_identifier(
            "git.remotes.application_id",
            remote.application_id,
        )?);
        if !names.insert(name.clone()) {
            return Err(ConfigError::DuplicateRemote {
                name: name.0.clone(),
            });
        }
        if !references.insert((server_id.clone(), application_id.clone())) {
            return Err(ConfigError::DuplicateCredentialReference);
        }
        let url = Url::parse(&remote.url).map_err(|_| ConfigError::InvalidRemoteUrl {
            name: name.0.clone(),
        })?;
        if url.scheme() != "https"
            || url.host_str().is_none()
            || !url.username().is_empty()
            || url.password().is_some()
            || url.query().is_some()
            || url.fragment().is_some()
        {
            return Err(ConfigError::InvalidRemoteUrl {
                name: name.0.clone(),
            });
        }
        remotes.push(GitRemote {
            name,
            url,
            server_id,
            application_id,
        });
    }
    Ok(remotes)
}

fn validate_identifier(field: &'static str, value: String) -> Result<String, ConfigError> {
    if value.is_empty()
        || value.len() > 128
        || !value
            .bytes()
            .all(|byte| byte.is_ascii_alphanumeric() || matches!(byte, b'.' | b'_' | b'-'))
    {
        return Err(ConfigError::InvalidField { field });
    }
    Ok(value)
}

fn validate_known_fields(value: &toml::Value, source: &Path) -> Result<(), ConfigError> {
    let root = value.as_table().ok_or_else(|| ConfigError::UnknownField {
        field: "<root>".to_owned(),
    })?;
    validate_table(
        root,
        "",
        &[
            "vault",
            "default_key",
            "key_material",
            "editor",
            "clipboard_timeout_seconds",
            "security",
            "ui",
            "git",
        ],
    )?;
    if let Some(security) = root.get("security") {
        let security = security.as_table().ok_or_else(|| ConfigError::Malformed {
            path: source.to_owned(),
        })?;
        validate_table(
            security,
            "security",
            &["inactivity_timeout_seconds", "biometric_unlock_enabled"],
        )?;
    }
    if let Some(ui) = root.get("ui") {
        let ui = ui.as_table().ok_or_else(|| ConfigError::Malformed {
            path: source.to_owned(),
        })?;
        validate_table(
            ui,
            "ui",
            &[
                "selected_mobile_tab",
                "home_remote_refreshed_at_unix_seconds",
                "watch_shared_totp_entries",
            ],
        )?;
    }
    let Some(git) = root.get("git") else {
        return Ok(());
    };
    let git = git.as_table().ok_or_else(|| ConfigError::Malformed {
        path: source.to_owned(),
    })?;
    validate_table(git, "git", &["user_name", "user_email", "remotes"])?;
    if let Some(remotes) = git.get("remotes") {
        let remotes = remotes.as_array().ok_or_else(|| ConfigError::Malformed {
            path: source.to_owned(),
        })?;
        for (index, remote) in remotes.iter().enumerate() {
            let remote = remote.as_table().ok_or_else(|| ConfigError::Malformed {
                path: source.to_owned(),
            })?;
            validate_table(
                remote,
                &format!("git.remotes[{index}]"),
                &["name", "url", "server_id", "application_id"],
            )?;
        }
    }
    Ok(())
}

fn validate_table(table: &toml::Table, prefix: &str, allowed: &[&str]) -> Result<(), ConfigError> {
    for key in table.keys() {
        if !allowed.contains(&key.as_str()) {
            let field = if prefix.is_empty() {
                key.clone()
            } else {
                format!("{prefix}.{key}")
            };
            return Err(ConfigError::UnknownField { field });
        }
    }
    Ok(())
}

fn reject_insecure_fields(value: &toml::Value, prefix: &str) -> Result<(), ConfigError> {
    match value {
        toml::Value::Table(table) => {
            for (key, nested) in table {
                let path = if prefix.is_empty() {
                    key.clone()
                } else {
                    format!("{prefix}.{key}")
                };
                let normalized = key.to_ascii_lowercase().replace('-', "_");
                if [
                    "password",
                    "passphrase",
                    "token",
                    "secret",
                    "credential",
                    "credentials",
                    "private_key",
                ]
                .contains(&normalized.as_str())
                {
                    return Err(ConfigError::InsecureField { field: path });
                }
                reject_insecure_fields(nested, &path)?;
            }
        }
        toml::Value::Array(values) => {
            for (index, nested) in values.iter().enumerate() {
                reject_insecure_fields(nested, &format!("{prefix}[{index}]"))?;
            }
        }
        _ => {}
    }
    Ok(())
}

fn resolve_path(base: &Path, path: &Path) -> PathBuf {
    if path.is_absolute() {
        normalize_absolute(path.to_owned())
    } else {
        normalize_absolute(base.join(path))
    }
}

fn normalize_absolute(path: PathBuf) -> PathBuf {
    let mut normalized = PathBuf::new();
    for component in path.components() {
        match component {
            Component::CurDir => {}
            Component::ParentDir => {
                if !normalized.pop() {
                    normalized.push(component.as_os_str());
                }
            }
            _ => normalized.push(component.as_os_str()),
        }
    }
    normalized
}

#[cfg(target_os = "windows")]
fn native_config_directory() -> Option<PathBuf> {
    env::var_os("APPDATA").map(PathBuf::from)
}

#[cfg(target_os = "macos")]
fn native_config_directory() -> Option<PathBuf> {
    env::var_os("HOME")
        .map(PathBuf::from)
        .map(|home| home.join("Library/Application Support"))
}

#[cfg(target_os = "ios")]
fn native_config_directory() -> Option<PathBuf> {
    env::var_os("HOME")
        .map(PathBuf::from)
        .map(|home| home.join("Library/Application Support"))
}

#[cfg(all(unix, not(any(target_os = "ios", target_os = "macos"))))]
fn native_config_directory() -> Option<PathBuf> {
    match env::var_os("XDG_CONFIG_HOME") {
        Some(path) if !path.is_empty() && Path::new(&path).is_absolute() => {
            Some(PathBuf::from(path))
        }
        _ => env::var_os("HOME")
            .filter(|home| !home.is_empty())
            .map(PathBuf::from)
            .map(|home| home.join(".config")),
    }
}

#[cfg(not(any(unix, target_os = "windows")))]
fn native_config_directory() -> Option<PathBuf> {
    None
}

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

    use crate::mobile::MobileTab;

    use super::{Config, ConfigError, GitRemote};

    #[test]
    fn mobile_clone_configuration_is_secret_free_and_never_replaced() {
        let temporary = tempfile::tempdir().expect("temporary directory");
        let original = temporary.path().join("original");
        let relocated = temporary.path().join("relocated");
        let vault = original.join("vault");
        let keys = original.join("keys");
        let source = original.join("config.toml");
        fs::create_dir_all(&vault).expect("vault");
        fs::create_dir(&keys).expect("keys");
        let remote = GitRemote::https(
            "origin",
            "https://example.test/team/passwords.git",
            "server-example",
            "repository-example",
        )
        .expect("remote");
        let config = Config::create_mobile_clone(source.clone(), &vault, &keys, "ALICE", &remote)
            .expect("create config");
        assert_eq!(config.mobile_home_refreshed_at(), None);
        config
            .update_mobile_home_refresh(1_789_000_000)
            .expect("persist refresh time");
        Config::load(Some(&source))
            .expect("reload timestamped config")
            .update_mobile_tab(MobileTab::Preferences)
            .expect("persist tab after refresh time");
        let contents = fs::read_to_string(&source).expect("read config");
        assert!(!contents.contains("token ="));
        assert!(!contents.contains("password ="));
        assert!(!contents.contains(&original.to_string_lossy().into_owned()));
        assert!(contents.contains("vault = \"vault\""));
        assert!(contents.contains("key_material = \"keys\""));
        assert!(contents.contains("https://example.test/team/passwords.git"));
        assert_eq!(
            Config::create_mobile_clone(source.clone(), &vault, &keys, "BOB", &remote)
                .expect_err("must not replace existing config"),
            ConfigError::AlreadyConfigured {
                path: source.clone()
            }
        );
        fs::rename(&original, &relocated).expect("relocate app container");
        let relocated_config =
            Config::load(Some(&relocated.join("config.toml"))).expect("reload config");
        assert_eq!(
            (
                relocated_config.default_key().as_str(),
                relocated_config.mobile_home_refreshed_at(),
                relocated_config.mobile_tab(),
            ),
            ("ALICE", Some(1_789_000_000), MobileTab::Preferences)
        );
    }
}
