use std::collections::{BTreeMap, BTreeSet};
use std::error::Error;
use std::fmt;
use std::fs;
use std::path::{Path, PathBuf};
use std::process::Command;

use anyhow::{Context, Result, bail};
use console::style;

#[derive(Clone, Debug)]
pub struct RemoteSpec {
    pub name: String,
    pub url: String,
    pub display: String,
}

#[derive(Clone, Debug, Eq, PartialEq)]
pub struct RemoteRefSnapshot {
    pub hash: String,
    pub refs: usize,
    pub branches: BTreeMap<String, String>,
    pub tags: BTreeMap<String, String>,
}

#[derive(Clone, Debug)]
pub struct BranchDecision {
    pub branch: String,
    pub sha: String,
    pub source_remotes: Vec<String>,
    pub target_remotes: Vec<String>,
}

#[derive(Clone, Debug)]
pub struct BranchConflict {
    pub branch: String,
    pub tips: Vec<(String, String)>,
}

#[derive(Clone, Debug)]
pub struct BranchDeletion {
    pub branch: String,
    pub deleted_remotes: Vec<String>,
    pub target_remotes: Vec<String>,
}

#[derive(Clone, Debug)]
pub struct BranchUpdate {
    pub branch: String,
    pub sha: String,
    pub target_remote: String,
    pub force: bool,
}

#[derive(Clone, Debug)]
pub struct RefBackup {
    pub refname: String,
    pub sha: String,
    pub description: String,
}

#[derive(Clone, Debug)]
pub struct BranchRebaseDecision {
    pub branch: String,
    pub sha: String,
    pub updates: Vec<BranchUpdate>,
}

#[derive(Clone, Debug)]
pub struct TagDecision {
    pub tag: String,
    pub sha: String,
    pub source_remotes: Vec<String>,
    pub target_remotes: Vec<String>,
}

#[derive(Clone, Debug)]
pub struct TagConflict {
    pub tag: String,
    pub tips: Vec<(String, String)>,
}

pub struct GitMirror {
    path: PathBuf,
    redactor: Redactor,
    dry_run: bool,
}

struct CommitterIdentity {
    name: String,
    email: String,
    date: String,
}

#[derive(Clone, Debug)]
pub struct Redactor {
    secrets: Vec<String>,
}

impl GitMirror {
    pub fn open(path: PathBuf, redactor: Redactor, dry_run: bool) -> Result<Self> {
        if !path.exists() {
            if dry_run {
                crate::logln!(
                    "  {} git init --bare {}",
                    style("dry-run").yellow().bold(),
                    style(path.display()).dim()
                );
                return Ok(Self {
                    path,
                    redactor,
                    dry_run,
                });
            }
            fs::create_dir_all(&path)?;
            run_plain("git", ["init", "--bare"], Some(&path), &redactor, dry_run)
                .with_context(|| format!("failed to initialize {}", path.display()))?;
        }
        Ok(Self {
            path,
            redactor,
            dry_run,
        })
    }

    pub fn configure_remotes(&self, remotes: &[RemoteSpec]) -> Result<()> {
        for remote in remotes {
            let existing = self.remote_url(&remote.name)?;
            match existing {
                Some(_) => self.run(["remote", "set-url", &remote.name, &remote.url])?,
                None => self.run(["remote", "add", &remote.name, &remote.url])?,
            }
        }
        Ok(())
    }

    pub fn fetch_remote(&self, remote: &RemoteSpec) -> Result<()> {
        let branch_refspec = format!("+refs/heads/*:refs/remotes/{}/*", remote.name);
        let tag_refspec = format!("+refs/tags/*:refs/remote-tags/{}/*", remote.name);
        crate::logln!(
            "  {} {}",
            style("fetch").cyan().bold(),
            style(&remote.display).dim()
        );
        self.run(["fetch", "--prune", &remote.name, &branch_refspec])?;
        self.run(["fetch", "--prune", &remote.name, &tag_refspec])
    }

    #[cfg(test)]
    pub fn cached_remote_refs_match(
        &self,
        remote: &RemoteSpec,
        expected: &RemoteRefSnapshot,
    ) -> Result<bool> {
        Ok(self
            .cached_remote_ref_snapshot(remote)?
            .is_some_and(|snapshot| &snapshot == expected))
    }

