"""Tests for glreview.git module."""

from __future__ import annotations

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

import pytest

from glreview.git import (
    GitError,
    checkout_branch,
    commit_all_changes,
    count_lines,
    create_branch,
    get_authenticated_push_url,
    get_current_branch,
    get_current_commit,
    get_full_commit,
    get_gitlab_host,
    get_gitlab_project,
    get_remote_url,
    has_changes_since,
    has_uncommitted_changes,
    push_branch,
    run_git,
)


class TestRunGit:
    """Tests for run_git function."""

    def test_runs_command(self, temp_git_repo):
        """Runs git command successfully."""
        result = run_git("status", cwd=temp_git_repo)
        assert result.returncode == 0

    def test_captures_output(self, temp_git_repo):
        """Captures stdout."""
        result = run_git("rev-parse", "--short", "HEAD", cwd=temp_git_repo)
        assert result.stdout.strip()  # Should have commit SHA

    def test_raises_on_failure(self, temp_git_repo):
        """Raises GitError on failure when check=True."""
        with pytest.raises(GitError, match="failed"):
            run_git("nonexistent-command", cwd=temp_git_repo)

    def test_no_raise_with_check_false(self, temp_git_repo):
        """Does not raise when check=False."""
        result = run_git("nonexistent-command", cwd=temp_git_repo, check=False)
        assert result.returncode != 0


class TestGetCurrentCommit:
    """Tests for get_current_commit function."""

    def test_returns_short_sha(self, temp_git_repo):
        """Returns short SHA by default."""
        commit = get_current_commit(cwd=temp_git_repo)
        assert len(commit) == 7  # Short SHA

    def test_returns_full_sha(self, temp_git_repo):
        """Returns full SHA when short=False."""
        commit = get_current_commit(cwd=temp_git_repo, short=False)
        assert len(commit) == 40  # Full SHA


class TestGetFullCommit:
    """Tests for get_full_commit function."""

    def test_expands_short_sha(self, temp_git_repo):
        """Expands short SHA to full."""
        short = get_current_commit(cwd=temp_git_repo, short=True)
        full = get_full_commit(short, cwd=temp_git_repo)
        assert len(full) == 40
        assert full.startswith(short)

    def test_returns_none_for_invalid(self, temp_git_repo):
        """Returns None for invalid SHA."""
        result = get_full_commit("invalidsha123", cwd=temp_git_repo)
        assert result is None


class TestHasChangesSince:
    """Tests for has_changes_since function."""

    def test_no_changes(self, temp_git_repo):
        """Returns False when file unchanged."""
        # README.md was created in initial commit
        commit = get_current_commit(cwd=temp_git_repo)
        changed = has_changes_since("README.md", commit, cwd=temp_git_repo)
        assert changed is False

    def test_has_changes(self, temp_git_repo):
        """Returns True when file changed."""
        # Get initial commit
        initial = get_current_commit(cwd=temp_git_repo)

        # Modify file and commit
        readme = temp_git_repo / "README.md"
        readme.write_text("# Updated\n")
        subprocess.run(["git", "add", "."], cwd=temp_git_repo, capture_output=True)
        subprocess.run(
            ["git", "commit", "-m", "Update"],
            cwd=temp_git_repo,
            capture_output=True,
        )

        # Check for changes
        changed = has_changes_since("README.md", initial, cwd=temp_git_repo)
        assert changed is True

    def test_none_commit_returns_true(self, temp_git_repo):
        """Returns True when commit is None."""
        changed = has_changes_since("README.md", None, cwd=temp_git_repo)
        assert changed is True

    def test_invalid_commit_returns_true(self, temp_git_repo):
        """Returns True when commit is invalid."""
        changed = has_changes_since("README.md", "invalidsha", cwd=temp_git_repo)
        assert changed is True


class TestGetRemoteUrl:
    """Tests for get_remote_url function."""

    def test_returns_url(self, temp_git_repo):
        """Returns remote URL when configured."""
        subprocess.run(
            ["git", "remote", "add", "origin", "git@gitlab.com:user/project.git"],
            cwd=temp_git_repo,
            capture_output=True,
        )
        url = get_remote_url(cwd=temp_git_repo)
        assert url == "git@gitlab.com:user/project.git"

    def test_returns_none_without_remote(self, temp_git_repo):
        """Returns None when no remote."""
        url = get_remote_url(cwd=temp_git_repo)
        assert url is None


