"""Unit tests for github/releases.py — git/gh subprocess wrappers (#106)."""
from __future__ import annotations

import subprocess
from pathlib import Path
from unittest.mock import MagicMock, patch

import pytest

from active_claude_github_mcp.github.releases import (
    ReleaseCommandError,
    clone_repo,
    commit_all,
    configure_user,
    create_annotated_tag,
    create_github_release,
    git_archive_tarball,
    head_sha,
    list_tracked_files,
    push_main,
    push_tag,
    remote_main_sha,
    replace_tree_contents,
    upload_release_asset,
)


def _ok(stdout: str = "", stderr: str = "", returncode: int = 0):
    mock = MagicMock(spec=subprocess.CompletedProcess)
    mock.stdout = stdout
    mock.stderr = stderr
    mock.returncode = returncode
    return mock


# ----- _run error wrapping ------------------------------------------------


class TestRunErrorWrapping:
    def test_nonzero_exit_raises_ReleaseCommandError(self, tmp_path):
        with patch("subprocess.run", return_value=_ok(returncode=1, stderr="boom")):
            with pytest.raises(ReleaseCommandError) as exc:
                head_sha(tmp_path)
            assert exc.value.returncode == 1
            assert "boom" in exc.value.stderr

    def test_filenotfound_raises_ReleaseCommandError(self, tmp_path):
        with patch("subprocess.run", side_effect=FileNotFoundError("git")):
            with pytest.raises(ReleaseCommandError) as exc:
                head_sha(tmp_path)
            assert exc.value.returncode == 127

    def test_timeout_raises_ReleaseCommandError(self, tmp_path):
        with patch(
            "subprocess.run",
            side_effect=subprocess.TimeoutExpired(["git"], 60),
        ):
            with pytest.raises(ReleaseCommandError) as exc:
                head_sha(tmp_path)
            assert exc.value.returncode == 124


# ----- Individual primitives ----------------------------------------------


class TestHeadSha:
    def test_returns_stripped_sha(self, tmp_path):
        with patch("subprocess.run", return_value=_ok(stdout="abc1234\n")):
            assert head_sha(tmp_path) == "abc1234"


class TestCreateAnnotatedTag:
    def test_invokes_git_tag_a(self, tmp_path):
        captured = {}

        def _capture(cmd, **kwargs):
            captured["cmd"] = cmd
            return _ok()

        with patch("subprocess.run", side_effect=_capture):
            create_annotated_tag(tmp_path, "v1.3.0", "release notes")

        assert captured["cmd"][:4] == ["git", "tag", "-a", "v1.3.0"]
        assert "-m" in captured["cmd"]
        assert "release notes" in captured["cmd"]


class TestPushTag:
    def test_invokes_git_push_origin_tag(self, tmp_path):
        captured = {}

        def _capture(cmd, **kwargs):
            captured["cmd"] = cmd
            return _ok()

        with patch("subprocess.run", side_effect=_capture):
            push_tag(tmp_path, "origin", "v1.3.0")

        assert captured["cmd"] == ["git", "push", "origin", "v1.3.0"]


class TestPushMain:
    def test_default_no_force(self, tmp_path):
        captured = {}

        def _capture(cmd, **kwargs):
            captured["cmd"] = cmd
            return _ok()

        with patch("subprocess.run", side_effect=_capture):
            push_main(tmp_path, "origin")
        assert captured["cmd"] == ["git", "push", "origin", "HEAD:main"]

    def test_force_uses_force_with_lease(self, tmp_path):
        captured = {}

        def _capture(cmd, **kwargs):
            captured["cmd"] = cmd
            return _ok()

        with patch("subprocess.run", side_effect=_capture):
            push_main(tmp_path, "origin", force=True)
        assert "--force-with-lease" in captured["cmd"]


class TestCloneRepo:
    def test_dest_exists_raises(self, tmp_path):
        existing = tmp_path / "x"
        existing.mkdir()
        with pytest.raises(ValueError, match="destination already exists"):
            clone_repo("https://example.invalid/r.git", existing)

    def test_invokes_git_clone(self, tmp_path):
        captured = {}

        def _capture(cmd, **kwargs):
            captured["cmd"] = cmd
            return _ok()

        target = tmp_path / "dest"
        with patch("subprocess.run", side_effect=_capture):
            clone_repo("https://example.invalid/r.git", target)
        assert captured["cmd"][:2] == ["git", "clone"]
        assert str(target) in captured["cmd"]


class TestListTrackedFiles:
    def test_filters_empty_lines(self, tmp_path):
        out = "a.py\n\nb/c.py\n\n"
        with patch("subprocess.run", return_value=_ok(stdout=out)):
            assert list_tracked_files(tmp_path) == ["a.py", "b/c.py"]


class TestConfigureUser:
    def test_sets_name_and_email(self, tmp_path):
        commands = []

        def _capture(cmd, **kwargs):
            commands.append(cmd)
            return _ok()

        with patch("subprocess.run", side_effect=_capture):
            configure_user(tmp_path, "Test", "test@example.invalid")

        assert commands[0] == ["git", "config", "user.name", "Test"]
        assert commands[1] == [
            "git", "config", "user.email", "test@example.invalid"
        ]