    pub fn cached_remote_ref_snapshot(
        &self,
        remote: &RemoteSpec,
    ) -> Result<Option<RemoteRefSnapshot>> {
        if !self.path.exists() || self.dry_run {
            return Ok(None);
        }
        let branches = self.remote_branches(&remote.name)?;
        let tags = self.remote_tags(&remote.name)?;
        let mut refs = Vec::with_capacity(branches.len() + tags.len());
        for (branch, sha) in branches {
            refs.push(format!("{sha}\trefs/heads/{branch}"));
        }
        for (tag, sha) in tags {
            refs.push(format!("{sha}\trefs/tags/{tag}"));
        }
        Ok(Some(snapshot_from_refs(refs)))
    }

    pub fn branch_decisions(
        &self,
        remotes: &[RemoteSpec],
    ) -> Result<(Vec<BranchDecision>, Vec<BranchConflict>)> {
        let mut by_branch: BTreeMap<String, Vec<(String, String)>> = BTreeMap::new();
        for remote in remotes {
            for (branch, sha) in self.remote_branches(&remote.name)? {
                by_branch
                    .entry(branch)
                    .or_default()
                    .push((remote.name.clone(), sha));
            }
        }

        let mut decisions = Vec::new();
        let mut conflicts = Vec::new();
        let all_remote_names = remotes
            .iter()
            .map(|remote| remote.name.clone())
            .collect::<Vec<_>>();

        for (branch, tips) in by_branch {
            let unique = tips
                .iter()
                .map(|(_, sha)| sha.clone())
                .collect::<BTreeSet<_>>();
            if unique.len() == 1 {
                let source_remotes = tips
                    .into_iter()
                    .map(|(remote, _)| remote)
                    .collect::<Vec<_>>();
                let target_remotes = missing_remotes(&all_remote_names, &source_remotes);
                decisions.push(BranchDecision {
                    branch,
                    sha: unique.into_iter().next().unwrap(),
                    source_remotes,
                    target_remotes,
                });
                continue;
            }

            if let Some(winner) = self.fast_forward_winner(unique.iter())? {
                let source_remotes = tips
                    .iter()
                    .filter_map(|(remote, sha)| (sha == &winner).then_some(remote))
                    .cloned()
                    .collect::<Vec<_>>();
                let target_remotes = missing_remotes(&all_remote_names, &source_remotes);
                decisions.push(BranchDecision {
                    branch,
                    sha: winner,
                    source_remotes,
                    target_remotes,
                });
            } else {
                conflicts.push(BranchConflict { branch, tips });
            }
        }

        Ok((decisions, conflicts))
    }

    pub fn tag_decisions(
        &self,
        remotes: &[RemoteSpec],
    ) -> Result<(Vec<TagDecision>, Vec<TagConflict>)> {
        let mut by_tag: BTreeMap<String, Vec<(String, String)>> = BTreeMap::new();
        for remote in remotes {
            for (tag, sha) in self.remote_tags(&remote.name)? {
                by_tag
                    .entry(tag)
                    .or_default()
                    .push((remote.name.clone(), sha));
            }
        }

        let mut decisions = Vec::new();
        let mut conflicts = Vec::new();
        let all_remote_names = remotes
            .iter()
            .map(|remote| remote.name.clone())
            .collect::<Vec<_>>();

        for (tag, tips) in by_tag {
            let unique = tips
                .iter()
                .map(|(_, sha)| sha.clone())
                .collect::<BTreeSet<_>>();
            if unique.len() == 1 {
                let source_remotes = tips
                    .into_iter()
                    .map(|(remote, _)| remote)
                    .collect::<Vec<_>>();
                let target_remotes = missing_remotes(&all_remote_names, &source_remotes);
                decisions.push(TagDecision {
                    tag,
                    sha: unique.into_iter().next().unwrap(),
                    source_remotes,
                    target_remotes,
                });
            } else {
                conflicts.push(TagConflict { tag, tips });
            }
        }

        Ok((decisions, conflicts))
    }

