"""Tests for git operations."""

from pathlib import Path
from unittest.mock import MagicMock

import pytest

from repo_sync_kitty.git.operations import (
    CloneError,
    FetchError,
    GitError,
    PushError,
    RepoManager,
)
from repo_sync_kitty.git.retry import RetryConfig, RetryManager, with_retry
from repo_sync_kitty.git.safety import SafetyChecker, SafetyReport, check_safe_to_pull


class TestRepoManager:
    """Tests for RepoManager class."""

    def test_exists_returns_false_for_nonexistent_path(self, tmp_path: Path) -> None:
        """Test exists() returns False when path doesn't exist."""
        mgr = RepoManager(tmp_path / "nonexistent")
        assert not mgr.exists()

    def test_exists_returns_false_for_non_git_directory(self, tmp_path: Path) -> None:
        """Test exists() returns False for directory without .git."""
        repo_path = tmp_path / "not-a-repo"
        repo_path.mkdir()
        mgr = RepoManager(repo_path)
        assert not mgr.exists()

    def test_exists_returns_true_for_git_directory(self, tmp_path: Path) -> None:
        """Test exists() returns True when .git directory present."""
        repo_path = tmp_path / "repo"
        repo_path.mkdir()
        (repo_path / ".git").mkdir()
        mgr = RepoManager(repo_path)
        assert mgr.exists()

    def test_repo_property_raises_for_non_repo(self, tmp_path: Path) -> None:
        """Test repo property raises GitError for non-repo path."""
        mgr = RepoManager(tmp_path / "nonexistent")
        with pytest.raises(GitError):
            _ = mgr.repo

    def test_is_rebasing_false_when_no_rebase(self, tmp_path: Path) -> None:
        """Test is_rebasing() returns False when no rebase in progress."""
        repo_path = tmp_path / "repo"
        repo_path.mkdir()
        (repo_path / ".git").mkdir()
        mgr = RepoManager(repo_path)
        assert not mgr.is_rebasing()

    def test_is_rebasing_true_when_rebase_merge_exists(self, tmp_path: Path) -> None:
        """Test is_rebasing() returns True when rebase-merge exists."""
        repo_path = tmp_path / "repo"
        repo_path.mkdir()
        git_dir = repo_path / ".git"
        git_dir.mkdir()
        (git_dir / "rebase-merge").mkdir()
        mgr = RepoManager(repo_path)
        assert mgr.is_rebasing()

    def test_is_rebasing_true_when_rebase_apply_exists(self, tmp_path: Path) -> None:
        """Test is_rebasing() returns True when rebase-apply exists."""
        repo_path = tmp_path / "repo"
        repo_path.mkdir()
        git_dir = repo_path / ".git"
        git_dir.mkdir()
        (git_dir / "rebase-apply").mkdir()
        mgr = RepoManager(repo_path)
        assert mgr.is_rebasing()

    def test_is_merging_false_when_no_merge(self, tmp_path: Path) -> None:
        """Test is_merging() returns False when no merge in progress."""
        repo_path = tmp_path / "repo"
        repo_path.mkdir()
        (repo_path / ".git").mkdir()
        mgr = RepoManager(repo_path)
        assert not mgr.is_merging()

    def test_is_merging_true_when_merge_head_exists(self, tmp_path: Path) -> None:
        """Test is_merging() returns True when MERGE_HEAD exists."""
        repo_path = tmp_path / "repo"
        repo_path.mkdir()
        git_dir = repo_path / ".git"
        git_dir.mkdir()
        (git_dir / "MERGE_HEAD").touch()
        mgr = RepoManager(repo_path)
        assert mgr.is_merging()

    def test_is_cherry_picking_false_when_no_cherry_pick(self, tmp_path: Path) -> None:
        """Test is_cherry_picking() returns False when no cherry-pick."""
        repo_path = tmp_path / "repo"
        repo_path.mkdir()
        (repo_path / ".git").mkdir()
        mgr = RepoManager(repo_path)
        assert not mgr.is_cherry_picking()

    def test_is_cherry_picking_true_when_cherry_pick_head_exists(
        self, tmp_path: Path
    ) -> None:
        """Test is_cherry_picking() returns True when CHERRY_PICK_HEAD exists."""
        repo_path = tmp_path / "repo"
        repo_path.mkdir()
        git_dir = repo_path / ".git"
        git_dir.mkdir()
        (git_dir / "CHERRY_PICK_HEAD").touch()
        mgr = RepoManager(repo_path)
        assert mgr.is_cherry_picking()

    def test_get_in_progress_operation_none(self, tmp_path: Path) -> None:
        """Test get_in_progress_operation() returns None when clean."""
        repo_path = tmp_path / "repo"
        repo_path.mkdir()
        (repo_path / ".git").mkdir()
        mgr = RepoManager(repo_path)
        assert mgr.get_in_progress_operation() is None

    @pytest.mark.parametrize(
        "marker,expected",
        [
            ("rebase-merge", "rebase"),
            ("rebase-apply", "rebase"),
        ],
    )
    def test_get_in_progress_operation_rebase(
        self, tmp_path: Path, marker: str, expected: str
    ) -> None:
        """Test get_in_progress_operation() detects rebase."""
        repo_path = tmp_path / "repo"
        repo_path.mkdir()
        git_dir = repo_path / ".git"
        git_dir.mkdir()
        (git_dir / marker).mkdir()
        mgr = RepoManager(repo_path)
        assert mgr.get_in_progress_operation() == expected

    def test_get_in_progress_operation_merge(self, tmp_path: Path) -> None:
        """Test get_in_progress_operation() detects merge."""
        repo_path = tmp_path / "repo"
        repo_path.mkdir()
        git_dir = repo_path / ".git"
        git_dir.mkdir()
        (git_dir / "MERGE_HEAD").touch()
        mgr = RepoManager(repo_path)
        assert mgr.get_in_progress_operation() == "merge"

    def test_get_in_progress_operation_cherry_pick(self, tmp_path: Path) -> None:
        """Test get_in_progress_operation() detects cherry-pick."""
        repo_path = tmp_path / "repo"
        repo_path.mkdir()
        git_dir = repo_path / ".git"
        git_dir.mkdir()
        (git_dir / "CHERRY_PICK_HEAD").touch()
        mgr = RepoManager(repo_path)
        assert mgr.get_in_progress_operation() == "cherry-pick"


