/*
 * SPDX-FileCopyrightText: 2020 Stalwart Labs LLC <hello@stalw.art>
 *
 * SPDX-License-Identifier: Apache-2.0 OR MIT
 */

use std::path::PathBuf;

use clap::{ArgAction, Args, Parser, Subcommand};
use regex::Regex;

use crate::error::Error;
use crate::exchange_ews::oauth::OAuthFlow;
use crate::exchange_ews::types::MailboxKind;
use crate::exchange_graph::types::{EventBodyFormat, MailboxKind as GraphMailboxKind};
use crate::inspect::InspectConfig;
use crate::jmap::account::AccountSelector;
use crate::jmap::http::Auth;
use crate::logging::Logger;
use crate::secret;
use crate::sync::import_dav::{DavAuth, DavImportConfig, DavKindArg};
use crate::sync::import_exchange_ews::{EwsAuth, EwsImportConfig};
use crate::sync::import_exchange_graph::{GraphAuth, GraphImportConfig};
use crate::sync::import_imap::{ImapAuth, ImapImportConfig};
use crate::sync::import_maildir::MaildirImportConfig;
use crate::sync::import_managesieve::{ManageSieveAuth, ManageSieveImportConfig};
use crate::sync::import_takeout::TakeoutImportConfig;
use crate::sync::{CommonConfig, ConnectConfig, ExportConfig, ImportConfig};
use crate::types::{ObjectType, parse_object_list};

#[derive(Parser)]
#[command(
    name = "vandelay",
    version,
    about = "Vandelay: the JMAP importer-exporter"
)]
pub struct Cli {
    #[command(subcommand)]
    command: Command,
}

#[derive(Subcommand)]
enum Command {
    #[command(about = "Read a source account into a local archive")]
    Import {
        #[command(subcommand)]
        source: Source,
    },
    #[command(about = "Push a local archive into a target JMAP server")]
    Export(Box<ExportArgs>),
    #[command(about = "Show the contents of a local archive (read-only)")]
    Inspect(Box<InspectArgs>),
}

#[derive(Subcommand)]
enum Source {
    #[command(about = "JMAP server")]
    Jmap(Box<JmapImportArgs>),
    #[command(about = "IMAP server")]
    Imap(Box<ImapImportArgs>),
    #[command(about = "CalDAV server")]
    Caldav(Box<DavImportArgs>),
    #[command(about = "CardDAV server")]
    Carddav(Box<DavImportArgs>),
    #[command(about = "WebDAV server (plain files)")]
    Webdav(Box<DavImportArgs>),
    #[command(about = "ManageSieve server (Sieve scripts only)")]
    Managesieve(Box<ManageSieveImportArgs>),
    #[command(about = "Local Maildir++ tree")]
    Maildir(Box<MaildirImportArgs>),
    #[command(about = "Google Takeout (or any mbox/ics/vcf directory tree)")]
    Takeout(Box<TakeoutImportArgs>),
    #[command(about = "MS Exchange via EWS (SOAP/XML)", name = "exchange-ews")]
    ExchangeEws(Box<ExchangeEwsImportArgs>),
    #[command(
        about = "MS Exchange Online via Microsoft Graph",
        name = "exchange-graph"
    )]
    ExchangeGraph(Box<ExchangeGraphImportArgs>),
}

#[derive(Args)]
struct GlobalArgs {
    #[arg(
        short = 'j',
        long,
        value_name = "N",
        help = "Worker pool size (default: logical CPUs)"
    )]
    threads: Option<usize>,

    #[arg(long, help = "Compute and report the full plan; perform no writes")]
    dry_run: bool,

    #[arg(
        short = 'v',
        long,
        action = ArgAction::Count,
        conflicts_with = "quiet",
        help = "Increase log verbosity (repeatable, max -vvv)",
        long_help = "Increase log verbosity (repeatable, max -vvv).\n  \
            (default) per-type start/finish lines and a final summary\n  \
            -v   per-chunk progress, account resolution, retry notices\n  \
            -vv  every JMAP call (method, accountId, args, status, timing)\n  \
            -vvv full request/response bodies and backoff delays (stderr)"
    )]
    verbose: u8,

    #[arg(
        short = 'q',
        long,
        help = "Quiet: warnings and errors only (verbosity level 0)"
    )]
    quiet: bool,

    #[arg(
        long,
        value_name = "N",
        default_value_t = 5,
        help = "Max retry attempts per request on transient failures"
    )]
    max_retries: u32,

    #[arg(long, help = "Accept self-signed / invalid TLS certificates")]
    allow_invalid_certs: bool,
}

#[derive(Args)]
#[command(group(clap::ArgGroup::new("auth").required(true).args(["auth_basic", "auth_bearer"])))]
#[command(group(clap::ArgGroup::new("account").required(true).args(["account_id", "account_name"])))]
struct JmapImportArgs {
    #[arg(long, value_name = "URL", help = "Base URL or full JMAP session URL")]
    url: String,

    #[arg(long, value_name = "USER", help = "HTTP Basic user")]
    auth_basic: Option<String>,

