//! Command-line argument definition and processing
// (c) 2024 Ross Younger

use std::collections::HashSet;
use std::ffi::OsString;

use anyhow::Result;
use clap::{ArgAction::SetTrue, Args as _, FromArgMatches as _, Parser};

use crate::config::Source as ConfigSource;
use crate::util::{dirwalk, path};
use crate::{CopyJobSpec, FileSpec, config::Manager, util::AddressFamily};

const META_JOBSPEC: &str = "command-line (user@host)";

#[derive(Clone, Copy, Debug, Default, PartialEq, clap::ValueEnum)]
#[value(rename_all = "kebab-case")]
pub(crate) enum MainMode {
    #[default]
    Client,
    Server,
    ShowConfig,
    HelpBuffers,
    ShowConfigFiles,
    ListFeatures,
    // remember to add any new mode to the default_value_ifs set in CliArgs::Mode
}

// N.B. This docstring goes into the autogenerated man page.
/// The QUIC Copier (qcp) is an experimental high-performance remote file copy utility
/// for long-distance internet connections.
/// It is intended as a drop-in replacement for scp.
///
/// qcp offers similar security to scp using existing, well-known mechanisms, and better
/// throughput on congested networks.

#[derive(Debug, Parser, Clone, Default)]
#[command(
    author,
    // we set short/long version strings explicitly, see custom_parse()
    about,
    long_about,
    override_usage = "qcp [OPTIONS] <SOURCE>... <DESTINATION>",
    before_help = r"e.g.   qcp some/file my-server:some-directory/
       qcp -r dir1 dir2 my-server:

Exactly one side (source(s) or destination) must be remote.
When copying multiple sources, the destination is a directory, which will be created if necessary.

Long options may be abbreviated where unambiguous.

qcp will read your ssh config file to resolve any host name aliases you may have defined. The idea is, if you can ssh directly to a given host, you should be able to qcp to it by the same name. However, some particularly complicated ssh config files may be too much for qcp to understand. (In particular, Match directives are not currently supported.) In that case, you can use --ssh-config to provide an alternative configuration (or set it in your qcp configuration file).
    ",
    infer_long_args(true),
    help_template(
    "\
{name} version {version}
{about-with-newline}
{usage-heading} {usage}
{before-help}
{all-args}{after-help}
"),
    styles=super::styles::CLAP_STYLES)]
#[allow(clippy::struct_excessive_bools)]
pub(crate) struct CliArgs {
    // MODE SELECTION ======================================================================

    // This fake argument brings the various mode options together.
    #[arg(hide = true, long("__mode"), num_args = 1, require_equals=true, default_value_ifs=[
        // syntax: (field within this struct, value, argument to apply if field==value)
        ("server", "true", "server"),
        ("show_config", "true", "show-config"),
        ("help_buffers", "true", "help-buffers"),
        ("config_files", "true", "show-config-files"),
        ("list_features", "true", "list-features"),
    ], default_value="client")]
    pub(crate) mode_: MainMode,

    /// Operate in server mode.
    ///
    /// This is what we run on the remote machine; it is not
    /// intended for interactive use.
    #[arg(long, hide = true, exclusive(true))]
    pub server: bool,

    // MODE SELECTION, part 2 ==============================================================
    // (These are down here to control clap's output ordering.)
    /// Outputs the local configuration, then exits.
    ///
    /// If a remote `SOURCE` or `DESTINATION` argument is given, outputs the configuration we would use
    /// for operations to that host.
    ///
    /// If not, outputs only global settings from configuration, which may be overridden by
    /// `Host` blocks in configuration files.
    ///
    #[arg(long, help_heading("Debug"), display_order(100))]
    pub show_config: bool,
    /// Outputs the paths to configuration file(s), then exits.
    ///
    /// This option cannot be used with any other option.
    #[arg(long, help_heading("Debug"), exclusive(true), display_order(100))]
    pub config_files: bool,

    /// Outputs additional information about kernel UDP buffer sizes and platform-specific tips.
    ///
    /// Note that the recommendations are based on the `udp_buffer` field in your configuration,
    /// which you can also set on the CLI.
    #[arg(long, help_heading("Tuning"), display_order(100))]
    pub help_buffers: bool,

    /// Outputs all known protocol features with their compatibility levels, then exits.
    ///
    /// This option cannot be used with any other option.
    #[arg(long, help_heading("Debug"), exclusive(true), display_order(100))]
    pub list_features: bool,

    // CLIENT-SIDE NON-CONFIGURABLE OPTIONS ================================================
    // (including positional arguments!)
    #[command(flatten)]
    /// The set of options which may only be provided via command-line.
    pub client_params: crate::client::Parameters,

    /// Log to a file
    ///
    /// By default the log receives everything printed to stderr.
    /// To override this behaviour, set the environment variable `RUST_LOG_FILE_DETAIL` (same semantics as `RUST_LOG`).
    #[arg(long, value_name("FILE"), display_order(0))]
    pub log_file: Option<String>,

    /// Forces use of IPv4
    ///
    /// This is a convenience alias for `--address-family inet`
    // this is actioned by our custom parser
    #[arg(
        short = '4',
        help_heading("Connection"),
        group("ip address"),
        action(SetTrue),
        display_order(0)
    )]
    pub ipv4_alias__: bool,
    /// Forces use of IPv6
    ///
    /// This is a convenience alias for `--address-family inet6`
    // this is actioned by our custom parser
    #[arg(
        short = '6',
        help_heading("Connection"),
        group("ip address"),
        action(SetTrue),
        display_order(0)
    )]
    pub ipv6_alias__: bool,

    // CONFIGURABLE OPTIONS ================================================================
    #[command(flatten)]
    /// The set of options which may be set in a config file or via command-line.
    /// See [`Configuration`](crate::config::Configuration).
    pub config: crate::config::Configuration_Optional,

    // JOB SPECIFICATION ====================================================================
    // (POSITIONAL ARGUMENTS!)
    /// One or more SOURCE paths followed by a DESTINATION path.
    ///
    /// The last path is always treated as the destination. All preceding paths are treated as sources.
    ///
    /// Exactly one side of the transfer (all sources, or the destination) must be remote.
    /// Remote paths take the form `server:path` or `user@server:path` (as in rcp or scp).
    #[arg(value_name = "SOURCE|DESTINATION", num_args = 0..)]
    pub paths: Vec<FileSpec>,
}

