// Copyright 2023-2026 Hugo Osvaldo Barrera
//
// SPDX-License-Identifier: EUPL-1.2

//! Configuration types and runtime storage initialization.

use std::{collections::HashMap, fs::File, mem::swap, path::PathBuf, sync::Arc, time::Duration};

use anyhow::{Context, anyhow, bail};
use base64::Engine as _;
use camino::{Utf8Path, Utf8PathBuf};
use hyper::{Request, Response, Uri, body::Incoming, header::HeaderValue};
use hyper_rustls::ConfigBuilderExt;
use log::{debug, info};
use rustls::{
    ClientConfig, RootCertStore,
    client::danger::DangerousClientConfigBuilder,
    pki_types::{CertificateDer, PrivateKeyDer, pem::PemObject as _},
};
use tokio::{
    sync::{Mutex, Notify},
    task::JoinSet,
};
use tower_service::Service;
use vstorage::{
    CollectionId, DiscoveryMode, ItemKind,
    base::Storage,
    caldav::{CalDavStorage, CollectionIdSegment},
    carddav::CardDavStorage,
    libdav::dav::WebDavClient,
    readonly::ReadOnlyStorage,
    sync::{
        Mode, OneWaySync, TwoWaySync,
        declare::{CollectionDescription, OnDelete, OnEmpty, StoragePair, SyncedCollection},
    },
    vdir::VdirStorage,
    webcal::WebCalStorage,
};
#[cfg(feature = "jmap")]
use vstorage::{
    jmap::JmapStorage,
    libjmap::{JmapClient, discover_session_resource},
};

use crate::{
    ConflictResolution, NamedPair, RawCommand, VERSION,
    cli::FilterNames,
    error::Error,
    proxy::UnixConnector,
    repair::NamedStorage,
    tls::{FingerprintAndWebPkiVerifier, FingerprintVerifier, cert_and_key_from_pemfile},
    transport::{Auth, build_http_client, https_connector, https_connector_proxy},
};

/// Default connection and I/O timeout for HTTP connections.
pub const DEFAULT_TIMEOUT: Duration = Duration::from_secs(30);

/// Default interval for periodic scans.
pub const DEFAULT_INTERVAL: Duration = Duration::from_secs(300);

/// Parameter that can be either a plain string or a command to execute.
#[derive(Debug, PartialEq)]
pub(crate) enum DynamicParam {
    String(String),
    RawCommand(RawCommand),
}

impl DynamicParam {
    /// Resolve the parameter to its string value.
    pub(crate) fn resolve(self) -> anyhow::Result<String> {
        match self {
            DynamicParam::String(s) => Ok(s),
            DynamicParam::RawCommand(cmd) => {
                let output = cmd
                    .command()
                    .stdout(std::process::Stdio::piped())
                    .output()
                    .context("Executing command for dynamic parameter")?;
                match output.status.code() {
                    Some(0) => Ok(std::str::from_utf8(&output.stdout)?.trim().to_owned()),
                    Some(code) => bail!("Command exited with status {code}."),
                    None => bail!("Command exited unexpectedly."),
                }
            }
        }
    }
}

/// Authentication configuration for a storage.
#[derive(Debug, Default, PartialEq)]
pub(crate) struct AuthConfig {
    pub(crate) username: Option<DynamicParam>,
    pub(crate) password: Option<DynamicParam>,
    // XXX: We'll want an AuthKind when we have OAuth, but this is good enough for now.
    pub(crate) use_digest: bool,
}

/// TLS configuration for a storage.
#[derive(Debug, Default, PartialEq)]
pub(crate) struct TlsConfig {
    pub(crate) root_path: Option<PathBuf>,
    pub(crate) fingerprint: Option<String>,
    /// Paths to client certificate and key files.
    /// If single path, it's a combined PEM file.
    /// If two paths, first is cert, second is key.
    pub(crate) client_cert_paths: Option<(PathBuf, Option<PathBuf>)>,
}

/// Storage block configuration.
#[derive(Debug, PartialEq)]
pub(crate) enum StorageBlock {
    Vdir(VdirBlock),
    CalDav(DavBlock),
    CardDav(DavBlock),
    Webcal(WebcalBlock),
    #[cfg(feature = "jmap")]
    Jmap(JmapBlock),
}