    #[arg(
        long,
        value_name = "PASS",
        help = "Basic password (prefer $VANDELAY_PASSWORD or prompt)"
    )]
    auth_password: Option<String>,

    #[arg(
        long,
        value_name = "TOKEN",
        num_args = 0..=1,
        help = "Bearer token (value optional: falls back to $VANDELAY_TOKEN or prompt)"
    )]
    auth_bearer: Option<Option<String>>,

    #[arg(long, value_name = "ID", help = "Source account id")]
    account_id: Option<String>,

    #[arg(long, value_name = "NAME", help = "Source account name")]
    account_name: Option<String>,

    #[arg(
        long,
        value_name = "LIST",
        help = "Comma-separated type list (default: all supported)"
    )]
    objects: Option<String>,

    #[arg(
        long,
        help = "Permit importing when the archive records a different JMAP source"
    )]
    allow_source_change: bool,

    #[command(flatten)]
    global: GlobalArgs,

    #[arg(
        value_name = "ARCHIVE",
        help = "Local SQLite archive (created if absent)"
    )]
    archive: PathBuf,
}

#[derive(Args)]
#[command(group(clap::ArgGroup::new("auth").required(true).args(["auth_basic", "auth_bearer"])))]
struct ImapImportArgs {
    #[arg(
        long,
        value_name = "URL",
        help = "imap://host[:port] or imaps://host[:port]"
    )]
    url: String,

    #[arg(
        long,
        value_name = "USER",
        help = "PLAIN (preferred) with LOGIN fallback"
    )]
    auth_basic: Option<String>,

    #[arg(
        long,
        value_name = "PASS",
        help = "Basic password (prefer $VANDELAY_PASSWORD or prompt)"
    )]
    auth_password: Option<String>,

    #[arg(
        long,
        value_name = "TOKEN",
        num_args = 0..=1,
        help = "Bearer token for XOAUTH2 (value optional: falls back to $VANDELAY_TOKEN or prompt)"
    )]
    auth_bearer: Option<Option<String>>,

    #[arg(
        long,
        value_name = "USER",
        help = "XOAUTH2 identity (required with --auth-bearer)"
    )]
    auth_user: Option<String>,

    #[arg(long, help = "Permit credentials on imap:// without STARTTLS")]
    allow_cleartext: bool,

    #[arg(long, help = "Enable COMPRESS=DEFLATE post-auth (off by default)")]
    compress: bool,

    #[arg(
        long,
        value_name = "REGEX",
        action = ArgAction::Append,
        help = "Folder include filter (repeatable)"
    )]
    include: Vec<String>,

    #[arg(
        long,
        value_name = "REGEX",
        action = ArgAction::Append,
        help = "Folder exclude filter (repeatable)"
    )]
    exclude: Vec<String>,

    #[arg(
        long,
        value_name = "ROLE",
        action = ArgAction::Append,
        help = "Drop folders matching a role (repeatable)"
    )]
    exclude_special: Vec<String>,

    #[arg(
        long,
        value_name = "NAME",
        action = ArgAction::Append,
        help = "Exact include; mutually exclusive with --include/--exclude"
    )]
    folder: Vec<String>,

    #[arg(long, help = "Keep only folders returned by LSUB")]
    subscribed_only: bool,

    #[arg(long, help = "Disable SPECIAL-USE name-heuristic fallback")]
    noautomap: bool,

    #[arg(long, help = "Import \\Deleted messages with the $deleted keyword")]
    include_deleted: bool,

    #[arg(
        long,
        value_name = "N",
        default_value_t = 256,
        help = "UIDs per metadata FETCH chunk"
    )]
    fetch_batch: usize,

    #[arg(
        long,
        value_name = "N",
        help = "Parallel IMAP connections for body fetch (1..=8). Default: min(--threads, 8)"
    )]
    imap_connections: Option<usize>,

    #[arg(
        long,
        help = "Permit importing when the archive records a different IMAP source"
    )]
    allow_source_change: bool,

    #[command(flatten)]
    global: GlobalArgs,

    #[arg(
        value_name = "ARCHIVE",
        help = "Local SQLite archive (created if absent)"
    )]
    archive: PathBuf,
}

#[derive(Args)]
#[command(group(clap::ArgGroup::new("auth").required(true).args(["auth_basic", "auth_bearer"])))]
#[command(group(clap::ArgGroup::new("account").required(true).args(["account_id", "account_name"])))]
struct ExportArgs {
    #[arg(long, value_name = "URL", help = "Base URL or full JMAP session URL")]
    url: String,

    #[arg(long, value_name = "USER", help = "HTTP Basic user")]
    auth_basic: Option<String>,

    #[arg(
        long,
        value_name = "PASS",
        help = "Basic password (prefer $VANDELAY_PASSWORD or prompt)"
    )]
    auth_password: Option<String>,

    #[arg(
        long,
        value_name = "TOKEN",
        num_args = 0..=1,
        help = "Bearer token (value optional: falls back to $VANDELAY_TOKEN or prompt)"
    )]
    auth_bearer: Option<Option<String>>,

    #[arg(long, value_name = "ID", help = "Target account id")]
    account_id: Option<String>,

    #[arg(long, value_name = "NAME", help = "Target account name")]
    account_name: Option<String>,

    #[arg(
        long,
        value_name = "LIST",
        help = "Types to export (default: all present in archive)"
    )]
    objects: Option<String>,

    #[arg(long, help = "Delete target objects that match nothing in the archive")]
    prune: bool,

    #[arg(long, help = "Skip the interactive --prune confirmation")]
    yes: bool,

    #[command(flatten)]
    global: GlobalArgs,

    #[arg(value_name = "ARCHIVE", help = "Local SQLite archive")]
    archive: PathBuf,
}

