// Copyright 2026 Hugo Osvaldo Barrera
//
// SPDX-License-Identifier: ISC

//! Configuration file parser.

use std::{
    collections::{HashMap, HashSet},
    iter::Peekable,
    path::PathBuf,
    time::Duration,
};

use anyhow::{Context, bail};
use log::error;
use scfg_scanner::{Scanner, Token};
use vstorage::{
    CollectionId, DiscoveryMode, ItemKind,
    caldav::CollectionIdSegment,
    sync::declare::{CollectionDescription, OnDelete, OnEmpty},
};

#[cfg(feature = "jmap")]
use crate::config::JmapBlock;
use crate::{
    ConflictResolution, RawCommand,
    cli::FilterNames,
    config::{
        AuthConfig, CollectionSpec, Config, DEFAULT_INTERVAL, DavBlock, DynamicParam, PairBlock,
        StorageBlock, TlsConfig, VdirBlock, WebcalBlock,
    },
};

/// Parser for scfg configuration files.
pub(crate) struct Parser<'a> {
    scanner: Peekable<Scanner<'a>>,
}

impl<'a> Parser<'a> {
    pub(crate) fn new(input: &'a str) -> Self {
        // TODO: could use std::iter::Filter to ignore comments.
        Parser {
            scanner: Scanner::new(input).peekable(),
        }
    }

