use std::{
    fmt::Display,
    io::Write,
    path::Path,
    process::{Command, Stdio},
};

use clap::ValueEnum;
use git2::{
    build::CheckoutBuilder, BranchType, Direction, FetchOptions, Oid, PushOptions, RemoteCallbacks,
    Signature, Status, StatusOptions,
};
use git2_credentials::CredentialHandler;
use log::log_enabled;
// use octocrate::repos::list_tags::Query;
use owo_colors::{OwoColorize, Style};
use tracing::instrument;

use crate::{
    client::graphql::{GraphQLGetOpenPRs, GraphQLGetTag, GraphQLLabelPR},
    Client, Error,
};

const GIT_USER_SIGNATURE: &str = "user.signingkey";
const DEFAULT_COMMIT_MESSAGE: &str = "chore: commit staged files";
const DEFAULT_REBASE_LOGINS: &str = "renovate,mend,app/renovate";

#[derive(ValueEnum, Debug, Default, Clone, Copy, PartialEq)]
pub enum Sign {
    #[default]
    Gpg,
    None,
}

/// Represents commit signing configuration with optional signoff
#[derive(Debug, Clone, Copy, PartialEq)]
pub struct SignConfig {
    pub sign: Sign,
    pub signoff: bool,
}

impl Default for SignConfig {
    fn default() -> Self {
        SignConfig {
            sign: Sign::Gpg,
            signoff: true,
        }
    }
}

impl SignConfig {
    /// Create a new SignConfig with the given sign type and signoff enabled
    pub fn new(sign: Sign) -> Self {
        SignConfig {
            sign,
            signoff: true,
        }
    }

    /// Create a new SignConfig with signoff setting
    pub fn with_signoff(sign: Sign, signoff: bool) -> Self {
        SignConfig { sign, signoff }
    }

    /// Check if signoff is enabled
    pub fn is_signoff_enabled(&self) -> bool {
        self.signoff
    }

    /// Set signoff enabled or disabled
    pub fn set_signoff(mut self, enabled: bool) -> Self {
        self.signoff = enabled;
        self
    }
}

/// Generate a signoff line from a git signature
/// Returns a string in the format: "Signed-off-by: Name \<email\>"
fn generate_signoff(sig: &Signature) -> String {
    format!(
        "Signed-off-by: {} <{}>",
        sig.name().unwrap_or(""),
        sig.email().unwrap_or("")
    )
}

/// Append signoff to commit message if enabled
fn append_signoff_to_message(message: &str, sig: &Signature, sign_config: &SignConfig) -> String {
    if !sign_config.is_signoff_enabled() {
        return message.to_string();
    }

    let signoff = generate_signoff(sig);

    // Check if message already contains this exact signoff
    if message.contains(&signoff) {
        return message.to_string();
    }

    // Append signoff with a blank line before it
    if message.is_empty() {
        signoff
    } else {
        format!("{message}\n\n{signoff}")
    }
}

#[derive(Debug, Default)]
pub struct BranchReport {
    pub ahead: usize,
    pub behind: usize,
}

impl Display for BranchReport {
    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
        match self {
            BranchReport {
                ahead: 0,
                behind: 0,
            } => write!(f, "Your branch is up to date."),
            _ => write!(
                f,
                "Your branch is {} commits ahead and {} commits behind",
                self.ahead, self.behind
            ),
        }
    }
}

pub trait GitOps {
    fn fetch_origin(&self) -> Result<(), Error>;
    fn fetch_branch(&self, branch: &str) -> Result<(), Error>;
    fn checkout_branch(&self, branch: &str) -> Result<(), Error>;
    fn branch_status(&self) -> Result<BranchReport, Error>;
    fn branch_list(&self) -> Result<String, Error>;
    fn repo_status(&self) -> Result<String, Error>;
    fn repo_files_not_staged(&self) -> Result<Vec<(String, Status)>, Error>;
    fn repo_files_staged(&self) -> Result<Vec<(String, Status)>, Error>;
    fn stage_files(&self, files: Vec<(String, Status)>) -> Result<(), Error>;
    #[allow(async_fn_in_trait)]
    async fn commit_changed_files(
        &self,
        sign_config: SignConfig,
        commit_message: &str,
        prefix: &str,
        tag_opt: Option<&str>,
    ) -> Result<(), Error>;
    fn commit_staged(
        &self,
        sign_config: SignConfig,
        commit_message: &str,
        prefix: &str,
        tag: Option<&str>,
    ) -> Result<(), Error>;
    fn push_commit(
        &self,
        prefix: &str,
        version: Option<&str>,
        no_push: bool,
        bot_user_name: &str,
    ) -> Result<(), Error>;
    #[allow(async_fn_in_trait)]
    async fn label_next_pr(
        &self,
        authors: &[String],
        label: Option<&str>,
        desc: Option<&str>,
        colour: Option<&str>,
    ) -> Result<Option<String>, Error>;
    fn create_tag(&self, tag: &str, commit_id: Oid, sig: &Signature) -> Result<(), Error>;
    fn create_signed_tag(&self, tag: &str) -> Result<(), Error>;
    #[allow(async_fn_in_trait)]
    async fn tag_exists(&self, tag: &str) -> bool;
    #[allow(async_fn_in_trait)]
    async fn get_commitish_for_tag(&self, version: &str) -> Result<String, Error>;
}