#[derive(Args)]
struct InspectArgs {
    #[arg(value_name = "ARCHIVE", help = "Local SQLite archive")]
    archive: PathBuf,

    #[arg(
        value_name = "TYPE",
        help = "Object type to dump (omit for a summary of all types)"
    )]
    target: Option<String>,

    #[arg(
        long,
        value_name = "N",
        help = "Maximum rows to show (omit to dump every row)"
    )]
    limit: Option<usize>,

    #[arg(
        long,
        value_name = "N",
        default_value_t = 0,
        help = "Skip the first N rows"
    )]
    offset: usize,
}

pub enum Action {
    Import(CommonConfig, ImportConfig),
    ImportImap(CommonConfig, ImapImportConfig),
    ImportDav(CommonConfig, DavImportConfig),
    ImportManageSieve(CommonConfig, ManageSieveImportConfig),
    ImportMaildir(CommonConfig, MaildirImportConfig),
    ImportTakeout(CommonConfig, TakeoutImportConfig),
    ImportExchangeEws(CommonConfig, EwsImportConfig),
    ImportExchangeGraph(CommonConfig, GraphImportConfig),
    Export(CommonConfig, ExportConfig),
    Inspect(InspectConfig),
}

#[derive(Args)]
#[command(group(clap::ArgGroup::new("auth").required(true).args(["auth_basic", "auth_bearer"])))]
pub struct ManageSieveImportArgs {
    #[arg(
        long,
        value_name = "URL",
        help = "sieve://host[:port] or sieves://host[:port]"
    )]
    url: String,

    #[arg(
        long,
        value_name = "USER",
        help = "SASL PLAIN (preferred) with LOGIN fallback"
    )]
    auth_basic: Option<String>,

    #[arg(
        long,
        value_name = "PASS",
        help = "Basic password (prefer $VANDELAY_PASSWORD or prompt)"
    )]
    auth_password: Option<String>,

    #[arg(
        long,
        value_name = "TOKEN",
        num_args = 0..=1,
        help = "Bearer token for OAUTHBEARER (value optional: $VANDELAY_TOKEN or prompt)"
    )]
    auth_bearer: Option<Option<String>>,

    #[arg(
        long,
        value_name = "USER",
        help = "OAUTHBEARER identity (required with --auth-bearer)"
    )]
    auth_user: Option<String>,

    #[arg(long, help = "Permit credentials on sieve:// without STARTTLS")]
    allow_cleartext: bool,

    #[arg(
        long,
        help = "Permit importing when the archive records a different ManageSieve source"
    )]
    allow_source_change: bool,

    #[command(flatten)]
    global: GlobalArgs,

    #[arg(
        value_name = "ARCHIVE",
        help = "Local SQLite archive (created if absent)"
    )]
    archive: PathBuf,
}

#[derive(Args)]
#[command(group(clap::ArgGroup::new("auth").required(true).args(["auth_basic", "auth_bearer"])))]
pub struct DavImportArgs {
    #[arg(long, value_name = "URL", help = "http(s)://host[:port][/path]")]
    url: String,

    #[arg(long, value_name = "USER", help = "HTTP Basic user")]
    auth_basic: Option<String>,

    #[arg(
        long,
        value_name = "PASS",
        help = "Basic password (prefer $VANDELAY_PASSWORD or prompt)"
    )]
    auth_password: Option<String>,

    #[arg(
        long,
        value_name = "TOKEN",
        num_args = 0..=1,
        help = "Bearer token (value optional: $VANDELAY_TOKEN or prompt)"
    )]
    auth_bearer: Option<Option<String>>,

    #[arg(long, help = "Permit credentials on http:// (no TLS)")]
    allow_cleartext: bool,

    #[arg(
        long,
        value_name = "N",
        default_value_t = 4,
        help = "Parallel DAV worker connections (hard cap 8)"
    )]
    dav_connections: usize,

    #[arg(
        long,
        value_name = "N",
        default_value_t = 50,
        help = "Hrefs per calendar-/addressbook-multiget REPORT batch"
    )]
    multiget_batch: usize,

    #[arg(
        long,
        help = "Permit importing when the archive records a different DAV source"
    )]
    allow_source_change: bool,

    #[command(flatten)]
    global: GlobalArgs,

    #[arg(
        value_name = "ARCHIVE",
        help = "Local SQLite archive (created if absent)"
    )]
    archive: PathBuf,
}

impl Cli {
    pub fn resolve(self) -> Result<Action, Error> {
        match self.command {
            Command::Import { source } => resolve_import(source),
            Command::Export(args) => resolve_export(*args),
            Command::Inspect(args) => resolve_inspect(*args),
        }
    }
}