    fn next_token(&mut self) -> Option<Result<Token<'a>, scfg_scanner::Error>> {
        self.scanner.next()
    }

    fn expect_parameter(&mut self) -> anyhow::Result<String> {
        let tok = self
            .next_token()
            .context("expected parameter, found EOF")??;
        match tok {
            Token::Parameter(p) => Ok(p.to_string()),
            tok => bail!("Expected parameter, found {tok:?}"),
        }
    }

    /// Return the next parameter if any, or `None` otherwise.
    fn try_parameter(&mut self) -> anyhow::Result<Option<String>> {
        match self.scanner.peek() {
            Some(Ok(Token::Parameter(_))) => {
                if let Some(Ok(Token::Parameter(p))) = self.next_token() {
                    Ok(Some(p.to_string()))
                } else {
                    unreachable!("peeked item must exist")
                }
            }
            Some(Ok(_)) | None => Ok(None),
            Some(Err(_)) => {
                if let Some(Err(err)) = self.next_token() {
                    Err(err.into())
                } else {
                    unreachable!("peeked error must exist")
                }
            }
        }
    }

    fn expect_block_start(&mut self) -> anyhow::Result<()> {
        let tok = self
            .next_token()
            .context("expected start block, found EOF")??;
        match tok {
            Token::BlockStart => Ok(()),
            tok => bail!("Expected block start, found {tok:?}"),
        }
    }

    fn parse_dynamic_param(&mut self) -> anyhow::Result<DynamicParam> {
        let tok = self
            .next_token()
            .context("Expected parameter or block.")??;
        match tok {
            Token::Parameter(p) => Ok(DynamicParam::String(p.to_string())),
            Token::BlockStart => self.parse_cmd_block(),
            tok => bail!("Expected parameter or block, found {tok:?}."),
        }
    }

    fn parse_cmd_block(&mut self) -> anyhow::Result<DynamicParam> {
        let tok = self
            .next_token()
            .context("Unexpected end of file in block")??;
        match tok {
            Token::Directive(d) if d == "cmd" => self.parse_cmd_directive(),
            Token::Directive(d) if d == "shell" => self.parse_shell_directive(),
            tok => bail!("Expected 'cmd' or 'shell' in block, found {tok:?}"),
        }
    }

    fn parse_cmd_directive(&mut self) -> anyhow::Result<DynamicParam> {
        let mut args = Vec::new();
        while let Some(tok) = self.next_token().transpose()? {
            match tok {
                Token::Parameter(p) => args.push(p.to_string()),
                Token::BlockEnd => break,
                tok => bail!("Expected parameter or block end, found {tok:?}"),
            }
        }
        let cmd = RawCommand::from_args(args.into_iter())
            .context("cmd must have at least one parameter")?;
        Ok(DynamicParam::RawCommand(cmd))
    }

    fn parse_shell_directive(&mut self) -> anyhow::Result<DynamicParam> {
        let mut args = Vec::new();
        while let Some(tok) = self.next_token().transpose()? {
            match tok {
                Token::Parameter(p) => args.push(p.to_string()),
                Token::BlockEnd => break,
                tok => bail!("Expected parameter or block end, found {tok:?}"),
            }
        }
        let cmd = RawCommand {
            command: "sh".to_string(),
            args: vec!["-c".to_string(), args.join(" ")],
        };
        Ok(DynamicParam::RawCommand(cmd))
    }

    fn parse_storage_block(&mut self) -> anyhow::Result<(String, StorageBlock)> {
        let name = self
            .expect_parameter()
            .context("Unexpected end of file in storage directive.")?;
        self.expect_block_start()
            .context("Unexpected end of file in storage directive.")?;

        // 'type' must be the first directive
        let storage_type = self.parse_type_first()?;

        // Now parse type-specific contents
        let block = match storage_type {
            StorageType::VdirIcalendar => self.parse_vdir_block_contents(ItemKind::Calendar),
            StorageType::VdirVcard => self.parse_vdir_block_contents(ItemKind::AddressBook),
            StorageType::Carddav => Ok(StorageBlock::CardDav(self.parse_dav_block_contents()?)),
            StorageType::Caldav => Ok(StorageBlock::CalDav(self.parse_dav_block_contents()?)),
            StorageType::Webcal => self.parse_webcal_block_contents(),
            #[cfg(feature = "jmap")]
            StorageType::JmapIcalendar => self.parse_jmap_block_contents(ItemKind::Calendar),
            #[cfg(feature = "jmap")]
            StorageType::JmapVcard => self.parse_jmap_block_contents(ItemKind::AddressBook),
        }?;

        Ok((name, block))
    }

    fn parse_type_first(&mut self) -> anyhow::Result<StorageType> {
        let tok = self
            .next_token()
            .context("Unexpected end of file in storage block")??;
        match tok {
            Token::Directive(d) if d == "type" => {
                let type_str = self
                    .expect_parameter()
                    .context("Expected parameter for type")?;
                StorageType::from_str(&type_str)
            }
            Token::Directive(d) => {
                bail!("First directive in storage block must be type, found: '{d}'.");
            }
            tok => bail!("Expected 'type' directive first, found {tok:?}."),
        }
    }

    fn parse_vdir_block_contents(&mut self, kind: ItemKind) -> anyhow::Result<StorageBlock> {
        let mut builder = VdirBlockBuilder::default();

        loop {
            let tok = self
                .next_token()
                .context("Unexpected end of file in storage block.")??;
            match tok {
                Token::Directive(d) => self
                    .handle_vdir_directive(&d, &mut builder)
                    .with_context(|| format!("Parsing {d}"))?,
                Token::BlockEnd => break,
                Token::Comment(_) => {}
                tok => bail!("Unexpected token in vdir storage block: {tok:?}"),
            }
        }

        let path = builder
            .path
            .context("vdir storage requires 'path' directive")?;
        Ok(StorageBlock::Vdir(VdirBlock {
            kind,
            read_only: builder.read_only,
            interval: builder.interval.unwrap_or(DEFAULT_INTERVAL),
            path,
            fileext: builder.fileext,
        }))
    }

    fn handle_vdir_directive(
        &mut self,
        directive: &str,
        builder: &mut VdirBlockBuilder,
    ) -> anyhow::Result<()> {
        match directive {
            "read_only" => builder.read_only = true,
            "interval" => {
                let secs: u64 = self
                    .expect_parameter()?
                    .parse()
                    .context("interval must be an integer")?;
                builder.interval = Some(Duration::from_secs(secs));
            }
            "path" => builder.path = Some(self.parse_dynamic_param()?),
            "fileext" => builder.fileext = Some(self.expect_parameter()?),
            "encoding" => {
                error!("Pimsync does not implement 'encoding' for vdir storages.");
                error!("If you need a specific encoding, please open an issue.");
                bail!("'encoding' is not implemented for vdir storages.");
            }
            d => bail!("vdir storage does not accept '{d}' directive"),
        }
        Ok(())
    }

    fn parse_dav_block_contents(&mut self) -> anyhow::Result<DavBlock> {
        let mut builder = DavBlockBuilder::default();

        loop {
            let tok = self
                .next_token()
                .context("Unexpected end of file in storage block.")??;
            match tok {
                Token::Directive(d) => self
                    .handle_dav_directive(&d, &mut builder)
                    .with_context(|| format!("Parsing {d}"))?,
                Token::BlockEnd => break,
                Token::Comment(_) => {}
                tok => bail!("Unexpected token in dav storage block: {tok:?}."),
            }
        }

        let url = builder
            .url
            .context("caldav/carddav storage requires 'url' directive.")?;
        Ok(DavBlock {
            read_only: builder.read_only,
            interval: builder.interval.unwrap_or(DEFAULT_INTERVAL),
            url,
            socket: builder.socket,
            collection_id_segment: builder.collection_id_segment,
            discovery_mode: builder.discovery_mode,
            auth: builder.auth,
            tls: builder.tls,
            user_agent: builder.user_agent,
        })
    }

    fn handle_dav_directive(
        &mut self,
        directive: &str,
        builder: &mut DavBlockBuilder,
    ) -> anyhow::Result<()> {
        match directive {
            "read_only" => builder.read_only = true,
            "interval" => {
                let secs: u64 = self
                    .expect_parameter()
                    .context("Expected parameter for interval.")?
                    .parse()
                    .context("interval must be an integer")?;
                builder.interval = Some(Duration::from_secs(secs));
            }
            "url" => builder.url = Some(self.parse_dynamic_param()?),
            "socket" => builder.socket = Some(self.expect_parameter()?),
            "collection_id_segment" => {
                builder.collection_id_segment = self.parse_collection_id_segment()?;
            }
            "discovery" => builder.discovery_mode = self.parse_discovery_mode()?,
            "username" => builder.auth.username = Some(self.parse_dynamic_param()?),
            "password" => builder.auth.password = Some(self.parse_dynamic_param()?),
            "auth" => builder.auth.use_digest = self.parse_auth_method()?,
            "user_agent" => builder.user_agent = Some(self.expect_parameter()?),
            "tls_root" => builder.tls.root_path = Some(self.expect_parameter()?.parse()?),
            "tls_fingerprint" => builder.tls.fingerprint = Some(self.expect_parameter()?),
            "auth_cert" => builder.tls.client_cert_paths = self.parse_auth_cert_directive()?,
            d => bail!("caldav/carddav storage does not accept '{d}' directive"),
        }
        Ok(())
    }

    fn parse_collection_id_segment(&mut self) -> anyhow::Result<CollectionIdSegment> {
        let val = self
            .expect_parameter()
            .context("Expected parameter for collection_id_segment")?;
        match val.as_str() {
            "last" => Ok(CollectionIdSegment::Last),
            "second-last" => Ok(CollectionIdSegment::SecondLast),
            _ => bail!("collection_id_segment must be 'last' or 'second-last'"),
        }
    }

    fn parse_discovery_mode(&mut self) -> anyhow::Result<DiscoveryMode> {
        let val = self
            .expect_parameter()
            .context("Expected parameter for discovery")?;
        match val.as_str() {
            "full" => Ok(DiscoveryMode::Full),
            "context" => Ok(DiscoveryMode::Context),
            "principal" => Ok(DiscoveryMode::Principal),
            "home-set" => Ok(DiscoveryMode::HomeSet),
            "collections" => Ok(DiscoveryMode::Collections),
            _ => bail!("discovery must be one of: full, context, principal, home-set, collections"),
        }
    }

    /// Returns true if Digest auth is enabled.
    // TODO: improve this API when implementing OAuth.
    fn parse_auth_method(&mut self) -> anyhow::Result<bool> {
        let val = self
            .expect_parameter()
            .context("Expected parameter for auth")?;
        match val.as_str() {
            "basic" => Ok(false),
            "digest" => Ok(true),
            _ => bail!("auth must be 'basic' or 'digest'"),
        }
    }

    fn parse_auth_cert_directive(&mut self) -> anyhow::Result<Option<(PathBuf, Option<PathBuf>)>> {
        let first: PathBuf = self
            .expect_parameter()
            .context("Expected parameter for auth_cert")?
            .parse()?;

        match self.next_token().transpose()? {
            Some(Token::Parameter(p)) => Ok(Some((first, Some(p.parse()?)))),
            Some(Token::BlockEnd) => Ok(Some((first, None))),
            Some(tok) => bail!("Expected parameter or block end after auth_cert, found {tok:?}"),
            None => bail!("Unexpected EOF in auth directive"),
        }
    }

    fn parse_webcal_block_contents(&mut self) -> anyhow::Result<StorageBlock> {
        let mut builder = WebcalBlockBuilder::default();

        loop {
            let tok = self
                .next_token()
                .context("Unexpected end of file in storage block.")??;
            match tok {
                Token::Directive(d) => self
                    .handle_webcal_directive(&d, &mut builder)
                    .with_context(|| format!("Parsing {d}"))?,
                Token::BlockEnd => break,
                Token::Comment(_) => {}
                tok => bail!("Unexpected token in webcal storage block: {tok:?}."),
            }
        }

        let url = builder
            .url
            .context("webcal storage requires 'url' directive")?;
        let collection_id = builder
            .collection_id
            .context("webcal storage requires 'collection_id' directive")?;
        Ok(StorageBlock::Webcal(WebcalBlock {
            interval: builder.interval.unwrap_or(DEFAULT_INTERVAL),
            url,
            collection_id,
            auth: builder.auth,
            tls: builder.tls,
            user_agent: builder.user_agent,
        }))
    }

    fn handle_webcal_directive(
        &mut self,
        directive: &str,
        builder: &mut WebcalBlockBuilder,
    ) -> anyhow::Result<()> {
        match directive {
            "interval" => {
                let secs: u64 = self
                    .expect_parameter()?
                    .parse()
                    .context("interval must be an integer")?;
                builder.interval = Some(Duration::from_secs(secs));
            }
            "url" => builder.url = Some(self.parse_dynamic_param()?),
            "collection_id" => builder.collection_id = Some(self.expect_parameter()?),
            "username" => builder.auth.username = Some(self.parse_dynamic_param()?),
            "password" => builder.auth.password = Some(self.parse_dynamic_param()?),
            "auth" => builder.auth.use_digest = self.parse_auth_method()?,
            "user_agent" => builder.user_agent = Some(self.expect_parameter()?),
            "tls_root" => builder.tls.root_path = Some(self.expect_parameter()?.parse()?),
            "tls_fingerprint" => builder.tls.fingerprint = Some(self.expect_parameter()?),
            "auth_cert" => builder.tls.client_cert_paths = self.parse_auth_cert_directive()?,
            d => bail!("webcal storage does not accept '{d}' directive"),
        }
        Ok(())
    }

    #[cfg(feature = "jmap")]
    fn parse_jmap_block_contents(&mut self, kind: ItemKind) -> anyhow::Result<StorageBlock> {
        let mut builder = JmapBlockBuilder::default();

        loop {
            let tok = self
                .next_token()
                .context("Unexpected end of file in storage block")??;
            match tok {
                Token::Directive(d) => self
                    .handle_jmap_directive(&d, &mut builder)
                    .with_context(|| format!("Parsing {d}"))?,
                Token::BlockEnd => break,
                Token::Comment(_) => {}
                tok => bail!("Unexpected token in jmap storage block: {tok:?}"),
            }
        }

        let url = builder
            .url
            .context("jmap storage requires 'url' directive")?;
        Ok(StorageBlock::Jmap(JmapBlock {
            kind,
            read_only: builder.read_only,
            interval: builder.interval.unwrap_or(DEFAULT_INTERVAL),
            url,
            auth: builder.auth,
            tls: builder.tls,
            user_agent: builder.user_agent,
        }))
    }

    #[cfg(feature = "jmap")]
    fn handle_jmap_directive(
        &mut self,
        directive: &str,
        builder: &mut JmapBlockBuilder,
    ) -> anyhow::Result<()> {
        match directive {
            "read_only" => builder.read_only = true,
            "interval" => {
                let secs: u64 = self
                    .expect_parameter()?
                    .parse()
                    .context("interval must be an integer")?;
                builder.interval = Some(Duration::from_secs(secs));
            }
            "url" => builder.url = Some(self.parse_dynamic_param()?),
            "username" => builder.auth.username = Some(self.parse_dynamic_param()?),
            "password" => builder.auth.password = Some(self.parse_dynamic_param()?),
            "auth" => builder.auth.use_digest = self.parse_auth_method()?,
            "user_agent" => builder.user_agent = Some(self.expect_parameter()?),
            "tls_root" => builder.tls.root_path = Some(self.expect_parameter()?.parse()?),
            "tls_fingerprint" => builder.tls.fingerprint = Some(self.expect_parameter()?),
            "auth_cert" => builder.tls.client_cert_paths = self.parse_auth_cert_directive()?,
            d => bail!("jmap storage does not accept '{d}' directive"),
        }
        Ok(())
    }

    fn parse_pair_block(&mut self) -> anyhow::Result<(String, PairBlock)> {
        let name = self
            .expect_parameter()
            .context("Unexpected end of file in pair directive.")?;
        self.expect_block_start()
            .context("Unexpected end of file in pair directive.")?;

        let mut builder = PairBlockBuilder::default();
        loop {
            let tok = self
                .next_token()
                .context("Unexpected end of file in pair block.")??;
            match tok {
                Token::BlockEnd => break,
                Token::Directive(d) => self
                    .handle_pair_directive(&d, &mut builder)
                    .with_context(|| format!("Parsing {d}"))?,
                Token::Comment(_) => {}
                tok => bail!("Unexpected token in pair block: {tok:?}."),
            }
        }

        Ok((name, builder.build()))
    }

    fn handle_pair_directive(
        &mut self,
        directive: &str,
        builder: &mut PairBlockBuilder,
    ) -> anyhow::Result<()> {
        match directive {
            "storage_a" => builder.storage_a = self.expect_parameter()?,
            "storage_b" => builder.storage_b = self.expect_parameter()?,
            "collections" => self.parse_collections_directive(builder)?,
            "collection" => self.parse_collection_directive(builder)?,
            "on_empty" => builder.on_empty = self.expect_parameter()?.parse()?,
            "on_delete" => builder.on_delete = self.expect_parameter()?.parse()?,
            "one_way" => builder.one_way = true,
            "conflict_resolution" => {
                builder.conflict_resolution = Some(self.parse_conflict_resolution()?);
            }
            _ => bail!("Unknown directive in pair block: {directive}"),
        }
        Ok(())
    }

    fn parse_collections_directive(
        &mut self,
        builder: &mut PairBlockBuilder,
    ) -> anyhow::Result<()> {
        let mut params = Vec::new();
        while let Some(p) = self.try_parameter()? {
            params.push(p);
        }
        let val = params.join(" ");
        let spec = match val.as_str() {
            "all" => CollectionSpec::All,
            "from a" => CollectionSpec::FromA,
            "from b" => CollectionSpec::FromB,
            _ => bail!("Invalid value for collections: {val}"),
        };
        builder.collections.push(spec);
        Ok(())
    }

    fn parse_collection_directive(&mut self, builder: &mut PairBlockBuilder) -> anyhow::Result<()> {
        let tok = self
            .next_token()
            .context("Expected parameter or block for collection")??;
        match tok {
            Token::Parameter(p) => {
                let id = p.parse().context("Parsing collection id")?;
                builder.collections.push(CollectionSpec::Named(id));
                Ok(())
            }
            Token::BlockStart => {
                let spec = self.parse_collection_mapping()?;
                builder.collections.push(spec);
                Ok(())
            }
            tok => bail!("Expected parameter or block for collection, found {tok:?}"),
        }
    }

    fn parse_collection_mapping(&mut self) -> anyhow::Result<CollectionSpec> {
        // TODO: use builder?
        let mut alias = None;
        let mut id_a = None;
        let mut href_a = None;
        let mut id_b = None;
        let mut href_b = None;

        loop {
            let tok = self
                .next_token()
                .context("Unexpected end of file in collection mapping")??;
            match tok {
                Token::BlockEnd => break,
                Token::Directive(d) => self.handle_collection_mapping_directive(
                    &d,
                    &mut alias,
                    &mut id_a,
                    &mut href_a,
                    &mut id_b,
                    &mut href_b,
                )?,
                Token::Comment(_) => {}
                tok => bail!("Unexpected token in collection mapping: {tok:?}"),
            }
        }

        let alias = alias.context("collection mapping must have an alias")?;
        let a = parse_individual_collection(id_a, href_a)?;
        let b = parse_individual_collection(id_b, href_b)?;

        Ok(CollectionSpec::Mapped { alias, a, b })
    }

    fn handle_collection_mapping_directive(
        &mut self,
        directive: &str,
        alias: &mut Option<String>,
        id_a: &mut Option<CollectionId>,
        href_a: &mut Option<String>,
        id_b: &mut Option<CollectionId>,
        href_b: &mut Option<String>,
    ) -> anyhow::Result<()> {
        match directive {
            "alias" => *alias = Some(self.expect_parameter()?),
            "id_a" => *id_a = Some(self.expect_parameter()?.parse()?),
            "href_a" => *href_a = Some(self.expect_parameter()?),
            "id_b" => *id_b = Some(self.expect_parameter()?.parse()?),
            "href_b" => *href_b = Some(self.expect_parameter()?),
            _ => bail!("Unknown directive in collection mapping: {directive}"),
        }
        Ok(())
    }

    fn parse_conflict_resolution(&mut self) -> anyhow::Result<ConflictResolution> {
        let first = self.expect_parameter()?;
        match first.as_str() {
            "keep" => self.parse_conflict_resolution_keep(),
            "cmd" => self.parse_conflict_resolution_cmd(),
            _ => bail!("Invalid parameter for conflict_resolution: {first}"),
        }
    }

    fn parse_conflict_resolution_keep(&mut self) -> anyhow::Result<ConflictResolution> {
        let side = self.expect_parameter()?;
        match side.as_str() {
            "a" => Ok(ConflictResolution::KeepA),
            "b" => Ok(ConflictResolution::KeepB),
            _ => bail!("Invalid parameter for conflict_resolution: keep {side}"),
        }
    }

    fn parse_conflict_resolution_cmd(&mut self) -> anyhow::Result<ConflictResolution> {
        let mut args = Vec::new();
        while let Some(p) = self.try_parameter()? {
            args.push(p);
        }
        let cmd = RawCommand::from_args(args.into_iter())
            .context("conflict_resolution cmd must have at least one parameter")?;
        Ok(ConflictResolution::Cmd(cmd))
    }

    /// Parse a configuration file and return a parsed [`Config`] instance.
    pub fn parse(mut self) -> anyhow::Result<Config> {
        let mut status_path: Option<DynamicParam> = None;
        let mut pairs = HashMap::new();
        let mut storages = HashMap::new();

        while let Some(tok) = self.next_token().transpose()? {
            match tok {
                Token::Directive(d) => {
                    self.handle_top_level_directive(
                        &d,
                        &mut status_path,
                        &mut pairs,
                        &mut storages,
                    )
                    .with_context(|| format!("Handling top level '{d}'."))?;
                }
                Token::Comment(_) => {}
                tok => bail!("Unexpected token at top level: {tok:?}"),
            }
        }

        let status_path = status_path.context("status_path is required")?;

        Ok(Config {
            status_path,
            pairs,
            storages,
        })
    }

    fn handle_top_level_directive(
        &mut self,
        directive: &str,
        status_path: &mut Option<DynamicParam>,
        pairs: &mut HashMap<String, PairBlock>,
        storages: &mut HashMap<String, StorageBlock>,
    ) -> anyhow::Result<()> {
        match directive {
            "status_path" => {
                *status_path = Some(self.parse_dynamic_param()?);
                Ok(())
            }
            "storage" => {
                let (name, block) = self.parse_storage_block()?;
                if storages.contains_key(&name) {
                    bail!("Duplicate storage: {name}");
                }
                storages.insert(name, block);
                Ok(())
            }
            "pair" => {
                let (name, block) = self.parse_pair_block()?;
                if pairs.contains_key(&name) {
                    bail!("Duplicate pair: {name}");
                }
                pairs.insert(name, block);
                Ok(())
            }
            _ => bail!("Unknown top-level directive: {directive}"),
        }
    }
}