impl CliArgs {
    /// Sets up and executes our parser
    pub(crate) fn custom_parse<I, T>(args: I) -> Result<Self, clap::Error>
    where
        I: IntoIterator<Item = T>,
        T: Into<OsString> + Clone,
    {
        let cli = clap::Command::new(clap::crate_name!());
        let cli = CliArgs::augment_args(cli).version(crate::version::short());
        let mut args = CliArgs::from_arg_matches(&cli.try_get_matches_from(args)?)?;
        // Custom logic: '-4' and '-6' convenience aliases
        if args.ipv4_alias__ {
            args.config.address_family = Some(AddressFamily::Inet);
        } else if args.ipv6_alias__ {
            args.config.address_family = Some(AddressFamily::Inet6);
        }
        Ok(args)
    }

    /// Applies any options derived from the jobspec to this configuration
    fn apply_jobspec_to(&self, mgr: &mut Manager) {
        mgr.merge_provider(self.remote_user_as_config());
    }

    fn sources_and_destination(&self) -> anyhow::Result<(Vec<FileSpec>, FileSpec)> {
        anyhow::ensure!(self.paths.len() >= 2, "source and destination are required");
        let mut paths = self.paths.clone();
        let destination = paths.pop().expect("destination must be present");
        Ok((paths, destination))
    }

    /// Returns:
    /// - Ok(true) on success
    /// - Ok(false) on partial success
    /// - Err(...) on fatal error
    pub(crate) fn jobspecs(&self) -> Result<(bool, Vec<CopyJobSpec>), anyhow::Error> {
        let (sources, destination) = self.sources_and_destination()?;
        let destination_is_remote = destination.user_at_host.is_some();

        let mut remote_hosts = HashSet::new();
        let mut remote_user: Option<&str> = None;
        let mut success = true;

        // Remote username must not vary
        for spec in sources.iter().chain(std::iter::once(&destination)) {
            if let Some(host) = spec.hostname() {
                let _ = remote_hosts.insert(host);
            }
            if let Some(user) = spec.remote_user() {
                if let Some(existing) = remote_user {
                    anyhow::ensure!(existing == user, "Only one remote user is supported");
                } else {
                    remote_user = Some(user);
                }
            }
        }
        anyhow::ensure!(remote_hosts.len() <= 1, "Only one remote host is supported");

        let remote_sources: Vec<_> = sources
            .iter()
            .filter(|s| s.user_at_host.is_some())
            .collect();

        // Check correct number of remotes, determine the path joiner function to use
        let join_fn = if destination_is_remote {
            anyhow::ensure!(
                remote_sources.is_empty() && sources.iter().all(|src| src.user_at_host.is_none()),
                "Only one remote side is supported"
            );
            path::join_remote
        } else {
            anyhow::ensure!(
                !remote_sources.is_empty(),
                "One file argument must be remote"
            );
            anyhow::ensure!(
                sources.iter().all(|src| src.user_at_host.is_some()),
                "Only one remote side is supported"
            );
            path::join_local
        };

        let multiple_sources = sources.len() > 1;
        let mut jobs = Vec::with_capacity(sources.len());

        if self.client_params.recurse && destination_is_remote {
            for source in sources {
                success &= dirwalk::recurse_local_source(
                    &source,
                    &destination,
                    self.client_params.preserve,
                    &mut jobs,
                )?;
            }
        } else {
            // Convert filenames into job specs
            for source in sources {
                let dest_filename =
                    // Append the destination leaf if there are multiple sources... unless this is a recursive get, in which case don't.
                    if multiple_sources && (!self.client_params.recurse || destination_is_remote) {
                        let leaf = path::basename_of(&source.filename)?;
                        join_fn(&destination.filename, &leaf)
                    } else {
                        destination.filename.clone()
                    };
                jobs.push(CopyJobSpec::try_new(
                    source,
                    FileSpec {
                        user_at_host: destination.user_at_host.clone(),
                        filename: dest_filename,
                    },
                    self.client_params.preserve,
                    false,
                )?);
            }
        }
        Ok((success, jobs))
    }