class TestRepoManagerWithMockedRepo:
    """Tests for RepoManager using mocked GitPython."""

    def test_get_current_branch_returns_name(self, tmp_path: Path) -> None:
        """Test get_current_branch() returns branch name."""
        repo_path = tmp_path / "repo"
        repo_path.mkdir()
        (repo_path / ".git").mkdir()
        mgr = RepoManager(repo_path)

        mock_repo = MagicMock()
        mock_repo.head.is_detached = False
        mock_repo.active_branch.name = "main"
        mgr._repo = mock_repo

        assert mgr.get_current_branch() == "main"

    def test_get_current_branch_returns_none_when_detached(
        self, tmp_path: Path
    ) -> None:
        """Test get_current_branch() returns None when detached."""
        repo_path = tmp_path / "repo"
        repo_path.mkdir()
        (repo_path / ".git").mkdir()
        mgr = RepoManager(repo_path)

        mock_repo = MagicMock()
        mock_repo.head.is_detached = True
        mgr._repo = mock_repo

        assert mgr.get_current_branch() is None

    def test_get_remotes_returns_list(self, tmp_path: Path) -> None:
        """Test get_remotes() returns list of remote names."""
        repo_path = tmp_path / "repo"
        repo_path.mkdir()
        (repo_path / ".git").mkdir()
        mgr = RepoManager(repo_path)

        mock_origin = MagicMock()
        mock_origin.name = "origin"
        mock_upstream = MagicMock()
        mock_upstream.name = "upstream"
        mock_repo = MagicMock()
        mock_repo.remotes = [mock_origin, mock_upstream]
        mgr._repo = mock_repo

        assert mgr.get_remotes() == ["origin", "upstream"]

    def test_is_clean_returns_true_when_clean(self, tmp_path: Path) -> None:
        """Test is_clean() returns True when no changes."""
        repo_path = tmp_path / "repo"
        repo_path.mkdir()
        (repo_path / ".git").mkdir()
        mgr = RepoManager(repo_path)

        mock_repo = MagicMock()
        mock_repo.is_dirty.return_value = False
        mgr._repo = mock_repo

        assert mgr.is_clean()
        mock_repo.is_dirty.assert_called_once_with(untracked_files=True)

    def test_is_clean_returns_false_when_dirty(self, tmp_path: Path) -> None:
        """Test is_clean() returns False when changes exist."""
        repo_path = tmp_path / "repo"
        repo_path.mkdir()
        (repo_path / ".git").mkdir()
        mgr = RepoManager(repo_path)

        mock_repo = MagicMock()
        mock_repo.is_dirty.return_value = True
        mgr._repo = mock_repo

        assert not mgr.is_clean()

    def test_is_detached_returns_true(self, tmp_path: Path) -> None:
        """Test is_detached() returns True when HEAD is detached."""
        repo_path = tmp_path / "repo"
        repo_path.mkdir()
        (repo_path / ".git").mkdir()
        mgr = RepoManager(repo_path)

        mock_repo = MagicMock()
        mock_repo.head.is_detached = True
        mgr._repo = mock_repo

        assert mgr.is_detached()


