"""Tests for the Git adapter and compatibility models."""

from __future__ import annotations

import subprocess
from pathlib import Path

import git as gitpython
import pytest

from metabranch.exceptions import (
    BranchAlreadyExistsError,
    DirtyWorkingTreeError,
    InvalidBranchError,
    InvalidGitRepositoryError,
    InvalidMetabranchError,
)
from metabranch.git import (
    Branch,
    GitBranch,
    GitClient,
    Metabranch,
    MetabranchConfig,
    get_git,
    get_repo,
)
from metabranch.models import (
    MetabranchConfigFile,
    MetabranchDefinition,
)
from tests.helpers import commit_file, git


def test_git_client_core_operations(monkeypatch, repo_with_branches: Path) -> None:
    monkeypatch.chdir(repo_with_branches)
    git_client = GitClient()

    assert get_repo().working_tree_dir == str(repo_with_branches)
    assert get_git().rev_parse("--abbrev-ref", "HEAD") == "master"
    assert git_client.current_branch_name() == "master"
    assert git_client.branch_exists("feature-one") is True
    assert git_client.branch_exists("missing") is False
    assert git_client.tracking_branch_name("feature-one") is None
    assert git_client.tracking_branch_name("missing") is None
    assert git_client.ahead_behind("feature-one") is None
    assert set(git_client.list_branches()) == {"master", "feature-one", "feature-two"}
    assert git_client.read_text_from_branch("master", "README.md") == "hello"
    assert git_client.read_text_from_branch("master", "missing.txt") is None
    assert git_client.read_text_from_ref("HEAD", "README.md") == "hello"
    assert git_client.read_text_from_ref("HEAD", "missing.txt") is None
    assert "On branch master" in git_client.status()
    assert "On branch master" in git_client.status("master")
    assert git_client.status("missing") == "missing: missing"
    assert git_client.is_dirty() is False

    (repo_with_branches / ".metabranch.yaml").write_text("schema_version: 1\n")
    assert git_client.is_dirty() is True
    assert git_client.is_dirty(ignore_paths=(".metabranch.yaml",)) is False
    assert git_client.path_has_changes(".metabranch.yaml") is True
    git_client.discard_path_changes(".metabranch.yaml")
    assert (repo_with_branches / ".metabranch.yaml").exists() is False

    commit_file(repo_with_branches, "master_only.txt", "master\n", "Master update")
    head_sha = git(repo_with_branches, "rev-parse", "HEAD")
    assert git_client.commit_subject(head_sha) == "Master update"
    assert git_client.commit_message(head_sha) == "Master update"
    git_client.checkout("feature-one")
    git_client.rebase("master")
    assert git_client.is_ancestor(head_sha, "feature-one") is True
    assert git_client.is_ancestor(git(repo_with_branches, "rev-parse", "feature-two"), "feature-one") is False
    git_client.checkout("master")
    git_client.merge_ff_only("feature-one")
    assert (repo_with_branches / "feature_one.txt").read_text() == "one\n"

    git_client.checkout("merge-target", create=True)
    git_client.merge("feature-two")
    assert (repo_with_branches / "feature_two.txt").read_text() == "two\n"

    git_client.checkout("master")
    git(repo_with_branches, "checkout", "-b", "reconcile-target")
    feature_head = git(repo_with_branches, "rev-parse", "feature-two")
    git_client.cherry_pick_no_commit(feature_head)
    assert git_client.has_pending_changes() is True
    git_client.commit("Replay feature two")
    assert git_client.has_pending_changes() is False

    git_client.checkout("plain-cherry", create=True)
    git(repo_with_branches, "reset", "--hard", "master")
    git_client.cherry_pick(feature_head)
    assert git(repo_with_branches, "log", "-1", "--format=%s") == "Feature two"
    assert git_client.replayed_commit_origins("plain-cherry") == set()

    git_client.checkout("recorded-cherry", create=True)
    git(repo_with_branches, "reset", "--hard", "master")
    git_client.cherry_pick(feature_head, record_origin=True)
    assert feature_head in git_client.replayed_commit_origins("recorded-cherry")

    git_client.checkout("meta", create=True)
    git(repo_with_branches, "reset", "--hard", "master")
    (repo_with_branches / ".metabranch.yaml").write_text("schema_version: 1\n")
    (repo_with_branches / "left_staged.txt").write_text("left staged\n")
    git(repo_with_branches, "add", "left_staged.txt")
    assert (
        git_client.commit_only_path(
            ".metabranch.yaml",
            subject="metabranch: initialize config",
            body="Metabranch-Metadata: true",
        )
        is True
    )
    assert git_client.path_has_changes(".metabranch.yaml") is False
    assert git(repo_with_branches, "status", "--short") == "A  left_staged.txt"
    assert git_client.commit_paths(git(repo_with_branches, "rev-parse", "HEAD")) == (
        ".metabranch.yaml",
    )
    commit_file(repo_with_branches, "meta_only.txt", "meta\n", "Meta only")
    unique_commits = git_client.list_unique_commits(
        "meta",
        exclude_refs=("master", "feature-one", "feature-two", "reconcile-target"),
    )
    assert len(unique_commits) == 2
    assert [commit.subject for commit in unique_commits] == [
        "metabranch: initialize config",
        "Meta only",
    ]
    assert "Feature one" in git_client.recent_commit_subjects("feature-one")
    assert git_client.recent_commit_subjects("feature-one", max_count=0) == ()

    (repo_with_branches / "README.md").write_text("changed\n")
    assert git_client.is_dirty() is True
    git_client.reset_hard("HEAD")
    assert (repo_with_branches / "README.md").read_text() == "hello\n"
    assert git_client.is_dirty() is False

    head = git(repo_with_branches, "rev-parse", "HEAD")
    git(repo_with_branches, "checkout", head)
    assert git_client.current_branch_name() == "HEAD"