    /// A best-effort attempt to extract a single remote host string from the parameters.
    ///
    /// # Returns
    /// If no remote hosts are present, `Ok(None)`
    /// If all remote paths use the same host and only one side of the transfer is remote, `Ok(Some(<host>))`
    ///
    /// # Errors
    /// If remote paths refer to multiple hosts or both the sources _and_ destination are remote
    /// an error is returned.
    pub(crate) fn remote_host_lossy(&self) -> anyhow::Result<Option<&str>> {
        if self.paths.is_empty() {
            return Ok(None);
        }
        let mut host: Option<&str> = None;
        let mut remote_in_sources = false;
        let mut remote_in_destination = false;
        for (idx, spec) in self.paths.iter().enumerate() {
            if let Some(h) = spec.hostname() {
                if let Some(existing) = host {
                    anyhow::ensure!(existing == h, "Only one remote host is supported");
                } else {
                    host = Some(h);
                }
                if idx == self.paths.len() - 1 {
                    remote_in_destination = true;
                } else {
                    remote_in_sources = true;
                }
            }
        }
        anyhow::ensure!(
            !(remote_in_sources && remote_in_destination),
            "Only one remote side is supported"
        );
        Ok(host)
    }

    /// Extracts the remote username from the jobspec, if there was one.
    /// We do this as a configuration because we allow it to be specified in multiple ways:
    /// * -l username  # same as for ssh/scp
    /// * `user@host:file`
    /// * our configuration file
    pub(crate) fn remote_user_as_config(&self) -> ConfigSource {
        let mut cfg = ConfigSource::new(META_JOBSPEC);
        let mut remote_user: Option<&str> = None;
        for spec in &self.paths {
            let user = spec.remote_user();
            if let Some(u) = user {
                if let Some(existing) = remote_user {
                    if existing != u {
                        // Conflicting usernames; we cannot pick one reliably.
                        remote_user = None;
                        break;
                    }
                } else {
                    remote_user = Some(u);
                }
            }
        }
        if let Some(u) = remote_user {
            cfg.add("remote_user", u.into());
        }
        cfg
    }
}

impl TryFrom<&CliArgs> for Manager {
    type Error = anyhow::Error;

    fn try_from(value: &CliArgs) -> Result<Self, Self::Error> {
        let host = value.remote_host_lossy()?;

        let mut mgr = Manager::standard(host);
        mgr.merge_provider(&value.config);

        value.apply_jobspec_to(&mut mgr);
        Ok(mgr)
    }
}

#[cfg(test)]
#[cfg_attr(coverage_nightly, coverage(off))]
mod test {
    use std::str::FromStr;

    use pretty_assertions::assert_eq;
    use rusty_fork::rusty_fork_test;

    use crate::{
        FileSpec,
        config::{Configuration_Optional, Manager, Source},
        util::AddressFamily,
    };

    use super::CliArgs;

    fn get_cli_args(src: bool, dst: bool) -> CliArgs {
        let src_spec = if src {
            FileSpec::from_str("myuser@myhost:myfile").unwrap()
        } else {
            FileSpec::from_str("myfile").unwrap()
        };
        let dst_spec = if dst {
            FileSpec::from_str("myuser@myhost:myfile").unwrap()
        } else {
            FileSpec::from_str("myfile").unwrap()
        };
        CliArgs {
            paths: vec![src_spec, dst_spec],
            ..Default::default()
        }
    }

    fn config_user(user: &str) -> Source {
        let mut prov = Source::new("test");
        prov.add("remote_user", user.into());
        prov
    }

    #[test]
    fn src_has_user() {
        let args = get_cli_args(true, false);

        let mut mgr = Manager::without_default(None);
        args.apply_jobspec_to(&mut mgr);
        let cfg = mgr.get::<Configuration_Optional>().unwrap();
        assert_eq!(cfg.remote_user, Some("myuser".to_owned()));
    }