fn resolve_import(source: Source) -> Result<Action, Error> {
    match source {
        Source::Jmap(args) => resolve_jmap_import(*args),
        Source::Imap(args) => resolve_imap_import(*args),
        Source::Caldav(args) => resolve_dav_import(*args, DavKindArg::Caldav),
        Source::Carddav(args) => resolve_dav_import(*args, DavKindArg::Carddav),
        Source::Webdav(args) => resolve_dav_import(*args, DavKindArg::Webdav),
        Source::Managesieve(args) => resolve_managesieve_import(*args),
        Source::Maildir(args) => resolve_maildir_import(*args),
        Source::Takeout(args) => resolve_takeout_import(*args),
        Source::ExchangeEws(args) => resolve_exchange_ews_import(*args),
        Source::ExchangeGraph(args) => resolve_exchange_graph_import(*args),
    }
}

fn resolve_managesieve_import(args: ManageSieveImportArgs) -> Result<Action, Error> {
    let common = common_config(&args.global, args.archive);
    let auth = resolve_managesieve_auth(
        args.auth_basic.as_deref(),
        args.auth_password.as_deref(),
        args.auth_bearer.as_ref(),
        args.auth_user.as_deref(),
    )?;
    Ok(Action::ImportManageSieve(
        common,
        ManageSieveImportConfig {
            url: args.url,
            auth,
            allow_cleartext: args.allow_cleartext,
            allow_source_change: args.allow_source_change,
        },
    ))
}

fn resolve_managesieve_auth(
    auth_basic: Option<&str>,
    auth_password: Option<&str>,
    auth_bearer: Option<&Option<String>>,
    auth_user: Option<&str>,
) -> Result<ManageSieveAuth, Error> {
    if let Some(user) = auth_basic {
        if auth_user.is_some() {
            return Err(Error::Usage(
                "--auth-user is only valid with --auth-bearer".to_owned(),
            ));
        }
        let password = secret::resolve(auth_password, "VANDELAY_PASSWORD", "password")?;
        return Ok(ManageSieveAuth::Basic {
            user: user.to_owned(),
            password,
        });
    }
    if auth_password.is_some() {
        return Err(Error::Usage(
            "--auth-password is only valid together with --auth-basic".to_owned(),
        ));
    }
    let bearer = auth_bearer.ok_or_else(|| {
        Error::Usage("exactly one of --auth-basic / --auth-bearer is required".to_owned())
    })?;
    let user = auth_user
        .ok_or_else(|| Error::Usage("--auth-user is required with --auth-bearer".to_owned()))?
        .to_owned();
    let token = secret::resolve(bearer.as_deref(), "VANDELAY_TOKEN", "bearer token")?;
    Ok(ManageSieveAuth::Bearer { user, token })
}

#[derive(Args)]
pub struct MaildirImportArgs {
    #[arg(
        value_name = "MAILDIR",
        help = "Path to the Maildir++ root (a directory containing cur/ new/ tmp/)"
    )]
    maildir: PathBuf,

    #[arg(
        value_name = "ARCHIVE",
        help = "Local SQLite archive (created if absent)"
    )]
    archive: PathBuf,

    #[arg(
        long,
        value_name = "REGEX",
        action = ArgAction::Append,
        help = "Folder include filter against canonical name (repeatable)"
    )]
    include: Vec<String>,

    #[arg(
        long,
        value_name = "REGEX",
        action = ArgAction::Append,
        help = "Folder exclude filter against canonical name (repeatable)"
    )]
    exclude: Vec<String>,

    #[arg(
        long,
        value_name = "NAME",
        action = ArgAction::Append,
        help = "Exact include; mutually exclusive with --include/--exclude"
    )]
    folder: Vec<String>,

    #[arg(long, help = "Disable the name-heuristic role assignment")]
    noautomap: bool,

    #[arg(long, help = "Import T-flagged messages with the $deleted keyword")]
    include_deleted: bool,

    #[arg(
        long,
        help = "Permit importing when the archive records a different maildir path"
    )]
    allow_source_change: bool,

    #[command(flatten)]
    global: GlobalArgs,
}

fn resolve_maildir_import(args: MaildirImportArgs) -> Result<Action, Error> {
    let common = common_config(&args.global, args.archive);
    if !args.folder.is_empty() && (!args.include.is_empty() || !args.exclude.is_empty()) {
        return Err(Error::Usage(
            "--folder is mutually exclusive with --include/--exclude".to_owned(),
        ));
    }
    let include = compile_regexes(&args.include, "--include")?;
    let exclude = compile_regexes(&args.exclude, "--exclude")?;
    Ok(Action::ImportMaildir(
        common,
        MaildirImportConfig {
            maildir: args.maildir,
            include,
            exclude,
            folder: args.folder,
            automap: !args.noautomap,
            include_deleted: args.include_deleted,
            allow_source_change: args.allow_source_change,
        },
    ))
}

#[derive(Args)]
pub struct TakeoutImportArgs {
    #[arg(
        value_name = "PATH",
        help = "Directory tree to scan recursively for .mbox / .ics / .vcf files"
    )]
    path: PathBuf,

    #[arg(
        value_name = "ARCHIVE",
        help = "Local SQLite archive (created if absent)"
    )]
    archive: PathBuf,

    #[arg(long, help = "Disable the system-label role assignment")]
    noautomap: bool,

    #[arg(
        long,
        help = "Permit importing when the archive records a takeout source at a different path"
    )]
    allow_source_change: bool,

    #[command(flatten)]
    global: GlobalArgs,
}