def test_git_client_tracking_helpers_cover_error_paths(
    monkeypatch, repo_with_branches: Path
) -> None:
    monkeypatch.chdir(repo_with_branches)
    git_client = GitClient()
    assert git_client.ref_commit_timestamp("does-not-exist") is None

    class BlankGit:
        def rev_list(self, *args):
            return ""

        def log(self, *args):
            return ""

        def diff_tree(self, *args):
            return ""

    class FailingGit:
        def rev_list(self, *args):
            raise gitpython.GitCommandError("rev-list", 1, stderr="boom")

        def log(self, *args):
            raise gitpython.GitCommandError("log", 1, stderr="boom")

    class TrackingHelperGitClient(GitClient):
        def __init__(self, fake_git) -> None:
            super().__init__()
            self._fake_git = fake_git

        @property
        def git(self):
            return self._fake_git

        def tracking_branch_name(self, name: str) -> str | None:
            return "origin/feature-one"

    blank_client = TrackingHelperGitClient(BlankGit())
    assert blank_client.ahead_behind("feature-one") == (0, 0)
    assert blank_client.ref_commit_timestamp("origin/feature-one") is None
    assert blank_client.list_unique_commits("feature-one", ("master",)) == []
    assert blank_client.commit_paths("deadbeef") == ()
    assert blank_client.replayed_commit_origins("missing") == set()

    failing_client = TrackingHelperGitClient(FailingGit())
    assert failing_client.ahead_behind("feature-one") is None

    class FailingIterRepo:
        def iter_commits(self, ref: str):
            raise gitpython.GitCommandError("log", 1, stderr="boom")

    class FailingIterGitClient(GitClient):
        @property
        def repo(self):
            return FailingIterRepo()

        def branch_exists(self, name: str) -> bool:
            return True

    assert FailingIterGitClient().replayed_commit_origins("feature-one") == set()


def test_git_client_commit_only_path_without_body(monkeypatch, repo_with_branches: Path) -> None:
    monkeypatch.chdir(repo_with_branches)
    git_client = GitClient()
    git_client.checkout("meta", create=True)

    assert (
        git_client.commit_only_path(
            ".metabranch.yaml",
            subject="metabranch: initialize config",
        )
        is False
    )

    (repo_with_branches / ".metabranch.yaml").write_text("schema_version: 1\n")
    assert (
        git_client.commit_only_path(
            ".metabranch.yaml",
            subject="metabranch: initialize config",
        )
        is True
    )