/// Storage type identifier for internal use.
enum StorageType {
    VdirIcalendar,
    VdirVcard,
    Carddav,
    Caldav,
    Webcal,
    #[cfg(feature = "jmap")]
    JmapIcalendar,
    #[cfg(feature = "jmap")]
    JmapVcard,
}

impl StorageType {
    fn from_str(s: &str) -> anyhow::Result<Self> {
        match s {
            "vdir/icalendar" => Ok(StorageType::VdirIcalendar),
            "vdir/vcard" => Ok(StorageType::VdirVcard),
            "carddav" => Ok(StorageType::Carddav),
            "caldav" => Ok(StorageType::Caldav),
            "webcal" => Ok(StorageType::Webcal),
            #[cfg(feature = "jmap")]
            "jmap/icalendar" => Ok(StorageType::JmapIcalendar),
            #[cfg(feature = "jmap")]
            "jmap/vcard" => Ok(StorageType::JmapVcard),
            #[cfg(not(feature = "jmap"))]
            "jmap/icalendar" | "jmap/vcard" => {
                bail!("pimsync compiled with JMAP support disabled.")
            }
            _ => bail!("Unknown storage type: {s}"),
        }
    }
}

// These "builders" below are just structs with hold multiple values. They merely
// simplify passing them around in bulk. There's no fancy logic to them.