impl GitOps for Client {
    fn create_tag(&self, tag: &str, commit_id: Oid, sig: &Signature) -> Result<(), Error> {
        let object = self.git_repo.find_object(commit_id, None)?;
        self.git_repo.tag(tag, &object, sig, tag, true)?;

        let mut revwalk = self.git_repo.revwalk()?;
        let reference = format!("refs/tags/{tag}");
        revwalk.push_ref(&reference)?;
        Ok(())
    }

    fn create_signed_tag(&self, tag: &str) -> Result<(), Error> {
        let workdir = self
            .git_repo
            .workdir()
            .ok_or_else(|| Error::GitError("repository has no working directory".into()))?;

        log::trace!("Creating GPG-signed tag {tag} in {}", workdir.display());

        let output = Command::new("git")
            .args(["tag", "-s", tag, "-m", tag])
            .current_dir(workdir)
            .output()?;

        if !output.status.success() {
            let stderr = String::from_utf8_lossy(&output.stderr);
            return Err(Error::GpgError(format!(
                "failed to create signed tag '{tag}': {stderr}"
            )));
        }

        log::info!("Created GPG-signed tag {tag}");
        Ok(())
    }

    // fn tag_exists(&self, tag: &str) -> bool {
    //     let names = self.git_repo.tag_names(Some(tag));

    //     if names.is_err() {
    //         return false;
    //     };

    //     let names = names.unwrap();

    //     if names.is_empty() {
    //         return false;
    //     }

    //     true
    // }

    async fn tag_exists(&self, tag: &str) -> bool {
        self.get_tag(tag).await.is_ok()
    }

    async fn get_commitish_for_tag(&self, tag: &str) -> Result<String, Error> {
        log::trace!("Get commitish for tag: {tag}");
        log::trace!(
            "Get tags for owner {:?} and repo: {:?}",
            self.owner(),
            self.repo()
        );

        let tags = self.get_tag(tag).await?;

        if let Some(commit_id) = tags.commit_sha() {
            log::trace!("Commit id: {commit_id}");
            Ok(commit_id)
        } else {
            Err(Error::TagNotFound(tag.to_string()))
        }

        // let mut page_number = 1;
        // let mut more_pages = true;
        // while more_pages {
        //     let query = Query {
        //         per_page: Some(50),
        //         page: Some(page_number),
        //     };

        //     let page = self
        //         .github_rest
        //         .repos
        //         .list_tags(self.owner(), self.repo())
        //         .query(&query)
        //         .send_with_response()
        //         .await?;

        //     for t in page.data {
        //         log::trace!("Tag: {}", t.name);
        //         if t.name == tag {
        //             return Ok(t.commit.sha);
        //         }
        //     }

        //     if let Some(link) = page.headers.get("link") {
        //         if let Ok(link) = link.to_str() {
        //             if !link.contains("rel=\"next\"") {
        //                 more_pages = false
        //             };
        //         }
        //     } else {
        //         more_pages = false;
        //     }

        //     page_number += 1;
        // }

        // Err(Error::TagNotFound(tag.to_string()))
    }

    /// Report the status of the git repo in a human readable format
    fn repo_status(&self) -> Result<String, Error> {
        let statuses = self.git_repo.statuses(None)?;

        log::trace!("Repo status length: {:?}", statuses.len());

        Ok(print_long(&statuses))
    }

    /// Report a list of the files that have not been staged
    fn repo_files_not_staged(&self) -> Result<Vec<(String, Status)>, Error> {
        let mut options = StatusOptions::new();
        options
            .show(git2::StatusShow::Workdir)
            .include_untracked(true)
            .recurse_untracked_dirs(true);
        log::trace!("Options: Show Workdir, include untracked, recurse ignored dirs");

        let statuses = self.git_repo.statuses(Some(&mut options))?;

        log::trace!("Repo status length: {:?}", statuses.len());
        if log::log_enabled!(log::Level::Trace) {
            for status in statuses.iter() {
                for status in status.status() {
                    log::trace!("Status: {status:?}");
                }
            }
        }

        let files: Vec<(String, Status)> = statuses
            .iter()
            .map(|s| (s.path().unwrap_or_default().to_string(), s.status()))
            .collect();

        log::trace!("Files: {files:#?}");

        Ok(files)
    }