class TestRepoManagerGitOperations:
    """Tests for RepoManager git operations with mocked Repo."""

    def _make_mgr(self, tmp_path: Path) -> tuple[RepoManager, MagicMock]:
        repo_path = tmp_path / "repo"
        repo_path.mkdir()
        (repo_path / ".git").mkdir()
        mgr = RepoManager(repo_path)
        mock_repo = MagicMock()
        mgr._repo = mock_repo
        return mgr, mock_repo

    def test_get_remote_urls(self, tmp_path: Path) -> None:
        mgr, mock_repo = self._make_mgr(tmp_path)
        r1 = MagicMock()
        r1.name = "origin"
        r1.url = "https://github.com/o/r.git"
        r2 = MagicMock()
        r2.name = "upstream"
        r2.url = "https://github.com/u/r.git"
        mock_repo.remotes = [r1, r2]
        result = mgr.get_remote_urls()
        assert result == {
            "origin": "https://github.com/o/r.git",
            "upstream": "https://github.com/u/r.git",
        }

    def test_has_staged_changes(self, tmp_path: Path) -> None:
        mgr, mock_repo = self._make_mgr(tmp_path)
        mock_repo.index.diff.return_value = [MagicMock()]
        assert mgr.has_staged_changes() is True

    def test_has_modified_files(self, tmp_path: Path) -> None:
        mgr, mock_repo = self._make_mgr(tmp_path)
        mock_repo.index.diff.return_value = [MagicMock()]
        assert mgr.has_modified_files() is True

    def test_has_untracked_files(self, tmp_path: Path) -> None:
        mgr, mock_repo = self._make_mgr(tmp_path)
        mock_repo.untracked_files = ["file.txt"]
        assert mgr.has_untracked_files() is True

    def test_get_head_commit(self, tmp_path: Path) -> None:
        mgr, mock_repo = self._make_mgr(tmp_path)
        mock_repo.head.commit.hexsha = "abc123"
        assert mgr.get_head_commit() == "abc123"

    def test_checkout(self, tmp_path: Path) -> None:
        mgr, mock_repo = self._make_mgr(tmp_path)
        mock_head = MagicMock()
        mock_repo.heads.__getitem__ = MagicMock(return_value=mock_head)
        mgr.checkout("main")
        mock_head.checkout.assert_called_once()

    def test_get_branches_at_head(self, tmp_path: Path) -> None:
        mgr, mock_repo = self._make_mgr(tmp_path)
        mock_repo.head.commit.hexsha = "abc123"
        b1 = MagicMock()
        b1.name = "main"
        b1.commit.hexsha = "abc123"
        b2 = MagicMock()
        b2.name = "develop"
        b2.commit.hexsha = "def456"
        mock_repo.heads = [b1, b2]
        assert mgr.get_branches_at_head() == ["main"]

    def test_get_remote_branches_at_head(self, tmp_path: Path) -> None:
        mgr, mock_repo = self._make_mgr(tmp_path)
        mock_repo.head.commit.hexsha = "abc123"
        ref = MagicMock()
        ref.commit.hexsha = "abc123"
        ref.name = "origin/main"
        remote = MagicMock()
        remote.name = "origin"
        remote.refs = [ref]
        mock_repo.remotes = [remote]
        result = mgr.get_remote_branches_at_head()
        assert result == [("origin", "main")]

    def test_has_branch_true(self, tmp_path: Path) -> None:
        mgr, mock_repo = self._make_mgr(tmp_path)
        b = MagicMock()
        b.name = "main"
        mock_repo.heads = [b]
        assert mgr.has_branch("main") is True

    def test_has_branch_false(self, tmp_path: Path) -> None:
        mgr, mock_repo = self._make_mgr(tmp_path)
        mock_repo.heads = []
        assert mgr.has_branch("main") is False

    def test_has_remote_true(self, tmp_path: Path) -> None:
        mgr, mock_repo = self._make_mgr(tmp_path)
        r = MagicMock()
        r.name = "origin"
        mock_repo.remotes = [r]
        assert mgr.has_remote("origin") is True

    def test_has_remote_false(self, tmp_path: Path) -> None:
        mgr, mock_repo = self._make_mgr(tmp_path)
        mock_repo.remotes = []
        assert mgr.has_remote("origin") is False

    def test_add_remote(self, tmp_path: Path) -> None:
        mgr, mock_repo = self._make_mgr(tmp_path)
        mock_repo.remotes = []
        mgr.add_remote("origin", "https://github.com/o/r.git")
        mock_repo.create_remote.assert_called_once_with(
            "origin", "https://github.com/o/r.git"
        )

    def test_add_remote_already_exists(self, tmp_path: Path) -> None:
        mgr, mock_repo = self._make_mgr(tmp_path)
        r = MagicMock()
        r.name = "origin"
        mock_repo.remotes = [r]
        with pytest.raises(GitError, match="already exists"):
            mgr.add_remote("origin", "url")

    def test_set_remote_url(self, tmp_path: Path) -> None:
        mgr, mock_repo = self._make_mgr(tmp_path)
        r = MagicMock()
        r.name = "origin"
        mock_repo.remotes = [r]
        mock_repo.remote.return_value = r
        mgr.set_remote_url("origin", "https://new.url")
        r.set_url.assert_called_once_with("https://new.url")

    def test_set_remote_url_not_exists(self, tmp_path: Path) -> None:
        mgr, mock_repo = self._make_mgr(tmp_path)
        mock_repo.remotes = []
        with pytest.raises(GitError, match="does not exist"):
            mgr.set_remote_url("origin", "url")

    def test_fetch_specific_remote(self, tmp_path: Path) -> None:
        mgr, mock_repo = self._make_mgr(tmp_path)
        mock_remote = MagicMock()
        mock_remote.fetch.return_value = []
        mock_repo.remote.return_value = mock_remote
        mgr.fetch("origin")
        mock_remote.fetch.assert_called_once()

    def test_fetch_all_remotes(self, tmp_path: Path) -> None:
        mgr, mock_repo = self._make_mgr(tmp_path)
        r1 = MagicMock()
        r1.url = "https://github.com/o/r.git"
        r1.fetch.return_value = []
        r2 = MagicMock()
        r2.url = "https://gitlab.com/o/r.git"
        r2.fetch.return_value = []
        mock_repo.remotes = [r1, r2]
        mgr.fetch()
        r1.fetch.assert_called_once()
        r2.fetch.assert_called_once()

    def test_push_specific_branch(self, tmp_path: Path) -> None:
        mgr, mock_repo = self._make_mgr(tmp_path)
        mock_remote = MagicMock()
        mock_repo.remote.return_value = mock_remote
        mgr.push("origin", branch="main")
        mock_remote.push.assert_called_once_with(refspec="main:main")

    def test_push_with_upstream(self, tmp_path: Path) -> None:
        mgr, mock_repo = self._make_mgr(tmp_path)
        mock_remote = MagicMock()
        mock_repo.remote.return_value = mock_remote
        mgr.push("origin", branch="main", set_upstream=True)
        mock_remote.push.assert_called_once_with(refspec="main:main", set_upstream=True)

    def test_push_all_branches(self, tmp_path: Path) -> None:
        mgr, mock_repo = self._make_mgr(tmp_path)
        mock_remote = MagicMock()
        mock_repo.remote.return_value = mock_remote
        mgr.push("origin", all_branches=True)
        mock_remote.push.assert_called_once_with(all=True)

    def test_clone_error(self, tmp_path: Path) -> None:
        from unittest.mock import patch

        from git import GitCommandError

        with patch("repo_sync_kitty.git.operations.Repo") as mock_repo_cls:
            mock_repo_cls.clone_from.side_effect = GitCommandError("clone", "fail")
            with pytest.raises(CloneError, match="Failed to clone"):
                RepoManager.clone("https://bad.url/repo.git", tmp_path / "dest")

    def test_fetch_error(self, tmp_path: Path) -> None:
        from git import GitCommandError

        mgr, mock_repo = self._make_mgr(tmp_path)
        mock_remote = MagicMock()
        mock_remote.fetch.side_effect = GitCommandError("fetch", "fail")
        mock_repo.remote.return_value = mock_remote
        with pytest.raises(FetchError, match="Failed to fetch"):
            mgr.fetch("origin")

    def test_fetch_skips_remote_without_url(self, tmp_path: Path) -> None:
        mgr, mock_repo = self._make_mgr(tmp_path)
        r_no_url = MagicMock(spec=[])  # no .url attribute
        r_with_url = MagicMock()
        r_with_url.url = "https://github.com/o/r.git"
        r_with_url.fetch.return_value = []
        mock_repo.remotes = [r_no_url, r_with_url]
        mgr.fetch()
        r_with_url.fetch.assert_called_once()

    def test_push_detached_head(self, tmp_path: Path) -> None:
        mgr, mock_repo = self._make_mgr(tmp_path)
        mock_repo.head.is_detached = True
        mock_repo.remote.return_value = MagicMock()
        with pytest.raises(PushError, match="HEAD is detached"):
            mgr.push("origin")

    def test_push_error(self, tmp_path: Path) -> None:
        from git import GitCommandError

        mgr, mock_repo = self._make_mgr(tmp_path)
        mock_remote = MagicMock()
        mock_remote.push.side_effect = GitCommandError("push", "fail")
        mock_repo.remote.return_value = mock_remote
        with pytest.raises(PushError, match="Failed to push"):
            mgr.push("origin", branch="main")

    def test_create_tracking_branch(self, tmp_path: Path) -> None:
        mgr, mock_repo = self._make_mgr(tmp_path)
        mock_local = MagicMock()
        mock_repo.create_head.return_value = mock_local
        mock_repo.heads.__getitem__ = MagicMock(return_value=mock_local)
        mock_ref = MagicMock()
        mock_repo.refs.__getitem__ = MagicMock(return_value=mock_ref)
        mgr.create_tracking_branch("main", "origin")
        mock_repo.create_head.assert_called_once_with("main", "origin/main")
        mock_local.set_tracking_branch.assert_called_once_with(mock_ref)

    def test_create_tracking_branch_error(self, tmp_path: Path) -> None:
        mgr, mock_repo = self._make_mgr(tmp_path)
        mock_repo.create_head.side_effect = Exception("bad ref")
        with pytest.raises(GitError, match="Failed to create tracking branch"):
            mgr.create_tracking_branch("main", "origin")

    def test_add_remote_error(self, tmp_path: Path) -> None:
        from git import GitCommandError

        mgr, mock_repo = self._make_mgr(tmp_path)
        mock_repo.remotes = []
        mock_repo.create_remote.side_effect = GitCommandError("remote", "fail")
        with pytest.raises(GitError, match="Failed to add remote"):
            mgr.add_remote("origin", "https://bad.url")

    def test_set_remote_url_error(self, tmp_path: Path) -> None:
        from git import GitCommandError

        mgr, mock_repo = self._make_mgr(tmp_path)
        r = MagicMock()
        r.name = "origin"
        mock_repo.remotes = [r]
        mock_remote = MagicMock()
        mock_remote.set_url.side_effect = GitCommandError("remote", "fail")
        mock_repo.remote.return_value = mock_remote
        with pytest.raises(GitError, match="Failed to set URL"):
            mgr.set_remote_url("origin", "https://bad.url")