#[derive(Default)]
struct VdirBlockBuilder {
    read_only: bool,
    interval: Option<Duration>,
    path: Option<DynamicParam>,
    fileext: Option<String>,
}

#[derive(Default)]
struct WebcalBlockBuilder {
    interval: Option<Duration>,
    url: Option<DynamicParam>,
    collection_id: Option<String>,
    auth: AuthConfig,
    tls: TlsConfig,
    user_agent: Option<String>,
}

#[cfg(feature = "jmap")]
#[derive(Default)]
struct JmapBlockBuilder {
    read_only: bool,
    interval: Option<Duration>,
    url: Option<DynamicParam>,
    auth: AuthConfig,
    tls: TlsConfig,
    user_agent: Option<String>,
}

#[derive(Default)]
struct DavBlockBuilder {
    read_only: bool,
    interval: Option<Duration>,
    url: Option<DynamicParam>,
    socket: Option<String>,
    collection_id_segment: CollectionIdSegment,
    discovery_mode: DiscoveryMode,
    auth: AuthConfig,
    tls: TlsConfig,
    user_agent: Option<String>,
}

#[derive(Default)]
struct PairBlockBuilder {
    storage_a: String,
    storage_b: String,
    collections: Vec<CollectionSpec>,
    on_empty: OnEmpty,
    on_delete: OnDelete,
    one_way: bool,
    conflict_resolution: Option<ConflictResolution>,
}