    pub fn push_branches(&self, remotes: &[RemoteSpec], branches: &[BranchDecision]) -> Result<()> {
        for remote in remotes {
            for branch in branches {
                if !branch.target_remotes.contains(&remote.name) {
                    continue;
                }
                let refspec = format!("{}:refs/heads/{}", branch.sha, branch.branch);
                crate::logln!(
                    "  {} {} {} {}",
                    style("push").green().bold(),
                    style("branch").dim(),
                    style(&branch.branch).cyan(),
                    style(format!("-> {}", remote.display)).dim()
                );
                self.run(["push", &remote.name, &refspec])?;
            }
        }
        Ok(())
    }

    pub fn push_branch_updates(
        &self,
        remotes: &[RemoteSpec],
        updates: &[BranchUpdate],
    ) -> Result<()> {
        for update in updates {
            let remote = remotes
                .iter()
                .find(|remote| remote.name == update.target_remote)
                .with_context(|| format!("unknown remote '{}'", update.target_remote))?;
            self.push_branch_update(remote, update)?;
        }
        Ok(())
    }

    pub fn remote_branch_names_with_prefix(
        &self,
        remote: &str,
        prefix: &str,
    ) -> Result<Vec<String>> {
        Ok(self
            .remote_branches(remote)?
            .into_iter()
            .filter_map(|(branch, _)| branch.starts_with(prefix).then_some(branch))
            .collect())
    }

    pub fn auto_rebase_branch_conflict(
        &self,
        remotes: &[RemoteSpec],
        branch: &str,
        tips: &[(String, String)],
    ) -> Result<BranchRebaseDecision> {
        let mut ordered_tips = Vec::new();
        for remote in remotes {
            let Some((_, sha)) = tips.iter().find(|(name, _)| name == &remote.name) else {
                continue;
            };
            if !ordered_tips
                .iter()
                .any(|(_, existing): &(String, String)| existing == sha)
            {
                ordered_tips.push((remote.name.clone(), sha.clone()));
            }
        }
        if ordered_tips.len() < 2 {
            bail!("branch {branch} does not have enough unique tips to auto-rebase");
        }

        let mut truth = ordered_tips[0].1.clone();
        for (remote, sha) in ordered_tips.iter().skip(1) {
            let base = self.merge_base(&truth, sha)?;
            crate::logln!(
                "  {} branch {} rebasing {}@{} onto {}",
                style("auto-rebase").cyan().bold(),
                style(branch).cyan(),
                remote,
                short_sha(sha),
                short_sha(&truth)
            );
            truth = self
                .rebase_tip_onto(&truth, &base, sha)
                .with_context(|| format!("auto-rebase failed for branch {branch}"))?;
        }

        let mut updates = Vec::new();
        for (remote, sha) in tips {
            if sha == &truth {
                continue;
            }
            updates.push(BranchUpdate {
                branch: branch.to_string(),
                sha: truth.clone(),
                target_remote: remote.clone(),
                force: !self.is_ancestor(sha, &truth)?,
            });
        }

        Ok(BranchRebaseDecision {
            branch: branch.to_string(),
            sha: truth,
            updates,
        })
    }

    pub fn push_tags(&self, remotes: &[RemoteSpec], tags: &[TagDecision]) -> Result<()> {
        for remote in remotes {
            for tag in tags {
                if !tag.target_remotes.contains(&remote.name) {
                    continue;
                }
                let refspec = format!("{}:refs/tags/{}", tag.sha, tag.tag);
                crate::logln!(
                    "  {} {} {} {}",
                    style("push").green().bold(),
                    style("tag").dim(),
                    style(&tag.tag).cyan(),
                    style(format!("-> {}", remote.display)).dim()
                );
                self.run(["push", &remote.name, &refspec])?;
            }
        }
        Ok(())
    }

    pub fn delete_branches(
        &self,
        remotes: &[RemoteSpec],
        deletions: &[BranchDeletion],
    ) -> Result<()> {
        for remote in remotes {
            for deletion in deletions {
                if !deletion.target_remotes.contains(&remote.name) {
                    continue;
                }
                let refspec = format!(":refs/heads/{}", deletion.branch);
                crate::logln!(
                    "  {} {} {} {}",
                    style("delete").red().bold(),
                    style("branch").dim(),
                    style(&deletion.branch).cyan(),
                    style(format!("-> {}", remote.display)).dim()
                );
                self.run(["push", &remote.name, &refspec])?;
            }
        }
        Ok(())
    }