    /// Report a list of the files that have not been staged
    fn repo_files_staged(&self) -> Result<Vec<(String, Status)>, Error> {
        let mut options = StatusOptions::new();
        options
            .show(git2::StatusShow::Index)
            .include_untracked(true)
            .recurse_untracked_dirs(true);
        log::trace!("Options: Show Index, include untracked, recurse ignored dirs");

        let statuses = self.git_repo.statuses(Some(&mut options))?;

        log::trace!("Repo status length: {:?}", statuses.len());

        let files: Vec<(String, Status)> = statuses
            .iter()
            .map(|s| (s.path().unwrap_or_default().to_string(), s.status()))
            .collect();

        Ok(files)
    }

    fn stage_files(&self, files: Vec<(String, Status)>) -> Result<(), Error> {
        let mut index = self.git_repo.index()?;

        for file in files {
            match file.1 {
                Status::INDEX_NEW
                | Status::INDEX_MODIFIED
                | Status::WT_NEW
                | Status::WT_MODIFIED => {
                    index.add_path(Path::new(&file.0))?;
                }
                Status::INDEX_DELETED | Status::WT_DELETED => {
                    index.remove_path(Path::new(&file.0))?;
                }
                _ => {}
            }
        }

        index.write()?;

        Ok(())
    }

    async fn commit_changed_files(
        &self,
        sign_config: SignConfig,
        commit_message: &str,
        prefix: &str,
        tag_opt: Option<&str>,
    ) -> Result<(), Error> {
        let hdr_style = Style::new().bold().underline();
        log::debug!("{}", "Check WorkDir".style(hdr_style));

        let files_in_workdir = self.repo_files_not_staged()?;

        log::debug!("WorkDir files:\n\t{files_in_workdir:?}");
        log::debug!("Staged files:\n\t{:?}", self.repo_files_staged()?);
        log::debug!("Branch status: {}", self.branch_status()?);

        log::info!("Stage the changes for commit");

        if !files_in_workdir.is_empty() {
            self.stage_files(files_in_workdir)?;
        }

        log::debug!("{}", "Check Staged".style(hdr_style));
        log::debug!("WorkDir files:\n\t{:?}", self.repo_files_not_staged()?);

        let files_staged_for_commit = self.repo_files_staged()?;

        log::debug!("Staged files:\n\t{files_staged_for_commit:?}");
        log::debug!("Branch status: {}", self.branch_status()?);

        if !files_staged_for_commit.is_empty() {
            log::info!("Commit the staged changes with commit message: {commit_message}");
            self.commit_staged(sign_config, commit_message, prefix, tag_opt)?;
            log::debug!("{}", "Check Committed".style(hdr_style));
            log::debug!("WorkDir files:\n\t{:?}", self.repo_files_not_staged()?);
            let files_staged_for_commit = self.repo_files_staged()?;
            log::debug!("Staged files:\n\t{files_staged_for_commit:?}");
            log::info!("Branch status: {}", self.branch_status()?);
        } else {
            log::info!("No files to commit")
        }

        Ok(())
    }