/// Configuration for vdir storages.
#[derive(Debug, PartialEq)]
pub(crate) struct VdirBlock {
    pub(crate) kind: ItemKind,
    pub(crate) read_only: bool,
    pub(crate) interval: Duration,
    pub(crate) path: DynamicParam,
    pub(crate) fileext: Option<String>,
}

/// Configuration for CalDAV and CardDAV storages.
#[derive(Debug, PartialEq)]
pub(crate) struct DavBlock {
    pub(crate) read_only: bool,
    pub(crate) interval: Duration,
    pub(crate) url: DynamicParam,
    pub(crate) socket: Option<String>,
    pub(crate) collection_id_segment: CollectionIdSegment,
    pub(crate) discovery_mode: DiscoveryMode,
    pub(crate) auth: AuthConfig,
    pub(crate) tls: TlsConfig,
    pub(crate) user_agent: Option<String>,
}

/// Configuration for webcal storages.
#[derive(Debug, PartialEq)]
pub(crate) struct WebcalBlock {
    pub(crate) interval: Duration,
    pub(crate) url: DynamicParam,
    pub(crate) collection_id: String,
    pub(crate) auth: AuthConfig,
    pub(crate) tls: TlsConfig,
    pub(crate) user_agent: Option<String>,
}

/// Configuration for JMAP storages.
#[cfg(feature = "jmap")]
#[derive(Debug, PartialEq)]
pub(crate) struct JmapBlock {
    pub(crate) kind: ItemKind,
    pub(crate) read_only: bool,
    pub(crate) interval: Duration,
    pub(crate) url: DynamicParam,
    pub(crate) auth: AuthConfig,
    pub(crate) tls: TlsConfig,
    pub(crate) user_agent: Option<String>,
}

/// Collection specification for a pair.
#[derive(Debug, PartialEq)]
pub(crate) enum CollectionSpec {
    All,
    FromA,
    FromB,
    Named(CollectionId),
    Mapped {
        alias: String,
        a: CollectionDescription,
        b: CollectionDescription,
    },
}

/// A complete pair block configuration.
#[derive(Debug, PartialEq)]
pub(crate) struct PairBlock {
    pub(crate) storage_a: String,
    pub(crate) storage_b: String,
    pub(crate) collections: Vec<CollectionSpec>,
    pub(crate) on_empty: OnEmpty,
    pub(crate) on_delete: OnDelete,
    pub(crate) one_way: bool,
    pub(crate) conflict_resolution: Option<ConflictResolution>,
}

/// A deserialised configuration file.
#[derive(Debug, PartialEq)]
pub(crate) struct Config {
    pub(crate) status_path: DynamicParam,
    pub(crate) pairs: HashMap<String, PairBlock>,
    pub(crate) storages: HashMap<String, StorageBlock>,
}

impl Config {
    /// Convert this configuration into an `App` instance.
    ///
    /// Consumes the configuration to avoid copying any data needlessly and frees any
    /// unnecessary data.
    pub(crate) async fn into_named_pairs(self) -> Result<Vec<NamedPair>, Error> {
        let status_path = self.status_path.resolve().map_err(Error::Config)?;
        let status_dir = expand_tilde(Utf8PathBuf::from(status_path))
            .context("Expanding tilde for status_dir")
            .map_err(Error::Config)?;

        let status_dir = Arc::<Utf8Path>::from(status_dir);
        let storages = Arc::new(StorageBuilder::new(self.storages));

        let mut tasks = JoinSet::new();
        for (name, block) in self.pairs {
            let storages = storages.clone();
            let status_dir = status_dir.clone();
            tasks.spawn(async move {
                info!("Initialising pair {name}");

                let ((storage_a, interval_a), (storage_b, interval_b)) = tokio::try_join!(
                    storages.get_storage(&block.storage_a),
                    storages.get_storage(&block.storage_b)
                )
                .map_err(Error::Other)?;

                let conflict_resolution = block.conflict_resolution;
                let mode: Arc<dyn Mode> = if block.one_way {
                    Arc::new(OneWaySync)
                } else {
                    Arc::new(TwoWaySync)
                };

                // TODO: metadata

                let status_path = status_dir.join(format!("{name}.status"));
                match (storage_a.item_kind(), storage_b.item_kind()) {
                    (ItemKind::Calendar, ItemKind::AddressBook) => Err(Error::Config(anyhow!(
                        "pair {name} mixes calendar storage with contacts storage"
                    ))),
                    (ItemKind::AddressBook, ItemKind::Calendar) => Err(Error::Config(anyhow!(
                        "pair {name} mixes contacts storage with calendar storage"
                    ))),
                    (ItemKind::AddressBook, ItemKind::AddressBook)
                    | (ItemKind::Calendar, ItemKind::Calendar) => Ok(NamedPair {
                        name,
                        inner: init_pair(
                            block.collections,
                            (storage_a, storage_b),
                            block.on_empty,
                            block.on_delete,
                            mode,
                        ),
                        status_path,
                        conflict_resolution,
                        intervals: (interval_a, interval_b),
                    }),
                }
            });
        }

        let mut pairs = Vec::new();
        while let Some(res) = tasks.join_next().await {
            match res {
                Ok(Ok(p)) => pairs.push(p),
                Ok(Err(err)) => return Err(err),
                Err(joinerr) => return Err(Error::Other(joinerr.into())),
            }
        }
        debug!("Initialised pairs.");

        Ok(pairs)
    }