class TestGetGitlabProject:
    """Tests for get_gitlab_project function."""

    def test_ssh_url(self, temp_git_repo):
        """Extracts project from SSH URL."""
        subprocess.run(
            ["git", "remote", "add", "origin", "git@gitlab.com:user/project.git"],
            cwd=temp_git_repo,
            capture_output=True,
        )
        project = get_gitlab_project(cwd=temp_git_repo)
        assert project == "user/project"

    def test_https_url(self, temp_git_repo):
        """Extracts project from HTTPS URL."""
        subprocess.run(
            ["git", "remote", "add", "origin", "https://gitlab.com/user/project.git"],
            cwd=temp_git_repo,
            capture_output=True,
        )
        project = get_gitlab_project(cwd=temp_git_repo)
        assert project == "user/project"

    def test_nested_project(self, temp_git_repo):
        """Handles nested project paths."""
        subprocess.run(
            [
                "git",
                "remote",
                "add",
                "origin",
                "git@git.ligo.org:org/subgroup/project.git",
            ],
            cwd=temp_git_repo,
            capture_output=True,
        )
        project = get_gitlab_project(cwd=temp_git_repo)
        # This implementation only gets last two path components
        assert "project" in project

    def test_returns_none_without_remote(self, temp_git_repo):
        """Returns None when no remote."""
        project = get_gitlab_project(cwd=temp_git_repo)
        assert project is None

    def test_unknown_url_format(self, temp_git_repo):
        """Returns None for unrecognized URL format."""
        # Use a path-style URL that doesn't match git@ or :// patterns
        subprocess.run(
            ["git", "remote", "add", "origin", "/local/path/repo.git"],
            cwd=temp_git_repo,
            capture_output=True,
        )
        project = get_gitlab_project(cwd=temp_git_repo)
        assert project is None


class TestGetGitlabHost:
    """Tests for get_gitlab_host function."""

    def test_ssh_url(self, temp_git_repo):
        """Extracts host from SSH URL."""
        subprocess.run(
            ["git", "remote", "add", "origin", "git@gitlab.com:user/project.git"],
            cwd=temp_git_repo,
            capture_output=True,
        )
        host = get_gitlab_host(cwd=temp_git_repo)
        assert host == "gitlab.com"

    def test_https_url(self, temp_git_repo):
        """Extracts host from HTTPS URL."""
        subprocess.run(
            ["git", "remote", "add", "origin", "https://git.ligo.org/user/project.git"],
            cwd=temp_git_repo,
            capture_output=True,
        )
        host = get_gitlab_host(cwd=temp_git_repo)
        assert host == "git.ligo.org"

    def test_returns_none_without_remote(self, temp_git_repo):
        """Returns None when no remote."""
        host = get_gitlab_host(cwd=temp_git_repo)
        assert host is None

    def test_unknown_url_format(self, temp_git_repo):
        """Returns None for unrecognized URL format."""
        # Use a path-style URL that doesn't match git@ or :// patterns
        subprocess.run(
            ["git", "remote", "add", "origin", "/local/path/repo.git"],
            cwd=temp_git_repo,
            capture_output=True,
        )
        host = get_gitlab_host(cwd=temp_git_repo)
        assert host is None


    @patch("glreview.git.get_remote_url")
    def test_git_at_url_with_empty_host(self, mock_url):
        """Returns None for git@ URL with empty host after @."""
        mock_url.return_value = "git@:user/project.git"
        host = get_gitlab_host(cwd=Path("/tmp"))
        assert host is None


class TestCountLines:
    """Tests for count_lines function."""

    def test_counts_lines(self, temp_dir):
        """Counts lines in file."""
        f = temp_dir / "test.py"
        f.write_text("line1\nline2\nline3\n")
        count = count_lines("test.py", cwd=temp_dir)
        assert count == 3

    def test_counts_lines_no_trailing_newline(self, temp_dir):
        """Counts lines without trailing newline."""
        f = temp_dir / "test.py"
        f.write_text("line1\nline2")
        count = count_lines("test.py", cwd=temp_dir)
        assert count == 2

    def test_empty_file(self, temp_dir):
        """Empty file has 0 lines."""
        f = temp_dir / "empty.py"
        f.write_text("")
        count = count_lines("empty.py", cwd=temp_dir)
        assert count == 0

    def test_nonexistent_file(self, temp_dir):
        """Nonexistent file returns 0."""
        count = count_lines("nonexistent.py", cwd=temp_dir)
        assert count == 0


