use std::fmt::Write;
use std::path::{
    PathBuf,
};

use clap::{
    ArgGroup,
    Command,
    CommandFactory,
    Parser,
    builder::StyledStr,
};

// Note: this file is used both from main.rs and from build.rs.  This
// means that we can only use what is under the cli module!
//
// We also try to minimize the number of build dependencies to reduce
// the build time.  This includes not depending on sequoia-openpgp.
// Since we do need a couple of types from sequoia-openpgp, we mock
// them in build.rs.  If you import another type here, you'll need to
// mock that too.
use crate::openpgp;
use openpgp::{
    KeyHandle,
    // Mock additional imports in build.rs!
};

pub mod paths;
use paths::StateDirectory;
use paths::StateDirectoryValueParser;

pub const GLOBAL_OPTIONS_HEADER: &str = "Global Options";

/// Builds the top-level Clap command.
pub fn build(globals_hidden: bool) -> clap::Command {
    let mut command = Cli::command()
    // To improve readability limit the width of the text columns.
        .max_term_width(100);

    // Change the globals to be hidden.
    if globals_hidden {
        fn add_after_help(command: &mut Command) {
            // We want to append to after_long_help.
            let mut after_long_help
                = if let Some(s) = command.get_after_long_help() {
                    let mut s = s.clone();
                    s.write_char('\n').expect("Can write to string");
                    s.write_char('\n').expect("Can write to string");
                    s
                } else if let Some(s) = command.get_after_help() {
                    // If after_long_help is not explicitly set, it
                    // falls back to after_help.  If we set
                    // after_long_help, the fallback no longer happens
                    // so we need to do it manually.
                    let mut s = s.clone();
                    s.write_char('\n').expect("Can write to string");
                    s.write_char('\n').expect("Can write to string");
                    s
                } else {
                    StyledStr::new()
                };

            after_long_help.write_str(&format!("\
{}:\n  See `sq-git --help` for a description of the global options.",
                                               GLOBAL_OPTIONS_HEADER))
                .expect("Can write to string");

            *command = command.clone()
                .after_long_help(after_long_help);

            for sc in command.get_subcommands_mut() {
                add_after_help(sc);
            }
        }

        command = command
            .mut_args(|mut a| {
                if a.is_global_set() {
                    a = a.hide(true);
                }
                a
            });

        add_after_help(&mut command);
    };

    command
}

#[derive(Parser)]
#[command(
    author,
    name = "sq-git",
    version = format!("{}{}",
                      clap::crate_version!(),
                      if let Some(v) = option_env!("VERGEN_GIT_DESCRIBE") {
                          format!("-g{}", v)
                      } else {
                          "".into()
                      }),
    about = "A tool to help protect a project's supply chain.",
    long_about = "\
`sq-git` is a tool that can help improve a project's supply chain \
security.

To use `sq-git`, you add a policy file (`openpgp-policy.toml`) to \
the root of a `git` repository.  The policy file includes a list of \
OpenPGP certificates, and the types of changes they are authorized to \
make.  The capabilities include adding a commit, and authorizing a new \
certificate.  See the `sq-git init` and `sq-git policy` \
subcommands for more details.

A commit is considered authorized if the commit is signed, and at \
least one immediate parent commit's policy authorizes the signer's \
certificate to make that type of change.

A downstream user authenticates a version of the project using the \
`sq-git log` subcommand.  They specify a trust root (a commit), \
which they've presumably audited, and `sq-git log` looks for an \
authenticated path from the trust root to the current `HEAD`.  If \
there is an authenticated path, then there is evidence that the \
project's maintainers authorized all of the intermediate changes.