    /// Initialise named storages from an already-parsed configuration.
    ///
    /// Storages are filtered by `enabled_storages`. If a requested name is not
    /// defined, an error is returned.
    pub(crate) async fn into_storages(
        self,
        mut enabled_storages: FilterNames,
    ) -> Result<Vec<NamedStorage>, Error> {
        let mut tasks = JoinSet::new();

        for (name, block) in self.storages {
            if !enabled_storages.wants(&name) {
                debug!("Skipping storage {name}; not enabled.");
                continue;
            }

            tasks.spawn(async move {
                init_storage(block)
                    .await
                    .with_context(|| format!("initialising storage {name}"))
                    .map(|(storage, _)| NamedStorage { name, storage })
            });
        }

        if let Some(missing) = enabled_storages.next_missing() {
            return Err(Error::Usage(anyhow!(
                "Missing definition for storage {missing}."
            )));
        }

        let mut storages = Vec::new();
        while let Some(res) = tasks.join_next().await {
            match res {
                Ok(Ok(ns)) => storages.push(ns),
                Ok(Err(err)) => return Err(Error::Other(err)),
                Err(joinerr) => return Err(Error::Other(joinerr.into())),
            }
        }
        Ok(storages)
    }
}

/// Wrapper around storage that is only initialised when required.
enum LazyStorage {
    /// Raw configuration options for this storage.
    Raw(Box<StorageBlock>),
    /// Notifier to await while the storage is initialised concurrently.
    Initialising(Arc<Notify>),
    /// Storage that has been initialised, plus the duration for its `monitor` interval.
    Ready(Arc<dyn Storage>, Duration),
}

/// Build storages using configuration as input.
struct StorageBuilder {
    /// Keys are names given to storages.
    raw: HashMap<String, Mutex<LazyStorage>>,
}

impl StorageBuilder {
    /// Create a new builder with raw configuration data.
    fn new(raw: HashMap<String, StorageBlock>) -> Self {
        let raw = raw
            .into_iter()
            .map(|(name, block)| (name, Mutex::new(LazyStorage::Raw(Box::new(block)))))
            .collect();
        StorageBuilder { raw }
    }