class TestCommitAll:
    def test_runs_add_then_commit(self, tmp_path):
        commands = []

        def _capture(cmd, **kwargs):
            commands.append(cmd)
            if cmd == ["git", "rev-parse", "HEAD"]:
                return _ok(stdout="newsha\n")
            return _ok()

        with patch("subprocess.run", side_effect=_capture):
            sha = commit_all(tmp_path, "msg")

        assert sha == "newsha"
        assert commands[0] == ["git", "add", "-A"]
        assert commands[1][:4] == ["git", "commit", "-m", "msg"]
        # No --allow-empty by default
        assert "--allow-empty" not in commands[1]

    def test_allow_empty(self, tmp_path):
        commands = []

        def _capture(cmd, **kwargs):
            commands.append(cmd)
            if cmd == ["git", "rev-parse", "HEAD"]:
                return _ok(stdout="sha\n")
            return _ok()

        with patch("subprocess.run", side_effect=_capture):
            commit_all(tmp_path, "msg", allow_empty=True)
        assert "--allow-empty" in commands[1]


class TestCreateGithubRelease:
    def test_returns_url_from_stdout(self, tmp_path):
        url = "https://github.com/o/r/releases/tag/v1.3.0"
        with patch("subprocess.run", return_value=_ok(stdout=f"...\n{url}\n")):
            got = create_github_release(
                tmp_path, "v1.3.0", "v1.3.0", tmp_path / "notes.md"
            )
        assert got == url


class TestRemoteMainSha:
    def test_extracts_sha_from_ls_remote(self, tmp_path):
        out = "deadbeef\trefs/heads/main\n"
        with patch("subprocess.run", return_value=_ok(stdout=out)):
            assert remote_main_sha(tmp_path, "origin") == "deadbeef"

    def test_empty_output_returns_none(self, tmp_path):
        with patch("subprocess.run", return_value=_ok(stdout="")):
            assert remote_main_sha(tmp_path, "origin") is None

    def test_nonzero_returns_none(self, tmp_path):
        with patch("subprocess.run", return_value=_ok(returncode=1)):
            assert remote_main_sha(tmp_path, "origin") is None


class TestReplaceTreeContents:
    def test_preserves_dot_git(self, tmp_path):
        target = tmp_path / "target"
        source = tmp_path / "source"
        target.mkdir()
        source.mkdir()
        (target / ".git").mkdir()
        (target / ".git" / "config").write_text("[core]\n")
        (target / "old.py").write_text("old")
        (target / "subdir").mkdir()
        (target / "subdir" / "stale.py").write_text("stale")
        (source / "new.py").write_text("new")
        (source / "pkg").mkdir()
        (source / "pkg" / "x.py").write_text("x")

        replace_tree_contents(target, source)

        assert (target / ".git" / "config").read_text() == "[core]\n"
        assert (target / "new.py").read_text() == "new"
        assert (target / "pkg" / "x.py").read_text() == "x"
        assert not (target / "old.py").exists()
        assert not (target / "subdir").exists()

    def test_target_must_exist(self, tmp_path):
        with pytest.raises(ValueError, match="target does not exist"):
            replace_tree_contents(tmp_path / "missing", tmp_path)


class TestUploadReleaseAsset:
    def test_passes_tag_and_path(self, tmp_path):
        asset = tmp_path / "active-claude-github.md"
        asset.write_text("# skill\n")
        with patch("subprocess.run", return_value=_ok()) as run:
            upload_release_asset(tmp_path, "v1.4.1", asset)
        cmd = run.call_args.args[0]
        assert cmd[:3] == ["gh", "release", "upload"]
        assert cmd[3] == "v1.4.1"
        assert cmd[4] == str(asset)

    def test_failure_raises_ReleaseCommandError(self, tmp_path):
        asset = tmp_path / "x.md"
        asset.write_text("x")
        with patch(
            "subprocess.run",
            return_value=_ok(returncode=1, stderr="asset already exists"),
        ):
            with pytest.raises(ReleaseCommandError, match="asset already exists"):
                upload_release_asset(tmp_path, "v1.4.1", asset)


class TestGitArchiveTarball:
    def test_command_shape(self, tmp_path):
        dest = tmp_path / "out.tar.gz"
        with patch("subprocess.run", return_value=_ok()) as run:
            git_archive_tarball(
                tmp_path, "v1.1.1", "active-claude-github-mcp-v1.1.1", dest
            )
        cmd = run.call_args.args[0]
        assert cmd[:2] == ["git", "archive"]
        assert "--format=tar.gz" in cmd
        assert "--prefix=active-claude-github-mcp-v1.1.1/" in cmd
        assert "-o" in cmd
        assert str(dest) in cmd
        assert cmd[-1] == "v1.1.1"

    def test_failure_raises_ReleaseCommandError(self, tmp_path):
        with patch(
            "subprocess.run",
            return_value=_ok(returncode=128, stderr="not a valid object name"),
        ):
            with pytest.raises(ReleaseCommandError, match="not a valid object name"):
                git_archive_tarball(
                    tmp_path, "v9.9.9", "x", tmp_path / "out.tar.gz"
                )


if __name__ == "__main__":
    import sys
    sys.exit(pytest.main([__file__, "-v"]))