To find an authenticated path, `sq-git` starts with the current \
commit, and tries to authenticate it using each of its parent commits. \
It repeats this process for each parent commit that authenticated it. \
If the trust root is reached, then the version is considered \
authenticated.",
    subcommand_required = true,
    arg_required_else_help = true,
    disable_colored_help = true,
    disable_version_flag = true,
    // We want a top-level `help` subcommand, but we don't want
    // subcommands groups (like `sq pki`) to have a `help` subcommand.
    // Users get used to being able to use `help` instead of `--help`,
    // and then are confused when `sq pki authenticate help` (i.e.,
    // using the `help` subcommand on an action) doesn't work.
    //
    // Note: this option is recursive.  So if we disable it here, then
    // we have to enable it for all of the top-level subcommands.
    disable_help_subcommand = false,
)]
pub struct Cli {
    #[clap(
        long = "home",
        value_name = "PATH",
        env = "SEQUOIA_HOME",
        global = true,
        help_heading = GLOBAL_OPTIONS_HEADER,
        help = "Set the home directory",
        long_help = format!("\
Set the home directory

Sequoia's default home directory is `{}`.  When using the default \
location, files are placed according to the local standard, \
e.g., the XDG Base Directory Specification.  When an alternate \
location is specified, the user data, configuration files, and \
cache data are placed under a single, unified directory.  This is \
a lightweight way to partially isolate Sequoia programs.

Use `default` to explicitly use the default location, use `none` to \
not use a home directory.",
            sequoia_directories::Home::default_location()
                .map(|p| {
                    let p = p.display().to_string();
                    if let Some(home) = dirs::home_dir() {
                        let home = home.display().to_string();
                        if let Some(rest) = p.strip_prefix(&home) {
                            return format!("$HOME{}", rest);
                        }
                    }
                    p
                })
                .unwrap_or("<unknown>".to_string())),
        value_parser = StateDirectoryValueParser::default(),
    )]
    pub home: Option<StateDirectory>,

    #[clap(
        long = "cert-store",
        value_name = "PATH",
        env = "SEQUOIA_CERT_STORE",
        global = true,
        help_heading = GLOBAL_OPTIONS_HEADER,
        help = "Specify the location of the certificate store",
        long_help = format!("\
Specify the location of the certificate store

By default, `sq-git` uses \
the OpenPGP certificate directory in Sequoia's home directory (see `--home`), \
{}.  This can be overridden by using this options, or setting the either \
the `SEQUOIA_CERT_STORE` or the `PGP_CERT_D` environment variable.

Use `default` to explicitly use the default cert store, use `none` to \
not use a cert store.",
            sequoia_directories::Home::default()
                .map(|home| {
                    let p = home.data_dir(sequoia_directories::Component::CertD);
                    let p = p.display().to_string();
                    if let Some(home) = dirs::home_dir() {
                        let home = home.display().to_string();
                        if let Some(rest) = p.strip_prefix(&home) {
                            return format!("$HOME{}", rest);
                        }
                    }
                    p
                })
                .unwrap_or("<unknown>".to_string())),
        value_parser = StateDirectoryValueParser::default(),
    )]
    pub cert_store: Option<StateDirectory>,

    #[arg(
        global = true,
        long = "output-format",
        value_name = "FORMAT",
        value_parser = ["human-readable", "json"],
        default_value = "human-readable",
        help_heading = GLOBAL_OPTIONS_HEADER,
        help = "Produces output in the specified format, if possible",
    )]
    pub output_format: String,

    #[command(subcommand)]
    pub subcommand: Subcommand,
}

#[derive(Parser, Debug)]
#[clap(
    name = "init",
    about = "Suggests how to create a policy",
    long_about = "\
Suggests how to create a policy

Suggests how to create a policy by analyzing recent commits.  The \
heuristic considers signed commits on the current branch that were \
made over the past half year, and suggests that the most frequent \
committer be made the project maintainer, and other committers be made \
committers.

Note: This is a *simple* heuristic; its recommendations should be \
viewed as a starting point.  In particular, you still need to do some \
due diligance.  It is essential that you review the suggested roles, \
and check that people actually control the certificates.  Ideally, you \
should ask each person for their OpenPGP fingerprint in person.  But \
in the very least you should ask them via email.",

    after_help = "\
Examples:

# Inspects the current branch and suggests how to create a policy.

$ sq-git init
",
)]
pub struct InitSubcommand {
    /// Show additional information.
    #[clap(
        short='v'
    )]
    pub verbose: bool,
}

/// Describe, update, and change the OpenPGP policy.
#[derive(Parser)]
#[clap(
    name = "policy",
    subcommand_required = true,
    arg_required_else_help = true,
    disable_help_subcommand = true,
)]
pub enum PolicySubcommand {
    /// Describes the policy.
    ///
    /// This reads in the policy and dumps it in a more descriptive
    /// format on stdout.
    ///
    /// By default the policy in the root of the repository's working
    /// tree is described.
    Describe {
        #[command(flatten)]
        policy_file: PolicyFileArg,

        /// Describe the policy from the specified commit.
        #[arg(long, conflicts_with="path")]
        commit: Option<String>,
    },

