#![allow(clippy::too_many_arguments)]

mod canonical;
mod error;

use std::collections::HashMap;
use std::process::ExitStatus;
use std::str::FromStr;
use std::{assert_eq, io};

use radicle::cob::store::access::WriteAs;
use thiserror::Error;

use radicle::cob;
use radicle::cob::object::ParseObjectId;
use radicle::cob::patch;
use radicle::cob::patch::cache::Patches as _;
use radicle::crypto;
use radicle::crypto::Signer as _;
use radicle::explorer::ExplorerResource;
use radicle::identity::Did;
use radicle::node;
use radicle::node::NodeId;
use radicle::storage;
use radicle::storage::git::transport::local::Url;
use radicle::storage::{ReadRepository, SignRepository as _, WriteRepository};
use radicle::{Profile, identity};
use radicle::{git, rad};
use radicle_cli as cli;
use radicle_term as term;

use crate::service::GitService;
use crate::service::NodeSession;
use crate::{Options, Verbosity, hint, warn};

const PATCHES_FOR_PREFIX: &str = "refs/for/";

#[derive(Debug, Error)]
pub(super) enum Error {
    /// Public key doesn't match the remote namespace we're pushing to.
    #[error("cannot push to remote namespace owned by {0}")]
    KeyMismatch(Did),
    /// No public key is given
    #[error(
        "no public key given as a remote namespace, perhaps you are attempting to push to restricted refs"
    )]
    NoKey,
    /// User tried to delete the canonical branch.
    #[error("refusing to delete default branch ref '{0}'")]
    DeleteForbidden(git::fmt::RefString),
    /// Identity document error.
    #[error("doc: {0}")]
    Doc(#[from] radicle::identity::doc::DocError),
    /// Identity payload error.
    #[error("payload: {0}")]
    Payload(#[from] radicle::identity::doc::PayloadError),
    /// Protocol error.
    #[error("protocol error: {0}")]
    Protocol(#[from] crate::protocol::Error),
    /// I/O error.
    #[error("i/o error: {0}")]
    Io(#[from] io::Error),
    /// Invalid reference name.
    #[error("invalid ref: {0}")]
    InvalidRef(#[from] radicle::git::fmt::Error),
    /// Git error.
    #[error("git: {0}")]
    Git(#[from] git::raw::Error),
    /// Storage error.
    #[error(transparent)]
    Storage(#[from] radicle::storage::Error),
    /// Profile error.
    #[error(transparent)]
    Profile(#[from] radicle::profile::Error),
    /// Signer error.
    #[error(transparent)]
    Signer(#[from] radicle::profile::SignerError),
    /// Parse error for object IDs.
    #[error(transparent)]
    ParseObjectId(#[from] ParseObjectId),
    /// Patch COB error.
    #[error(transparent)]
    Patch(#[from] radicle::cob::patch::Error),
    /// Error from COB patch cache.
    #[error(transparent)]
    PatchCache(#[from] patch::cache::Error),
    /// Patch edit message error.
    #[error(transparent)]
    PatchEdit(#[from] cli::terminal::patch::Error),
    /// Policy config error.
    #[error("node policy: {0}")]
    Policy(#[from] node::policy::config::Error),
    /// Patch not found in store.
    #[error("patch `{0}` not found")]
    NotFound(patch::PatchId),
    /// Revision not found in store.
    #[error("revision `{0}` not found")]
    RevisionNotFound(patch::RevisionId),
    /// Patch is empty.
    #[error("patch commits are already included in the base branch")]
    EmptyPatch,
    /// COB store error.
    #[error(transparent)]
    Cob(#[from] radicle::cob::store::Error),
    /// General repository error.
    #[error(transparent)]
    Repository(#[from] radicle::storage::RepositoryError),
    /// Quorum error.
    #[error(transparent)]
    Quorum(#[from] radicle::git::canonical::error::QuorumError),
    #[error(transparent)]
    CanonicalRefs(#[from] radicle::identity::doc::CanonicalRefsError),
    #[error(transparent)]
    PushAction(#[from] error::PushAction),
    #[error(transparent)]
    Canonical(#[from] error::CanonicalUnrecoverable),
    #[error("could not determine object type for {oid}")]
    UnknownObjectType { oid: git::Oid },
    #[error(transparent)]
    FindObjects(#[from] git::canonical::error::FindObjectsError),
    /// Conflicting merge targets.
    #[error("conflicting merge targets: push option '{0}' and magic ref '{1}' specified")]
    ConflictingTargets(cob::patch::TargetBranch, cob::patch::TargetBranch),
    /// Default branch error.
    #[error(transparent)]
    DefaultBranch(#[from] radicle::identity::doc::DefaultBranchError),

    /// Error sending pack from the working copy to storage.
    #[error(
        "`git send-pack` failed with exit status {status}, stderr and stdout follow:\n{stderr}\n{stdout}"
    )]
    SendPackFailed {
        status: ExitStatus,
        stderr: String,
        stdout: String,
    },

    /// Received an unexpected command after the first `push` command.
    #[error("unexpected command after first `push`: {0:?}")]
    UnexpectedCommand(crate::protocol::Command),

    #[error(transparent)]
    CommandError(#[from] CommandError),
}

/// Push command.
enum Command {
    /// Update ref.
    Push(git::fmt::refspec::Refspec<git::Oid, git::fmt::RefString>),
    /// Delete ref.
    Delete(git::fmt::RefString),
}

#[derive(Debug, thiserror::Error)]
pub(super) enum CommandError {
    #[error("expected refspec of the form `[<src>]:<dst>`, got {rev}")]
    Empty { rev: String },
    #[error("failed to parse destination reference ({rev}): {err}")]
    Delete {
        rev: String,
        #[source]
        err: git::fmt::Error,
    },
    #[error("failed to parse source revision ({rev}): {source}")]
    Revision {
        rev: String,
        source: git::raw::Error,
    },
}

impl Command {
    /// Parse a `Command` given the input string, expected to be of the form
    /// `[src]:dst`.
    ///
    /// If `src` is not provided, then the `Command` is deleting the `dst`
    /// reference.
    ///
    /// If the `src` is provided, which can be any Git [revision], then `dst` is
    /// being updating with the `src` value.
    ///
    /// [revision]: https://git-scm.com/docs/revisions
    fn parse(s: &str, repo: &git::raw::Repository) -> Result<Self, CommandError> {
        let Some((src, dst)) = s.split_once(':') else {
            return Err(CommandError::Empty { rev: s.to_string() });
        };
        let dst = git::fmt::RefString::try_from(dst).map_err(|err| CommandError::Delete {
            rev: dst.to_string(),
            err,
        })?;

        if src.is_empty() {
            Ok(Self::Delete(dst))
        } else {
            let (src, force) = if let Some(stripped) = src.strip_prefix('+') {
                (stripped, true)
            } else {
                (src, false)
            };
            let src = repo
                .revparse_single(src)
                .map_err(|err| CommandError::Revision {
                    rev: src.to_string(),
                    source: err,
                })?
                .id()
                .into();

            Ok(Self::Push(git::fmt::refspec::Refspec { src, dst, force }))
        }
    }

    /// Return the destination refname.
    fn dst(&self) -> &git::fmt::RefStr {
        match self {
            Self::Push(rs) => rs.dst.as_refstr(),
            Self::Delete(rs) => rs,
        }
    }
}

enum PushAction {
    OpenPatch {
        target: Option<cob::patch::TargetBranch>,
    },
    UpdatePatch {
        dst: git::fmt::Qualified<'static>,
        patch: patch::PatchId,
    },
    PushRef {
        dst: git::fmt::Qualified<'static>,
    },
}

impl PushAction {
    fn new(dst: &git::fmt::RefString) -> Result<Self, error::PushAction> {
        if dst == &*rad::PATCHES_REFNAME {
            Ok(Self::OpenPatch { target: None })
        } else if let Some(stripped) = dst.as_str().strip_prefix(PATCHES_FOR_PREFIX) {
            let target = cob::patch::TargetBranch::try_from(stripped).map_err(|_| {
                error::PushAction::InvalidRef {
                    refname: dst.clone(),
                }
            })?;

            Ok(Self::OpenPatch {
                target: Some(target),
            })
        } else {
            let dst = git::fmt::Qualified::from_refstr(dst)
                .ok_or_else(|| error::PushAction::InvalidRef {
                    refname: dst.clone(),
                })?
                .to_owned();

            if let Some(oid) = dst.strip_prefix(git::fmt::refname!("refs/heads/patches")) {
                let patch = git::Oid::from_str(oid)
                    .map_err(|source| error::PushAction::InvalidPatchId {
                        suffix: oid.to_string(),
                        source,
                    })
                    .map(patch::PatchId::from)?;
                Ok(Self::UpdatePatch { dst, patch })
            } else {
                Ok(Self::PushRef { dst })
            }
        }
    }
}

/// Run a git push command.
pub(super) fn run(
    mut specs: Vec<String>,
    remote: Option<git::fmt::RefString>,
    url: Url,
    stored: &storage::git::Repository,
    profile: &Profile,
    command_reader: &mut crate::protocol::LineReader<impl io::Read>,
    opts: Options,
    expected_refs: &[String],
    git: &impl GitService,
    node: &mut impl NodeSession,
) -> Result<Vec<String>, Error> {
    const LOG_MESSAGE: &str = "set-canonical-reference from git-push (radicle)";

    // Don't allow push if either of these conditions is true:
    //
    // 1. Our key is not in ssh-agent, which means we won't be able to sign the refs.
    // 2. Our key is not the one loaded in the profile, which means that the signed refs
    //    won't match the remote we're pushing to.
    // 3. The URL namespace is not set.
    let nid = url.namespace.ok_or(Error::NoKey).and_then(|ns| {
        (profile.id() == &ns)
            .then_some(ns)
            .ok_or(Error::KeyMismatch(ns.into()))
    })?;
    let signer = profile.signer()?;
    let mut ok = HashMap::new();
    let hints = opts.hints || profile.hints();
    let mut output = Vec::new();

    assert_eq!(signer.public_key(), &nid);

    // Read all the `push` lines.
    for line in command_reader.by_ref() {
        match line?? {
            crate::protocol::Line::Blank => {
                // An empty line means end of input.
                break;
            }
            crate::protocol::Line::Valid(crate::protocol::Command::Push(spec)) => {
                specs.push(spec);
            }
            crate::protocol::Line::Valid(command) => return Err(Error::UnexpectedCommand(command)),
        }
    }
    let delegates = stored.delegates()?;
    let mut set_canonical_refs: Vec<(git::fmt::Qualified, git::canonical::Object)> =
        Vec::with_capacity(specs.len());

    // Rely on the environment variable `GIT_DIR`.
    let working = git::raw::Repository::open_from_env()?;

    // For each refspec, push a ref or delete a ref.
    for spec in specs {
        let cmd = Command::parse(&spec, &working)?;
        let result = match &cmd {
            Command::Delete(dst) => {
                // Delete refs.
                let refname = nid.to_namespace().join(dst);
                let (canonical_ref, _) = &stored.head()?;

                if *dst == canonical_ref.to_ref_string() && delegates.contains(&Did::from(nid)) {
                    return Err(Error::DeleteForbidden(dst.clone()));
                }
                stored
                    .raw()
                    .find_reference(&refname)
                    .and_then(|mut r| r.delete())
                    .map(|_| None)
                    .map_err(Error::from)
            }
            Command::Push(git::fmt::refspec::Refspec { src, dst, force }) => {
                let signer = profile.signer()?;
                let patches = crate::patches_mut(profile, stored, &signer)?;
                let action = PushAction::new(dst)?;

                match action {
                    PushAction::OpenPatch { target } => {
                        let mut push_opts = opts.clone();
                        if let Some(magic_target) = target {
                            if let cob::patch::MergeTarget::Branch(opt_target) = &opts.target
                                && magic_target != *opt_target
                            {
                                return Err(Error::ConflictingTargets(
                                    opt_target.clone(),
                                    magic_target,
                                ));
                            }
                            push_opts.target = cob::patch::MergeTarget::Branch(magic_target);
                        }

                        patch_open(
                            src, &remote, &nid, &working, stored, patches, profile, push_opts, git,
                        )
                    }
                    PushAction::UpdatePatch { dst, patch } => patch_update(
                        src,
                        &dst,
                        *force,
                        patch,
                        &nid,
                        &working,
                        stored,
                        patches,
                        &signer,
                        opts.clone(),
                        expected_refs,
                        git,
                    ),
                    PushAction::PushRef { dst } => {
                        let identity = stored.identity()?;
                        let crefs = identity.doc().canonical_refs()?;
                        let rules = crefs.rules();
                        let me = Did::from(nid);

                        let explorer = push(
                            src,
                            &dst,
                            *force,
                            &nid,
                            &working,
                            stored,
                            patches,
                            &signer,
                            opts.verbosity,
                            expected_refs,
                            git,
                        )?;
                        // If we're trying to update the canonical head, make sure
                        // we don't diverge from the current head. This only applies
                        // to repos with more than one delegate.
                        //
                        // Note that we *do* allow rolling back to a previous commit on the
                        // canonical branch.
                        if let Some(canonical) = rules.canonical(dst.clone(), stored) {
                            let object = working
                                .find_object(src.into(), None)
                                .map(|obj| git::canonical::Object::new(&obj))?
                                .ok_or(Error::UnknownObjectType { oid: *src })?;

                            let canonical = canonical::Canonical::new(me, object, canonical)?;
                            match canonical.quorum() {
                                Ok(quorum) => set_canonical_refs.push(quorum),
                                Err(e) => canonical::io::handle_error(e)?,
                            }
                        }
                        Ok(explorer)
                    }
                }
            }
        };

        match result {
            // Let Git tooling know that this ref has been pushed.
            Ok(resource) => {
                output.push(format!("ok {}", cmd.dst()));
                ok.insert(spec, resource);
            }
            // Let Git tooling know that there was an error pushing the ref.
            Err(e) => output.push(format!("error {} {e}", cmd.dst())),
        }
    }

    // Sign refs and sync if at least one ref pushed successfully.
    if !ok.is_empty() {
        let _ = stored.sign_refs(&signer)?;

        stored.set_canonical_symbolic_refs(LOG_MESSAGE)?;

        for (refname, object) in &set_canonical_refs {
            let oid = object.id();
            let kind = object.object_type();
            let print_update = || {
                eprintln!(
                    "{} Canonical reference {} updated to target {kind} {}",
                    term::PREFIX_SUCCESS,
                    term::format::secondary(refname),
                    term::format::secondary(oid),
                )
            };

            match stored.backend.refname_to_id(refname.as_str()) {
                Ok(new) if oid != new => {
                    stored
                        .backend
                        .reference(refname.as_str(), oid.into(), true, LOG_MESSAGE)?;
                    print_update();
                }
                Err(e) if e.code() == git::raw::ErrorCode::NotFound => {
                    stored.backend.reference(
                        refname.as_str(),
                        oid.into(),
                        true,
                        "set-canonical-reference from git-push (radicle)",
                    )?;
                    print_update();
                }
                _ => {}
            }
        }

        if !opts.no_sync {
            if profile.policies()?.is_seeding(&stored.id)? {
                // Connect to local node and announce refs to the network.
                // If our node is not running, we simply skip this step, as the
                // refs will be announced eventually, when the node restarts.
                if node.is_running() {
                    // Nb. allow this to fail. The push to local storage was still successful.
                    node.sync(stored, ok.into_values().flatten().collect(), opts, profile)
                        .ok();
                } else if hints {
                    hint("offline push, your node is not running");
                    hint("to sync with the network, run `rad node start`");
                }
            } else if hints {
                hint("you are not seeding this repository; skipping sync");
            }
        }
    }

    Ok(output)
}

fn patch_base(
    head: &git::Oid,
    opts: &Options,
    stored: &storage::git::Repository,
) -> Result<git::Oid, Error> {
    Ok(if let Some(base) = opts.base {
        base
    } else {
        // Computation of the canonical head is required only if the user
        // did not specify a base explicitly. This allows the user to
        // continue updating patches even while the canonical head cannot
        // be computed, e.g. while they wait for their fellow delegates
        // to converge and sync.
        let (_, target) = stored.canonical_head()?;
        stored.merge_base(&target, head)?
    })
}

/// Before opening or updating patches, we want to evaluate the merge base of the
/// patch and the default branch. In order to do that, the respective heads must
/// be present in the same Git repository.
///
/// Unfortunately, we don't have an easy way to transfer the objects without
/// creating a reference (be it in storage or working copy).
///
/// We choose to push a temporary reference to storage, which gets deleted on
/// [`Drop::drop`].
struct TempPatchRef<'a, G> {
    stored: &'a storage::git::Repository,
    reference: git::fmt::Namespaced<'a>,
    git: &'a G,
}

impl<'a, G: GitService> TempPatchRef<'a, G> {
    fn new(
        stored: &'a storage::git::Repository,
        head: &git::Oid,
        nid: &NodeId,
        git: &'a G,
    ) -> Self {
        let reference = git::refs::storage::staging::patch(nid, *head);
        Self {
            stored,
            reference,
            git,
        }
    }

    fn push(&self, src: &git::Oid, verbosity: Verbosity) -> Result<(), Error> {
        push_ref(
            src,
            &self.reference,
            false,
            self.stored.raw(),
            verbosity,
            &[],
            self.git,
        )
    }
}

impl<'a, G> Drop for TempPatchRef<'a, G> {
    fn drop(&mut self) {
        if let Err(err) = self
            .stored
            .raw()
            .find_reference(&self.reference)
            .and_then(|mut r| r.delete())
        {
            eprintln!(
                "{} Failed to delete temporary reference {} in storage: {err}",
                term::PREFIX_WARNING,
                term::format::tertiary(&self.reference),
            );
        }
    }
}

/// Open a new patch.
fn patch_open(
    head: &git::Oid,
    upstream: &Option<git::fmt::RefString>,
    nid: &NodeId,
    working: &git::raw::Repository,
    stored: &storage::git::Repository,
    mut patches: patch::Cache<
        '_,
        storage::git::Repository,
        WriteAs<'_, impl crypto::Signer>,
        cob::cache::StoreWriter,
    >,
    profile: &Profile,
    opts: Options,
    git: &impl GitService,
) -> Result<Option<ExplorerResource>, Error> {
    let temp = TempPatchRef::new(stored, head, nid, git);
    temp.push(head, opts.verbosity)?;
    let base = patch_base(head, &opts, stored)?;

    if base == *head {
        warn(format!(
            "attempted to create a patch using the commit {head}, but this commit is already included in the base branch"
        ));
        return Err(Error::EmptyPatch);
    }

    let (title, description) = cli::terminal::patch::get_create_message(
        opts.message,
        &stored.backend,
        &base.into(),
        &head.into(),
    )?;

    let patch = if opts.draft {
        patches.draft(title, &description, opts.target.clone(), base, *head, &[])
    } else {
        patches.create(title, &description, opts.target.clone(), base, *head, &[])
    }?;

    let action = if patch.is_draft() {
        "drafted"
    } else {
        "opened"
    };
    let patch = patch.id;

    eprintln!(
        "{} Patch {} {action}",
        term::PREFIX_SUCCESS,
        term::format::tertiary(patch),
    );

    // Create long-lived patch head reference, now that we know the Patch ID.
    //
    //  refs/namespaces/<nid>/refs/heads/patches/<patch-id>
    //
    let refname = git::refs::patch(&patch).with_namespace(nid.into());
    let _ = stored.raw().reference(
        refname.as_str(),
        head.into(),
        true,
        "Create reference for patch head",
    )?;

    if let Some(upstream) = upstream {
        if let Some(local_branch) = opts.branch.into_branch_name(&patch) {
            fn strip_refs_heads(qualified: git::fmt::Qualified) -> git::fmt::RefString {
                let (_refs, _heads, x, xs) = qualified.non_empty_components();
                std::iter::once(x).chain(xs).collect()
            }

            working.reference(
                &local_branch,
                head.into(),
                true,
                "Create local branch for patch",
            )?;

            let remote_branch = git::refs::workdir::patch_upstream(&patch);
            let remote_branch = working.reference(
                &remote_branch,
                head.into(),
                true,
                "Create remote tracking branch for patch",
            )?;
            debug_assert!(remote_branch.is_remote());

            let local_branch = strip_refs_heads(local_branch);
            let upstream_branch = git::refs::patch(&patch);
            git::set_upstream(working, upstream, &local_branch, &upstream_branch)?;

            eprintln!(
                "{} Branch {} created",
                term::PREFIX_SUCCESS,
                term::format::tertiary(&local_branch),
            );
            hint(format!(
                "to update, run `git push {upstream} {local_branch}`"
            ));
        }
        // Set up current branch so that pushing updates the patch.
        else if let Some(branch) =
            rad::setup_patch_upstream(&patch, *head, working, upstream, false)?
            && let Some(name) = branch.name()?
            && profile.hints()
        {
            // Remove the remote portion of the name, i.e.
            // rad/patches/deadbeef -> patches/deadbeef
            let name = name.split_once('/').unwrap_or_default().1;
            hint(format!(
                "to update, run `git push` or `git push {upstream} --force-with-lease HEAD:{name}`"
            ));
        }
    }

    Ok(Some(ExplorerResource::Patch { id: patch }))
}

/// Update an existing patch.
#[allow(clippy::too_many_arguments)]
fn patch_update<Signer>(
    head: &git::Oid,
    dst: &git::fmt::Qualified,
    force: bool,
    patch_id: patch::PatchId,
    nid: &NodeId,
    working: &git::raw::Repository,
    stored: &storage::git::Repository,
    mut patches: patch::Cache<
        '_,
        storage::git::Repository,
        WriteAs<'_, Signer>,
        cob::cache::StoreWriter,
    >,
    signer: &Signer,
    opts: Options,
    expected_refs: &[String],
    git: &impl GitService,
) -> Result<Option<ExplorerResource>, Error>
where
    Signer: crypto::Signer,
{
    let Ok(Some(patch)) = patches.get(&patch_id) else {
        return Err(Error::NotFound(patch_id));
    };

    let temp = TempPatchRef::new(stored, head, nid, git);
    temp.push(head, opts.verbosity)?;

    let base = patch_base(head, &opts, stored)?;

    // Don't update patch if it already has a matching revision.
    if patch
        .revisions()
        .any(|(_, r)| r.head() == *head && *r.base() == base)
    {
        return Ok(None);
    }

    let (latest_id, latest) = patch.latest();
    let latest = latest.clone();

    let message = cli::terminal::patch::get_update_message(
        opts.message,
        &stored.backend,
        &latest,
        &head.into(),
    )?;

    let dst = dst.with_namespace(nid.into());
    push_ref(
        head,
        &dst,
        force,
        stored.raw(),
        opts.verbosity,
        expected_refs,
        git,
    )?;

    let mut patch_mut = patch::PatchMut::new(patch_id, patch, &mut patches);
    let revision = patch_mut.update(message, base, *head)?;
    let Some(revision) = patch_mut.revision(&revision).cloned() else {
        return Err(Error::RevisionNotFound(revision));
    };

    eprintln!(
        "{} Patch {} updated to revision {}",
        term::PREFIX_SUCCESS,
        term::format::tertiary(cli::terminal::format::cob(&patch_id)),
        term::format::dim(revision.id())
    );

    // In this case, the patch was already merged via git, and pushed to storage.
    // To handle this situation, we simply update the patch state to "merged".
    //
    // This can happen if for eg. a patch commit is amended, the patch branch is merged
    // and pushed, but the patch hasn't yet been updated. On push to the patch branch,
    // it'll seem like the patch is "empty", because the changes are already in the base branch.
    if base == *head && patch_mut.is_open() {
        patch_merge(patch_mut, revision.id(), *head, working, signer)?;
    } else {
        eprintln!(
            "To compare against your previous revision {}, run:\n\n   {}\n",
            term::format::tertiary(cli::terminal::format::cob(&cob::ObjectId::from(
                git::Oid::from(latest_id)
            ))),
            patch::RangeDiff::new(&latest, &revision).to_command()
        );
    }

    Ok(Some(ExplorerResource::Patch { id: patch_id }))
}

fn push<Signer>(
    src: &git::Oid,
    dst: &git::fmt::Qualified,
    force: bool,
    nid: &NodeId,
    working: &git::raw::Repository,
    stored: &storage::git::Repository,
    mut patches: patch::Cache<
        '_,
        storage::git::Repository,
        WriteAs<'_, Signer>,
        cob::cache::StoreWriter,
    >,
    signer: &Signer,
    verbosity: Verbosity,
    expected_refs: &[String],
    git: &impl GitService,
) -> Result<Option<ExplorerResource>, Error>
where
    Signer: crypto::Signer,
{
    let head = *src;
    let namespaced_dst = dst.with_namespace(nid.into());

    let old = {
        // In some rare cases, pushing to the default branch is a new action for a delegate.
        // If this is the case, use the canonical head before the push to determine the old side.
        //
        // Note that this is only important for reverting and merging, but must happen before the push.
        let old = stored
            .backend
            .find_reference(namespaced_dst.as_str())
            .ok()
            .map(|old| old.peel_to_commit().map(|c| c.id().into()))
            .transpose()?
            .or_else(|| stored.canonical_head().map(|(_, oid)| oid).ok());

        push_ref(
            src,
            &namespaced_dst,
            force,
            stored.raw(),
            verbosity,
            expected_refs,
            git,
        )?;

        old
    };

    if let Some(old) = old {
        let identity = stored.identity()?;
        let crefs = identity.doc().canonical_refs()?;
        let rules = crefs.rules();
        let me = Did::from(nid);

        // If we're pushing to a valid canonical branch, we want to see if any patches got
        // merged or reverted, and if so, update the patch COB.
        if let Some((_, rule)) = rules.matches(dst).next()
            && rule.allowed().contains(&me)
        {
            patch_revert_all(old, head, dst, &stored.backend, &mut patches, &identity)?;
            patch_merge_all(old, head, dst, working, &mut patches, signer, &identity)?;
        }
    }
    Ok(Some(ExplorerResource::Tree { oid: head }))
}

/// Revert all patches that are no longer included in the base branch.
fn patch_revert_all(
    old: git::Oid,
    new: git::Oid,
    pushed_dst: &git::fmt::Qualified,
    stored: &git::raw::Repository,
    patches: &mut patch::Cache<
        '_,
        storage::git::Repository,
        WriteAs<'_, impl crypto::Signer>,
        cob::cache::StoreWriter,
    >,
    identity: &radicle::identity::Identity,
) -> Result<(), Error> {
    // Find all commits reachable from the old OID but not from the new OID.
    let mut revwalk = stored.revwalk()?;
    revwalk.push(old.into())?;
    revwalk.hide(new.into())?;

    // List of commits that have been dropped.
    let dropped = revwalk
        .map(|r| r.map(git::Oid::from))
        .collect::<Result<Vec<git::Oid>, _>>()?;
    if dropped.is_empty() {
        return Ok(());
    }

    let identity_doc = identity.doc();

    // Get the list of merged patches.
    let merged = patches
        .merged()?
        // Skip patches that failed to load.
        .filter_map(|patch| patch.ok())
        // Only consider patches that target the destination branch
        .filter(|(id, patch)| merge_targets_match(pushed_dst, identity_doc, id, patch))
        .collect::<Vec<_>>();

    for (id, patch) in merged {
        let revisions = patch
            .revisions()
            .map(|(id, r)| (id, r.head()))
            .collect::<Vec<_>>();

        for commit in &dropped {
            if let Some((revision_id, _)) = revisions.iter().find(|(_, head)| commit == head) {
                // Simply refreshing the cache entry will pick up on the fact that this patch
                // is no longer merged in the canonical branch.
                match patches.write(&id) {
                    Ok(()) => {
                        eprintln!(
                            "{} Patch {} reverted at revision {}",
                            term::PREFIX_WARNING,
                            term::format::tertiary(&id),
                            term::format::dim(cli::terminal::format::oid(*revision_id)),
                        );
                    }
                    Err(e) => {
                        eprintln!("{} Error reverting patch {id}: {e}", term::PREFIX_ERROR);
                    }
                }
                break;
            }
        }
    }

    Ok(())
}

fn merge_targets_match(
    pushed_target: &git::fmt::Qualified,
    doc: &identity::Doc,
    id: &patch::PatchId,
    patch: &patch::Patch,
) -> bool {
    patch
        .is_targeted_by(doc, pushed_target)
        .unwrap_or_else(|e| {
            log::warn!("Failed to resolve merge target for patch {}: {}", id, e);
            false
        })
}

/// Merge all patches that have been included in the base branch.
fn patch_merge_all<Signer>(
    old: git::Oid,
    new: git::Oid,
    pushed_dst: &git::fmt::Qualified,
    working: &git::raw::Repository,
    patches: &mut patch::Cache<
        '_,
        storage::git::Repository,
        WriteAs<'_, Signer>,
        cob::cache::StoreWriter,
    >,
    signer: &Signer,
    identity: &radicle::identity::Identity,
) -> Result<(), Error>
where
    Signer: crypto::Signer,
{
    let mut revwalk = working.revwalk()?;
    revwalk.push_range(&format!("{old}..{new}"))?;

    // These commits are ordered by children first and then parents.
    let commits = revwalk
        .map(|r| r.map(git::Oid::from))
        .collect::<Result<Vec<git::Oid>, _>>()?;
    if commits.is_empty() {
        return Ok(());
    }

    let identity_doc = identity.doc();

    let open = patches
        .opened()?
        .chain(patches.drafted()?)
        // Skip patches that failed to load.
        .filter_map(|patch| patch.ok())
        // Only consider patches that target the destination branch
        .filter(|(id, patch)| merge_targets_match(pushed_dst, identity_doc, id, patch))
        .collect::<Vec<_>>();

    for (id, patch) in open {
        // Later revisions are more likely to be merged, so we build the list backwards.
        let revisions = patch
            .revisions()
            .rev()
            .map(|(id, r)| (id, r.head()))
            .collect::<Vec<_>>();

        // Try to find a revision to merge. Favor revisions that match the more recent commits.
        // It's possible for more than one revision to be merged by this push, so we pick the
        // revision that is closest to the tip of the commit chain we're pushing.
        for commit in &commits {
            if let Some((revision_id, head)) = revisions.iter().find(|(_, head)| commit == head) {
                let patch = patch::PatchMut::new(id, patch, patches);
                patch_merge(patch, *revision_id, *head, working, signer)?;

                break;
            }
        }
    }
    Ok(())
}

fn patch_merge<Signer>(
    mut patch: patch::PatchMut<
        '_,
        '_,
        '_,
        storage::git::Repository,
        Signer,
        impl cob::cache::Update<patch::Patch>,
    >,
    revision: patch::RevisionId,
    commit: git::Oid,
    working: &git::raw::Repository,
    signer: &Signer,
) -> Result<(), Error>
where
    Signer: crypto::Signer,
{
    let (latest, _) = patch.latest();
    let merged = patch.merge(revision, commit)?;

    if revision == latest {
        eprintln!(
            "{} Patch {} merged",
            term::PREFIX_SUCCESS,
            term::format::tertiary(merged.patch)
        );
    } else {
        eprintln!(
            "{} Patch {} merged at revision {}",
            term::PREFIX_SUCCESS,
            term::format::tertiary(merged.patch),
            term::format::dim(cli::terminal::format::oid(revision)),
        );
    }

    // Delete patch references that were created when the patch was opened.
    // Note that we don't return an error if we can't delete the refs, since it's
    // not critical.
    merged.cleanup(working, signer).ok();

    Ok(())
}

/// Push a single reference to storage.
fn push_ref(
    src: &git::Oid,
    dst: &git::fmt::Namespaced,
    force: bool,
    stored: &git::raw::Repository,
    verbosity: Verbosity,
    expected_refs: &[String],
    git: &impl GitService,
) -> Result<(), Error> {
    let path = dunce::canonicalize(stored.path())?.display().to_string();
    // Nb. The *force* indicator (`+`) is processed by Git tooling before we even reach this code.
    // This happens during the `list for-push` phase.
    let refspec = git::fmt::refspec::Refspec { src, dst, force };

    let mut args = vec!["send-pack".to_string()];

    let verbosity: git::Verbosity = verbosity.into();
    args.extend(verbosity.into_flag());

    args.extend([path.to_string(), refspec.to_string()]);

    for expected in expected_refs {
        args.push(format!(
            "--force-with-lease=refs/namespaces/{}/{expected}",
            dst.namespace()
        ));
    }

    // Rely on the environment variable `GIT_DIR`.
    let working = None;

    let output = git.send_pack(working, &args)?;

    if !output.status.success() {
        return Err(Error::SendPackFailed {
            stderr: String::from_utf8_lossy(&output.stderr).to_string(),
            stdout: String::from_utf8_lossy(&output.stdout).to_string(),
            status: output.status,
        });
    }

    Ok(())
}