    /// Returns a storage with a matching name.
    ///
    /// Ensures that each storage is initialised only once. If two concurrent calls would return
    /// the same storage, one of them will wait until the other resolves the storage.
    ///
    /// # Errors
    ///
    /// Only returns an error if the call to `init_storage` fails. Errors include the name of the
    /// failing storage, so can be bubbled up verbatim.
    async fn get_storage(
        &self,
        storage_name: &str,
    ) -> anyhow::Result<(Arc<dyn Storage>, Duration)> {
        let Some(value) = self.raw.get(storage_name) else {
            bail!("Storage {storage_name} is not defined.")
        };
        let mut lock = value.lock().await;
        match &*lock {
            LazyStorage::Raw(_) => {
                // Set state to "initialising"
                let mut data = LazyStorage::Initialising(Arc::new(Notify::new()));
                swap(&mut *lock, &mut data);
                drop(lock);

                // Initialising the storage might take some time (e.g.: a few network round trips),
                // so we do this after releasing the lock.
                let LazyStorage::Raw(block) = data else {
                    unreachable!("Data was mutated while we held a lock.");
                };
                let (storage, duration) = init_storage(*block)
                    .await
                    .with_context(|| format!("Initialising storage {storage_name}"))?;

                // Keep a copy of Arc<Storage> for other calls to get_storage.
                let mut data = LazyStorage::Ready(storage.clone(), duration);
                let mut lock = value.lock().await;
                swap(&mut *lock, &mut data);
                drop(lock);

                // Notify others waiting for this storage to be initialised.
                let LazyStorage::Initialising(notify) = data else {
                    unreachable!("Value was mutated while initialising.");
                };
                notify.notify_waiters();

                Ok((storage, duration))
            }
            LazyStorage::Initialising(notify) => {
                let notify = notify.clone();
                let notify = notify.notified();
                drop(lock);

                notify.await;
                let lock = value.lock().await;
                let LazyStorage::Ready(storage, duration) = &*lock else {
                    unreachable!("Received notification for non-ready storage.");
                };
                Ok((storage.clone(), *duration))
                // Dropping lock releases it.
            }
            LazyStorage::Ready(storage, duration) => Ok((storage.clone(), *duration)),
        }
    }
}

fn init_pair(
    collections: Vec<CollectionSpec>,
    storages: (Arc<dyn Storage>, Arc<dyn Storage>),
    on_empty: OnEmpty,
    on_delete: OnDelete,
    mode: Arc<dyn Mode>,
) -> StoragePair {
    let mut pair = StoragePair::new(storages.0, storages.1);

    for collection in collections {
        pair = match collection {
            CollectionSpec::All => pair.with_all_from_a().with_all_from_b(),
            CollectionSpec::FromA => pair.with_all_from_a(),
            CollectionSpec::FromB => pair.with_all_from_b(),
            CollectionSpec::Named(id) => pair.with_mapping(SyncedCollection::direct(id)),
            CollectionSpec::Mapped { alias, a, b } => {
                pair.with_mapping(SyncedCollection::Mapped { alias, a, b })
            }
        }
    }

    pair.on_empty(on_empty).on_delete(on_delete).with_mode(mode)
}

/// Initialise a storage based on the given configuration.
async fn init_storage(block: StorageBlock) -> anyhow::Result<(Arc<dyn Storage>, Duration)> {
    let interval = match &block {
        StorageBlock::Vdir(v) => v.interval,
        StorageBlock::CalDav(d) | StorageBlock::CardDav(d) => d.interval,
        StorageBlock::Webcal(w) => w.interval,
        #[cfg(feature = "jmap")]
        StorageBlock::Jmap(j) => j.interval,
    };
    let storage = match block {
        StorageBlock::Vdir(v) => init_vdir_storage(v)?,
        StorageBlock::CalDav(d) => init_caldav_storage(d).await?,
        StorageBlock::CardDav(d) => init_carddav_storage(d).await?,
        StorageBlock::Webcal(w) => init_webcal_storage(w)?,
        #[cfg(feature = "jmap")]
        StorageBlock::Jmap(j) => init_jmap_storage(j).await?,
    };
    Ok((storage, interval))
}

fn init_vdir_storage(block: VdirBlock) -> anyhow::Result<Arc<dyn Storage>> {
    let path = block
        .path
        .resolve()
        .context("resolving path for vdir storage")?;
    let path = expand_tilde(Utf8PathBuf::from(path)).context("Expanding tilde for storage")?;

    let mut builder = VdirStorage::builder(path)?;

    if let Some(fileext) = block.fileext {
        // v0.X series expected the leading dot. This is not ideal and should be deprecated.
        let fileext = fileext
            .strip_prefix('.')
            .map(String::from)
            .unwrap_or(fileext);
        builder = builder.with_extension(fileext);
    }

    Ok(into_arc(builder.build(block.kind), block.read_only))
}