    pub fn backup_refs(&self, backups: &[RefBackup]) -> Result<Vec<String>> {
        let mut refs = Vec::new();
        for backup in backups {
            crate::logln!(
                "  {} {}",
                style("backup").cyan().bold(),
                style(&backup.description).dim()
            );
            self.run(["update-ref", &backup.refname, &backup.sha])?;
            refs.push(backup.refname.clone());
        }
        Ok(refs)
    }

    pub fn create_bundle(&self, path: &Path, refs: &[String]) -> Result<bool> {
        if refs.is_empty() {
            return Ok(false);
        }
        if self.dry_run {
            crate::logln!(
                "  {} git bundle create {} {}",
                style("dry-run").yellow().bold(),
                style(path.display()).dim(),
                style(refs.join(" ")).dim()
            );
            return Ok(false);
        }
        if let Some(parent) = path.parent() {
            fs::create_dir_all(parent)
                .with_context(|| format!("failed to create {}", parent.display()))?;
        }

        let output = self
            .command()
            .arg("bundle")
            .arg("create")
            .arg(path)
            .args(refs)
            .output()
            .with_context(|| "failed to run git bundle create")?;
        if !output.status.success() {
            let stdout = self
                .redactor
                .redact(&String::from_utf8_lossy(&output.stdout));
            let stderr = self
                .redactor
                .redact(&String::from_utf8_lossy(&output.stderr));
            return Err(GitCommandError::new("git bundle create", stdout, stderr).into());
        }
        crate::logln!(
            "  {} {}",
            style("backup bundle").cyan().bold(),
            style(path.display()).dim()
        );
        Ok(true)
    }

    fn push_branch_update(&self, remote: &RemoteSpec, update: &BranchUpdate) -> Result<()> {
        let refspec = if update.force {
            format!("+{}:refs/heads/{}", update.sha, update.branch)
        } else {
            format!("{}:refs/heads/{}", update.sha, update.branch)
        };
        let label = if update.force { "force-push" } else { "push" };
        crate::logln!(
            "  {} {} {} {}",
            style(label).green().bold(),
            style("branch").dim(),
            style(&update.branch).cyan(),
            style(format!("-> {}", remote.display)).dim()
        );
        self.run(["push", &remote.name, &refspec])
    }

    fn remote_url(&self, name: &str) -> Result<Option<String>> {
        let output = self
            .command()
            .args(["remote", "get-url", name])
            .output()
            .with_context(|| "failed to run git remote get-url")?;
        if output.status.success() {
            Ok(Some(
                String::from_utf8_lossy(&output.stdout).trim().to_string(),
            ))
        } else {
            Ok(None)
        }
    }

    fn remote_branches(&self, remote: &str) -> Result<Vec<(String, String)>> {
        let prefix = format!("refs/remotes/{remote}/");
        let output = self.output(["for-each-ref", "--format=%(refname) %(objectname)", &prefix])?;
        let mut branches = Vec::new();
        for line in output.lines() {
            let Some((refname, sha)) = line.split_once(' ') else {
                continue;
            };
            let Some(branch) = refname.strip_prefix(&prefix) else {
                continue;
            };
            if branch == "HEAD" {
                continue;
            }
            branches.push((branch.to_string(), sha.to_string()));
        }
        Ok(branches)
    }

    fn remote_tags(&self, remote: &str) -> Result<Vec<(String, String)>> {
        let prefix = format!("refs/remote-tags/{remote}/");
        let output = self.output(["for-each-ref", "--format=%(refname) %(objectname)", &prefix])?;
        let mut tags = Vec::new();
        for line in output.lines() {
            let Some((refname, sha)) = line.split_once(' ') else {
                continue;
            };
            let Some(tag) = refname.strip_prefix(&prefix) else {
                continue;
            };
            tags.push((tag.to_string(), sha.to_string()));
        }
        Ok(tags)
    }

