//! The `git.push` release step: push the release branch and tags, with
//! automatic recovery from a non-fast-forward rejection when the remote branch
//! advanced after the release commit/tag were created (issue #3611).
//!
//! Split out of `executor.rs` to keep the step module focused.

use crate::core::component::Component;
use crate::core::error::{Error, Result};

use super::super::advanced_remote::{
    push_release_branch, rebase_onto_advanced_remote_and_push, AdvancedRemoteRecovery,
};
use super::super::types::ReleaseStepResult;
use super::{step_failed, step_success};

pub(crate) fn run_git_push(
    component: &Component,
    component_id: &str,
    release_tag: Option<&str>,
    target_branch: Option<&str>,
) -> Result<ReleaseStepResult> {
    let current_branch = crate::core::git::current_branch(std::path::Path::new(
        &component.local_path,
    ))
    .ok_or_else(|| {
        Error::validation_invalid_argument(
            "branch",
            "Release push requires a checked-out branch",
            Some(component.local_path.clone()),
            Some(vec![
                "Check out the release branch before running `homeboy release`.".to_string(),
            ]),
        )
    })?;
    let branch = target_branch.unwrap_or(&current_branch);
    if branch != current_branch {
        log_status!(
            "release",
            "Pushing release commit from local branch '{}' to default branch '{}'.",
            current_branch,
            branch
        );
    }
    let output = push_release_branch(component, component_id, &branch)?;
    let data = serde_json::to_value(&output)
        .map_err(|e| Error::internal_json(e.to_string(), Some("git push output".to_string())))?;

    if output.success {
        return Ok(step_success("git.push", "git.push", Some(data), Vec::new()));
    }

    // The branch push was rejected. When the remote branch advanced after the
    // release commit + tag were created (issue #3611), git rejects the branch
    // ref as non-fast-forward — typically leaving the tag pushed but the branch
    // behind. Attempt a clean, non-force recovery: fetch, rebase the release
    // commit onto the advanced remote head, and re-push the branch.
    if is_non_fast_forward_rejection(&output.stderr) {
        match recover_advanced_remote_push(component, component_id, &branch, release_tag) {
            Ok(Some(recovered)) => {
                let AdvancedRemoteRecovery { push, advance } = recovered;
                let recovered_data = serde_json::to_value(&push).map_err(|e| {
                    Error::internal_json(e.to_string(), Some("git push output".to_string()))
                })?;
                // Make the race loud: the reviewed dry-run plan no longer matches
                // the published release contents (issue #6141). Skip the warning
                // for no-op recoveries (already reconciled / spurious rejection).
                if !advance.is_noop() {
                    advance.warn(&branch);
                }
                log_status!(
                    "release",
                    "Remote {} advanced during release — rebased the release commit onto the new head and re-pushed.",
                    branch
                );
                let mut data = serde_json::json!({
                    "success": true,
                    "recovered": "advanced-remote-rebased",
                    "branch": branch,
                    "push": recovered_data,
                });
                if !advance.is_noop() {
                    data["concurrent_advance"] = advance.to_json();
                }
                return Ok(step_success("git.push", "git.push", Some(data), Vec::new()));
            }
            Ok(None) => {
                // Recovery was not safe to perform automatically; fall through
                // to the failure path with explicit recovery guidance.
            }
            Err(recover_err) => {
                log_status!(
                    "release",
                    "⚠ Automatic recovery from advanced remote failed: {}",
                    recover_err
                );
            }
        }

        let error = push_error_message(&output);
        return Ok(step_failed(
            "git.push",
            "git.push",
            Some(data),
            Some(error),
            non_fast_forward_recovery_hints(component_id, &branch),
        ));
    }

    let error = push_error_message(&output);
    Ok(step_failed(
        "git.push",
        "git.push",
        Some(data),
        Some(error),
        Vec::new(),
    ))
}