async fn init_carddav_storage(block: DavBlock) -> anyhow::Result<Arc<dyn Storage>> {
    let url: Uri = block
        .url
        .resolve()?
        .parse()
        .context("Parsing carddav url")?;

    let collection_id_segment = block.collection_id_segment;
    let auth = resolve_auth(block.auth)?;
    let tls_config = build_tls_config(&block.tls)?;
    let user_agent = build_user_agent(block.user_agent.as_deref())?;

    if let Some(socket) = block.socket {
        let client = build_http_client(UnixConnector::new(socket), auth, user_agent);
        finish_carddav(
            client,
            url,
            collection_id_segment,
            block.read_only,
            block.discovery_mode,
        )
        .await
    } else if let Ok(proxy_url) = std::env::var("http_proxy") {
        let proxy_uri: Uri = proxy_url
            .parse()
            .context("parsing http_proxy environment variable")?;
        info!("using HTTP proxy {proxy_uri}");
        let connector = https_connector_proxy(tls_config, proxy_uri);
        let client = build_http_client(connector, auth, user_agent);
        finish_carddav(
            client,
            url,
            collection_id_segment,
            block.read_only,
            block.discovery_mode,
        )
        .await
    } else {
        let connector = https_connector(tls_config);
        let client = build_http_client(connector, auth, user_agent);
        finish_carddav(
            client,
            url,
            collection_id_segment,
            block.read_only,
            block.discovery_mode,
        )
        .await
    }
}

async fn finish_carddav<S>(
    client: S,
    url: Uri,
    collection_id_segment: CollectionIdSegment,
    ro: bool,
    discovery_mode: DiscoveryMode,
) -> anyhow::Result<Arc<dyn Storage>>
where
    S: Service<Request<String>, Response = Response<Incoming>> + Send + Sync + 'static,
    S::Error: std::error::Error + Send + Sync,
    S::Future: Send + Sync,
{
    let webdav = WebDavClient::new(url, client);
    let mut builder = CardDavStorage::builder(webdav);
    builder = builder
        .with_collection_id_segment(collection_id_segment)
        .with_discovery(discovery_mode);
    Ok(into_arc(builder.build().await?, ro))
}

async fn init_caldav_storage(block: DavBlock) -> anyhow::Result<Arc<dyn Storage>> {
    let url: Uri = block.url.resolve()?.parse().context("Parsing caldav url")?;

    let collection_id_segment = block.collection_id_segment;
    let auth = resolve_auth(block.auth)?;
    let tls_config = build_tls_config(&block.tls)?;
    let user_agent = build_user_agent(block.user_agent.as_deref())?;

    if let Some(ref socket) = block.socket {
        let client = build_http_client(UnixConnector::new(socket), auth, user_agent);
        finish_caldav(
            client,
            url,
            collection_id_segment,
            block.read_only,
            block.discovery_mode,
        )
        .await
    } else if let Ok(proxy_url) = std::env::var("http_proxy") {
        let proxy_uri: Uri = proxy_url
            .parse()
            .context("parsing http_proxy environment variable")?;
        info!("using HTTP proxy {proxy_uri}");
        let connector = https_connector_proxy(tls_config, proxy_uri);
        let client = build_http_client(connector, auth, user_agent);
        finish_caldav(
            client,
            url,
            collection_id_segment,
            block.read_only,
            block.discovery_mode,
        )
        .await
    } else {
        let connector = https_connector(tls_config);
        let client = build_http_client(connector, auth, user_agent);
        finish_caldav(
            client,
            url,
            collection_id_segment,
            block.read_only,
            block.discovery_mode,
        )
        .await
    }
}

async fn finish_caldav<S>(
    client: S,
    url: Uri,
    collection_id_segment: CollectionIdSegment,
    ro: bool,
    discovery_mode: DiscoveryMode,
) -> anyhow::Result<Arc<dyn Storage>>
where
    S: Service<Request<String>, Response = Response<Incoming>> + Send + Sync + 'static,
    S::Error: std::error::Error + Send + Sync,
    S::Future: Send + Sync,
{
    let webdav = WebDavClient::new(url, client);
    let mut builder = CalDavStorage::builder(webdav);
    builder = builder
        .with_collection_id_segment(collection_id_segment)
        .with_discovery(discovery_mode);
    Ok(into_arc(builder.build().await?, ro))
}