    /// Shows changes between two policies.
    ///
    /// If no arguments are given, compares `HEAD`'s policy, and the
    /// working tree's policy file (`openpgp-policy.toml`).  If one
    /// argument is given, the policy in the corresponding commit or
    /// file is compared with the working tree's policy file.
    /// Otherwise, the policies of the two commits or files are
    /// compared.
    ///
    /// Exit status:
    ///
    /// The exit code is 0 if the policies are the same, and 1 if they
    /// are different.
    #[clap(group(ArgGroup::new("cert-ref").args(&["old", "old_commit", "old_file"])))]
    #[clap(group(ArgGroup::new("new-ref").args(&["new", "new_commit", "new_file"])))]
    Diff {
        /// The old policy.
        ///
        /// This is first interpreted as a commit.  If there is no
        /// such commit, it is interpreted as a filename.
        ///
        /// When using this command from a script, you should prefer
        /// `--old-commit` or `--old-file` instead as they are
        /// explicit.
        old: Option<String>,

        /// The old policy as taken from the specified commit.
        #[arg(long)]
        old_commit: Option<String>,

        /// The old policy as taken from the specified file.
        #[arg(long)]
        old_file: Option<PathBuf>,

        /// The new policy.
        ///
        /// This is first interpreted as a commit.  If there is no
        /// such commit, it is interpreted as a filename.
        ///
        /// When using this command from a script, you should prefer
        /// `--new-commit` or `--new-file` instead as they are
        /// explicit.
        new: Option<String>,

        /// The new policy as taken from the specified commit.
        #[arg(long)]
        new_commit: Option<String>,

        /// The new policy as taken from the specified file.
        #[arg(long)]
        new_file: Option<PathBuf>,
    },