class TestSafetyReport:
    """Tests for SafetyReport dataclass."""

    def test_init_with_defaults(self) -> None:
        """Test SafetyReport default values are safe defaults."""
        report = SafetyReport()
        assert not report.safe_to_pull
        assert report.reasons == []
        assert report.current_branch is None
        assert report.expected_branch is None
        assert not report.is_clean
        assert not report.is_ahead
        assert report.in_progress_operation is None
        assert not report.is_detached

    def test_init_with_unsafe_conditions(self) -> None:
        """Test SafetyReport with multiple unsafe conditions."""
        report = SafetyReport(
            safe_to_pull=False,
            reasons=["uncommitted changes", "ahead of remote"],
            current_branch="main",
            is_ahead=True,
        )
        assert not report.safe_to_pull
        assert len(report.reasons) == 2
        assert "uncommitted changes" in report.reasons
        assert "ahead of remote" in report.reasons


class TestSafetyChecker:
    """Tests for SafetyChecker class."""

    def test_check_safe_when_clean(self, tmp_path: Path) -> None:
        """Test check returns safe when repo is clean."""
        repo_path = tmp_path / "repo"
        repo_path.mkdir()
        (repo_path / ".git").mkdir()
        mgr = RepoManager(repo_path)

        # Mock the repo
        mock_repo = MagicMock()
        mock_repo.head.is_detached = False
        mock_repo.active_branch.name = "main"
        mock_repo.is_dirty.return_value = False
        mock_repo.index.diff.return_value = []
        mock_repo.untracked_files = []
        mock_repo.refs = []
        mgr._repo = mock_repo

        checker = SafetyChecker(mgr)
        report = checker.check()

        assert report.safe_to_pull
        assert report.reasons == []
        assert report.is_clean

    def test_check_unsafe_when_detached(self, tmp_path: Path) -> None:
        """Test check returns unsafe when HEAD is detached."""
        repo_path = tmp_path / "repo"
        repo_path.mkdir()
        (repo_path / ".git").mkdir()
        mgr = RepoManager(repo_path)

        mock_repo = MagicMock()
        mock_repo.head.is_detached = True
        mock_repo.is_dirty.return_value = False
        mock_repo.index.diff.return_value = []
        mock_repo.untracked_files = []
        mock_repo.refs = []
        mgr._repo = mock_repo

        checker = SafetyChecker(mgr)
        report = checker.check()

        assert not report.safe_to_pull
        assert "HEAD is detached" in report.reasons
        assert report.is_detached

    def test_check_unsafe_when_wrong_branch(self, tmp_path: Path) -> None:
        """Test check returns unsafe when on wrong branch."""
        repo_path = tmp_path / "repo"
        repo_path.mkdir()
        (repo_path / ".git").mkdir()
        mgr = RepoManager(repo_path)

        mock_repo = MagicMock()
        mock_repo.head.is_detached = False
        mock_repo.active_branch.name = "develop"
        mock_repo.is_dirty.return_value = False
        mock_repo.index.diff.return_value = []
        mock_repo.untracked_files = []
        mock_repo.refs = []
        mgr._repo = mock_repo

        checker = SafetyChecker(mgr)
        report = checker.check(expected_branch="main")

        assert not report.safe_to_pull
        assert any("expected 'main'" in r for r in report.reasons)

    def test_check_unsafe_when_dirty(self, tmp_path: Path) -> None:
        """Test check returns unsafe when repo is dirty."""
        repo_path = tmp_path / "repo"
        repo_path.mkdir()
        (repo_path / ".git").mkdir()
        mgr = RepoManager(repo_path)

        mock_repo = MagicMock()
        mock_repo.head.is_detached = False
        mock_repo.active_branch.name = "main"
        mock_repo.is_dirty.return_value = True
        mock_repo.index.diff.side_effect = [["staged"], ["modified"]]
        mock_repo.untracked_files = ["untracked.txt"]
        mock_repo.refs = []
        mgr._repo = mock_repo

        checker = SafetyChecker(mgr)
        report = checker.check()

        assert not report.safe_to_pull
        assert not report.is_clean

    def test_check_safe_to_pull_convenience_function(self, tmp_path: Path) -> None:
        """Test check_safe_to_pull convenience function."""
        repo_path = tmp_path / "repo"
        repo_path.mkdir()
        (repo_path / ".git").mkdir()
        mgr = RepoManager(repo_path)

        mock_repo = MagicMock()
        mock_repo.head.is_detached = False
        mock_repo.active_branch.name = "main"
        mock_repo.is_dirty.return_value = False
        mock_repo.index.diff.return_value = []
        mock_repo.untracked_files = []
        mock_repo.refs = []
        mgr._repo = mock_repo

        report = check_safe_to_pull(mgr)
        assert report.safe_to_pull