def test_git_client_discard_path_changes_ignores_missing_untracked_path(tmp_path: Path) -> None:
    class FailingResetGit:
        def reset(self, *args):
            raise gitpython.GitCommandError("reset", 1, stderr="boom")

    class DiscardOnlyGitClient(GitClient):
        def __init__(self) -> None:
            super().__init__()
            self._fake_git = FailingResetGit()

        @property
        def git(self):
            return self._fake_git

        @property
        def working_dir(self) -> Path:
            return tmp_path

        def read_text_from_ref(self, ref: str, path: str) -> str | None:
            return None

    DiscardOnlyGitClient().discard_path_changes("missing.txt")


def test_git_client_abort_in_progress_operation_covers_success_and_failures() -> None:
    calls: list[tuple[str, tuple[str, ...]]] = []

    class AbortHelperGit:
        def cherry_pick(self, *args):
            calls.append(("cherry_pick", args))
            raise gitpython.GitCommandError("cherry-pick", 1, stderr="boom")

        def rebase(self, *args):
            calls.append(("rebase", args))

        def merge(self, *args):
            calls.append(("merge", args))
            raise gitpython.GitCommandError("merge", 1, stderr="boom")

        def reset(self, *args):
            calls.append(("reset", args))

    class AbortHelperGitClient(GitClient):
        def __init__(self) -> None:
            super().__init__()
            self._fake_git = AbortHelperGit()

        @property
        def git(self):
            return self._fake_git

    AbortHelperGitClient().abort_in_progress_operation()
    assert calls == [
        ("cherry_pick", ("--abort",)),
        ("rebase", ("--abort",)),
        ("merge", ("--abort",)),
        ("reset", ("--hard", "HEAD")),
    ]


def test_git_client_abort_in_progress_operation_ignores_reset_failure() -> None:
    calls: list[tuple[str, tuple[str, ...]]] = []

    class FailingResetAbortGit:
        def cherry_pick(self, *args):
            calls.append(("cherry_pick", args))

        def rebase(self, *args):
            calls.append(("rebase", args))

        def merge(self, *args):
            calls.append(("merge", args))

        def reset(self, *args):
            calls.append(("reset", args))
            raise gitpython.GitCommandError("reset", 1, stderr="boom")

    class FailingResetAbortGitClient(GitClient):
        def __init__(self) -> None:
            super().__init__()
            self._fake_git = FailingResetAbortGit()

        @property
        def git(self):
            return self._fake_git

    FailingResetAbortGitClient().abort_in_progress_operation()
    assert calls[-1] == ("reset", ("--hard", "HEAD"))


def test_git_client_detects_and_continues_in_progress_operations(
    tmp_path: Path,
) -> None:
    calls: list[tuple[str, tuple[str, ...]]] = []

    class ContinueGit:
        def cherry_pick(self, *args):
            calls.append(("cherry_pick", args))

        def rebase(self, *args):
            calls.append(("rebase", args))

        def merge(self, *args):
            calls.append(("merge", args))

    class ContinueGitClient(GitClient):
        def __init__(self) -> None:
            super().__init__()
            self._fake_git = ContinueGit()

        @property
        def git(self):
            return self._fake_git

        @property
        def git_dir(self) -> Path:
            return tmp_path

    git_client = ContinueGitClient()

    assert git_client.in_progress_operation() is None
    assert git_client.continue_in_progress_operation() is None

    (tmp_path / "CHERRY_PICK_HEAD").write_text("abc123\n")
    assert git_client.in_progress_operation() == "cherry-pick"
    assert git_client.continue_in_progress_operation() == "cherry-pick"
    (tmp_path / "CHERRY_PICK_HEAD").unlink()

    (tmp_path / "rebase-apply").mkdir()
    assert git_client.in_progress_operation() == "rebase"
    assert git_client.continue_in_progress_operation() == "rebase"
    (tmp_path / "rebase-apply").rmdir()

    (tmp_path / "rebase-merge").mkdir()
    assert git_client.in_progress_operation() == "rebase"
    (tmp_path / "rebase-merge").rmdir()

    (tmp_path / "MERGE_HEAD").write_text("def456\n")
    assert git_client.in_progress_operation() == "merge"
    assert git_client.continue_in_progress_operation() == "merge"

    assert calls == [
        ("cherry_pick", ("--continue",)),
        ("rebase", ("--continue",)),
        ("merge", ("--continue",)),
    ]