/// Recover from a non-fast-forward branch rejection caused by the remote
/// advancing after the release commit/tag were created (issues #3611, #5502).
///
/// Fetches `origin`, then classifies the local/remote relationship: an
/// already-reconciled or spurious rejection (remote head equal to or contained
/// in HEAD) is re-pushed directly; a genuine divergence is reconciled by
/// [`rebase_onto_advanced_remote_and_push`], which rebases HEAD onto the advanced
/// remote head, moves the release tag onto the rebased commit (issue #5502), and
/// re-pushes the branch.
///
/// Returns:
/// - `Ok(Some(recovery))` when the re-push (with or without a rebase) was issued,
/// - `Ok(None)` when automatic recovery is unsafe (rebase conflict, or the remote
///   branch is unexpectedly gone) — the caller emits manual guidance,
/// - `Err(_)` on an unexpected git failure.
fn recover_advanced_remote_push(
    component: &Component,
    component_id: &str,
    branch: &str,
    release_tag: Option<&str>,
) -> Result<Option<AdvancedRemoteRecovery>> {
    let path = &component.local_path;
    crate::core::git::fetch_origin(path)?;

    let Some(remote_commit) = crate::core::git::remote_branch_commit(path, branch)? else {
        // The branch is not on the remote at all — non-fast-forward against a
        // missing branch is unexpected; don't guess, defer to manual recovery.
        return Ok(None);
    };
    let head_commit = crate::core::git::get_head_commit(path)?;

    // Already reconciled (a retry after a manual fix), or the remote head is
    // already contained in HEAD (spurious rejection): re-push directly without a
    // rebase. Confirming an ancestor relationship also guards against replaying
    // onto unrelated history in the divergent branch below.
    if remote_commit == head_commit
        || crate::core::git::is_ancestor(path, &remote_commit, &head_commit)?
    {
        return push_release_branch(component, component_id, branch)
            .map(|push| Some(AdvancedRemoteRecovery::without_advance(push)));
    }

    // Histories diverged: the remote advanced after the release commit. Rebase
    // the release commit onto the advanced remote head and re-push (never force).
    log_status!(
        "release",
        "Rebasing release commit onto advanced remote {} ({})...",
        branch,
        &remote_commit[..remote_commit.len().min(8)]
    );
    rebase_onto_advanced_remote_and_push(
        component,
        component_id,
        branch,
        &head_commit,
        &remote_commit,
        release_tag,
    )
}

/// True when git's stderr indicates a non-fast-forward / stale-remote branch
/// rejection — the signature of the advanced-remote race in issue #3611.
pub(crate) fn is_non_fast_forward_rejection(stderr: &str) -> bool {
    let lower = stderr.to_ascii_lowercase();
    lower.contains("[rejected]")
        || lower.contains("non-fast-forward")
        || lower.contains("fetch first")
        || lower.contains("tip of your current branch is behind")
        || lower.contains("updates were rejected")
}

fn push_error_message(output: &crate::core::git::GitOutput) -> String {
    let stderr = output.stderr.trim();
    if !stderr.is_empty() {
        return stderr.to_string();
    }
    let stdout = output.stdout.trim();
    if !stdout.is_empty() {
        return stdout.to_string();
    }
    "git push failed".to_string()
}

/// Hints emitted when the branch push was rejected as non-fast-forward and
/// automatic recovery did not complete. They give the operator a deterministic,
/// non-force recovery path (issue #3611).
fn non_fast_forward_recovery_hints(
    component_id: &str,
    branch: &str,
) -> Vec<crate::core::error::Hint> {
    vec![
        crate::core::error::Hint {
            message: format!(
                "Remote '{}' advanced after the release commit/tag were created. The tag may already be pushed; the branch was rejected as non-fast-forward.",
                branch
            ),
        },
        crate::core::error::Hint {
            message: format!(
                "Reconcile and finish the release without re-tagging or force-pushing: homeboy release {} --recover",
                component_id
            ),
        },
        crate::core::error::Hint {
            message: format!(
                "Or resolve manually: git fetch origin && git rebase origin/{branch} && git push origin HEAD:{branch}",
            ),
        },
    ]
}

#[cfg(test)]
mod tests {
    use super::{is_non_fast_forward_rejection, run_git_push};
    use crate::core::component::Component;
    use crate::core::release::types::ReleaseStepStatus;
    use std::process::Command;