impl PairBlockBuilder {
    fn build(self) -> PairBlock {
        PairBlock {
            storage_a: self.storage_a,
            storage_b: self.storage_b,
            collections: self.collections,
            on_empty: self.on_empty,
            on_delete: self.on_delete,
            one_way: self.one_way,
            conflict_resolution: self.conflict_resolution,
        }
    }
}

fn parse_individual_collection(
    id: Option<CollectionId>,
    href: Option<String>,
) -> anyhow::Result<CollectionDescription> {
    match (id, href) {
        (None, None) => bail!("collection block must define either id or href"),
        (None, Some(href)) => Ok(CollectionDescription::Href { href }),
        (Some(id), None) => Ok(CollectionDescription::Id { id }),
        (Some(_), Some(_)) => bail!("collection block cannot define both id and href"),
    }
}

/// Parse a given file as a configuration file.
///
/// If `enabled_pairs` is not `None`, only pairs with a matching name will be loaded.
pub(crate) fn parse_config(
    raw_config: &str,
    mut enabled_pairs: FilterNames,
) -> anyhow::Result<Config> {
    let mut config = Parser::new(raw_config).parse()?;

    config.pairs.retain(|name, _| enabled_pairs.wants(name));
    if let Some(missing) = enabled_pairs.next_missing() {
        bail!("Requested pair missing from configuration: {missing}");
    }

    // Collect names of used storages.
    let mut used_storages = HashSet::<&str>::new();
    for pair in config.pairs.values() {
        used_storages.insert(&pair.storage_a);
        used_storages.insert(&pair.storage_b);
    }

    // Retain only used storages
    config
        .storages
        .retain(|name, _| used_storages.contains(name.as_str()));

    // Validate that all referenced storages exist.
    for pair in config.pairs.values() {
        if !config.storages.contains_key(&pair.storage_a) {
            bail!(
                "Storage '{}' referenced by pair is not defined",
                pair.storage_a
            );
        }
        if !config.storages.contains_key(&pair.storage_b) {
            bail!(
                "Storage '{}' referenced by pair is not defined",
                pair.storage_b
            );
        }
    }

    Ok(config)
}