    /// Changes the authorizations.
    ///
    /// A certificate can delegate any of its capabilities to another
    /// certificate without breaking an authentication chain.
    ///
    /// To fork a project, you create a new policy file.
    Authorize {
        #[command(flatten)]
        policy_file: PolicyFileArg,

        name: String,

        #[command(flatten)]
        cert: CertArg,

        /// Grant the certificate the sign-commit capability.
        ///
        /// This capability allows the certificate to sign commits.
        /// That is, when authenticating a version of the repository,
        /// a commit is considered authenticated if it is signed by a
        /// certificate with this capability.
        #[arg(long, overrides_with = "no_sign_commit",
              default_value_ifs(
                  [("committer", "true", Some("true")),
                   ("release_manager", "true", Some("true")),
                   ("project_maintainer", "true", Some("true"))]))
        ]
        sign_commit: bool,

        /// Rescind the sign-commit capability from a certificate.
        ///
        /// Removes the sign-commit capability for the certificate.
        /// Note: this operation is not retroactive; commits signed
        /// with the certificate prior to the policy change are still
        /// considered authenticated.
        #[clap(long)]
        no_sign_commit: bool,

        /// Grant the certificate the sign-tag capability.
        ///
        /// This capability allows the certificate to sign tags.  That
        /// is, when authenticating a tag, a tag is considered
        /// authenticated if it is signed by a certificate with this
        /// capability.
        #[arg(long, overrides_with = "no_sign_tag",
              default_value_ifs(
                  [("release_manager", "true", Some("true")),
                   ("project_maintainer", "true", Some("true"))]))
        ]
        sign_tag: bool,
        /// Rescind the sign-tag capability from a certificate.
        ///
        /// Removes the sign-tag capability for the certificate.
        /// Note: this operation is not retroactive; tags signed with
        /// the certificate prior to the policy change are still
        /// considered authenticated.
        #[clap(long)]
        no_sign_tag: bool,

        /// Grant the certificate the sign-archive capability.
        ///
        /// This capability allows the certificate to sign tarballs or
        /// other archives.  That is, when authenticating an archive,
        /// an archive is considered authenticated if it is signed by
        /// a certificate with this capability.
        #[arg(long, overrides_with = "no_sign_archive",
              default_value_ifs(
                  [("release_manager", "true", Some("true")),
                   ("project_maintainer", "true", Some("true"))]))
        ]
        sign_archive: bool,
        /// Rescind the sign-archive capability from a certificate.
        ///
        /// Removes the sign-archive capability for the certificate.
        /// Note: this operation is not retroactive; archives signed
        /// with the certificate prior to the policy change are still
        /// considered authenticated.
        #[clap(long)]
        no_sign_archive: bool,

        /// Grant the certificate the add-user capability.
        ///
        /// This capability allows the certificate add users to the
        /// policy file, and to grant them capabilities.  A
        /// certificate that has this capability is only allowed to
        /// grant capabilities that it has.  That is, if Alice has the
        /// `sign-commit` and `add-user` capability, she can grant Bob
        /// either of those capabilities, but she is can't grant him
        /// the `sign-tag` capability, because she does not have that
        /// capability.
        #[arg(long, overrides_with = "no_add_user",
              default_value_ifs(
                  [("project_maintainer", "true", Some("true"))]))
        ]
        add_user: bool,
        /// Rescind the add-user capability from a certificate.
        ///
        /// Removes the add-user capability for the certificate.
        /// Note: this operation is not retroactive; operations that
        /// rely on this grant prior to the policy change are still
        /// considered authenticated.
        ///
        /// Rescinding the add-user capability from a certificate does
        /// not rescind any grants that that certificate made.  That
        /// is, if Alice grants Bob the can-sign and add-user
        /// capability, Bob grants Carol the can-sign capability, and
        /// then Alice rescinds Bob's can-sign and add-user
        /// capabilities, Carol still has the can-sign capability.  In
        /// this way, a grant is a copy of a capability.
        #[clap(long)]
        no_add_user: bool,

        /// Grants the certificate the retire-user capability.
        ///
        /// This capability allows the certificate to rescind
        /// arbitrary capabilities.  That is, if Alice has the
        /// retire-user capability, she can rescind Bob's can-sign
        /// capability even if she didn't grant him that capability.
        #[arg(long, overrides_with = "no_retire_user",
              default_value_ifs(
                  [("project_maintainer", "true", Some("true"))]))
        ]
        retire_user: bool,
        /// Rescind the retire-user capability from a certificate.
        ///
        /// Removes the retire-user capability from a certificate.
        /// The specified certificate cannot no longer rescind
        /// capabilities even those that they granted.
        #[clap(long)]
        no_retire_user: bool,

        /// Grants the certificate the audit capability.
        ///
        /// This capability allows the certificate to audit commits.
        /// If Alice has the audit capability, Bob has the can-sign
        /// capability, and then Bob revokes his key, because it was
        /// compromised, then all commits that Bob signed are
        /// considered invalid.  Alice can recover from this situation
        /// by auditing Bob's commit.  After auditing each commit, she
        /// marks it as good using `sq-git policy goodlist`.
        #[arg(long, overrides_with = "no_audit",
              default_value_ifs(
                  [("project_maintainer", "true", Some("true"))]))
        ]
        audit: bool,
        /// Rescind the audit capability from a certificate.
        ///
        /// Removes the audit capability from a certificate.  The
        /// specified certificate cannot no longer mark arbitrary
        /// commits as good.
        #[clap(long)]
        no_audit: bool,

        /// Grants all capabilities relevant to a project maintainer.
        ///
        /// A project maintainer is a person who is responsible for
        /// maintaining the project.  This options grants the
        /// certificate all capabilities.
        #[arg(long)]
        project_maintainer: bool,

        /// Grants all capabilities relevant to a release manager.
        ///
        /// A release manager is authorized to commit changes, and
        /// make releases.  This options grants the certificate the
        /// `sign-tag`, `sign-archive`, and `sign-commit`
        /// capabilities.
        #[arg(long)]
        release_manager: bool,

        /// Grants all capabilities relevant to a committer.
        ///
        /// A committer is authorized to commit changes to the code.
        /// This options grants the certificate the `sign-commit`
        /// capability.
        #[arg(long)]
        committer: bool,
    },

    /// Exports the certificates associated with an entity.
    #[clap(group(ArgGroup::new("some-entity").args(&["name", "all"]).required(true)))]
    Export {
        #[command(flatten)]
        policy_file: PolicyFileArg,

        /// Use the policy in the specified commit.
        #[arg(long, value_name = "COMMIT", conflicts_with="path")]
        commit: Option<String>,

        /// The name of the entity whose certificates should be
        /// exported.
        #[arg(long, value_name = "NAME")]
        name: Option<String>,

        /// Exports all of the certificates.
        #[arg(long)]
        all: bool,
    },

    /// Updates the OpenPGP certificates in the policy.
    ///
    /// `sq-git` looks for updates to the certificates listed in the
    /// policy file in the user's certificate store, and on
    /// the main public keyservers.
    ///
    /// Examples:
    ///
    /// # Look for certificates updates.
    ///
    /// $ sq-git policy sync
    Sync {
        #[command(flatten)]
        policy_file: PolicyFileArg,

        /// Looks for updates on the specified keyservers.
        ///
        /// In addition to looking in the local certificate store,
        /// also looks for updates in the specified keyserver.
        #[clap(
            long, short='s',
            default_values_t = [
                "hkps://keys.openpgp.org".to_string(),
                "hkps://mail-api.proton.me".to_string(),
                "hkps://keys.mailvelope.com".to_string(),
                "hkps://keyserver.ubuntu.com".to_string(),
                "hkps://sks.pod01.fleetstreetops.com".to_string(),
            ],
        )]
        keyserver: Vec<String>,

        /// Don't look for updates on any keyservers.
        ///
        /// Updates are still looked for in the user's certificate
        /// store.
        #[arg(long)]
        disable_keyservers: bool,
    },

    /// Adds the given commit to the commit goodlist.
    ///
    /// This requires the audit capability to not break an
    /// authentication chain.
    Goodlist {
        #[command(flatten)]
        policy_file: PolicyFileArg,

        commit: String,
    },
}