fn resolve_takeout_import(args: TakeoutImportArgs) -> Result<Action, Error> {
    let common = common_config(&args.global, args.archive);
    Ok(Action::ImportTakeout(
        common,
        TakeoutImportConfig {
            takeout_root: args.path,
            allow_source_change: args.allow_source_change,
            automap: !args.noautomap,
        },
    ))
}

fn resolve_dav_import(args: DavImportArgs, kind: DavKindArg) -> Result<Action, Error> {
    let common = common_config(&args.global, args.archive);
    let auth = resolve_dav_auth(
        args.auth_basic.as_deref(),
        args.auth_password.as_deref(),
        args.auth_bearer.as_ref(),
    )?;
    let dav_connections = args.dav_connections.clamp(1, 8).min(common.threads.max(1));
    let multiget_batch = args.multiget_batch.max(1);
    Ok(Action::ImportDav(
        common,
        DavImportConfig {
            kind,
            url: args.url,
            auth,
            allow_cleartext: args.allow_cleartext,
            dav_connections,
            multiget_batch,
            allow_source_change: args.allow_source_change,
        },
    ))
}

fn resolve_dav_auth(
    auth_basic: Option<&str>,
    auth_password: Option<&str>,
    auth_bearer: Option<&Option<String>>,
) -> Result<DavAuth, Error> {
    if let Some(user) = auth_basic {
        let password = secret::resolve(auth_password, "VANDELAY_PASSWORD", "password")?;
        return Ok(DavAuth::Basic {
            user: user.to_owned(),
            password,
        });
    }
    if auth_password.is_some() {
        return Err(Error::Usage(
            "--auth-password is only valid together with --auth-basic".to_owned(),
        ));
    }
    let bearer = auth_bearer.ok_or_else(|| {
        Error::Usage("exactly one of --auth-basic / --auth-bearer is required".to_owned())
    })?;
    let token = secret::resolve(bearer.as_deref(), "VANDELAY_TOKEN", "bearer token")?;
    Ok(DavAuth::Bearer { token })
}

fn resolve_jmap_import(args: JmapImportArgs) -> Result<Action, Error> {
    let common = common_config(&args.global, args.archive);
    let auth = resolve_auth(
        args.auth_basic.as_deref(),
        args.auth_password.as_deref(),
        args.auth_bearer.as_ref(),
    )?;
    let account = resolve_account(args.account_id, args.account_name)?;
    let objects = args.objects.as_deref().map(parse_object_list).transpose()?;

    Ok(Action::Import(
        common,
        ImportConfig {
            connect: ConnectConfig {
                url: args.url,
                auth,
                account,
            },
            objects,
            allow_source_change: args.allow_source_change,
        },
    ))
}

fn resolve_imap_import(args: ImapImportArgs) -> Result<Action, Error> {
    let common = common_config(&args.global, args.archive);
    let auth = resolve_imap_auth(
        args.auth_basic.as_deref(),
        args.auth_password.as_deref(),
        args.auth_bearer.as_ref(),
        args.auth_user.as_deref(),
    )?;
    if !args.folder.is_empty() && (!args.include.is_empty() || !args.exclude.is_empty()) {
        return Err(Error::Usage(
            "--folder is mutually exclusive with --include/--exclude".to_owned(),
        ));
    }
    let include = compile_regexes(&args.include, "--include")?;
    let exclude = compile_regexes(&args.exclude, "--exclude")?;
    let imap_connections = args.imap_connections.unwrap_or(common.threads).clamp(1, 8);

    Ok(Action::ImportImap(
        common,
        ImapImportConfig {
            url: args.url,
            auth,
            allow_cleartext: args.allow_cleartext,
            compress: args.compress,
            include,
            exclude,
            exclude_special: args.exclude_special,
            folder: args.folder,
            subscribed_only: args.subscribed_only,
            automap: !args.noautomap,
            include_deleted: args.include_deleted,
            fetch_batch: args.fetch_batch,
            imap_connections,
            allow_source_change: args.allow_source_change,
        },
    ))
}

fn compile_regexes(raw: &[String], flag: &str) -> Result<Vec<Regex>, Error> {
    let mut out = Vec::with_capacity(raw.len());
    for r in raw {
        let re =
            Regex::new(r).map_err(|e| Error::Usage(format!("{flag} {r:?}: invalid regex: {e}")))?;
        out.push(re);
    }
    Ok(out)
}

fn resolve_imap_auth(
    auth_basic: Option<&str>,
    auth_password: Option<&str>,
    auth_bearer: Option<&Option<String>>,
    auth_user: Option<&str>,
) -> Result<ImapAuth, Error> {
    if let Some(user) = auth_basic {
        if auth_user.is_some() {
            return Err(Error::Usage(
                "--auth-user is only valid with --auth-bearer".to_owned(),
            ));
        }
        let password = secret::resolve(auth_password, "VANDELAY_PASSWORD", "password")?;
        return Ok(ImapAuth::Basic {
            user: user.to_owned(),
            password,
        });
    }
    if auth_password.is_some() {
        return Err(Error::Usage(
            "--auth-password is only valid together with --auth-basic".to_owned(),
        ));
    }
    let bearer = auth_bearer.ok_or_else(|| {
        Error::Usage("exactly one of --auth-basic / --auth-bearer is required".to_owned())
    })?;
    let user = auth_user
        .ok_or_else(|| Error::Usage("--auth-user is required with --auth-bearer".to_owned()))?
        .to_owned();
    let token = secret::resolve(bearer.as_deref(), "VANDELAY_TOKEN", "bearer token")?;
    Ok(ImapAuth::Bearer { user, token })
}