    fn commit_staged(
        &self,
        sign_config: SignConfig,
        commit_message: &str,
        prefix: &str,
        tag: Option<&str>,
    ) -> Result<(), Error> {
        log::trace!("Commit staged with sign {:?}", sign_config.sign);

        log::trace!("Checking commit message: `{commit_message}`");
        let commit_message = if !commit_message.is_empty() {
            log::trace!("Using commit message passed to the function");
            commit_message
        } else if !self.commit_message.is_empty() {
            log::trace!("Using commit message set in the calling struct");
            &self.commit_message.clone()
        } else {
            log::trace!("Using default commit message");
            DEFAULT_COMMIT_MESSAGE
        };

        log::debug!("Commit message: {commit_message}");

        let mut index = self.git_repo.index()?;
        let tree_id = index.write_tree()?;
        let head = self.git_repo.head()?;
        let parent = self.git_repo.find_commit(head.target().unwrap())?;
        let sig = self.git_repo.signature()?;

        // Append signoff to commit message if enabled
        let commit_message_with_signoff =
            append_signoff_to_message(commit_message, &sig, &sign_config);
        log::trace!("Commit message with signoff: {commit_message_with_signoff}");

        let commit_id = match sign_config.sign {
            Sign::None => self.git_repo.commit(
                Some("HEAD"),
                &sig,
                &sig,
                &commit_message_with_signoff,
                &self.git_repo.find_tree(tree_id)?,
                &[&parent],
            )?,
            Sign::Gpg => {
                let commit_buffer = self.git_repo.commit_create_buffer(
                    &sig,
                    &sig,
                    &commit_message_with_signoff,
                    &self.git_repo.find_tree(tree_id)?,
                    &[&parent],
                )?;
                let commit_str = std::str::from_utf8(&commit_buffer).unwrap();

                let signature = self.git_repo.config()?.get_string(GIT_USER_SIGNATURE)?;

                let short_sign = signature[12..].to_string();
                log::trace!("Signature short: {short_sign}");

                let gpg_args = vec!["--status-fd", "2", "-bsau", signature.as_str()];
                log::trace!("gpg args: {gpg_args:?}");

                let mut cmd = Command::new("gpg");
                cmd.args(gpg_args)
                    .stdin(Stdio::piped())
                    .stdout(Stdio::piped())
                    .stderr(Stdio::piped());

                let mut child = cmd.spawn()?;

                let mut stdin = child.stdin.take().ok_or(Error::Stdin)?;
                log::trace!("Secured access to stdin");

                log::trace!("Input for signing:\n-----\n{commit_str}\n-----");

                stdin.write_all(commit_str.as_bytes())?;
                log::trace!("writing complete");
                drop(stdin); // close stdin to not block indefinitely
                log::trace!("stdin closed");

                let output = child.wait_with_output()?;
                log::trace!("secured output");

                if !output.status.success() {
                    let stderr = String::from_utf8_lossy(&output.stderr);
                    log::trace!("stderr: {stderr}");
                    return Err(Error::Stdout(stderr.to_string()));
                }

                let stderr = std::str::from_utf8(&output.stderr)?;

                if !stderr.contains("\n[GNUPG:] SIG_CREATED ") {
                    return Err(Error::GpgError(
                        "failed to sign data, program gpg failed, SIG_CREATED not seen in stderr"
                            .to_string(),
                    ));
                }
                log::trace!("Error checking completed without error");

                let commit_signature = std::str::from_utf8(&output.stdout)?;

                log::trace!("secured signed commit:\n{commit_signature}");

                let commit_id =
                    self.git_repo
                        .commit_signed(commit_str, commit_signature, Some("gpgsig"))?;

                // manually advance to the new commit id
                self.git_repo
                    .head()?
                    .set_target(commit_id, commit_message)?;

                log::trace!("head updated");

                commit_id
            }
        };

        if let Some(version_tag) = tag {
            let version_tag = format!("{prefix}{version_tag}");
            if requires_signed_tag(&sign_config.sign) {
                self.create_signed_tag(&version_tag)?;
            } else {
                self.create_tag(&version_tag, commit_id, &sig)?;
            }
        }

        Ok(())
    }

    fn push_commit(
        &self,
        prefix: &str,
        version: Option<&str>,
        no_push: bool,
        _bot_user_name: &str,
    ) -> Result<(), Error> {
        log::trace!("version: {version:?} and no_push: {no_push}");
        let mut remote = self.git_repo.find_remote("origin")?;
        let remote_url = remote.url().unwrap_or("<unknown>").to_string();

        // When we have a GitHub App token and the remote is SSH, re-write to
        // HTTPS so that git2 offers USER_PASS_PLAINTEXT in the credential
        // callback.  SSH remotes only offer SSH_KEY/SSH_MEMORY/SSH_CUSTOM and
        // the token path is never reached.
        let token_connect = self.github_token.clone();
        let https_url = ssh_to_https_url(&remote_url);
        if !token_connect.is_empty() && https_url != remote_url {
            log::info!("Re-writing SSH remote to HTTPS for App token auth: {https_url}");
            self.git_repo.remote_set_url("origin", &https_url)?;
            remote = self.git_repo.find_remote("origin")?;
        }
        let remote_url = remote.url().unwrap_or("<unknown>").to_string();
        log::info!("Push target: {remote_url}");
        let mut callbacks = RemoteCallbacks::new();
        callbacks.credentials(move |url, username, allowed| {
            log::info!(
                "Push auth: url={url}, username={}, credential_types={allowed:?}",
                username.unwrap_or("<none>")
            );
            make_credential(&token_connect, url, username, allowed)
        });
        let mut connection = remote.connect_auth(Direction::Push, Some(callbacks), None)?;
        let remote = connection.remote();

        let local_branch = self
            .git_repo
            .find_branch(self.branch_or_main(), BranchType::Local)?;
        log::trace!("Found local branch: {}", local_branch.name()?.unwrap());

        if log_enabled!(log::Level::Trace) {
            list_tags();
        };

        let branch_ref = local_branch.into_reference();
        let mut push_refs = Vec::new();
        let commit_ref = branch_ref
            .name()
            .map(|br| format!("{br}:{br}"))
            .unwrap_or_default();

        if !commit_ref.is_empty() {
            push_refs.push(&commit_ref)
        }

        #[allow(unused_assignments)]
        let mut tag_ref = String::from("");

        if let Some(version_tag) = version {
            log::trace!("Found version tag: {prefix}{version_tag}");
            tag_ref = format!("refs/tags/{prefix}{version_tag}:refs/tags/{prefix}{version_tag}");
            log::trace!("Tag ref: {tag_ref}");
            push_refs.push(&tag_ref);
        };

        log::trace!("Push refs: {push_refs:?}");
        let token_push = self.github_token.clone();
        let mut call_backs = RemoteCallbacks::new();
        call_backs.credentials(move |url, username, allowed| {
            log::info!(
                "Push auth: url={url}, username={}, credential_types={allowed:?}",
                username.unwrap_or("<none>")
            );
            make_credential(&token_push, url, username, allowed)
        });
        call_backs.push_transfer_progress(progress_bar);
        let mut push_opts = PushOptions::new();
        push_opts.remote_callbacks(call_backs);

        if !no_push {
            remote.push(&push_refs, Some(&mut push_opts))?;
        }

        Ok(())
    }