#[derive(clap::Subcommand)]
pub enum Subcommand {
    Init(InitSubcommand),

    Policy {
        #[command(subcommand)]
        command: PolicySubcommand,
    },

    /// Lists and verifies commits.
    ///
    /// Lists and verifies that the commits from the given trust root
    /// to the target commit adhere to the policy.
    ///
    /// A version is considered authenticated if there is a path from
    /// the trust root to the target commit on which each commit can
    /// be authenticated by its parent.
    ///
    /// If the key used to sign a commit is hard revoked, then the
    /// commit is considered bad.  `sq-git` looks for hard revocations
    /// in all of the commits that it examines.  Thus, if a project
    /// maintainer adds a hard revocation to a commit's policy file,
    /// it will cause later *and* earlier commits signed with that key
    /// to be considered invalid.  This is useful when a key has been
    /// compromised.
    ///
    /// When a key has been hard revoked, downstream users either need
    /// to start using a more recent trust root, or the upstream
    /// project maintainers need to audit the relevant commits.  If
    /// the commits are considered benign, they can be added to a
    /// goodlist using `sq-git policy goodlist`.  When a commit is
    /// considered authenticated, but the certificate has been hard
    /// revoked, `sq-git` looks to see whether the commit has been
    /// goodlisted by a commit that is on an authenticated path from
    /// the commit in question to the target.  If so, the commit is
    /// considered to be authenticated.
    Log {
        #[command(flatten)]
        policy_file: PolicyFileArg,

        /// Specifies the trust root.
        ///
        /// If no policy is specified, then the value of the git
        /// repository's `sequoia.trustRoot` configuration key is
        /// used as the trust root.
        #[arg(long, value_name = "COMMIT")]
        trust_root: Option<String>,

        /// Continues to check commits even when it is clear that the
        /// target commit cannot be authenticated.
        ///
        /// Causes `sq-git log` to continue to check commits rather
        /// than stopping as soon as it is clear that the version
        /// can't be authenticated.
        #[arg(long)]
        keep_going: bool,

        /// After authenticating the current version, prunes the
        /// certificates.
        ///
        /// After authenticating the current version, prunes unused
        /// components of the certificates.  In particular, subkeys
        /// that were not used to verify a signature, and user IDs
        /// that were never considered primary are removed.
        ///
        /// This does not remove unused certificates from the policy
        /// file; this just minimizes them.
        ///
        /// This requires the `retire-user` capability.
        #[arg(long)]
        prune_certs: bool,

        /// The commits to check.
        ///
        /// If not specified, HEAD is authenticated with respect to
        /// the trust root.
        ///
        /// If a single commit ID is specified, the specified commit
        /// is authenticated with respect to the trust root.
        ///
        /// If a commit range like `3895a3a..3b388ae` is specified,
        /// the end of the range is authenticated with respect to the
        /// trust root, and there must be an authenticated path from
        /// the trust root via the start of the range to the end of
        /// the range.
        commit_range: Option<String>,
    },