fn resolve_export(args: ExportArgs) -> Result<Action, Error> {
    let common = common_config(&args.global, args.archive);
    let auth = resolve_auth(
        args.auth_basic.as_deref(),
        args.auth_password.as_deref(),
        args.auth_bearer.as_ref(),
    )?;
    let account = resolve_account(args.account_id, args.account_name)?;
    let objects = args.objects.as_deref().map(parse_object_list).transpose()?;

    Ok(Action::Export(
        common,
        ExportConfig {
            connect: ConnectConfig {
                url: args.url,
                auth,
                account,
            },
            objects,
            prune: args.prune,
            yes: args.yes,
        },
    ))
}

fn resolve_inspect(args: InspectArgs) -> Result<Action, Error> {
    let target = match args.target.as_deref() {
        Some(token) => Some(ObjectType::parse(token)?),
        None => None,
    };
    if let Some(limit) = args.limit
        && limit == 0
    {
        return Err(Error::Usage("--limit must be greater than zero".to_owned()));
    }
    Ok(Action::Inspect(InspectConfig {
        archive: args.archive,
        target,
        limit: args.limit,
        offset: args.offset,
    }))
}

#[derive(Args)]
#[command(group(clap::ArgGroup::new("ews_auth").required(true).args([
    "auth_basic", "auth_bearer",
])))]
pub struct ExchangeEwsImportArgs {
    #[arg(long, value_name = "URL", help = "Fully-qualified EWS endpoint URL")]
    url: Option<String>,

    #[arg(
        long,
        value_name = "EMAIL",
        help = "Target mailbox SMTP address (required for autodiscover and app-only OAuth)"
    )]
    mailbox: Option<String>,

    #[arg(
        long,
        value_name = "KIND",
        default_value = "primary",
        help = "Mailbox surface: primary | archive | public-folders"
    )]
    mailbox_kind: String,

    #[arg(long, value_name = "USER", help = "Basic auth user (on-prem only)")]
    auth_basic: Option<String>,

    #[arg(
        long,
        value_name = "PASS",
        help = "Basic password (prefer $VANDELAY_PASSWORD or prompt)"
    )]
    auth_password: Option<String>,

    #[arg(
        long,
        value_name = "TOKEN",
        num_args = 0..=1,
        help = "Bearer token (value optional: $VANDELAY_TOKEN or device-code flow)"
    )]
    auth_bearer: Option<Option<String>>,

    #[arg(
        long,
        value_name = "TENANT",
        help = "Entra tenant id or .onmicrosoft.com short name"
    )]
    ews_tenant: Option<String>,

    #[arg(long, value_name = "ID", help = "Entra app (client) id")]
    ews_client_id: Option<String>,

    #[arg(
        long,
        value_name = "SECRET",
        help = "OAuth client secret (app-only flow; prefer $VANDELAY_EWS_CLIENT_SECRET)"
    )]
    ews_client_secret: Option<String>,

    #[arg(long, help = "Use the OAuth device-code interactive flow")]
    ews_device_code: bool,

    #[arg(
        long,
        value_name = "N",
        default_value_t = 4,
        help = "Parallel EWS worker connections (hard cap 8)"
    )]
    ews_connections: usize,

    #[arg(
        long,
        value_name = "N",
        default_value_t = 20,
        help = "ItemIds per GetItem batch"
    )]
    ews_getitem_batch: usize,

    #[arg(
        long,
        value_name = "N",
        default_value_t = 5,
        help = "AttachmentIds per GetAttachment batch"
    )]
    ews_attachment_batch: usize,

    #[arg(long, help = "Force FindItem even when SyncFolderItems would work")]
    ews_no_syncfolderitems: bool,

    #[arg(
        long,
        help = "Permit importing when the archive records a different EWS source"
    )]
    allow_source_change: bool,

    #[command(flatten)]
    global: GlobalArgs,

    #[arg(
        value_name = "ARCHIVE",
        help = "Local SQLite archive (created if absent)"
    )]
    archive: PathBuf,
}

fn resolve_exchange_ews_import(args: ExchangeEwsImportArgs) -> Result<Action, Error> {
    let archive = args.archive.clone();
    let common = common_config(&args.global, archive);
    let mailbox_kind = match args.mailbox_kind.as_str() {
        "primary" => MailboxKind::Primary,
        "archive" => MailboxKind::Archive,
        "public-folders" => MailboxKind::PublicFolders,
        other => {
            return Err(Error::Usage(format!(
                "--mailbox-kind must be primary | archive | public-folders, got {other:?}"
            )));
        }
    };
    let auth = resolve_exchange_ews_auth(&args)?;
    let ews_connections = args.ews_connections.clamp(1, 8).min(common.threads.max(1));
    let config = EwsImportConfig {
        url: args.url,
        mailbox: args.mailbox,
        mailbox_kind,
        auth,
        ews_connections,
        getitem_batch: args.ews_getitem_batch.max(1),
        attachment_batch: args.ews_attachment_batch.max(1),
        use_syncfolderitems: !args.ews_no_syncfolderitems,
        allow_source_change: args.allow_source_change,
    };
    Ok(Action::ImportExchangeEws(common, config))
}