fn init_webcal_storage(block: WebcalBlock) -> anyhow::Result<Arc<dyn Storage>> {
    let url: Uri = block.url.resolve()?.parse().context("Parsing webcal url")?;

    let collection_id: CollectionId = block
        .collection_id
        .parse()
        .context("Parsing collection_id")?;

    let auth = resolve_auth(block.auth)?;
    let tls_config = build_tls_config(&block.tls)?;
    let user_agent = build_user_agent(block.user_agent.as_deref())?;

    if let Ok(proxy_url) = std::env::var("http_proxy") {
        let proxy_uri: Uri = proxy_url
            .parse()
            .context("parsing http_proxy environment variable")?;
        info!("using HTTP proxy {proxy_uri}");
        let connector = https_connector_proxy(tls_config, proxy_uri);
        let client = build_http_client(connector, auth, user_agent);
        Ok(Arc::new(
            WebCalStorage::builder(client, url, collection_id).build(),
        ))
    } else {
        let connector = https_connector(tls_config);
        let client = build_http_client(connector, auth, user_agent);
        Ok(Arc::new(
            WebCalStorage::builder(client, url, collection_id).build(),
        ))
    }
}

#[cfg(feature = "jmap")]
async fn init_jmap_storage(block: JmapBlock) -> anyhow::Result<Arc<dyn Storage>> {
    log::warn!(
        "THE JMAP IMPLEMENTATION IS EXPERIMENTAL AND MAY HAVE BUGS WHICH COULD LEAD TO DATA LOSS!",
    );
    let url = block.url.resolve()?;
    let auth = resolve_auth(block.auth)?;
    let tls_config = build_tls_config(&block.tls)?;
    let user_agent = build_user_agent(block.user_agent.as_deref())?;

    if let Ok(proxy_url) = std::env::var("http_proxy") {
        let proxy_uri: Uri = proxy_url
            .parse()
            .context("parsing http_proxy environment variable")?;
        info!("Using HTTP proxy {proxy_uri}");
        let connector = https_connector_proxy(tls_config, proxy_uri);
        let client = build_http_client(connector, auth, user_agent);
        finish_jmap(client, url, block.kind, block.read_only).await
    } else {
        let connector = https_connector(tls_config);
        let client = build_http_client(connector, auth, user_agent);
        finish_jmap(client, url, block.kind, block.read_only).await
    }
}

#[cfg(feature = "jmap")]
async fn finish_jmap<S>(
    mut client: S,
    url: String,
    item_kind: ItemKind,
    ro: bool,
) -> anyhow::Result<Arc<dyn Storage>>
where
    S: Service<Request<String>, Response = Response<Incoming>> + Send + Sync + Clone + 'static,
    S::Error: std::error::Error + Send + Sync,
    S::Future: Send + Sync,
{
    let url = url.parse().context("Parsing JMAP url")?;
    let (session_url, session_resource) = discover_session_resource(&mut client, &url).await?;
    let api_url = session_resource
        .api_url()
        .context("JMAP server returned no api_url")?
        .parse()
        .context("JMAP server returned an invalid api_url")?;
    let client = JmapClient::new(client, session_url, api_url);
    let builder = JmapStorage::builder(client);
    Ok(into_arc(builder.build(item_kind), ro))
}

/// Parses authentication configuration and returns an `Auth` instance.
///
/// A `cmd` block is not supported here; such commands should be resolved before calling this
/// function.
fn resolve_auth(auth: AuthConfig) -> anyhow::Result<Auth> {
    let username = match auth.username {
        Some(p) => p.resolve().context("resolving username")?,
        None => return Ok(Auth::None),
    };

    let password = match auth.password {
        Some(p) => p.resolve().context("resolving password")?,
        None => String::new(),
    };

    if auth.use_digest {
        Ok(Auth::Digest {
            username: username.into(),
            password: password.into(),
        })
    } else {
        // Default: Basic auth.
        let credentials =
            base64::engine::general_purpose::STANDARD.encode(format!("{username}:{password}"));
        let mut value: HeaderValue = format!("Basic {credentials}")
            .try_into()
            .context("encoding auth header")?;
        value.set_sensitive(true);
        Ok(Auth::Basic(value))
    }
}