    fn fast_forward_winner<'a>(
        &self,
        shas: impl Iterator<Item = &'a String> + Clone,
    ) -> Result<Option<String>> {
        for candidate in shas.clone() {
            let mut is_descendant = true;
            for other in shas.clone() {
                if candidate == other {
                    continue;
                }
                if !self.is_ancestor(other, candidate)? {
                    is_descendant = false;
                    break;
                }
            }
            if is_descendant {
                return Ok(Some(candidate.clone()));
            }
        }
        Ok(None)
    }

    fn merge_base(&self, left: &str, right: &str) -> Result<String> {
        Ok(self.output(["merge-base", left, right])?.trim().to_string())
    }

    fn rebase_tip_onto(&self, onto: &str, base: &str, tip: &str) -> Result<String> {
        if self.dry_run {
            return Ok(format!("dry-run-rebased-{}", short_sha(tip)));
        }

        let commits = self.replay_commits(base, tip)?;
        let worktree = tempfile::TempDir::new().context("failed to create temporary worktree")?;
        let worktree_path = worktree.path().to_path_buf();
        self.run([
            "worktree",
            "add",
            "--detach",
            worktree_path.to_str().unwrap(),
            onto,
        ])?;

        let replay_result = self.replay_commits_preserving_committer(&worktree_path, &commits);
        if let Err(error) = replay_result {
            let _ = self.worktree_git(&worktree_path, ["cherry-pick", "--abort"]);
            let _ = self.worktree_git(&worktree_path, ["reset", "--hard"]);
            let _ = self.run([
                "worktree",
                "remove",
                "--force",
                worktree_path.to_str().unwrap(),
            ]);
            return Err(error);
        }
        let rebased = self.worktree_git_output(&worktree_path, ["rev-parse", "HEAD"])?;
        self.run([
            "worktree",
            "remove",
            "--force",
            worktree_path.to_str().unwrap(),
        ])?;
        Ok(rebased.trim().to_string())
    }

    fn replay_commits(&self, base: &str, tip: &str) -> Result<Vec<String>> {
        let range = format!("{base}..{tip}");
        Ok(self
            .output([
                "rev-list",
                "--reverse",
                "--topo-order",
                "--no-merges",
                &range,
            ])?
            .lines()
            .map(str::trim)
            .filter(|line| !line.is_empty())
            .map(ToOwned::to_owned)
            .collect())
    }

    fn replay_commits_preserving_committer(
        &self,
        worktree: &Path,
        commits: &[String],
    ) -> Result<()> {
        for commit in commits {
            let committer = self.committer_identity(commit)?;
            self.worktree_git(worktree, ["cherry-pick", "--no-commit", commit])?;
            self.worktree_git_with_committer(
                worktree,
                ["commit", "--no-gpg-sign", "-C", commit],
                &committer,
            )?;
        }
        Ok(())
    }

    fn committer_identity(&self, commit: &str) -> Result<CommitterIdentity> {
        let output = self.output(["show", "-s", "--format=%cn%x00%ce%x00%cI", commit])?;
        let output = output.trim_end_matches('\n');
        let mut parts = output.split('\0');
        let name = parts
            .next()
            .filter(|value| !value.is_empty())
            .ok_or_else(|| anyhow::anyhow!("commit {commit} has no committer name"))?;
        let email = parts
            .next()
            .filter(|value| !value.is_empty())
            .ok_or_else(|| anyhow::anyhow!("commit {commit} has no committer email"))?;
        let date = parts
            .next()
            .filter(|value| !value.is_empty())
            .ok_or_else(|| anyhow::anyhow!("commit {commit} has no committer date"))?;
        if parts.next().is_some() {
            bail!("commit {commit} has unexpected committer metadata");
        }
        Ok(CommitterIdentity {
            name: name.to_string(),
            email: email.to_string(),
            date: date.to_string(),
        })
    }

    pub fn is_ancestor(&self, ancestor: &str, descendant: &str) -> Result<bool> {
        let status = self
            .command()
            .args(["merge-base", "--is-ancestor", ancestor, descendant])
            .status()
            .with_context(|| "failed to run git merge-base")?;
        match status.code() {
            Some(0) => Ok(true),
            Some(1) => Ok(false),
            _ => bail!("git merge-base failed for {ancestor} and {descendant}"),
        }
    }

    fn run<const N: usize>(&self, args: [&str; N]) -> Result<()> {
        run_plain(
            "git",
            std::iter::once("--git-dir")
                .chain(std::iter::once(self.path.to_str().unwrap()))
                .chain(args),
            None,
            &self.redactor,
            self.dry_run,
        )
    }

    fn output<const N: usize>(&self, args: [&str; N]) -> Result<String> {
        if self.dry_run {
            return Ok(String::new());
        }
        let output = self
            .command()
            .args(args)
            .output()
            .with_context(|| "failed to run git")?;
        if output.status.success() {
            Ok(String::from_utf8_lossy(&output.stdout).to_string())
        } else {
            Err(GitCommandError::new(
                "git",
                "",
                self.redactor
                    .redact(&String::from_utf8_lossy(&output.stderr)),
            )
            .into())
        }
    }

    fn command(&self) -> Command {
        let mut command = Command::new("git");
        command.arg("--git-dir").arg(&self.path);
        command
    }

    fn worktree_git<const N: usize>(&self, worktree: &Path, args: [&str; N]) -> Result<()> {
        run_plain(
            "git",
            [
                "-C",
                worktree.to_str().unwrap(),
                "-c",
                "user.name=refray",
                "-c",
                "user.email=refray@example.invalid",
            ]
            .into_iter()
            .chain(args),
            None,
            &self.redactor,
            self.dry_run,
        )
    }

    fn worktree_git_output<const N: usize>(
        &self,
        worktree: &Path,
        args: [&str; N],
    ) -> Result<String> {
        let output = Command::new("git")
            .arg("-C")
            .arg(worktree)
            .args(args)
            .output()
            .with_context(|| "failed to run git")?;
        if output.status.success() {
            Ok(String::from_utf8_lossy(&output.stdout).to_string())
        } else {
            Err(GitCommandError::new(
                "git",
                self.redactor
                    .redact(&String::from_utf8_lossy(&output.stdout)),
                self.redactor
                    .redact(&String::from_utf8_lossy(&output.stderr)),
            )
            .into())
        }
    }

    fn worktree_git_with_committer<const N: usize>(
        &self,
        worktree: &Path,
        args: [&str; N],
        committer: &CommitterIdentity,
    ) -> Result<()> {
        let output = Command::new("git")
            .arg("-C")
            .arg(worktree)
            .args(args)
            .env("GIT_COMMITTER_NAME", &committer.name)
            .env("GIT_COMMITTER_EMAIL", &committer.email)
            .env("GIT_COMMITTER_DATE", &committer.date)
            .output()
            .with_context(|| "failed to run git")?;
        if output.status.success() {
            Ok(())
        } else {
            Err(GitCommandError::new(
                "git",
                self.redactor
                    .redact(&String::from_utf8_lossy(&output.stdout)),
                self.redactor
                    .redact(&String::from_utf8_lossy(&output.stderr)),
            )
            .into())
        }
    }
}