def test_git_client_skips_in_progress_operations(tmp_path: Path) -> None:
    calls: list[tuple[str, tuple[str, ...]]] = []

    class SkipGit:
        def cherry_pick(self, *args):
            calls.append(("cherry_pick", args))

        def rebase(self, *args):
            calls.append(("rebase", args))

    class SkipGitClient(GitClient):
        def __init__(self) -> None:
            super().__init__()
            self._fake_git = SkipGit()

        @property
        def git(self):
            return self._fake_git

        @property
        def git_dir(self) -> Path:
            return tmp_path

    git_client = SkipGitClient()

    assert git_client.skip_in_progress_operation() is None

    (tmp_path / "CHERRY_PICK_HEAD").write_text("abc123\n")
    assert git_client.skip_in_progress_operation() == "cherry-pick"
    (tmp_path / "CHERRY_PICK_HEAD").unlink()

    (tmp_path / "rebase-merge").mkdir()
    assert git_client.skip_in_progress_operation() == "rebase"

    assert calls == [
        ("cherry_pick", ("--skip",)),
        ("rebase", ("--skip",)),
    ]


def test_git_client_has_unmerged_paths_uses_diff_output() -> None:
    class UnmergedGit:
        def __init__(self, output: str) -> None:
            self.output = output

        def diff(self, *args):
            return self.output

    class UnmergedGitClient(GitClient):
        def __init__(self, output: str) -> None:
            super().__init__()
            self._fake_git = UnmergedGit(output)

        @property
        def git(self):
            return self._fake_git

    assert UnmergedGitClient("").has_unmerged_paths() is False
    assert UnmergedGitClient("feature.txt\n").has_unmerged_paths() is True


def test_git_client_discard_path_changes_restores_tracked_file(tmp_path: Path) -> None:
    calls: list[tuple[str, tuple[str, ...]]] = []

    class RestoreGit:
        def restore(self, *args):
            calls.append(("restore", args))

    class RestoreGitClient(GitClient):
        def __init__(self) -> None:
            super().__init__()
            self._fake_git = RestoreGit()

        @property
        def git(self):
            return self._fake_git

        def read_text_from_ref(self, ref: str, path: str) -> str | None:
            return "tracked"

        @property
        def working_dir(self) -> Path:
            return tmp_path

    RestoreGitClient().discard_path_changes("tracked.txt")
    assert calls == [
        ("restore", ("--source=HEAD", "--staged", "--worktree", "--", "tracked.txt"))
    ]


def test_git_client_pull_and_push(monkeypatch, repo: Path, bare_remote: Path, tmp_path: Path) -> None:
    monkeypatch.chdir(repo)
    git(repo, "remote", "add", "origin", str(bare_remote))
    git_client = GitClient()
    git_client.push("master")

    git(repo, "checkout", "-b", "feature-one")
    commit_file(repo, "feature_one.txt", "one\n", "Feature one")
    git_client.push("feature-one")
    assert git_client.tracking_branch_name("feature-one") == "origin/feature-one"

    other = tmp_path / "other"
    subprocess.run(
        ["git", "clone", str(bare_remote), str(other)],
        check=True,
        capture_output=True,
        text=True,
    )
    git(other, "config", "user.name", "Metabranch Tester")
    git(other, "config", "user.email", "tester@example.com")
    git(other, "checkout", "feature-one")
    commit_file(other, "feature_one.txt", "updated\n", "Remote update")
    git(other, "push")

    git(repo, "fetch", "origin")
    assert git_client.ahead_behind("feature-one") == (0, 1)
    assert git_client.ref_commit_timestamp("origin/feature-one") is not None
    git_client.pull("feature-one")
    assert (repo / "feature_one.txt").read_text() == "updated\n"
    assert git_client.ahead_behind("feature-one") == (0, 0)

    git(repo, "checkout", "-b", "meta")
    commit_file(repo, ".metabranch.yaml", "schema_version: 1\n", "Config")
    git(repo, "push", "-u", "origin", "meta")

    git_client.push("meta")
    assert "refs/heads/meta" in git(bare_remote, "show-ref")
    assert git_client.tracking_branch_name("meta") == "origin/meta"
    assert git_client.ref_commit_timestamp(
        "origin/meta",
        path=".metabranch.yaml",
        first_addition=True,
    ) is not None


def test_git_client_invalid_repo_raises(tmp_path: Path, monkeypatch) -> None:
    monkeypatch.chdir(tmp_path)
    with pytest.raises(InvalidGitRepositoryError):
        GitClient().repo