/// List all pair names defined in the configuration file.
pub(crate) fn list_pair_names(raw_config: &str) -> anyhow::Result<Vec<String>> {
    let config = Parser::new(raw_config).parse()?;
    Ok(config.pairs.into_keys().collect())
}

/// List all storage names defined in the configuration file.
pub(crate) fn list_storage_names(raw_config: &str) -> anyhow::Result<Vec<String>> {
    let config = Parser::new(raw_config).parse()?;
    Ok(config.storages.into_keys().collect())
}

#[cfg(test)]
mod test {
    use std::{collections::HashMap, time::Duration};
    use vstorage::{
        DiscoveryMode, ItemKind,
        caldav::CollectionIdSegment,
        sync::declare::{CollectionDescription, OnDelete, OnEmpty},
    };

    use crate::{
        ConflictResolution, RawCommand,
        cli::FilterNames,
        config::{
            AuthConfig, CollectionSpec, Config, DEFAULT_INTERVAL, DavBlock, DynamicParam,
            PairBlock, StorageBlock, TlsConfig, VdirBlock, WebcalBlock,
        },
        parseconf::parse_config,
    };

    #[test]
    fn missing_storage() {
        let config = r#"
status_path "~/.local/share/pimsync/status/"

storage local {
    type vdir/vcard
    read_only
    path ~/.local/share/contacts/
    fileext vcf
}

pair contacts {
    storage_a local
    storage_b remote
    collections all
}
"#;
        let result = parse_config(config, FilterNames::new());
        let err = result.unwrap_err();
        let expected = "Storage 'remote' referenced by pair is not defined";
        assert!(err.to_string().contains(expected));
    }