    fn git(path: &std::path::Path, args: &[&str]) {
        let output = Command::new("git")
            .args(args)
            .current_dir(path)
            .output()
            .expect("run git");
        assert!(
            output.status.success(),
            "git {:?} failed: {}",
            args,
            String::from_utf8_lossy(&output.stderr)
        );
    }

    #[test]
    fn git_push_step_fails_when_git_push_fails() {
        let temp = tempfile::tempdir().expect("tempdir");
        let init = Command::new("git")
            .arg("init")
            .current_dir(temp.path())
            .output()
            .expect("git init");
        assert!(init.status.success());

        let component = Component {
            id: "fixture".to_string(),
            local_path: temp.path().to_string_lossy().to_string(),
            ..Component::default()
        };

        let result = run_git_push(&component, "fixture", None, None)
            .expect("push step should return result");

        assert_eq!(result.status, ReleaseStepStatus::Failed);
        assert!(!result.error.unwrap().trim().is_empty());
        assert_eq!(
            result
                .data
                .and_then(|data| data.get("success").and_then(serde_json::Value::as_bool)),
            Some(false)
        );
    }

    #[test]
    fn test_run_git_push_without_upstream() {
        let local = tempfile::tempdir().expect("local tempdir");
        let remote = tempfile::tempdir().expect("remote tempdir");
        git(remote.path(), &["init", "--bare"]);
        git(local.path(), &["init"]);
        git(local.path(), &["checkout", "-b", "main"]);
        git(local.path(), &["config", "user.name", "Homeboy Test"]);
        git(
            local.path(),
            &["config", "user.email", "homeboy@example.test"],
        );
        git(
            local.path(),
            &[
                "remote",
                "add",
                "origin",
                remote.path().to_str().expect("remote path"),
            ],
        );
        std::fs::write(local.path().join("release.txt"), "release").expect("write fixture");
        git(local.path(), &["add", "release.txt"]);
        git(local.path(), &["commit", "-m", "release: v1.0.0"]);
        git(
            local.path(),
            &["tag", "-a", "v1.0.0", "-m", "Release v1.0.0"],
        );

        let upstream = Command::new("git")
            .args(["rev-parse", "--abbrev-ref", "@{upstream}"])
            .current_dir(local.path())
            .output()
            .expect("check upstream");
        assert!(
            !upstream.status.success(),
            "fixture should not have upstream"
        );

        let component = Component {
            id: "fixture".to_string(),
            local_path: local.path().to_string_lossy().to_string(),
            ..Component::default()
        };

        let result = run_git_push(&component, "fixture", Some("v1.0.0"), None)
            .expect("push step should return result");

        assert_eq!(result.status, ReleaseStepStatus::Success);
        git(remote.path(), &["show-ref", "--verify", "refs/heads/main"]);
        git(remote.path(), &["show-ref", "--verify", "refs/tags/v1.0.0"]);
    }

    #[test]
    fn run_git_push_can_push_release_worktree_head_to_default_branch() {
        let local = tempfile::tempdir().expect("local tempdir");
        let remote = tempfile::tempdir().expect("remote tempdir");
        git(remote.path(), &["init", "--bare", "-b", "main"]);
        git(
            local.path(),
            &["clone", remote.path().to_str().unwrap(), "."],
        );
        git(local.path(), &["config", "user.name", "Homeboy Test"]);
        git(
            local.path(),
            &["config", "user.email", "homeboy@example.test"],
        );
        std::fs::write(local.path().join("README.md"), "base").expect("write fixture");
        git(local.path(), &["add", "."]);
        git(local.path(), &["commit", "-m", "base"]);
        git(local.path(), &["push", "origin", "main"]);

        git(local.path(), &["checkout", "-q", "-b", "release/v1.0.0"]);
        std::fs::write(local.path().join("release.txt"), "release").expect("write release");
        git(local.path(), &["add", "."]);
        git(local.path(), &["commit", "-m", "release: v1.0.0"]);
        git(
            local.path(),
            &["tag", "-a", "v1.0.0", "-m", "Release v1.0.0"],
        );

        let component = Component {
            id: "fixture".to_string(),
            local_path: local.path().to_string_lossy().to_string(),
            ..Component::default()
        };

        let result = run_git_push(&component, "fixture", Some("v1.0.0"), Some("main"))
            .expect("push step should return result");

        assert_eq!(result.status, ReleaseStepStatus::Success);
        git(remote.path(), &["show-ref", "--verify", "refs/heads/main"]);
        git(remote.path(), &["show-ref", "--verify", "refs/tags/v1.0.0"]);
        git(local.path(), &["fetch", "origin"]);
        let remote_main_subjects = Command::new("git")
            .args(["log", "--format=%s", "origin/main"])
            .current_dir(local.path())
            .output()
            .expect("git log");
        assert!(String::from_utf8_lossy(&remote_main_subjects.stdout).contains("release: v1.0.0"));
        assert_eq!(
            Command::new("git")
                .args(["branch", "--show-current"])
                .current_dir(local.path())
                .output()
                .expect("git branch")
                .stdout,
            b"release/v1.0.0\n".to_vec()
        );
    }