/// Parse TLS configuration directives and return a rustls [`ClientConfig`].
fn build_tls_config(tls: &TlsConfig) -> anyhow::Result<ClientConfig> {
    let mut root_store = None;
    let mut fp_verifier = None;

    if let Some(ref path) = tls.root_path {
        let mut store = RootCertStore::empty();
        let certs: Vec<_> = CertificateDer::pem_file_iter(path)
            .with_context(|| format!("opening {}", path.display()))?
            .collect::<Result<_, _>>()
            .with_context(|| format!("parsing {}", path.display()))?;
        for cert in certs {
            store.add(cert)?;
        }
        root_store = Some(store);
    }

    if let Some(ref fingerprint) = tls.fingerprint {
        fp_verifier = Some(FingerprintVerifier::new(fingerprint)?);
    }

    let tls_config = ClientConfig::builder();
    // FIXME: loads and parses certs again for each client.
    let tls_config = match (root_store, fp_verifier) {
        (None, None) => tls_config.with_native_roots()?,
        (None, Some(verifier)) => DangerousClientConfigBuilder { cfg: tls_config }
            .with_custom_certificate_verifier(Arc::from(verifier)),
        (Some(root_store), None) => tls_config.with_root_certificates(root_store),
        (Some(root_store), Some(fp_verifier)) => {
            let verifier = FingerprintAndWebPkiVerifier::new(fp_verifier, root_store)?;
            DangerousClientConfigBuilder { cfg: tls_config }
                .with_custom_certificate_verifier(Arc::from(verifier))
        }
    };

    let tls_config = match &tls.client_cert_paths {
        None => tls_config.with_no_client_auth(),
        Some((cert_path, None)) => {
            let (certs, key) = cert_and_key_from_pemfile(cert_path)?;
            tls_config.with_client_auth_cert(certs, key)?
        }
        Some((cert_path, Some(key_path))) => {
            let certs: Vec<_> = CertificateDer::pem_file_iter(cert_path)
                .with_context(|| format!("opening {}", cert_path.display()))?
                .collect::<Result<_, _>>()
                .with_context(|| format!("parsing {}", cert_path.display()))?;
            let key = PrivateKeyDer::from_pem_file(key_path)
                .with_context(|| format!("loading key from {}", key_path.display()))?;
            tls_config.with_client_auth_cert(certs, key)?
        }
    };

    Ok(tls_config)
}

fn build_user_agent(ua: Option<&str>) -> anyhow::Result<HeaderValue> {
    match ua {
        Some(s) => {
            if s.is_empty() {
                bail!("user_agent must not be empty");
            }
            s.try_into()
                .context("converting user_agent into a header value")
        }
        None => Ok(default_user_agent()),
    }
}

// INVARIANT: Does not panic; function is idempotent and has a dedicated test.
fn default_user_agent() -> HeaderValue {
    // Ideally this should be const and computed at compile-time.
    format!("pimsync/{}", VERSION.strip_prefix("v").unwrap_or(VERSION))
        .try_into()
        .expect("default UA is a valid header value")
}

fn into_arc<S: Storage + 'static>(storage: S, read_only: bool) -> Arc<dyn Storage> {
    if read_only {
        Arc::new(ReadOnlyStorage::from(storage))
    } else {
        Arc::new(storage)
    }
}

/// # Errors
///
/// If this path starts with tilde AND the home directory is non-UTF8. If the path is a relative
/// path.
fn expand_tilde(orig: Utf8PathBuf) -> anyhow::Result<Utf8PathBuf> {
    let mut iter = orig.as_str().chars();
    let result = if let Some('~') = iter.next()
        && let Some('/') = iter.next()
    {
        let home = std::env::home_dir().expect("must resolve home path to expand tilde");
        let home = Utf8PathBuf::try_from(home)?;
        let rest = iter.collect::<String>();
        home.join(rest)
    } else {
        orig
    };
    if result.is_relative() {
        bail!("Using relative path in the configuration file is unsupported.");
    }
    Ok(result)
}

/// Open the configuration file, expecting it in the default path.
///
/// Returns the path of the file opened and the file itself.
pub(crate) fn open_default_path() -> anyhow::Result<(PathBuf, File)> {
    let path = if let Some(xdg) = std::env::var_os("XDG_CONFIG_HOME") {
        PathBuf::from(xdg).join("pimsync/pimsync.conf")
    } else {
        #[allow(deprecated)]
        let home = std::env::home_dir().context("Could not resolve $XDG_CONFIG_HOME nor $HOME.")?;
        home.join(".config/pimsync/pimsync.conf")
    };

    let file =
        File::open(&path).with_context(|| format!("Could not open {}.", path.to_string_lossy()))?;
    debug!("Opened config file {}", path.to_string_lossy());
    Ok((path, file))
}

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

    #[test]
    fn test_default_user_agent() {
        // Validate invariant; function does not panic.
        let _ = default_user_agent();
    }
}