class TestRetryConfig:
    """Tests for RetryConfig dataclass."""

    def test_default_values(self) -> None:
        """Test RetryConfig default values."""
        config = RetryConfig()
        assert config.max_retries == 3
        assert config.base_delay == 1.0
        assert config.max_delay == 60.0
        assert config.exponential_base == 2.0
        assert config.jitter is True

    def test_custom_values(self) -> None:
        """Test RetryConfig with custom values."""
        config = RetryConfig(
            max_retries=5,
            base_delay=0.5,
            max_delay=30.0,
            exponential_base=3.0,
            jitter=False,
        )
        assert config.max_retries == 5
        assert config.base_delay == 0.5


class TestRetryManager:
    """Tests for RetryManager class."""

    def test_get_delay_exponential(self) -> None:
        """Test get_delay calculates exponential backoff."""
        config = RetryConfig(base_delay=1.0, exponential_base=2.0, jitter=False)
        manager = RetryManager(config)

        assert manager.get_delay(0) == 1.0
        assert manager.get_delay(1) == 2.0
        assert manager.get_delay(2) == 4.0
        assert manager.get_delay(3) == 8.0

    def test_get_delay_respects_max(self) -> None:
        """Test get_delay respects max_delay."""
        config = RetryConfig(
            base_delay=1.0, max_delay=5.0, exponential_base=2.0, jitter=False
        )
        manager = RetryManager(config)

        assert manager.get_delay(10) == 5.0  # Would be 1024 without max

    def test_execute_success_first_try(self) -> None:
        """Test execute succeeds on first try."""
        config = RetryConfig(max_retries=3)
        manager = RetryManager(config)

        result = manager.execute(lambda: "success")

        assert result.success
        assert result.value == "success"
        assert result.attempts == 1
        assert result.total_delay == 0.0

    def test_execute_retries_on_git_error(self) -> None:
        """Test execute retries on GitError."""
        config = RetryConfig(max_retries=2, base_delay=0.01, jitter=False)
        manager = RetryManager(config)

        call_count = 0

        def failing_then_success() -> str:
            nonlocal call_count
            call_count += 1
            if call_count < 2:
                raise GitError("temporary failure")
            return "success"

        result = manager.execute(failing_then_success)

        assert result.success
        assert result.value == "success"
        assert result.attempts == 2

    def test_execute_fails_after_max_retries(self) -> None:
        """Test execute fails after max retries exhausted."""
        config = RetryConfig(max_retries=2, base_delay=0.01, jitter=False)
        manager = RetryManager(config)

        def always_fails() -> str:
            raise GitError("permanent failure")

        result = manager.execute(always_fails)

        assert not result.success
        assert result.attempts == 3  # Initial + 2 retries
        assert isinstance(result.last_error, GitError)


class TestWithRetry:
    """Tests for with_retry convenience function."""

    def test_with_retry_success(self) -> None:
        """Test with_retry returns success."""
        result = with_retry(lambda: 42)
        assert result.success
        assert result.value == 42

    def test_with_retry_with_custom_config(self) -> None:
        """Test with_retry uses custom config."""
        config = RetryConfig(max_retries=1, base_delay=0.01, jitter=False)
        call_count = 0

        def count_calls() -> int:
            nonlocal call_count
            call_count += 1
            if call_count < 2:
                raise GitError("fail")
            return call_count

        result = with_retry(count_calls, config=config)
        assert result.success
        assert call_count == 2