    #[test]
    fn dynamic_params() {
        let config = r#"
status_path "~/.local/share/pimsync/status/"

storage remote {
    type carddav
    url https://example.com/
    socket /tmp/dav.sock
    user_agent "Mozilla/5.0 (X11; Linux x86_64; rv:150.0) Gecko/20100101 Firefox/150.0"
    username {
        cmd echo testuser
    }
    password {
        shell pass show communication/migadu.com | head -1
    }
}

pair contacts {
    storage_a remote
    storage_b remote
    collections all
}
"#;
        let result = parse_config(config, FilterNames::new()).unwrap();
        let expected = Config {
            status_path: DynamicParam::String("~/.local/share/pimsync/status/".to_string()),
            pairs: HashMap::from([(
                "contacts".to_string(),
                PairBlock {
                    storage_a: "remote".to_string(),
                    storage_b: "remote".to_string(),
                    collections: vec![CollectionSpec::All],
                    on_empty: OnEmpty::default(),
                    on_delete: OnDelete::default(),
                    one_way: false,
                    conflict_resolution: None,
                },
            )]),
            storages: HashMap::from([(
                "remote".to_string(),
                StorageBlock::CardDav(DavBlock {
                    read_only: false,
                    interval: DEFAULT_INTERVAL,
                    url: DynamicParam::String("https://example.com/".to_string()),
                    socket: Some("/tmp/dav.sock".to_string()),
                    collection_id_segment: CollectionIdSegment::default(),
                    discovery_mode: DiscoveryMode::Full,
                    auth: AuthConfig {
                        username: Some(DynamicParam::RawCommand(RawCommand {
                            command: "echo".to_string(),
                            args: vec!["testuser".to_string()],
                        })),
                        password: Some(DynamicParam::RawCommand(RawCommand {
                            command: "sh".to_string(),
                            args: vec![
                                "-c".to_string(),
                                "pass show communication/migadu.com | head -1".to_string(),
                            ],
                        })),
                        use_digest: false,
                    },
                    tls: TlsConfig::default(),
                    user_agent: Some(
                        "Mozilla/5.0 (X11; Linux x86_64; rv:150.0) Gecko/20100101 Firefox/150.0"
                            .to_string(),
                    ),
                }),
            )]),
        };
        assert_eq!(result, expected);
    }

    #[test]
    #[allow(clippy::too_many_lines)]
    fn collections_and_collection_directive() {
        let config = r#"
status_path "~/.local/share/pimsync/status/"

storage a {
    type vdir/icalendar
    path /tmp/a
    interval 60
}
storage b {
    type vdir/icalendar
    path /tmp/b
    fileext .icalendar
}

storage webcal {
    type webcal
    url https://example.com/holidays.ics
    collection_id holidays
}

pair test {
    storage_a a
    storage_b b
    collections from a
    collections from b
    collection my-id
}

pair webcal-sync {
    storage_a webcal
    storage_b b
    one_way
    collection {
        alias work-cal
        href_a work/
        id_b holidays
    }
}
"#;
        let result = parse_config(config, FilterNames::new()).unwrap();
        let expected = Config {
            status_path: DynamicParam::String("~/.local/share/pimsync/status/".to_string()),
            pairs: HashMap::from([
                (
                    "test".to_string(),
                    PairBlock {
                        storage_a: "a".to_string(),
                        storage_b: "b".to_string(),
                        collections: vec![
                            CollectionSpec::FromA,
                            CollectionSpec::FromB,
                            CollectionSpec::Named("my-id".parse().unwrap()),
                        ],
                        on_empty: OnEmpty::default(),
                        on_delete: OnDelete::default(),
                        one_way: false,
                        conflict_resolution: None,
                    },
                ),
                (
                    "webcal-sync".to_string(),
                    PairBlock {
                        storage_a: "webcal".to_string(),
                        storage_b: "b".to_string(),
                        collections: vec![CollectionSpec::Mapped {
                            alias: "work-cal".to_string(),
                            a: CollectionDescription::Href {
                                href: "work/".to_string(),
                            },
                            b: CollectionDescription::Id {
                                id: "holidays".parse().unwrap(),
                            },
                        }],
                        on_empty: OnEmpty::default(),
                        on_delete: OnDelete::default(),
                        one_way: true,
                        conflict_resolution: None,
                    },
                ),
            ]),
            storages: HashMap::from([
                (
                    "a".to_string(),
                    StorageBlock::Vdir(VdirBlock {
                        kind: ItemKind::Calendar,
                        read_only: false,
                        interval: Duration::from_secs(60),
                        path: DynamicParam::String("/tmp/a".to_string()),
                        fileext: None,
                    }),
                ),
                (
                    "b".to_string(),
                    StorageBlock::Vdir(VdirBlock {
                        kind: ItemKind::Calendar,
                        read_only: false,
                        interval: DEFAULT_INTERVAL,
                        path: DynamicParam::String("/tmp/b".to_string()),
                        fileext: Some(".icalendar".to_string()),
                    }),
                ),
                (
                    "webcal".to_string(),
                    StorageBlock::Webcal(WebcalBlock {
                        interval: DEFAULT_INTERVAL,
                        url: DynamicParam::String("https://example.com/holidays.ics".to_string()),
                        collection_id: "holidays".to_string(),
                        auth: AuthConfig::default(),
                        tls: TlsConfig::default(),
                        user_agent: None,
                    }),
                ),
            ]),
        };
        assert_eq!(result, expected);
    }

    #[test]
    fn conflict_resolution() {
        let config = r#"
status_path "~/.local/share/pimsync/status/"

storage a {
    type vdir/vcard
    path /tmp/a
}
storage b {
    type vdir/vcard
    path /tmp/b
}

pair test {
    storage_a a
    storage_b b
    collections all
    conflict_resolution keep a
}
"#;
        let result = parse_config(config, FilterNames::new()).unwrap();
        let expected = Config {
            status_path: DynamicParam::String("~/.local/share/pimsync/status/".to_string()),
            pairs: HashMap::from([(
                "test".to_string(),
                PairBlock {
                    storage_a: "a".to_string(),
                    storage_b: "b".to_string(),
                    collections: vec![CollectionSpec::All],
                    on_empty: OnEmpty::default(),
                    on_delete: OnDelete::default(),
                    one_way: false,
                    conflict_resolution: Some(ConflictResolution::KeepA),
                },
            )]),
            storages: HashMap::from([
                (
                    "a".to_string(),
                    StorageBlock::Vdir(VdirBlock {
                        kind: ItemKind::AddressBook,
                        read_only: false,
                        interval: DEFAULT_INTERVAL,
                        path: DynamicParam::String("/tmp/a".to_string()),
                        fileext: None,
                    }),
                ),
                (
                    "b".to_string(),
                    StorageBlock::Vdir(VdirBlock {
                        kind: ItemKind::AddressBook,
                        read_only: false,
                        interval: DEFAULT_INTERVAL,
                        path: DynamicParam::String("/tmp/b".to_string()),
                        fileext: None,
                    }),
                ),
            ]),
        };
        assert_eq!(result, expected);
    }