    #[test]
    fn dest_has_user() {
        let args = get_cli_args(false, true);

        let mut mgr = Manager::without_default(None);
        args.apply_jobspec_to(&mut mgr);
        let cfg = mgr.get::<Configuration_Optional>().unwrap();
        assert_eq!(cfg.remote_user, Some("myuser".to_owned()));
    }

    #[test]
    fn neither_has_user() {
        let args = get_cli_args(false, false);

        let mut mgr = Manager::without_default(None);
        args.apply_jobspec_to(&mut mgr);
        let cfg = mgr.get::<Configuration_Optional>().unwrap();
        assert_eq!(cfg.remote_user, None);
    }

    #[test]
    fn config_has_user() {
        let args = get_cli_args(false, false);

        let mut mgr = Manager::without_default(None);
        mgr.merge_provider(config_user("user1"));
        args.apply_jobspec_to(&mut mgr);
        let cfg = mgr.get::<Configuration_Optional>().unwrap();
        assert_eq!(cfg.remote_user, Some("user1".into()));
    }

    #[test]
    fn there_can_be_only_one_remote() {
        let args = get_cli_args(true, true);
        let mgr = Manager::try_from(&args);
        assert!(mgr.is_err());
    }

    #[test]
    fn priority() {
        let args = get_cli_args(true, false);

        // a configuration with a remote_user set:
        let mut mgr = Manager::without_default(None);
        mgr.merge_provider(config_user("user2"));
        // now apply a job with a remote user:
        args.apply_jobspec_to(&mut mgr);
        let cfg = mgr.get::<Configuration_Optional>().unwrap();
        assert_eq!(cfg.remote_user, Some("myuser".to_owned()));
    }

    #[test]
    fn custom_parse_aliases() {
        // CAUTION: As a side effect, this test reads your user and system config files. If they contain errors, it will fail.
        let table = [("-4", AddressFamily::Inet), ("-6", AddressFamily::Inet6)];
        for (alias, family) in table {
            let args = ["qcp", alias, "myuser@myhost:myfile", "."];
            let result = CliArgs::custom_parse(args).unwrap();
            assert_eq!(
                result.paths[0],
                FileSpec::from_str("myuser@myhost:myfile").unwrap()
            );
            assert_eq!(result.config.address_family.unwrap(), family);

            let mgr = Manager::try_from(&result).unwrap();
            assert_eq!(
                mgr.get::<Configuration_Optional>()
                    .unwrap()
                    .address_family
                    .unwrap(),
                family
            );
        }
    }

    #[test]
    fn conflicting_options() {
        let args = ["qcp", "--server", "--show-config"];
        let result = CliArgs::custom_parse(args);
        assert!(result.is_err());
        println!("{result:?}");
        let err = result.unwrap_err();
        assert_eq!(err.kind(), clap::error::ErrorKind::ArgumentConflict);
        eprintln!("{err}");
        assert!(
            err.to_string()
                .contains("the argument '--server' cannot be used with one or more of the other")
        );
    }

    rusty_fork_test! {
        // This test affects environment variables, so run in a separate process
        #[test]
        fn parse_color() {
            let args = ["qcp", "--color", "always"];
            let result = CliArgs::custom_parse(args);
            assert!(result.is_ok());
        }
    }

    #[test]
    fn cli_option_capitalisation() {
        let args = &[
            "qcp",
            "--time-format",
            "uTc-mIcro",
            "--address-family",
            "iNEt6",
            "--color",
            "NONE",
            "--tls-auth-type",
            "X509",
            "--congestion",
            "bBr",
            //
            "--show-config",
        ];
        let res = CliArgs::custom_parse(args).inspect_err(|e| eprintln!("{e}"));
        assert!(res.is_ok());
    }

    #[test]
    fn cli_accepts_eng_quantities() {
        let args = &[
            "qcp",
            "--tx",
            "100M",
            "--rx",
            "42k",
            "--udp-buffer",
            "3M5",
            "--show-config",
            "host:file",
            "file",
        ];
        let res = CliArgs::custom_parse(args).inspect_err(|e| eprintln!("{e}"));
        assert!(res.is_ok());
    }

    #[test]
    fn cli_repeatable_arguments() {
        let args = &[
            "qcp",
            "-S",
            "-p",
            "-S",
            "222",
            "--ssh-config",
            "myfile",
            "--ssh-config",
            "myfile2",
        ];
        let res = CliArgs::custom_parse(args).inspect_err(|e| eprintln!("{e}"));
        assert!(res.is_ok());
    }

    #[test]
    fn recurse_jobspecs() {
        let args = &["qcp", "-r", "no-such-dir/", "desthost:otherdir"];
        let res = CliArgs::custom_parse(args)
            .inspect_err(|e| eprintln!("{e}"))
            .unwrap();
        let _ = res
            .jobspecs()
            .expect_err("nonexistent directory should have failed to recurse");
    }
}