fn resolve_exchange_ews_auth(args: &ExchangeEwsImportArgs) -> Result<EwsAuth, Error> {
    if let Some(user) = args.auth_basic.as_deref() {
        let password = secret::resolve(
            args.auth_password.as_deref(),
            "VANDELAY_PASSWORD",
            "password",
        )?;
        return Ok(EwsAuth::Basic {
            user: user.to_owned(),
            password,
        });
    }
    if args.auth_password.is_some() {
        return Err(Error::Usage(
            "--auth-password is only valid with --auth-basic".to_owned(),
        ));
    }
    let Some(bearer) = args.auth_bearer.as_ref() else {
        return Err(Error::Usage(
            "exactly one of --auth-basic / --auth-bearer is required".to_owned(),
        ));
    };
    if let Some(token) = bearer.clone() {
        return Ok(EwsAuth::Bearer { token });
    }
    let tenant = args
        .ews_tenant
        .clone()
        .unwrap_or_else(|| "common".to_owned());
    let client_id = args.ews_client_id.clone().ok_or_else(|| {
        Error::Usage(
            "--ews-client-id is required when --auth-bearer is used without an inline token"
                .to_owned(),
        )
    })?;
    if args.ews_device_code {
        return Ok(EwsAuth::OAuth(OAuthFlow::DeviceCode { tenant, client_id }));
    }
    if let Some(secret) = args.ews_client_secret.clone() {
        let resolved = secret::resolve(
            Some(secret.as_str()),
            "VANDELAY_EWS_CLIENT_SECRET",
            "client secret",
        )?;
        return Ok(EwsAuth::OAuth(OAuthFlow::ClientCredentials {
            tenant,
            client_id,
            client_secret: resolved,
        }));
    }
    if let Ok(env_secret) = std::env::var("VANDELAY_EWS_CLIENT_SECRET")
        && !env_secret.is_empty()
    {
        return Ok(EwsAuth::OAuth(OAuthFlow::ClientCredentials {
            tenant,
            client_id,
            client_secret: env_secret,
        }));
    }
    let token = secret::resolve(None, "VANDELAY_TOKEN", "bearer token")?;
    Ok(EwsAuth::Bearer { token })
}

#[derive(Args)]
pub struct ExchangeGraphImportArgs {
    #[arg(
        long,
        value_name = "UUID",
        help = "Entra app (client) id (required unless --access-token is given)"
    )]
    client_id: Option<String>,

    #[arg(
        long,
        value_name = "ID",
        default_value = "common",
        help = "Entra authority tenant"
    )]
    tenant: String,

    #[arg(
        long,
        value_name = "UPN|UUID",
        help = "Target a different mailbox the signed-in account can read"
    )]
    user: Option<String>,

    #[arg(
        long,
        value_name = "KIND",
        default_value = "primary",
        value_parser = parse_graph_mailbox_kind,
        help = "Mailbox surface: primary | archive (public-folders is rejected: use exchange-ews)"
    )]
    mailbox_kind: GraphMailboxKind,

    #[arg(
        long,
        value_name = "TOKEN",
        num_args = 0..=1,
        help = "Pre-acquired bearer token (skip device-code flow). Resolves: flag value -> $VANDELAY_GRAPH_TOKEN -> prompt."
    )]
    access_token: Option<Option<String>>,

    #[arg(
        long,
        value_name = "LIST",
        help = "Comma-separated surface list (mail,calendar,contacts; default all)"
    )]
    objects: Option<String>,

    #[arg(
        long,
        value_name = "FMT",
        default_value = "text",
        value_parser = parse_event_body_format,
        help = "outlook.body-content-type for event GETs: text | html"
    )]
    event_body_format: EventBodyFormat,

    #[arg(
        long,
        value_name = "N",
        default_value_t = 4,
        help = "Parallel Graph worker connections (hard cap 16)"
    )]
    graph_connections: usize,

    #[arg(
        long,
        value_name = "N",
        default_value_t = 100,
        help = "Per-page size ($top query parameter, range [1, 1000])"
    )]
    top: usize,

    #[arg(
        long,
        value_name = "URL",
        default_value = "https://graph.microsoft.com/v1.0",
        help = "Microsoft Graph API base URL (override for national clouds or tests)",
        hide = true
    )]
    api_base: String,

    #[arg(
        long,
        help = "Permit importing when the archive records a different exchange_graph source"
    )]
    allow_source_change: bool,

    #[command(flatten)]
    global: GlobalArgs,

    #[arg(
        value_name = "ARCHIVE",
        help = "Local SQLite archive (created if absent)"
    )]
    archive: PathBuf,
}

fn parse_graph_mailbox_kind(s: &str) -> Result<GraphMailboxKind, String> {
    GraphMailboxKind::parse(s).map_err(|e| e.to_string())
}

fn parse_event_body_format(s: &str) -> Result<EventBodyFormat, String> {
    EventBodyFormat::parse(s).map_err(|e| e.to_string())
}