    #[test]
    fn test_parse_conflict_resolution_cmd() {
        let config = r#"
status_path "~/.local/share/pimsync/status/"

storage a {
    type vdir/vcard
    path /tmp/a
}
storage b {
    type vdir/vcard
    path /tmp/b
}

pair test {
    storage_a a
    storage_b b
    collections all
    conflict_resolution cmd nvim -d
}
"#;
        let result = parse_config(config, FilterNames::new()).unwrap();
        let expected = Config {
            status_path: DynamicParam::String("~/.local/share/pimsync/status/".to_string()),
            pairs: HashMap::from([(
                "test".to_string(),
                PairBlock {
                    storage_a: "a".to_string(),
                    storage_b: "b".to_string(),
                    collections: vec![CollectionSpec::All],
                    on_empty: OnEmpty::default(),
                    on_delete: OnDelete::default(),
                    one_way: false,
                    conflict_resolution: Some(ConflictResolution::Cmd(RawCommand {
                        command: "nvim".to_string(),
                        args: vec!["-d".to_string()],
                    })),
                },
            )]),
            storages: HashMap::from([
                (
                    "a".to_string(),
                    StorageBlock::Vdir(VdirBlock {
                        kind: ItemKind::AddressBook,
                        read_only: false,
                        interval: DEFAULT_INTERVAL,
                        path: DynamicParam::String("/tmp/a".to_string()),
                        fileext: None,
                    }),
                ),
                (
                    "b".to_string(),
                    StorageBlock::Vdir(VdirBlock {
                        kind: ItemKind::AddressBook,
                        read_only: false,
                        interval: DEFAULT_INTERVAL,
                        path: DynamicParam::String("/tmp/b".to_string()),
                        fileext: None,
                    }),
                ),
            ]),
        };
        assert_eq!(result, expected);
    }

    #[test]
    fn test_reject_unknown_directive() {
        let config = r#"
status_path "~/.local/share/pimsync/status/"

storage a {
    type vdir/vcard
    path /tmp/a
    unknown_directive value
}
"#;
        let result = parse_config(config, FilterNames::new());
        let err = result.unwrap_err();

        let expected = "vdir storage does not accept 'unknown_directive' directive";
        assert!(format!("{err:?}").contains(expected));
    }

    #[test]
    fn type_must_be_first() {
        let config = r#"
status_path "~/.local/share/pimsync/status/"

storage a {
    path /tmp/a
    type vdir/vcard
}
"#;
        let result = parse_config(config, FilterNames::new());
        let err = result.unwrap_err();
        let expected = "First directive in storage block must be type";
        assert!(format!("{err:?}").contains(expected));
    }

    #[test]
    fn reject_cross_type_directive() {
        let config = r#"
status_path "~/.local/share/pimsync/status/"

storage a {
    type vdir/vcard
    path /tmp/a
    socket /tmp/test.sock
}

storage b {
    type vdir/vcard
    path /tmp/b
}

pair test {
    storage_a a
    storage_b b
    collections all
}
"#;
        let result = parse_config(config, FilterNames::new());
        let err = result.unwrap_err();
        let expected = "vdir storage does not accept 'socket'";
        assert!(format!("{err:?}").contains(expected));
    }

    #[test]
    fn dynamic_param_resolve_string() {
        let param = DynamicParam::String("test".to_string());
        assert_eq!(param.resolve().unwrap(), "test");
    }

    #[test]
    fn dynamic_param_resolve_cmd() {
        let param = DynamicParam::RawCommand(crate::RawCommand {
            command: "echo".to_string(),
            args: vec!["hello".to_string()],
        });
        assert_eq!(param.resolve().unwrap(), "hello");
    }

    #[test]
    fn dynamic_param_resolve_shell() {
        let param = DynamicParam::RawCommand(crate::RawCommand {
            command: "sh".to_string(),
            args: vec!["-c".to_string(), "echo -n world".to_string()],
        });
        assert_eq!(param.resolve().unwrap(), "world");
    }

    #[test]
    fn discovery_mode_directive() {
        let config = r#"
status_path "~/.local/share/pimsync/status/"

storage remote {
    type caldav
    url https://example.com/caldav/
    discovery home-set
}

storage local {
    type vdir/icalendar
    path /tmp/local
}

pair test {
    storage_a remote
    storage_b local
    collections all
}
"#;
        let result = parse_config(config, FilterNames::new()).unwrap();
        let storage = result.storages.get("remote").unwrap();
        assert!(matches!(
            storage,
            StorageBlock::CalDav(DavBlock {
                discovery_mode: DiscoveryMode::HomeSet,
                ..
            })
        ));
    }
}