    #[test]
    fn test_is_non_fast_forward_rejection() {
        // The exact shape of git's stderr from issue #3611's failed push.
        let stderr = " ! [rejected]        HEAD -> main (fetch first)\n\
            error: failed to push some refs to 'https://github.com/owner/repo.git'\n\
            hint: Updates were rejected because the remote contains work that you do not\n\
            hint: have locally.";
        assert!(is_non_fast_forward_rejection(stderr));
        assert!(is_non_fast_forward_rejection("hint: non-fast-forward"));
        assert!(is_non_fast_forward_rejection(
            "Updates were rejected because the tip of your current branch is behind"
        ));
        // Unrelated failures must not trigger the rebase-recovery path.
        assert!(!is_non_fast_forward_rejection(
            "fatal: Authentication failed"
        ));
        assert!(!is_non_fast_forward_rejection(""));
    }

    /// Issue #3611: when the remote branch advances after the release commit and
    /// tag are created, `run_git_push` must rebase the release commit onto the
    /// advanced remote head and re-push — without force-pushing or re-tagging.
    #[test]
    fn run_git_push_recovers_when_remote_advanced() {
        let remote = tempfile::tempdir().expect("remote tempdir");
        let other = tempfile::tempdir().expect("other clone tempdir");
        let local = tempfile::tempdir().expect("local tempdir");
        git(remote.path(), &["init", "--bare", "-b", "main"]);

        let setup_identity = |dir: &std::path::Path| {
            git(dir, &["config", "user.name", "Homeboy Test"]);
            git(dir, &["config", "user.email", "homeboy@example.test"]);
            git(dir, &["config", "commit.gpgsign", "false"]);
        };

        // Seed the remote with an initial commit via the "other" clone.
        git(
            other.path(),
            &["clone", remote.path().to_str().unwrap(), "."],
        );
        setup_identity(other.path());
        std::fs::write(other.path().join("base.txt"), "base").unwrap();
        git(other.path(), &["add", "."]);
        git(other.path(), &["commit", "-m", "base"]);
        git(other.path(), &["push", "origin", "main"]);

        // The release clone starts from that base.
        git(
            local.path(),
            &["clone", remote.path().to_str().unwrap(), "."],
        );
        setup_identity(local.path());

        // The remote advances AFTER the release clone was made.
        std::fs::write(other.path().join("advance.txt"), "advance").unwrap();
        git(other.path(), &["add", "."]);
        git(other.path(), &["commit", "-m", "remote advance"]);
        git(other.path(), &["push", "origin", "main"]);

        // The release commit + tag are created locally (mirroring the release
        // pipeline state right before the racing push).
        std::fs::write(local.path().join("release.txt"), "release").unwrap();
        git(local.path(), &["add", "."]);
        git(local.path(), &["commit", "-m", "release: v1.0.0"]);
        git(
            local.path(),
            &["tag", "-a", "v1.0.0", "-m", "Release v1.0.0"],
        );

        let component = Component {
            id: "fixture".to_string(),
            local_path: local.path().to_string_lossy().to_string(),
            ..Component::default()
        };

        let result = run_git_push(&component, "fixture", Some("v1.0.0"), None)
            .expect("push step returns a result");

        assert_eq!(
            result.status,
            ReleaseStepStatus::Success,
            "push should recover from the advanced remote: {:?}",
            result.error
        );
        assert_eq!(
            result
                .data
                .as_ref()
                .and_then(|d| d.get("recovered").and_then(serde_json::Value::as_str)),
            Some("advanced-remote-rebased")
        );

        // Issue #6141: the recovery must surface the newly-included commit range
        // (the commit that landed on the remote during the release window) in
        // structured output so downstream summaries can report what changed.
        let advance = result
            .data
            .as_ref()
            .and_then(|d| d.get("concurrent_advance"))
            .expect("concurrent_advance must be present after an advanced-remote recovery");
        assert_eq!(
            advance.get("count").and_then(serde_json::Value::as_u64),
            Some(1),
            "exactly one commit ('remote advance') was auto-included: {:?}",
            advance
        );
        let commits = advance
            .get("commits")
            .and_then(serde_json::Value::as_array)
            .expect("commits array");
        assert!(
            commits
                .iter()
                .any(|c| c.get("subject").and_then(serde_json::Value::as_str)
                    == Some("remote advance")),
            "newly-included range must list the 'remote advance' commit: {:?}",
            commits
        );
        assert!(
            advance
                .get("range")
                .and_then(serde_json::Value::as_str)
                .is_some_and(|r| r.contains("..")),
            "advance must expose a base..head range: {:?}",
            advance
        );

        // The remote main now contains BOTH the remote advance and the release
        // commit (the release commit was rebased on top), and the tag is pushed.
        git(remote.path(), &["show-ref", "--verify", "refs/tags/v1.0.0"]);
        // Refresh remote-tracking ref first.
        git(local.path(), &["fetch", "origin"]);
        let log = Command::new("git")
            .args(["log", "--format=%s", "origin/main"])
            .current_dir(local.path())
            .output()
            .expect("git log");
        let subjects = String::from_utf8_lossy(&log.stdout);
        assert!(
            subjects.contains("release: v1.0.0"),
            "remote main must contain the release commit, got: {}",
            subjects
        );
        assert!(
            subjects.contains("remote advance"),
            "remote main must retain the advance commit (no force-push), got: {}",
            subjects
        );

        // Issue #5502: the tag must follow the rebased release commit so it is an
        // ancestor of the pushed branch — not stranded on the orphaned original.
        let head = rev(local.path(), "origin/main");
        let tag_commit = rev(local.path(), "v1.0.0^{commit}");
        assert_eq!(
            tag_commit, head,
            "tag v1.0.0 must point at the rebased release commit on origin/main"
        );
        // And the same on the remote itself (the moved tag was force-pushed).
        let remote_tag = rev(remote.path(), "v1.0.0^{commit}");
        let remote_head = rev(remote.path(), "main");
        assert_eq!(
            remote_tag, remote_head,
            "remote tag v1.0.0 must point at remote main's HEAD (the release commit)"
        );

        // The ancestry invariant deploy relies on must hold.
        let is_ancestor = Command::new("git")
            .args(["merge-base", "--is-ancestor", "v1.0.0", "origin/main"])
            .current_dir(local.path())
            .status()
            .expect("merge-base");
        assert!(
            is_ancestor.success(),
            "tag v1.0.0 must be an ancestor of origin/main (deploy ancestry invariant)"
        );

        // Exactly one release commit exists on the branch line (no duplicate).
        let release_commits = subjects
            .lines()
            .filter(|s| s.trim() == "release: v1.0.0")
            .count();
        assert_eq!(
            release_commits, 1,
            "exactly one release commit must exist on the branch, got: {}",
            subjects
        );
    }

    fn rev(dir: &std::path::Path, refname: &str) -> String {
        let output = Command::new("git")
            .args(["rev-parse", refname])
            .current_dir(dir)
            .output()
            .expect("git rev-parse");
        assert!(
            output.status.success(),
            "git rev-parse {} failed: {}",
            refname,
            String::from_utf8_lossy(&output.stderr)
        );
        String::from_utf8_lossy(&output.stdout).trim().to_string()
    }
}