    /// Verifies signatures on archives like release tarballs.
    Verify {
        #[command(flatten)]
        policy_file: PolicyFileArg,

        /// Read the policy from this commit.
        ///
        /// Falls back to using the value of the git repository's
        /// `sequoia.trustRoot` configuration key.  Can be overridden
        /// using `--policy-file`.
        #[arg(long, value_name = "COMMIT")]
        trust_root: Option<String>,

        /// The signature to verify.
        #[arg(long, value_name = "FILENAME")]
        signature: PathBuf,

        /// The archive that the signature protects.
        #[arg(long, value_name = "FILENAME")]
        archive: PathBuf,
    },

    /// A `git update hook` that enforces the policy.
    ///
    /// Insert the following line into `hooks/update` on the shared
    /// git server to make it enforce the policy embedded in the
    /// repository starting at the trust root `COMMIT`.
    ///
    ///     sq-git update-hook --trust-root=<COMMIT> "$@"
    ///
    /// When a branch is pushed that is not previously known to the
    /// server, `sq-git update-hook` checks that all commits starting
    /// from the trust root to the pushed commit adhere to the policy.
    ///
    /// When a branch is pushed that is previously known to the
    /// server, i.e. the branch is updated, `sq-git update-hook`
    /// checks that all new commits starting from the commit
    /// previously known to the server to the pushed commit adhere to
    /// the policy.  If there is no path from the previously known
    /// commit to the new one, the branch has been rebased.  Then, we
    /// fall back to searching for a path from the trust root.
    UpdateHook {
        #[command(flatten)]
        policy_file: PolicyFileArg,

        /// The commit to use as a trust root.
        #[arg(long, value_name = "COMMIT", required = true)]
        trust_root: String,

        /// The name of the ref being updated
        ///
        /// Supplied as the first argument to the update hook, see
        /// `githooks(5)`.
        ref_name: String,

        /// The old object name stored in the ref
        ///
        /// Supplied as the second argument to the update hook, see
        /// `githooks(5)`.
        old_object: String,

        /// The new object name stored in the ref
        ///
        /// Supplied as third argument to the update hook, see
        /// `githooks(5)`.
        new_object: String,
    }
}

#[derive(clap::Args, Debug)]
#[clap(group(ArgGroup::new("cert")
             .args(&["value", "cert_handle", "cert_file"])
             .required(true)))]
pub struct CertArg {
    /// The filename, fingerprint or Key ID of the certificate to
    /// authenticate
    ///
    /// This is first interpreted as a filename.  If that file does
    /// not exist, then it is interpreted as a fingerprint or Key ID,
    /// and read from the certificate store.  To avoid ambiguity, use
    /// `--cert` or `--cert-file` instead.
    ///
    /// See the top-level option `--home` for more information about
    /// the certificate store.
    #[arg(value_name="FILE|FINGERPRINT|KEYID")]
    pub value: Option<String>,

    /// The fingerprint or Key ID of the certificate to use
    ///
    /// This is read from the user's default certificate
    /// directory.
    ///
    /// See the top-level option `--home` for more information about
    /// the certificate store.
    #[arg(long="cert", value_name="FINGERPRINT|KEYID")]
    pub cert_handle: Option<KeyHandle>,

    /// The file containing the certificate to authorize.
    ///
    /// The file must contain exactly one certificate.
    #[arg(long, value_name="FILE")]
    pub cert_file: Option<PathBuf>,
}

#[derive(clap::Args, Debug)]
pub struct PolicyFileArg {
    /// Use an alternate policy.
    ///
    /// The default policy is the `openpgp-policy.toml` file in the
    /// root of the repository's working tree.
    #[arg(long="policy-file",
          value_name = "POLICY",
    )]
    pub path: Option<PathBuf>,
}