    /// Rebase the next pr of dependency updates if any
    #[instrument(skip(self))]
    async fn label_next_pr(
        &self,
        authors: &[String],
        label: Option<&str>,
        desc: Option<&str>,
        colour: Option<&str>,
    ) -> Result<Option<String>, Error> {
        log::debug!("Rebase next PR");

        let prs = self.get_open_pull_requests().await?;

        if prs.is_empty() {
            return Ok(None);
        };

        log::trace!("Found {prs:?} open PRs");

        let qualified_authors: Vec<String> = if authors.is_empty() {
            DEFAULT_REBASE_LOGINS
                .split(',')
                .map(|i| i.to_string())
                .collect()
        } else {
            authors.to_vec()
        };

        let mut prs: Vec<_> = prs
            .iter()
            .filter(|pr| qualified_authors.contains(&pr.login))
            .collect();

        if prs.is_empty() {
            log::trace!("Found no open PRs for {qualified_authors:?}");
            return Ok(None);
        };

        log::trace!("Found {prs:?} open PRs for {qualified_authors:?}");

        prs.sort_by(|a, b| a.number.cmp(&b.number));
        let next_pr = &prs[0];

        log::trace!("Next PR: {}", next_pr.number);

        self.add_label_to_pr(next_pr.number, label, desc, colour)
            .await?;

        Ok(Some(next_pr.number.to_string()))
    }

    fn branch_list(&self) -> Result<String, Error> {
        let branches = self.git_repo.branches(None)?;

        let mut output = String::from("\nList of branches:\n");
        for item in branches {
            let (branch, branch_type) = item?;
            output = format!(
                "{}\n# Branch and type: {:?}\t{:?}",
                output,
                branch.name(),
                branch_type
            );
        }
        output = format!("{output}\n");

        Ok(output)
    }

    fn fetch_origin(&self) -> Result<(), Error> {
        let mut remote = self.git_repo.find_remote("origin")?;
        let mut callbacks = RemoteCallbacks::new();
        let git_config = git2::Config::open_default().unwrap();
        let mut ch = CredentialHandler::new(git_config);
        callbacks.credentials(move |url, username, allowed| {
            ch.try_next_credential(url, username, allowed)
        });
        let mut fetch_opts = FetchOptions::new();
        fetch_opts.remote_callbacks(callbacks);
        remote.fetch(&[] as &[&str], Some(&mut fetch_opts), None)?;
        Ok(())
    }

    fn fetch_branch(&self, branch: &str) -> Result<(), Error> {
        let mut remote = self.git_repo.find_remote("origin")?;
        let git_config = git2::Config::open_default().unwrap();
        let mut ch = CredentialHandler::new(git_config);
        let mut callbacks = RemoteCallbacks::new();
        callbacks.credentials(move |url, username, allowed| {
            ch.try_next_credential(url, username, allowed)
        });

        let mut fetch_opts = FetchOptions::new();
        fetch_opts.remote_callbacks(callbacks);

        let refspec = format!("refs/heads/{branch}:refs/remotes/origin/{branch}");
        remote.fetch(&[&refspec], Some(&mut fetch_opts), None)?;

        log::debug!("Fetched branch: {branch}");
        Ok(())
    }

    fn checkout_branch(&self, branch: &str) -> Result<(), Error> {
        let origin_ref = format!("origin/{branch}");
        let remote_branch = self.git_repo.find_branch(&origin_ref, BranchType::Remote)?;
        let commit = remote_branch.get().peel_to_commit()?;

        // Create or fast-forward local branch to match the remote
        match self.git_repo.find_branch(branch, BranchType::Local) {
            Ok(local_branch) => {
                let mut reference = local_branch.into_reference();
                reference.set_target(commit.id(), "checkout: fast-forward")?;
            }
            Err(_) => {
                self.git_repo.branch(branch, &commit, false)?;
            }
        }

        self.git_repo.set_head(&format!("refs/heads/{branch}"))?;
        self.git_repo
            .checkout_head(Some(CheckoutBuilder::default().force()))?;

        log::debug!("Checked out branch: {branch}");
        Ok(())
    }