fn resolve_exchange_graph_import(args: ExchangeGraphImportArgs) -> Result<Action, Error> {
    let common = common_config(&args.global, args.archive.clone());
    let mailbox_kind = args.mailbox_kind;
    let event_body_format = args.event_body_format;
    let auth = resolve_graph_auth(
        args.access_token.as_ref(),
        args.client_id.as_deref(),
        &args.tenant,
    )?;
    let objects = args
        .objects
        .as_deref()
        .map(crate::types::parse_object_list)
        .transpose()?;
    let top = args.top.clamp(1, 1000);
    let graph_connections = args
        .graph_connections
        .clamp(1, 16)
        .min(common.threads.max(1));
    Ok(Action::ImportExchangeGraph(
        common,
        GraphImportConfig {
            auth,
            api_base: args.api_base,
            user_target: args.user,
            mailbox_kind,
            objects,
            event_body_format,
            graph_connections,
            top,
            allow_source_change: args.allow_source_change,
        },
    ))
}

fn resolve_graph_auth(
    access_token: Option<&Option<String>>,
    client_id: Option<&str>,
    tenant: &str,
) -> Result<GraphAuth, Error> {
    if let Some(slot) = access_token {
        let token = secret::resolve(slot.as_deref(), "VANDELAY_GRAPH_TOKEN", "bearer token")?;
        return Ok(GraphAuth::PreAcquired { token });
    }
    let client_id = client_id
        .ok_or_else(|| {
            Error::Usage(
                "--client-id is required for exchange-graph (unless --access-token is given)"
                    .to_owned(),
            )
        })?
        .to_owned();
    let authority = crate::exchange_graph::oauth::default_authority(tenant);
    Ok(GraphAuth::DeviceCode {
        authority,
        client_id,
    })
}

fn common_config(global: &GlobalArgs, archive: PathBuf) -> CommonConfig {
    let threads = global
        .threads
        .filter(|n| *n > 0)
        .unwrap_or_else(num_cpus::get)
        .max(1);
    CommonConfig {
        archive,
        threads,
        dry_run: global.dry_run,
        max_retries: global.max_retries,
        allow_invalid_certs: global.allow_invalid_certs,
        logger: Logger::from_flags(global.quiet, global.verbose),
    }
}

fn resolve_auth(
    auth_basic: Option<&str>,
    auth_password: Option<&str>,
    auth_bearer: Option<&Option<String>>,
) -> Result<Auth, Error> {
    if let Some(user) = auth_basic {
        let password = secret::resolve(auth_password, "VANDELAY_PASSWORD", "password")?;
        return Ok(Auth::Basic {
            user: user.to_owned(),
            password,
        });
    }
    if auth_password.is_some() {
        return Err(Error::Usage(
            "--auth-password is only valid together with --auth-basic".to_owned(),
        ));
    }
    let bearer = auth_bearer.ok_or_else(|| {
        Error::Usage("exactly one of --auth-basic / --auth-bearer is required".to_owned())
    })?;
    let token = secret::resolve(bearer.as_deref(), "VANDELAY_TOKEN", "bearer token")?;
    Ok(Auth::Bearer { token })
}

fn resolve_account(
    account_id: Option<String>,
    account_name: Option<String>,
) -> Result<AccountSelector, Error> {
    match (account_id, account_name) {
        (Some(id), None) => Ok(AccountSelector::Id(id)),
        (None, Some(name)) => Ok(AccountSelector::Name(name)),
        _ => Err(Error::Usage(
            "exactly one of --account-id / --account-name is required".to_owned(),
        )),
    }
}

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

    fn lock() -> std::sync::MutexGuard<'static, ()> {
        static M: std::sync::Mutex<()> = std::sync::Mutex::new(());
        M.lock().unwrap_or_else(|e| e.into_inner())
    }

    #[test]
    fn auth_password_without_basic_is_usage_error() {
        let _g = lock();
        unsafe {
            env::remove_var("VANDELAY_TOKEN");
            env::remove_var("VANDELAY_PASSWORD");
        }
        let bearer: Option<String> = Some("t".to_owned());
        let r = resolve_auth(None, Some("hunter2"), Some(&bearer));
        match r {
            Err(Error::Usage(m)) => {
                assert!(m.contains("auth-password"), "msg was: {m}");
            }
            other => panic!("expected Usage error, got {other:?}"),
        }
    }

    #[test]
    fn auth_basic_with_password_resolves() {
        let _g = lock();
        unsafe {
            env::remove_var("VANDELAY_TOKEN");
            env::remove_var("VANDELAY_PASSWORD");
        }
        let auth = resolve_auth(Some("alice"), Some("pw"), None).unwrap();
        match auth {
            Auth::Basic { user, password } => {
                assert_eq!(user, "alice");
                assert_eq!(password, "pw");
            }
            _ => panic!("expected Basic"),
        }
    }

    #[test]
    fn auth_bearer_inline_token_resolves() {
        let _g = lock();
        unsafe {
            env::remove_var("VANDELAY_TOKEN");
            env::remove_var("VANDELAY_PASSWORD");
        }
        let bearer: Option<String> = Some("t".to_owned());
        let auth = resolve_auth(None, None, Some(&bearer)).unwrap();
        assert!(matches!(auth, Auth::Bearer { token } if token == "t"));
    }
}