fn short_sha(sha: &str) -> &str {
    sha.get(..12).unwrap_or(sha)
}

pub fn ls_remote_refs(remote: &RemoteSpec, redactor: &Redactor) -> Result<RemoteRefSnapshot> {
    let output = Command::new("git")
        .args(["ls-remote", "--heads", "--tags", "--refs", &remote.url])
        .output()
        .with_context(|| "failed to run git ls-remote")?;
    if !output.status.success() {
        let stdout = redactor.redact(&String::from_utf8_lossy(&output.stdout));
        let stderr = redactor.redact(&String::from_utf8_lossy(&output.stderr));
        return Err(GitCommandError::new("git ls-remote", stdout, stderr).into());
    }

    let refs = String::from_utf8_lossy(&output.stdout)
        .lines()
        .map(str::trim)
        .filter(|line| !line.is_empty())
        .map(ToOwned::to_owned)
        .collect::<Vec<_>>();

    Ok(snapshot_from_refs(refs))
}

fn snapshot_from_refs(mut refs: Vec<String>) -> RemoteRefSnapshot {
    refs.sort();
    let mut branches = BTreeMap::new();
    let mut tags = BTreeMap::new();
    for line in &refs {
        let Some((sha, reference)) = line.split_once('\t') else {
            continue;
        };
        if let Some(branch) = reference.strip_prefix("refs/heads/") {
            branches.insert(branch.to_string(), sha.to_string());
        } else if let Some(tag) = reference.strip_prefix("refs/tags/") {
            tags.insert(tag.to_string(), sha.to_string());
        }
    }

    RemoteRefSnapshot {
        hash: stable_ref_hash(&refs),
        refs: refs.len(),
        branches,
        tags,
    }
}