    fn branch_status(&self) -> Result<BranchReport, Error> {
        let branch_remote = self.git_repo.find_branch(
            format!("origin/{}", self.branch_or_main()).as_str(),
            git2::BranchType::Remote,
        )?;

        if branch_remote.get().target() == self.git_repo.head()?.target() {
            return Ok(BranchReport::default());
        }

        let local = self.git_repo.head()?.target().unwrap();
        let remote = branch_remote.get().target().unwrap();

        let (ahead, behind) = self.git_repo.graph_ahead_behind(local, remote)?;

        Ok(BranchReport { ahead, behind })
    }
}

fn make_credential(
    token: &str,
    url: &str,
    username: Option<&str>,
    allowed: git2::CredentialType,
) -> Result<git2::Cred, git2::Error> {
    if allowed.contains(git2::CredentialType::USER_PASS_PLAINTEXT) && !token.is_empty() {
        log::info!("Authenticating with GitHub App/PAT token");
        git2::Cred::userpass_plaintext("x-access-token", token)
    } else {
        let git_config = git2::Config::open_default().unwrap();
        CredentialHandler::new(git_config).try_next_credential(url, username, allowed)
    }
}

fn progress_bar(current: usize, total: usize, bytes: usize) {
    let percent = (current as f32 / total as f32) * 100.0;

    let percent = percent as u8;

    log::trace!("Calculated percent: {percent}");

    match percent {
        10 => log::trace!("{percent}%"),
        25 => log::trace!("{percent}%"),
        40 => log::trace!("{percent}%"),
        55 => log::trace!("{percent}%"),
        80 => log::trace!("{percent}%"),
        95 => log::trace!("{percent}%"),
        100 => log::trace!("{percent}%"),
        _ => {}
    }

    log::trace!(
        "{}:- current: {}, total: {}, bytes: {}",
        "Push progress".blue().underline().bold(),
        current.blue().bold(),
        total.blue().bold(),
        bytes.blue().bold()
    );
}

fn list_tags() {
    let output = Command::new("ls")
        .arg("-R")
        .arg(".git/refs")
        .output()
        .expect("ls of the git refs");
    let stdout = output.stdout;
    log::trace!("ls: {}", String::from_utf8_lossy(&stdout));

    let out_string = String::from_utf8_lossy(&stdout);

    let files = out_string.split_terminator("\n").collect::<Vec<&str>>();
    log::trace!("Files: {files:#?}");
}