class TestGetCurrentBranch:
    """Tests for get_current_branch function."""

    def test_returns_branch_name(self, temp_git_repo):
        """Returns the current branch name."""
        branch = get_current_branch(cwd=temp_git_repo)
        # Default branch name varies (main, master, etc.)
        assert isinstance(branch, str)
        assert len(branch) > 0

    def test_raises_on_detached_head(self, temp_git_repo):
        """Raises GitError on detached HEAD."""
        commit = get_current_commit(cwd=temp_git_repo, short=False)
        subprocess.run(
            ["git", "checkout", commit],
            cwd=temp_git_repo,
            capture_output=True,
        )
        with pytest.raises(GitError, match="Detached HEAD"):
            get_current_branch(cwd=temp_git_repo)


class TestCreateBranch:
    """Tests for create_branch function."""

    def test_creates_and_checks_out(self, temp_git_repo):
        """Creates a new branch and checks it out."""
        create_branch("feature-test", cwd=temp_git_repo)
        branch = get_current_branch(cwd=temp_git_repo)
        assert branch == "feature-test"

    def test_raises_on_duplicate(self, temp_git_repo):
        """Raises GitError when branch already exists."""
        create_branch("feature-dup", cwd=temp_git_repo)
        # Go back to original branch
        subprocess.run(
            ["git", "checkout", "-"],
            cwd=temp_git_repo,
            capture_output=True,
        )
        with pytest.raises(GitError):
            create_branch("feature-dup", cwd=temp_git_repo)


class TestCheckoutBranch:
    """Tests for checkout_branch function."""

    def test_checks_out_existing_branch(self, temp_git_repo):
        """Checks out an existing branch."""
        original = get_current_branch(cwd=temp_git_repo)
        create_branch("other-branch", cwd=temp_git_repo)
        checkout_branch(original, cwd=temp_git_repo)
        assert get_current_branch(cwd=temp_git_repo) == original

    def test_raises_on_nonexistent_branch(self, temp_git_repo):
        """Raises GitError for nonexistent branch."""
        with pytest.raises(GitError):
            checkout_branch("nonexistent-branch", cwd=temp_git_repo)


class TestCommitAllChanges:
    """Tests for commit_all_changes function."""

    def test_commits_and_returns_sha(self, temp_git_repo):
        """Commits changes and returns SHA."""
        # Modify tracked file (git add -u only stages tracked files)
        (temp_git_repo / "README.md").write_text("modified\n")
        sha = commit_all_changes("Update README", cwd=temp_git_repo)
        assert isinstance(sha, str)
        assert len(sha) == 7  # Short SHA

    def test_raises_when_no_changes(self, temp_git_repo):
        """Raises GitError when there are no changes."""
        with pytest.raises(GitError, match="No changes"):
            commit_all_changes("Empty commit", cwd=temp_git_repo)

    def test_ignores_untracked_files(self, temp_git_repo):
        """Does not stage untracked files (prevents accidental secret commits)."""
        (temp_git_repo / ".env").write_text("SECRET=password\n")
        with pytest.raises(GitError, match="No changes"):
            commit_all_changes("Should not commit .env", cwd=temp_git_repo)


class TestPushBranch:
    """Tests for push_branch function."""

    @patch("glreview.git.run_git")
    def test_calls_push_with_correct_args(self, mock_run_git):
        """Calls git push with -u origin."""
        mock_run_git.return_value = MagicMock(returncode=0)
        push_branch("my-branch", cwd=Path("/tmp"))
        mock_run_git.assert_called_once_with(
            "push", "-u", "origin", "my-branch", cwd=Path("/tmp")
        )

    @patch("glreview.git.run_git")
    def test_calls_push_with_custom_remote(self, mock_run_git):
        """Calls git push with a custom remote URL."""
        mock_run_git.return_value = MagicMock(returncode=0)
        push_branch("my-branch", cwd=Path("/tmp"), remote="https://oauth2:tok@host/repo")
        mock_run_git.assert_called_once_with(
            "push", "-u", "https://oauth2:tok@host/repo", "my-branch", cwd=Path("/tmp")
        )