fn stable_ref_hash(refs: &[String]) -> String {
    // FNV-1a is enough here: this is a deterministic change detector, not a
    // security boundary.
    let mut hash = 0xcbf2_9ce4_8422_2325u64;
    for line in refs {
        for byte in line.bytes().chain(std::iter::once(b'\n')) {
            hash ^= u64::from(byte);
            hash = hash.wrapping_mul(0x0000_0100_0000_01b3);
        }
    }
    format!("{hash:016x}")
}

#[derive(Debug)]
pub struct GitCommandError {
    program: String,
    stdout: String,
    stderr: String,
}

impl GitCommandError {
    fn new(
        program: impl Into<String>,
        stdout: impl Into<String>,
        stderr: impl Into<String>,
    ) -> Self {
        Self {
            program: program.into(),
            stdout: stdout.into(),
            stderr: stderr.into(),
        }
    }

    pub fn stderr(&self) -> &str {
        &self.stderr
    }
}

impl fmt::Display for GitCommandError {
    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
        write!(
            f,
            "{} failed\nstdout: {}\nstderr: {}",
            self.program, self.stdout, self.stderr
        )
    }
}

impl Error for GitCommandError {}

pub fn is_disabled_repository_error(error: &anyhow::Error) -> bool {
    error
        .chain()
        .filter_map(|cause| cause.downcast_ref::<GitCommandError>())
        .any(|error| is_disabled_repository_stderr(error.stderr()))
}

pub fn is_missing_repository_error(error: &anyhow::Error) -> bool {
    error
        .chain()
        .filter_map(|cause| cause.downcast_ref::<GitCommandError>())
        .any(|error| is_missing_repository_stderr(error.stderr()))
}

fn missing_remotes(all_remote_names: &[String], source_remotes: &[String]) -> Vec<String> {
    all_remote_names
        .iter()
        .filter(|remote| !source_remotes.contains(remote))
        .cloned()
        .collect()
}

fn is_disabled_repository_stderr(stderr: &str) -> bool {
    let stderr = stderr.to_ascii_lowercase();
    stderr.contains("access to this repository has been disabled")
        || stderr.contains("repository has been disabled")
        || stderr.contains("disabled by github staff")
        || stderr.contains("dmca takedown")
}

fn is_missing_repository_stderr(stderr: &str) -> bool {
    let stderr = stderr.to_ascii_lowercase();
    (stderr.contains("repository") && stderr.contains("not found"))
        || stderr.contains("project you were looking for could not be found")
        || stderr.contains("does not appear to be a git repository")
        || stderr.contains("the requested url returned error: 404")
}

impl Redactor {
    pub fn new(secrets: Vec<String>) -> Self {
        let secrets = secrets
            .into_iter()
            .filter(|secret| !secret.is_empty())
            .collect();
        Self { secrets }
    }

    pub fn redact(&self, value: &str) -> String {
        let mut redacted = value.to_string();
        for secret in &self.secrets {
            redacted = redacted.replace(secret, "<redacted>");
        }
        redacted
    }
}

fn run_plain<I, S>(
    program: &str,
    args: I,
    current_dir: Option<&Path>,
    redactor: &Redactor,
    dry_run: bool,
) -> Result<()>
where
    I: IntoIterator<Item = S>,
    S: AsRef<str>,
{
    let args = args
        .into_iter()
        .map(|arg| arg.as_ref().to_string())
        .collect::<Vec<_>>();
    if dry_run {
        crate::logln!(
            "  {} {} {}",
            style("dry-run").yellow().bold(),
            program,
            style(redactor.redact(&args.join(" "))).dim()
        );
        return Ok(());
    }

    let mut command = Command::new(program);
    command.args(&args);
    if let Some(current_dir) = current_dir {
        command.current_dir(current_dir);
    }
    let output = command
        .output()
        .with_context(|| format!("failed to run {program}"))?;
    if output.status.success() {
        Ok(())
    } else {
        let stdout = redactor.redact(&String::from_utf8_lossy(&output.stdout));
        let stderr = redactor.redact(&String::from_utf8_lossy(&output.stderr));
        Err(GitCommandError::new(program, stdout, stderr).into())
    }
}

pub fn safe_remote_name(value: &str) -> String {
    value
        .chars()
        .map(|ch| {
            if ch.is_ascii_alphanumeric() || ch == '-' || ch == '_' {
                ch
            } else {
                '_'
            }
        })
        .collect()
}

#[cfg(test)]
#[path = "../tests/unit/git.rs"]
mod tests;