// This function print out an output similar to git's status command in long
// form, including the command-line hints.
fn print_long(statuses: &git2::Statuses) -> String {
    let mut header = false;
    let mut rm_in_workdir = false;
    let mut changes_in_index = false;
    let mut changed_in_workdir = false;

    let mut output = String::new();

    // Print index changes
    for entry in statuses
        .iter()
        .filter(|e| e.status() != git2::Status::CURRENT)
    {
        if entry.status().contains(git2::Status::WT_DELETED) {
            rm_in_workdir = true;
        }
        let is_status = match entry.status() {
            s if s.contains(git2::Status::INDEX_NEW) => "new file: ",
            s if s.contains(git2::Status::INDEX_MODIFIED) => "modified: ",
            s if s.contains(git2::Status::INDEX_DELETED) => "deleted: ",
            s if s.contains(git2::Status::INDEX_RENAMED) => "renamed: ",
            s if s.contains(git2::Status::INDEX_TYPECHANGE) => "typechange:",
            _ => continue,
        };
        if !header {
            output = format!(
                "{output}\n\
                # Changes to be committed:
                #   (use \"git reset HEAD <file>...\" to unstage)
                #"
            );
            header = true;
        }

        let old_path = entry.head_to_index().unwrap().old_file().path();
        let new_path = entry.head_to_index().unwrap().new_file().path();
        match (old_path, new_path) {
            (Some(old), Some(new)) if old != new => {
                output = format!(
                    "{}\n#\t{}  {} -> {}",
                    output,
                    is_status,
                    old.display(),
                    new.display()
                );
            }
            (old, new) => {
                output = format!(
                    "{}\n#\t{}  {}",
                    output,
                    is_status,
                    old.or(new).unwrap().display()
                );
            }
        }
    }

    if header {
        changes_in_index = true;
        output = format!("{output}\n");
    }
    header = false;

    // Print workdir changes to tracked files
    for entry in statuses.iter() {
        // With `Status::OPT_INCLUDE_UNMODIFIED` (not used in this example)
        // `index_to_workdir` may not be `None` even if there are no differences,
        // in which case it will be a `Delta::Unmodified`.
        if entry.status() == git2::Status::CURRENT || entry.index_to_workdir().is_none() {
            continue;
        }

        let is_status = match entry.status() {
            s if s.contains(git2::Status::WT_MODIFIED) => "modified: ",
            s if s.contains(git2::Status::WT_DELETED) => "deleted: ",
            s if s.contains(git2::Status::WT_RENAMED) => "renamed: ",
            s if s.contains(git2::Status::WT_TYPECHANGE) => "typechange:",
            _ => continue,
        };

        if !header {
            output = format!(
                "{}\n# Changes not staged for commit:\n#   (use \"git add{} <file>...\" to update what will be committed)\n#   (use \"git checkout -- <file>...\" to discard changes in working directory)\n#               ",
                output,
                if rm_in_workdir { "/rm" } else { "" }
            );
            header = true;
        }

        let old_path = entry.index_to_workdir().unwrap().old_file().path();
        let new_path = entry.index_to_workdir().unwrap().new_file().path();
        match (old_path, new_path) {
            (Some(old), Some(new)) if old != new => {
                output = format!(
                    "{}\n#\t{}  {} -> {}",
                    output,
                    is_status,
                    old.display(),
                    new.display()
                );
            }
            (old, new) => {
                output = format!(
                    "{}\n#\t{}  {}",
                    output,
                    is_status,
                    old.or(new).unwrap().display()
                );
            }
        }
    }

    if header {
        changed_in_workdir = true;
        output = format!("{output}\n#\n");
    }
    header = false;

    // Print untracked files
    for entry in statuses
        .iter()
        .filter(|e| e.status() == git2::Status::WT_NEW)
    {
        if !header {
            output = format!(
                "{output}# Untracked files\n#   (use \"git add <file>...\" to include in what will be committed)\n#"
            );
            header = true;
        }
        let file = entry.index_to_workdir().unwrap().old_file().path().unwrap();
        output = format!("{}\n#\t{}", output, file.display());
    }
    header = false;

    // Print ignored files
    for entry in statuses
        .iter()
        .filter(|e| e.status() == git2::Status::IGNORED)
    {
        if !header {
            output = format!(
                "{output}\n# Ignored files\n#   (use \"git add -f <file>...\" to include in what will be committed)\n#"
            );
            header = true;
        }
        let file = entry.index_to_workdir().unwrap().old_file().path().unwrap();
        output = format!("{}\n#\t{}", output, file.display());
    }

    if !changes_in_index && changed_in_workdir {
        output = format!(
            "{output}\n
            no changes added to commit (use \"git add\" and/or \
            \"git commit -a\")"
        );
    }

    output
}

/// Returns `true` when tags created during a commit should be GPG-signed.
pub(crate) fn requires_signed_tag(sign: &Sign) -> bool {
    matches!(sign, Sign::Gpg)
}

/// Convert a GitHub SSH remote URL to its HTTPS equivalent.
///
/// Handles both SCP-style (`git@github.com:org/repo.git`) and URL-style
/// (`ssh://git@github.com/org/repo.git`) formats.  Non-GitHub URLs and
/// already-HTTPS URLs are returned unchanged so callers can apply this
/// unconditionally.
pub(crate) fn ssh_to_https_url(url: &str) -> String {
    // SCP-style: git@github.com:org/repo.git
    if let Some(rest) = url.strip_prefix("git@github.com:") {
        return format!("https://github.com/{rest}");
    }
    // URL-style: ssh://git@github.com/org/repo.git
    if let Some(rest) = url
        .strip_prefix("ssh://git@github.com/")
        .or_else(|| url.strip_prefix("ssh://github.com/"))
    {
        return format!("https://github.com/{rest}");
    }
    url.to_string()
}

#[cfg(test)]
mod tests {
    use super::*;
    use git2::Signature;
    use rstest::rstest;

    #[test]
    fn test_ssh_to_https_url_scp_format() {
        let url = "git@github.com:digital-prstv/circleci-toolkit.git";
        assert_eq!(
            ssh_to_https_url(url),
            "https://github.com/digital-prstv/circleci-toolkit.git"
        );
    }

    #[test]
    fn test_ssh_to_https_url_ssh_scheme() {
        let url = "ssh://git@github.com/digital-prstv/circleci-toolkit.git";
        assert_eq!(
            ssh_to_https_url(url),
            "https://github.com/digital-prstv/circleci-toolkit.git"
        );
    }

    #[test]
    fn test_ssh_to_https_url_already_https() {
        let url = "https://github.com/digital-prstv/circleci-toolkit.git";
        assert_eq!(ssh_to_https_url(url), url);
    }

    #[test]
    fn test_ssh_to_https_url_non_github() {
        let url = "git@gitlab.com:org/repo.git";
        assert_eq!(ssh_to_https_url(url), url);
    }

    #[test]
    fn test_default_rebase_logins_includes_app_renovate() {
        let logins: Vec<&str> = DEFAULT_REBASE_LOGINS.split(',').collect();
        assert!(
            logins.contains(&"app/renovate"),
            "DEFAULT_REBASE_LOGINS should include 'app/renovate' (GitHub App bot login), got: {logins:?}"
        );
    }

    #[test]
    fn test_default_rebase_logins_preserves_existing_defaults() {
        let logins: Vec<&str> = DEFAULT_REBASE_LOGINS.split(',').collect();
        assert!(logins.contains(&"renovate"), "should include 'renovate'");
        assert!(logins.contains(&"mend"), "should include 'mend'");
    }

    #[test]
    fn test_sign_default() {
        let sign = Sign::default();
        assert_eq!(sign, Sign::Gpg, "Default should be Gpg");
    }

    #[test]
    fn test_sign_config_default() {
        let sign_config = SignConfig::default();
        assert_eq!(sign_config.sign, Sign::Gpg);
        assert!(sign_config.signoff, "Default should have signoff enabled");
    }

    #[test]
    fn test_sign_config_is_signoff_enabled() {
        let config_enabled = SignConfig::with_signoff(Sign::Gpg, true);
        assert!(config_enabled.is_signoff_enabled());

        let config_disabled = SignConfig::with_signoff(Sign::Gpg, false);
        assert!(!config_disabled.is_signoff_enabled());

        let config_none_enabled = SignConfig::with_signoff(Sign::None, true);
        assert!(config_none_enabled.is_signoff_enabled());

        let config_none_disabled = SignConfig::with_signoff(Sign::None, false);
        assert!(!config_none_disabled.is_signoff_enabled());
    }

    #[test]
    fn test_sign_config_set_signoff() {
        let config = SignConfig::new(Sign::Gpg);
        assert!(config.is_signoff_enabled());

        let config_disabled = config.set_signoff(false);
        assert!(!config_disabled.is_signoff_enabled());
        assert_eq!(config_disabled.sign, Sign::Gpg);

        let config_none = SignConfig::new(Sign::None);
        let config_enabled = config_none.set_signoff(true);
        assert!(config_enabled.is_signoff_enabled());
        assert_eq!(config_enabled.sign, Sign::None);
    }

    #[test]
    fn test_generate_signoff() {
        let sig = Signature::now("Test User", "test@example.com").unwrap();
        let signoff = generate_signoff(&sig);
        assert_eq!(signoff, "Signed-off-by: Test User <test@example.com>");
    }

    #[rstest]
    #[case(
        "feat: add new feature",
        true,
        "feat: add new feature\n\nSigned-off-by: Test User <test@example.com>"
    )]
    #[case("feat: add new feature", false, "feat: add new feature")]
    #[case("", true, "Signed-off-by: Test User <test@example.com>")]
    #[case("", false, "")]
    fn test_append_signoff_to_message(
        #[case] message: &str,
        #[case] signoff_enabled: bool,
        #[case] expected: &str,
    ) {
        let sig = Signature::now("Test User", "test@example.com").unwrap();
        let sign_config = SignConfig::with_signoff(Sign::Gpg, signoff_enabled);
        let result = append_signoff_to_message(message, &sig, &sign_config);
        assert_eq!(result, expected);
    }

    #[test]
    fn test_append_signoff_already_contains_signoff() {
        let sig = Signature::now("Test User", "test@example.com").unwrap();
        let sign_config = SignConfig::with_signoff(Sign::Gpg, true);
        let message = "feat: add feature\n\nSigned-off-by: Test User <test@example.com>";
        let result = append_signoff_to_message(message, &sig, &sign_config);
        // Should not duplicate the signoff
        assert_eq!(result, message);
    }

    #[test]
    fn test_append_signoff_with_different_signoff() {
        let sig = Signature::now("Test User", "test@example.com").unwrap();
        let sign_config = SignConfig::with_signoff(Sign::Gpg, true);
        let message = "feat: add feature\n\nSigned-off-by: Other User <other@example.com>";
        let result = append_signoff_to_message(message, &sig, &sign_config);
        // Should append a second signoff since it's different
        assert_eq!(result, "feat: add feature\n\nSigned-off-by: Other User <other@example.com>\n\nSigned-off-by: Test User <test@example.com>");
    }

    #[test]
    fn test_append_signoff_none_variant() {
        let sig = Signature::now("Test User", "test@example.com").unwrap();
        let sign_enabled = SignConfig::with_signoff(Sign::None, true);
        let sign_disabled = SignConfig::with_signoff(Sign::None, false);

        let message = "chore: update files";

        let result_enabled = append_signoff_to_message(message, &sig, &sign_enabled);
        assert_eq!(
            result_enabled,
            "chore: update files\n\nSigned-off-by: Test User <test@example.com>"
        );

        let result_disabled = append_signoff_to_message(message, &sig, &sign_disabled);
        assert_eq!(result_disabled, "chore: update files");
    }

    #[test]
    fn test_requires_signed_tag_gpg() {
        assert!(
            requires_signed_tag(&Sign::Gpg),
            "Sign::Gpg must produce a GPG-signed tag"
        );
    }

    #[test]
    fn test_requires_signed_tag_none() {
        assert!(
            !requires_signed_tag(&Sign::None),
            "Sign::None must not sign the tag"
        );
    }
}