def test_models_compatibility_layer(monkeypatch, repo_with_branches: Path, bare_remote: Path) -> None:
    monkeypatch.chdir(repo_with_branches)
    git(repo_with_branches, "remote", "add", "origin", str(bare_remote))
    git(repo_with_branches, "push", "-u", "origin", "master")
    git(repo_with_branches, "push", "-u", "origin", "feature-one")
    git(repo_with_branches, "push", "-u", "origin", "feature-two")

    branch = GitBranch(name="feature-one")
    assert branch.exists is True
    assert GitBranch(name="missing").exists is False
    assert (branch == object()) is False

    GitBranch(name="compat").checkout(create=True)
    with pytest.raises(BranchAlreadyExistsError):
        GitBranch(name="compat").checkout(create=True)
    with pytest.raises(gitpython.GitCommandError):
        GitBranch(name="missing").checkout()
    GitBranch(name="master").checkout()

    definition = MetabranchDefinition(
        name="meta",
        base_ref="master",
        branches=("feature-one",),
    )
    GitBranch(name="meta").checkout(create=True)
    config = MetabranchConfig(
        metabranch=Metabranch.from_definition(definition),
    )
    config.save()
    assert config.exists is True
    assert git(repo_with_branches, "log", "-1", "--format=%s") == "metabranch: initialize config"
    assert (
        git(repo_with_branches, "log", "-1", "--format=%B").strip().endswith(
            "Metabranch-Metadata: true"
        )
        is True
    )
    assert MetabranchConfigFile.exists_for(GitClient()) is True
    assert str(MetabranchConfigFile.path_for(GitClient())).endswith(".metabranch.yaml")
    MetabranchConfigFile.save_for(GitClient(), definition)

    loaded = Metabranch(name="meta")
    assert loaded.exists is True
    assert MetabranchConfigFile.exists_in_branch(GitClient(), "feature-two") is False
    loaded_definition = loaded.to_definition(base_ref="master")
    assert loaded_definition.name == "meta"
    loaded.add("feature-two")
    loaded.add("feature-two")
    assert [branch.name for branch in loaded.branches] == ["feature-two"]
    loaded.remove("feature-two")
    assert loaded.branches == []
    with pytest.raises(InvalidBranchError):
        loaded.add("missing")

    config.load()
    assert config.metabranch is not None
    assert config.metabranch.base_ref == "master"
    assert config.metabranch.status()[0].startswith("On branch")
    assert config.metabranch.status()[1] == "feature-one: available"

    with config as current:
        current.add("feature-two")
    config.load()
    assert config.metabranch is not None
    assert {branch.name for branch in config.metabranch.branches} == {
        "feature-one",
        "feature-two",
    }

    git(repo_with_branches, "push", "-u", "origin", "meta")
    config.metabranch.push()
    assert "refs/heads/feature-one" in git(bare_remote, "show-ref")

    commit_file(repo_with_branches, "master_again.txt", "master\n", "Master again")
    GitBranch(name="feature-one").rebase("master")
    git(repo_with_branches, "checkout", "meta")
    config.load()
    assert config.metabranch is not None
    config.metabranch.branches[:] = [Branch(name="feature-one"), Branch(name="feature-two")]
    config.metabranch.rebase("master")

    repo = gitpython.Repo(search_parent_directories=True)
    assert repo.active_branch.name == "meta"
    assert (repo_with_branches / "feature_one.txt").read_text() == "one\n"
    assert (repo_with_branches / "feature_two.txt").read_text() == "two\n"
    assert MetabranchConfigFile.exists_for(GitClient()) is True

    empty = MetabranchConfig()
    git(repo_with_branches, "checkout", "master")
    with pytest.raises(InvalidMetabranchError):
        empty.load()
    with pytest.raises(InvalidMetabranchError):
        MetabranchConfigFile.load_for(GitClient())
    with pytest.raises(InvalidMetabranchError):
        empty.save()

    original_load = MetabranchConfig.load

    def fake_load(self) -> None:
        self.metabranch = None

    MetabranchConfig.load = fake_load
    try:
        with pytest.raises(InvalidMetabranchError):
            empty.__enter__()
    finally:
        MetabranchConfig.load = original_load