class TestGetAuthenticatedPushUrl:
    """Tests for get_authenticated_push_url function."""

    @patch("glreview.git.get_remote_url")
    def test_returns_none_for_ssh_remote(self, mock_url):
        """Returns None for SSH remotes (SSH keys handle auth)."""
        mock_url.return_value = "git@gitlab.com:user/project.git"
        assert get_authenticated_push_url(cwd=Path("/tmp")) is None

    @patch("glreview.git.get_remote_url")
    def test_returns_none_when_no_remote(self, mock_url):
        """Returns None when no remote URL."""
        mock_url.return_value = None
        assert get_authenticated_push_url(cwd=Path("/tmp")) is None

    @patch("glreview.git.get_remote_url")
    def test_returns_none_when_no_token(self, mock_url, monkeypatch):
        """Returns None when no GitLab token is set."""
        mock_url.return_value = "https://gitlab.com/user/project.git"
        monkeypatch.delenv("GITLAB_PRIVATE_TOKEN", raising=False)
        monkeypatch.delenv("GITLAB_TOKEN", raising=False)
        monkeypatch.delenv("CI_JOB_TOKEN", raising=False)
        assert get_authenticated_push_url(cwd=Path("/tmp")) is None

    @patch("glreview.git.get_remote_url")
    def test_embeds_private_token(self, mock_url, monkeypatch):
        """Embeds GITLAB_PRIVATE_TOKEN in HTTPS URL."""
        mock_url.return_value = "https://git.ligo.org/user/project.git"
        monkeypatch.setenv("GITLAB_PRIVATE_TOKEN", "my-secret-token")
        monkeypatch.delenv("GITLAB_TOKEN", raising=False)
        monkeypatch.delenv("CI_JOB_TOKEN", raising=False)
        result = get_authenticated_push_url(cwd=Path("/tmp"))
        assert result == "https://oauth2:my-secret-token@git.ligo.org/user/project.git"

    @patch("glreview.git.get_remote_url")
    def test_falls_back_to_gitlab_token(self, mock_url, monkeypatch):
        """Falls back to GITLAB_TOKEN when GITLAB_PRIVATE_TOKEN not set."""
        mock_url.return_value = "https://gitlab.com/user/project.git"
        monkeypatch.delenv("GITLAB_PRIVATE_TOKEN", raising=False)
        monkeypatch.setenv("GITLAB_TOKEN", "fallback-token")
        monkeypatch.delenv("CI_JOB_TOKEN", raising=False)
        result = get_authenticated_push_url(cwd=Path("/tmp"))
        assert result == "https://oauth2:fallback-token@gitlab.com/user/project.git"

    @patch("glreview.git.get_remote_url")
    def test_falls_back_to_ci_job_token(self, mock_url, monkeypatch):
        """Falls back to CI_JOB_TOKEN when no private tokens set."""
        mock_url.return_value = "https://gitlab.com/user/project.git"
        monkeypatch.delenv("GITLAB_PRIVATE_TOKEN", raising=False)
        monkeypatch.delenv("GITLAB_TOKEN", raising=False)
        monkeypatch.setenv("CI_JOB_TOKEN", "ci-token")
        result = get_authenticated_push_url(cwd=Path("/tmp"))
        assert result == "https://oauth2:ci-token@gitlab.com/user/project.git"


class TestHasUncommittedChanges:
    """Tests for has_uncommitted_changes function."""

    def test_clean_repo(self, temp_git_repo):
        """Returns False for clean repo."""
        assert has_uncommitted_changes(cwd=temp_git_repo) is False

    def test_dirty_repo(self, temp_git_repo):
        """Returns True when tracked files have uncommitted changes."""
        # Modify the tracked README.md (created by temp_git_repo fixture)
        (temp_git_repo / "README.md").write_text("modified\n")
        assert has_uncommitted_changes(cwd=temp_git_repo) is True

    def test_untracked_files_ignored(self, temp_git_repo):
        """Returns False when only untracked files exist."""
        (temp_git_repo / "untracked.py").write_text("x = 1\n")
        assert has_uncommitted_changes(cwd=temp_git_repo) is False
