"""Tests for glreview.cli module."""

from __future__ import annotations

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

import pytest
from click.testing import CliRunner

from glreview.cli import main
from glreview.registry import ModuleStatus, Registry, save_registry


@pytest.fixture
def runner():
    """Create a CLI runner."""
    return CliRunner()


@pytest.fixture
def project_dir(temp_dir):
    """Create a project directory with source files."""
    # Create source files
    src = temp_dir / "src"
    src.mkdir()
    (src / "main.py").write_text("def hello():\n    return 'Hello'\n")
    (src / "utils.py").write_text("def add(a, b):\n    return a + b\n")

    # Create pyproject.toml
    pyproject = temp_dir / "pyproject.toml"
    pyproject.write_text("""[tool.glreview]
sources = ["src/**/*.py"]
""")

    return temp_dir


class TestInit:
    """Tests for init command."""

    @patch("glreview.cli.get_current_commit")
    def test_init_creates_registry(self, mock_commit, runner, project_dir, monkeypatch):
        """Init creates a new registry."""
        mock_commit.return_value = "abc123"
        monkeypatch.chdir(project_dir)

        result = runner.invoke(main, ["init"])

        assert result.exit_code == 0
        assert "Initialized" in result.output
        assert (project_dir / ".glreview/registry.json").exists()

    @patch("glreview.cli.get_current_commit")
    def test_init_is_idempotent(self, mock_commit, runner, project_dir, monkeypatch):
        """Init on existing registry syncs instead of failing."""
        mock_commit.return_value = "abc123"
        monkeypatch.chdir(project_dir)

        # First init
        runner.invoke(main, ["init"])
        # Second init should sync
        result = runner.invoke(main, ["init"])

        assert result.exit_code == 0
        assert "syncing" in result.output.lower() or "sync" in result.output.lower()


class TestStatus:
    """Tests for status command."""

    @pytest.fixture
    def registry_file(self, project_dir):
        """Create a registry file."""
        registry = Registry()
        registry.set(ModuleStatus(path="src/main.py", status="reviewed", lines=50))
        registry.set(
            ModuleStatus(
                path="src/utils.py",
                status="needs_review",
                lines=30,
            )
        )
        save_registry(registry, project_dir / ".glreview/registry.json")
        return project_dir / ".glreview/registry.json"

    @patch("glreview.cli.has_changes_since")
    def test_status_summary(
        self, mock_has_changes, runner, project_dir, registry_file, monkeypatch
    ):
        """Status shows summary."""
        mock_has_changes.return_value = False
        monkeypatch.chdir(project_dir)

        result = runner.invoke(main, ["status"])

        assert result.exit_code == 0
        assert "Review Progress" in result.output or "reviewed" in result.output.lower()

    @patch("glreview.cli.has_changes_since")
    def test_status_single_module(
        self, mock_has_changes, runner, project_dir, registry_file, monkeypatch
    ):
        """Status shows single module details."""
        mock_has_changes.return_value = False
        monkeypatch.chdir(project_dir)

        result = runner.invoke(main, ["status", "src/main.py"])

        assert result.exit_code == 0
        assert "src/main.py" in result.output
        assert "reviewed" in result.output.lower()

    @patch("glreview.cli.has_changes_since")
    def test_status_json(
        self, mock_has_changes, runner, project_dir, registry_file, monkeypatch
    ):
        """Status --json outputs JSON."""
        mock_has_changes.return_value = False
        monkeypatch.chdir(project_dir)

        result = runner.invoke(main, ["status", "--json"])

        assert result.exit_code == 0
        data = json.loads(result.output)
        assert "total" in data

    def test_status_unknown_module(
        self, runner, project_dir, registry_file, monkeypatch
    ):
        """Status exits with error for unknown module."""
        monkeypatch.chdir(project_dir)

        result = runner.invoke(main, ["status", "nonexistent.py"])

        assert result.exit_code == 1
        assert "not in registry" in result.output.lower()


class TestList:
    """Tests for list command."""

    @pytest.fixture
    def registry_file(self, project_dir):
        """Create a registry file."""
        registry = Registry()
        registry.set(ModuleStatus(path="src/main.py", status="reviewed"))
        registry.set(
            ModuleStatus(path="src/utils.py", status="needs_review")
        )
        save_registry(registry, project_dir / ".glreview/registry.json")
        return project_dir / ".glreview/registry.json"

    def test_list_all(self, runner, project_dir, registry_file, monkeypatch):
        """List shows all modules."""
        monkeypatch.chdir(project_dir)

        result = runner.invoke(main, ["list"])

        assert result.exit_code == 0
        assert "src/main.py" in result.output
        assert "src/utils.py" in result.output

    def test_list_filter_status(self, runner, project_dir, registry_file, monkeypatch):
        """List filters by status."""
        monkeypatch.chdir(project_dir)

        result = runner.invoke(main, ["list", "--status", "reviewed"])

        assert result.exit_code == 0
        assert "src/main.py" in result.output
        assert "src/utils.py" not in result.output

    def test_list_no_matches(self, runner, project_dir, registry_file, monkeypatch):
        """List shows message when no modules match filters."""
        monkeypatch.chdir(project_dir)

        # Filter for status that doesn't exist in registry
        result = runner.invoke(main, ["list", "--status", "in_progress"])

        assert result.exit_code == 0
        assert "no modules" in result.output.lower()


class TestSync:
    """Tests for sync command."""

    @pytest.fixture
    def registry_file(self, project_dir):
        """Create a registry file."""
        registry = Registry()
        registry.set(ModuleStatus(path="src/main.py", status="reviewed"))
        registry.set(ModuleStatus(path="src/deleted.py", status="needs_review"))
        save_registry(registry, project_dir / ".glreview/registry.json")
        return project_dir / ".glreview/registry.json"

    @patch("glreview.cli_reports.get_current_commit")
    def test_sync_adds_new(
        self, mock_commit, runner, project_dir, registry_file, monkeypatch
    ):
        """Sync adds new modules."""
        mock_commit.return_value = "abc123"
        monkeypatch.chdir(project_dir)

        result = runner.invoke(main, ["sync"])

        assert result.exit_code == 0
        # src/utils.py should be added
        assert "utils.py" in result.output or "synced" in result.output.lower()

    @patch("glreview.cli_reports.get_current_commit")
    def test_sync_removes_deleted(
        self, mock_commit, runner, project_dir, registry_file, monkeypatch
    ):
        """Sync removes deleted modules."""
        mock_commit.return_value = "abc123"
        monkeypatch.chdir(project_dir)

        result = runner.invoke(main, ["sync"])

        assert result.exit_code == 0
        # src/deleted.py should be removed
        assert "deleted.py" in result.output or "synced" in result.output.lower()


class TestReport:
    """Tests for report command."""

    @pytest.fixture
    def registry_file(self, project_dir):
        """Create a registry file."""
        registry = Registry()
        registry.set(
            ModuleStatus(
                path="src/main.py",
                status="reviewed",
                lines=50,
                last_reviewed_commit="abc123",
            )
        )
        registry.set(
            ModuleStatus(path="src/utils.py", status="needs_review", lines=30)
        )
        save_registry(registry, project_dir / ".glreview/registry.json")
        return project_dir / ".glreview/registry.json"

    @patch("glreview.cli.has_changes_since")
    def test_report_markdown(
        self, mock_has_changes, runner, project_dir, registry_file, monkeypatch
    ):
        """Report generates markdown."""
        mock_has_changes.return_value = False
        monkeypatch.chdir(project_dir)

        result = runner.invoke(main, ["report"])

        assert result.exit_code == 0
        assert "Coverage" in result.output
        assert "|" in result.output  # Table formatting

    @patch("glreview.cli.has_changes_since")
    def test_report_json(
        self, mock_has_changes, runner, project_dir, registry_file, monkeypatch
    ):
        """Report --format json outputs JSON."""
        mock_has_changes.return_value = False
        monkeypatch.chdir(project_dir)

        result = runner.invoke(main, ["report", "--format", "json"])

        assert result.exit_code == 0
        data = json.loads(result.output)
        assert "total" in data
        assert "coverage_pct" in data

    @patch("glreview.cli.has_changes_since")
    def test_report_badge(
        self, mock_has_changes, runner, project_dir, registry_file, monkeypatch
    ):
        """Report --format badge outputs badge URL."""
        mock_has_changes.return_value = False
        monkeypatch.chdir(project_dir)

        result = runner.invoke(main, ["report", "--format", "badge"])

        assert result.exit_code == 0
        assert "shields.io" in result.output

    @patch("glreview.cli.has_changes_since")
    def test_report_output_file(
        self, mock_has_changes, runner, project_dir, registry_file, monkeypatch
    ):
        """Report --output writes to file."""
        mock_has_changes.return_value = False
        monkeypatch.chdir(project_dir)

        output_path = project_dir / "report.md"
        result = runner.invoke(main, ["report", "--output", str(output_path)])

        assert result.exit_code == 0
        assert output_path.exists()
        content = output_path.read_text()
        assert "Coverage" in content


class TestReportFailUnder:
    """Tests for report --fail-under option."""

    @pytest.fixture
    def partial_registry(self, project_dir):
        """Create registry with 50% coverage (1 reviewed, 1 needs_review)."""
        registry = Registry()
        registry.set(
            ModuleStatus(
                path="src/main.py",
                status="reviewed",
                lines=50,
                last_reviewed_commit="abc123",
            )
        )
        registry.set(
            ModuleStatus(path="src/utils.py", status="needs_review", lines=30)
        )
        save_registry(registry, project_dir / ".glreview/registry.json")
        return project_dir

    @pytest.fixture
    def full_registry(self, project_dir):
        """Create registry with 100% coverage (all reviewed)."""
        registry = Registry()
        registry.set(
            ModuleStatus(
                path="src/main.py",
                status="reviewed",
                lines=50,
                last_reviewed_commit="abc123",
            )
        )
        registry.set(
            ModuleStatus(
                path="src/utils.py",
                status="reviewed",
                lines=30,
                last_reviewed_commit="abc123",
            )
        )
        save_registry(registry, project_dir / ".glreview/registry.json")
        return project_dir

    @patch("glreview.review_state.has_changes_since")
    def test_fails_when_below_threshold(
        self, mock_changes, runner, partial_registry, monkeypatch,
    ):
        """Exits 1 when coverage is below --fail-under threshold."""
        mock_changes.return_value = False
        monkeypatch.chdir(partial_registry)

        result = runner.invoke(main, ["report", "--fail-under", "100"])

        assert result.exit_code == 1
        assert "FAIL" in result.output

    @patch("glreview.review_state.has_changes_since")
    def test_passes_when_at_threshold(
        self, mock_changes, runner, full_registry, monkeypatch,
    ):
        """Exits 0 when coverage meets --fail-under threshold."""
        mock_changes.return_value = False
        monkeypatch.chdir(full_registry)

        result = runner.invoke(main, ["report", "--fail-under", "100"])

        assert result.exit_code == 0
        assert "FAIL" not in result.output

    @patch("glreview.review_state.has_changes_since")
    def test_no_fail_under_always_passes(
        self, mock_changes, runner, partial_registry, monkeypatch,
    ):
        """Without --fail-under, exits 0 regardless of coverage."""
        mock_changes.return_value = False
        monkeypatch.chdir(partial_registry)

        result = runner.invoke(main, ["report"])

        assert result.exit_code == 0


class TestCiConfig:
    """Tests for ci-config command."""

    def test_ci_config_outputs_yaml(self, runner):
        """ci-config outputs YAML configuration."""
        result = runner.invoke(main, ["ci-config"])

        assert result.exit_code == 0
        assert "gitlab-ci.yml" in result.output
        assert "stages:" in result.output
        assert "glreview" in result.output


class TestCheck:
    """Tests for check command."""

    @pytest.fixture
    def registry_file(self, project_dir):
        """Create a registry with reviewed module."""
        registry = Registry()
        registry.set(
            ModuleStatus(
                path="src/main.py",
                status="reviewed",
                lines=50,
                last_reviewed_commit="abc123",
            )
        )
        save_registry(registry, project_dir / ".glreview/registry.json")
        return project_dir / ".glreview/registry.json"

    @patch("glreview.review_state.has_changes_since")
    def test_check_passes_no_changes(
        self, mock_has_changes, runner, project_dir, registry_file, monkeypatch
    ):
        """Check passes when no reviewed files changed."""
        mock_has_changes.return_value = False
        monkeypatch.chdir(project_dir)

        result = runner.invoke(main, ["check"])

        assert result.exit_code == 0
        assert "No reviewed files have changed" in result.output

    @patch("glreview.review_state.has_changes_since")
    def test_check_fails_with_changes(
        self, mock_has_changes, runner, project_dir, registry_file, monkeypatch
    ):
        """Check fails when reviewed files changed."""
        mock_has_changes.return_value = True
        monkeypatch.chdir(project_dir)

        result = runner.invoke(main, ["check"])

        assert result.exit_code == 1
        assert "changed" in result.output.lower()


class TestVersion:
    """Tests for version option."""

    def test_version(self, runner):
        """--version shows version."""
        result = runner.invoke(main, ["--version"])

        assert result.exit_code == 0
        # Should contain version number
        assert "glreview" in result.output.lower() or "version" in result.output.lower()


class TestStart:
    """Tests for start command."""

    @pytest.fixture
    def registry_file(self, project_dir):
        """Create a registry file with modules."""
        registry = Registry()
        registry.set(ModuleStatus(path="src/main.py", status="needs_review", lines=50))
        registry.set(
            ModuleStatus(
                path="src/utils.py",
                status="in_progress",
                review_issue="#42",
                lines=30,
            )
        )
        registry.set(
            ModuleStatus(
                path="src/done.py",
                status="reviewed",
                last_reviewed_commit="abc123",
                lines=20,
            )
        )
        save_registry(registry, project_dir / ".glreview/registry.json")
        return project_dir / ".glreview/registry.json"

    def test_start_module_not_found(self, runner, project_dir, registry_file, monkeypatch):
        """Start fails for unknown module."""
        monkeypatch.chdir(project_dir)

        result = runner.invoke(main, ["start", "nonexistent.py"])

        assert result.exit_code == 1
        assert "not in registry" in result.output.lower()

    @patch("glreview.review_service.get_current_commit")
    @patch("glreview.cli.gitlab_available")
    @patch("glreview.cli.get_current_commit")
    def test_start_already_in_progress(
        self, mock_commit, mock_gitlab, mock_svc_commit, runner, project_dir, registry_file, monkeypatch
    ):
        """Start fails for module already in progress."""
        mock_commit.return_value = "abc123"
        mock_svc_commit.return_value = "abc123"
        mock_gitlab.return_value = True
        monkeypatch.chdir(project_dir)

        result = runner.invoke(main, ["start", "src/utils.py"])

        assert result.exit_code == 1
        assert "already in progress" in result.output.lower()

    @patch("glreview.review_state.has_changes_since")
    @patch("glreview.review_service.get_current_commit")
    @patch("glreview.cli.gitlab_available")
    @patch("glreview.cli.get_current_commit")
    def test_start_already_reviewed_no_changes(
        self, mock_cli_commit, mock_cli_gitlab, mock_svc_commit, mock_has_changes, runner, project_dir, registry_file, monkeypatch
    ):
        """Start exits when reviewed module has no changes."""
        mock_cli_commit.return_value = "abc123"
        mock_cli_gitlab.return_value = True
        mock_svc_commit.return_value = "abc123"
        mock_has_changes.return_value = False
        monkeypatch.chdir(project_dir)

        result = runner.invoke(main, ["start", "src/done.py"])

        assert result.exit_code == 0
        assert "already reviewed" in result.output.lower()

    @patch("glreview.cli.gitlab_available")
    @patch("glreview.cli.get_current_commit")
    def test_start_no_gitlab_no_issue(
        self, mock_commit, mock_gitlab, runner, project_dir, registry_file, monkeypatch
    ):
        """Start without GitLab and no issue number fails."""
        mock_commit.return_value = "abc123"
        mock_gitlab.return_value = False
        monkeypatch.chdir(project_dir)

        result = runner.invoke(main, ["start", "src/main.py"])

        assert result.exit_code == 1
        assert "gitlab" in result.output.lower() or "issue" in result.output.lower()

    @patch("glreview.review_service.get_current_commit")
    @patch("glreview.cli.gitlab_available")
    @patch("glreview.cli.get_current_commit")
    def test_start_with_manual_issue(
        self, mock_cli_commit, mock_gitlab, mock_svc_commit, runner, project_dir, registry_file, monkeypatch
    ):
        """Start with manual issue number succeeds."""
        mock_cli_commit.return_value = "abc123"
        mock_svc_commit.return_value = "abc123"
        mock_gitlab.return_value = False
        monkeypatch.chdir(project_dir)

        result = runner.invoke(main, ["start", "src/main.py", "--issue", "#99"])

        assert result.exit_code == 0
        assert "review started" in result.output.lower()

    @patch("glreview.review_service.create_issue")
    @patch("glreview.review_service.get_gitlab_project")
    @patch("glreview.review_service.get_gitlab_host")
    @patch("glreview.review_service.gitlab_available")
    @patch("glreview.review_service.get_current_commit")
    @patch("glreview.cli.gitlab_available")
    @patch("glreview.cli.get_current_commit")
    def test_start_creates_issue(
        self, mock_cli_commit, mock_cli_gitlab, mock_svc_commit, mock_svc_gitlab,
        mock_svc_host, mock_svc_project, mock_create,
        runner, project_dir, registry_file, monkeypatch
    ):
        """Start creates GitLab issue when available."""
        mock_cli_commit.return_value = "abc123"
        mock_svc_commit.return_value = "abc123"
        mock_cli_gitlab.return_value = True
        mock_svc_gitlab.return_value = True
        mock_svc_host.return_value = "gitlab.com"
        mock_svc_project.return_value = "org/project"
        mock_issue = MagicMock()
        mock_issue.number = "100"
        mock_issue.url = "https://gitlab.com/org/project/-/issues/100"
        mock_create.return_value = mock_issue
        monkeypatch.chdir(project_dir)

        result = runner.invoke(main, ["start", "src/main.py", "--assignee", "@alice"])

        assert result.exit_code == 0
        assert "review started" in result.output.lower()
        mock_create.assert_called_once()

    @patch("glreview.review_service.create_issue")
    @patch("glreview.review_service.get_gitlab_project")
    @patch("glreview.review_service.get_gitlab_host")
    @patch("glreview.review_service.gitlab_available")
    @patch("glreview.review_service.get_current_commit")
    @patch("glreview.cli.gitlab_available")
    @patch("glreview.cli.get_current_commit")
    def test_start_issue_creation_fails(
        self, mock_cli_commit, mock_cli_gitlab, mock_svc_commit, mock_svc_gitlab,
        mock_svc_host, mock_svc_project, mock_create,
        runner, project_dir, registry_file, monkeypatch
    ):
        """Start fails when issue creation fails."""
        mock_cli_commit.return_value = "abc123"
        mock_svc_commit.return_value = "abc123"
        mock_cli_gitlab.return_value = True
        mock_svc_gitlab.return_value = True
        mock_svc_host.return_value = "gitlab.com"
        mock_svc_project.return_value = "org/project"
        mock_create.return_value = None
        monkeypatch.chdir(project_dir)

        result = runner.invoke(main, ["start", "src/main.py"])

        assert result.exit_code == 1
        assert "failed" in result.output.lower()


class TestSignoff:
    """Tests for signoff command."""

    @pytest.fixture
    def registry_file(self, project_dir):
        """Create a registry with in-progress module."""
        registry = Registry()
        registry.set(
            ModuleStatus(
                path="src/main.py",
                status="in_progress",
                review_issue="#42",
                review_started_commit="start123",
                assigned_reviewer="@alice",
                lines=50,
            )
        )
        registry.set(
            ModuleStatus(
                path="src/done.py",
                status="reviewed",
                last_reviewed_commit="abc123",
                reviewers=["@bob"],
                lines=20,
            )
        )
        registry.set(ModuleStatus(path="src/utils.py", status="needs_review", lines=30))
        save_registry(registry, project_dir / ".glreview/registry.json")
        return project_dir / ".glreview/registry.json"

    def test_signoff_module_not_found(self, runner, project_dir, registry_file, monkeypatch):
        """Signoff fails for unknown module."""
        monkeypatch.chdir(project_dir)

        result = runner.invoke(main, ["signoff", "nonexistent.py"])

        assert result.exit_code == 1
        assert "not in registry" in result.output.lower()

    @patch("glreview.cli.get_current_commit")
    def test_signoff_already_reviewed(
        self, mock_commit, runner, project_dir, registry_file, monkeypatch
    ):
        """Signoff exits for already reviewed module."""
        mock_commit.return_value = "abc123"
        monkeypatch.chdir(project_dir)

        result = runner.invoke(main, ["signoff", "src/done.py"])

        assert result.exit_code == 0
        assert "already reviewed" in result.output.lower()

    def test_signoff_not_in_progress(self, runner, project_dir, registry_file, monkeypatch):
        """Signoff fails for module not in progress."""
        monkeypatch.chdir(project_dir)

        result = runner.invoke(main, ["signoff", "src/utils.py"])

        assert result.exit_code == 1
        assert "not in review" in result.output.lower()

    @patch("glreview.cli.has_changes_since")
    @patch("glreview.cli.get_current_commit")
    def test_signoff_file_changed_during_review(
        self, mock_commit, mock_has_changes, runner, project_dir, registry_file, monkeypatch
    ):
        """Signoff fails when file changed during review."""
        mock_commit.return_value = "current123"
        mock_has_changes.return_value = True
        monkeypatch.chdir(project_dir)

        result = runner.invoke(main, ["signoff", "src/main.py", "--reviewer", "@bob"])

        assert result.exit_code == 1
        assert "changed during review" in result.output.lower()

    @patch("glreview.cli.gitlab_available")
    @patch("glreview.cli.has_changes_since")
    @patch("glreview.cli.get_current_commit")
    def test_signoff_with_acknowledge(
        self, mock_commit, mock_has_changes, mock_gitlab, runner, project_dir, registry_file, monkeypatch
    ):
        """Signoff with --acknowledge succeeds even with changes."""
        mock_commit.return_value = "current123"
        mock_has_changes.return_value = True
        mock_gitlab.return_value = False
        monkeypatch.chdir(project_dir)

        result = runner.invoke(
            main, ["signoff", "src/main.py", "--reviewer", "@bob", "--acknowledge"]
        )

        assert result.exit_code == 0
        assert "reviewed" in result.output.lower()

    @patch("glreview.cli.gitlab_available")
    @patch("glreview.cli.has_changes_since")
    @patch("glreview.cli.get_current_commit")
    def test_signoff_success(
        self, mock_commit, mock_has_changes, mock_gitlab, runner, project_dir, registry_file, monkeypatch
    ):
        """Signoff succeeds with reviewer."""
        mock_commit.return_value = "current123"
        mock_has_changes.return_value = False
        mock_gitlab.return_value = False
        monkeypatch.chdir(project_dir)

        result = runner.invoke(
            main, ["signoff", "src/main.py", "--reviewer", "@bob", "--skip-verify"]
        )

        assert result.exit_code == 0
        assert "reviewed" in result.output.lower()

    @patch("glreview.cli.get_issue")
    @patch("glreview.cli.gitlab_available")
    @patch("glreview.cli.has_changes_since")
    @patch("glreview.cli.get_current_commit")
    def test_signoff_verifies_issue_closed(
        self, mock_commit, mock_has_changes, mock_gitlab, mock_get_issue,
        runner, project_dir, registry_file, monkeypatch
    ):
        """Signoff verifies issue is closed."""
        mock_commit.return_value = "current123"
        mock_has_changes.return_value = False
        mock_gitlab.return_value = True
        mock_issue = MagicMock()
        mock_issue.state = "opened"
        mock_get_issue.return_value = mock_issue
        monkeypatch.chdir(project_dir)

        result = runner.invoke(main, ["signoff", "src/main.py", "--reviewer", "@bob"])

        assert result.exit_code == 1
        assert "not closed" in result.output.lower()

    @patch("glreview.cli.get_issue")
    @patch("glreview.cli.gitlab_available")
    @patch("glreview.cli.has_changes_since")
    @patch("glreview.cli.get_current_commit")
    def test_signoff_issue_closed_extracts_reviewer(
        self, mock_commit, mock_has_changes, mock_gitlab, mock_get_issue,
        runner, project_dir, registry_file, monkeypatch
    ):
        """Signoff extracts reviewer from closed issue."""
        mock_commit.return_value = "current123"
        mock_has_changes.return_value = False
        mock_gitlab.return_value = True
        mock_issue = MagicMock()
        mock_issue.state = "closed"
        mock_issue.assignees = ["charlie"]
        mock_get_issue.return_value = mock_issue
        monkeypatch.chdir(project_dir)

        result = runner.invoke(main, ["signoff", "src/main.py"])

        assert result.exit_code == 0
        assert "@charlie" in result.output


class TestCancel:
    """Tests for cancel command."""

    @pytest.fixture
    def registry_file(self, project_dir):
        """Create a registry with in-progress module."""
        registry = Registry()
        registry.set(
            ModuleStatus(
                path="src/main.py",
                status="in_progress",
                review_issue="#42",
                review_started_commit="start123",
                lines=50,
            )
        )
        registry.set(
            ModuleStatus(
                path="src/prev.py",
                status="in_progress",
                review_issue="#99",
                last_reviewed_commit="prev123",
                reviewers=["@old"],
                lines=30,
            )
        )
        registry.set(ModuleStatus(path="src/utils.py", status="needs_review", lines=20))
        save_registry(registry, project_dir / ".glreview/registry.json")
        return project_dir / ".glreview/registry.json"

    def test_cancel_module_not_found(self, runner, project_dir, registry_file, monkeypatch):
        """Cancel fails for unknown module."""
        monkeypatch.chdir(project_dir)

        result = runner.invoke(main, ["cancel", "nonexistent.py"])

        assert result.exit_code == 1
        assert "not in registry" in result.output.lower()

    def test_cancel_not_in_progress(self, runner, project_dir, registry_file, monkeypatch):
        """Cancel fails for module not in progress."""
        monkeypatch.chdir(project_dir)

        result = runner.invoke(main, ["cancel", "src/utils.py"])

        assert result.exit_code == 1
        assert "not in progress" in result.output.lower()

    @patch("glreview.cli.gitlab_available")
    @patch("glreview.cli.get_current_commit")
    def test_cancel_no_gitlab_no_leave_open(
        self, mock_commit, mock_gitlab, runner, project_dir, registry_file, monkeypatch
    ):
        """Cancel without GitLab fails when trying to close issue."""
        mock_commit.return_value = "abc123"
        mock_gitlab.return_value = False
        monkeypatch.chdir(project_dir)

        result = runner.invoke(main, ["cancel", "src/main.py"])

        assert result.exit_code == 1
        assert "cannot close issue" in result.output.lower()

    @patch("glreview.cli.gitlab_available")
    @patch("glreview.cli.get_current_commit")
    def test_cancel_leave_open(
        self, mock_commit, mock_gitlab, runner, project_dir, registry_file, monkeypatch
    ):
        """Cancel with --leave-open succeeds without GitLab."""
        mock_commit.return_value = "abc123"
        mock_gitlab.return_value = False
        monkeypatch.chdir(project_dir)

        result = runner.invoke(main, ["cancel", "src/main.py", "--leave-open"])

        assert result.exit_code == 0
        assert "review canceled" in result.output.lower()

    @patch("glreview.cli.close_issue")
    @patch("glreview.cli.gitlab_available")
    @patch("glreview.cli.get_current_commit")
    def test_cancel_closes_issue(
        self, mock_commit, mock_gitlab, mock_close, runner, project_dir, registry_file, monkeypatch
    ):
        """Cancel closes GitLab issue."""
        mock_commit.return_value = "abc123"
        mock_gitlab.return_value = True
        mock_close.return_value = True
        monkeypatch.chdir(project_dir)

        result = runner.invoke(main, ["cancel", "src/main.py"])

        assert result.exit_code == 0
        assert "closed issue" in result.output.lower()
        mock_close.assert_called_once()

    @patch("glreview.cli.add_issue_comment")
    @patch("glreview.cli.gitlab_available")
    @patch("glreview.cli.get_current_commit")
    def test_cancel_restart(
        self, mock_commit, mock_gitlab, mock_comment, runner, project_dir, registry_file, monkeypatch
    ):
        """Cancel --restart updates start commit."""
        mock_commit.return_value = "new456"
        mock_gitlab.return_value = True
        mock_comment.return_value = True
        monkeypatch.chdir(project_dir)

        result = runner.invoke(main, ["cancel", "src/main.py", "--restart"])

        assert result.exit_code == 0
        assert "review restarted" in result.output.lower()
        assert "new456" in result.output

    @patch("glreview.cli.gitlab_available")
    @patch("glreview.cli.get_current_commit")
    def test_cancel_restores_previous_review(
        self, mock_commit, mock_gitlab, runner, project_dir, registry_file, monkeypatch
    ):
        """Cancel restores previous review status."""
        mock_commit.return_value = "abc123"
        mock_gitlab.return_value = False
        monkeypatch.chdir(project_dir)

        result = runner.invoke(main, ["cancel", "src/prev.py", "--leave-open"])

        assert result.exit_code == 0
        assert "restored to reviewed" in result.output.lower()


class TestClaudeReview:
    """Tests for claude-review command."""

    @pytest.fixture
    def registry_file(self, project_dir):
        """Create a registry with in-progress module."""
        registry = Registry()
        registry.set(
            ModuleStatus(
                path="src/main.py",
                status="in_progress",
                review_issue="#42",
                lines=50,
            )
        )
        registry.set(ModuleStatus(path="src/utils.py", status="needs_review", lines=30))
        save_registry(registry, project_dir / ".glreview/registry.json")
        return project_dir / ".glreview/registry.json"

    def test_claude_review_not_found(self, runner, project_dir, registry_file, monkeypatch):
        """Claude review fails for unknown module."""
        monkeypatch.chdir(project_dir)

        result = runner.invoke(main, ["claude-review", "nonexistent.py"])

        assert result.exit_code == 1
        assert "not found" in result.output.lower() and "registry" in result.output.lower()

    def test_claude_review_not_in_progress(self, runner, project_dir, registry_file, monkeypatch):
        """Claude review fails for module not in progress."""
        monkeypatch.chdir(project_dir)

        result = runner.invoke(main, ["claude-review", "src/utils.py"])

        assert result.exit_code == 1
        assert "not in progress" in result.output.lower()

    @patch("glreview.claude.claude_available")
    def test_claude_review_cli_not_found(
        self, mock_available, runner, project_dir, registry_file, monkeypatch
    ):
        """Claude review fails when CLI not available."""
        mock_available.return_value = False
        monkeypatch.chdir(project_dir)

        result = runner.invoke(main, ["claude-review", "src/main.py"])

        assert result.exit_code == 1
        assert "not found" in result.output.lower()

    def test_claude_review_dry_run(self, runner, project_dir, registry_file, monkeypatch):
        """Claude review dry run shows prompt."""
        monkeypatch.chdir(project_dir)

        result = runner.invoke(main, ["claude-review", "src/main.py", "--dry-run"])

        assert result.exit_code == 0
        assert "prompt" in result.output.lower()
        assert "src/main.py" in result.output

    @patch("glreview.claude.run_claude_review")
    @patch("glreview.claude.claude_available")
    def test_claude_review_success(
        self, mock_available, mock_run, runner, project_dir, registry_file, monkeypatch
    ):
        """Claude review succeeds."""
        mock_available.return_value = True
        mock_run.return_value = ("LGTM - code looks good", True)
        monkeypatch.chdir(project_dir)

        result = runner.invoke(main, ["claude-review", "src/main.py"])

        assert result.exit_code == 0
        assert "LGTM" in result.output

    @patch("glreview.claude.run_claude_review")
    @patch("glreview.claude.claude_available")
    def test_claude_review_failure(
        self, mock_available, mock_run, runner, project_dir, registry_file, monkeypatch
    ):
        """Claude review handles failure."""
        mock_available.return_value = True
        mock_run.return_value = ("API Error", False)
        monkeypatch.chdir(project_dir)

        result = runner.invoke(main, ["claude-review", "src/main.py"])

        assert result.exit_code == 1
        assert "error" in result.output.lower()


class TestReviewers:
    """Tests for reviewers command."""

    @patch("glreview.cli.gitlab_available")
    def test_reviewers_no_gitlab(self, mock_gitlab, runner, project_dir, monkeypatch):
        """Reviewers shows warning when GitLab unavailable."""
        mock_gitlab.return_value = False
        monkeypatch.chdir(project_dir)

        result = runner.invoke(main, ["reviewers"])

        assert result.exit_code == 0
        assert "gitlab" in result.output.lower() and "not" in result.output.lower()

    @patch("glreview.cli.get_project_members")
    @patch("glreview.cli.gitlab_available")
    def test_reviewers_lists_members(
        self, mock_gitlab, mock_members, runner, project_dir, monkeypatch
    ):
        """Reviewers lists project members."""
        mock_gitlab.return_value = True

        from glreview.gitlab import GitLabMember
        mock_members.return_value = [
            GitLabMember("alice", "Alice Smith", 40),
            GitLabMember("bob", "Bob Jones", 30),
        ]
        monkeypatch.chdir(project_dir)

        result = runner.invoke(main, ["reviewers"])

        assert result.exit_code == 0
        assert "alice" in result.output.lower()
        assert "bob" in result.output.lower()

    @patch("glreview.cli.get_project_members")
    @patch("glreview.cli.gitlab_available")
    def test_reviewers_empty_list(
        self, mock_gitlab, mock_members, runner, project_dir, monkeypatch
    ):
        """Reviewers handles empty member list."""
        mock_gitlab.return_value = True
        mock_members.return_value = []
        monkeypatch.chdir(project_dir)

        result = runner.invoke(main, ["reviewers"])

        assert result.exit_code == 0
        assert "could not fetch" in result.output.lower()


class TestInitEdgeCases:
    """Additional tests for init command edge cases."""

    @patch("glreview.cli.get_current_commit")
    def test_init_no_modules_found(self, mock_commit, runner, temp_dir, monkeypatch):
        """Init with no matching modules."""
        mock_commit.return_value = "abc123"
        # Create pyproject but no source files
        pyproject = temp_dir / "pyproject.toml"
        pyproject.write_text("""[tool.glreview]
sources = ["nonexistent/**/*.py"]
""")
        monkeypatch.chdir(temp_dir)

        result = runner.invoke(main, ["init"])

        assert result.exit_code == 0
        assert "no modules found" in result.output.lower()


class TestStatusEdgeCases:
    """Additional tests for status command edge cases."""

    @pytest.fixture
    def registry_file(self, project_dir):
        """Create a registry with various module states."""
        registry = Registry()
        registry.set(
            ModuleStatus(
                path="src/main.py",
                status="reviewed",
                last_reviewed_commit="abc123",
                last_reviewed_date="2024-01-15",
                reviewers=["@alice"],
                lines=50,
            )
        )
        registry.set(
            ModuleStatus(
                path="src/in_prog.py",
                status="in_progress",
                review_issue="#42",
                assigned_reviewer="@bob",
                review_started_commit="def456",
                lines=30,
            )
        )
        save_registry(registry, project_dir / ".glreview/registry.json")
        return project_dir / ".glreview/registry.json"

    def test_status_empty_registry(self, runner, project_dir, monkeypatch):
        """Status with empty registry."""
        # Create empty registry
        registry = Registry()
        save_registry(registry, project_dir / ".glreview/registry.json")
        monkeypatch.chdir(project_dir)

        result = runner.invoke(main, ["status"])

        assert result.exit_code == 0
        assert "no modules" in result.output.lower()

    @patch("glreview.cli.has_changes_since")
    def test_status_single_module_json(
        self, mock_has_changes, runner, project_dir, registry_file, monkeypatch
    ):
        """Status single module with JSON output."""
        mock_has_changes.return_value = False
        monkeypatch.chdir(project_dir)

        result = runner.invoke(main, ["status", "src/main.py", "--json"])

        assert result.exit_code == 0
        data = json.loads(result.output)
        # to_dict() doesn't include path, but has status
        assert data["status"] == "reviewed"
        assert data["last_reviewed_commit"] == "abc123"

    @patch("glreview.review_state.has_changes_since")
    def test_status_shows_in_progress(
        self, mock_has_changes, runner, project_dir, registry_file, monkeypatch
    ):
        """Status shows in-progress modules."""
        mock_has_changes.return_value = False
        monkeypatch.chdir(project_dir)

        result = runner.invoke(main, ["status"])

        assert result.exit_code == 0
        assert "in progress" in result.output.lower()
        assert "@bob" in result.output or "bob" in result.output

    @patch("glreview.review_state.has_changes_since")
    def test_status_single_module_stale(
        self, mock_has_changes, runner, project_dir, registry_file, monkeypatch
    ):
        """Status shows stale warning for module with changes."""
        mock_has_changes.return_value = True  # Module is stale
        monkeypatch.chdir(project_dir)

        result = runner.invoke(main, ["status", "src/main.py"])

        assert result.exit_code == 0
        assert "changes detected" in result.output.lower()

    @patch("glreview.review_state.has_changes_since")
    def test_status_single_module_no_changes(
        self, mock_has_changes, runner, project_dir, registry_file, monkeypatch
    ):
        """Status shows no changes for module."""
        mock_has_changes.return_value = False
        monkeypatch.chdir(project_dir)

        result = runner.invoke(main, ["status", "src/main.py"])

        assert result.exit_code == 0
        assert "no changes" in result.output.lower()

    @patch("glreview.review_state.has_changes_since")
    def test_status_single_module_in_progress(
        self, mock_has_changes, runner, project_dir, registry_file, monkeypatch
    ):
        """Status shows in-progress module details."""
        mock_has_changes.return_value = False
        monkeypatch.chdir(project_dir)

        result = runner.invoke(main, ["status", "src/in_prog.py"])

        assert result.exit_code == 0
        assert "issue" in result.output.lower()
        assert "assigned" in result.output.lower()

    @patch("glreview.review_state.ReviewStateChecker.is_module_stale")
    def test_status_single_module_stale_check_error(
        self, mock_is_stale, runner, project_dir, registry_file, monkeypatch
    ):
        """Status handles stale check error."""
        mock_is_stale.return_value = (False, "Could not fetch")
        monkeypatch.chdir(project_dir)

        result = runner.invoke(main, ["status", "src/main.py"])

        assert result.exit_code == 0
        assert "could not check" in result.output.lower()


class TestCustomTemplate:
    """Tests for custom issue template loading."""

    @pytest.fixture
    def registry_file(self, project_dir):
        """Create a registry file."""
        registry = Registry()
        registry.set(ModuleStatus(path="src/main.py", status="needs_review", lines=50))
        save_registry(registry, project_dir / ".glreview/registry.json")

        # Create source file
        src = project_dir / "src"
        src.mkdir(exist_ok=True)
        (src / "main.py").write_text("def main(): pass\n")
        return project_dir / ".glreview/registry.json"

    @patch("glreview.review_service.create_issue")
    @patch("glreview.review_service.get_gitlab_project")
    @patch("glreview.review_service.get_gitlab_host")
    @patch("glreview.review_service.gitlab_available")
    @patch("glreview.review_service.get_current_commit")
    @patch("glreview.cli.gitlab_available")
    @patch("glreview.cli.get_current_commit")
    def test_start_with_custom_template(
        self, mock_cli_commit, mock_cli_gitlab, mock_svc_commit, mock_svc_gitlab,
        mock_svc_host, mock_svc_project, mock_create,
        runner, project_dir, registry_file, monkeypatch
    ):
        """Start uses custom template when it exists."""
        mock_cli_commit.return_value = "abc123"
        mock_svc_commit.return_value = "abc123"
        mock_cli_gitlab.return_value = True
        mock_svc_gitlab.return_value = True
        mock_svc_host.return_value = "gitlab.com"
        mock_svc_project.return_value = "org/project"
        mock_issue = MagicMock()
        mock_issue.number = "100"
        mock_issue.url = "https://gitlab.com/org/project/-/issues/100"
        mock_create.return_value = mock_issue

        # Create custom template
        templates = project_dir / "templates"
        templates.mkdir()
        (templates / "custom_issue.md").write_text("## Custom Template\n{{ path }}\n")

        # Configure custom template in pyproject.toml
        pyproject = project_dir / "pyproject.toml"
        pyproject.write_text("""[tool.glreview]
sources = ["src/**/*.py"]
issue_template = "templates/custom_issue.md"
""")

        monkeypatch.chdir(project_dir)
        result = runner.invoke(main, ["start", "src/main.py"])

        assert result.exit_code == 0
        # Custom template should be loaded and used
        mock_create.assert_called_once()

    def test_start_with_missing_custom_template(
        self, runner, project_dir, registry_file, monkeypatch
    ):
        """Start fails when custom template file is missing (config validation)."""
        from glreview.errors import ConfigurationError

        # Configure custom template that doesn't exist
        pyproject = project_dir / "pyproject.toml"
        pyproject.write_text("""[tool.glreview]
sources = ["src/**/*.py"]
issue_template = "templates/nonexistent.md"
""")

        monkeypatch.chdir(project_dir)
        result = runner.invoke(main, ["start", "src/main.py"])

        assert result.exit_code != 0
        assert isinstance(result.exception, ConfigurationError)
        assert "template not found" in str(result.exception).lower()


class TestReportEdgeCases:
    """Additional tests for report command edge cases."""

    @pytest.fixture
    def registry_file(self, project_dir):
        """Create a registry with modules and corresponding source files."""
        # Create source files so sync doesn't remove them
        src = project_dir / "src"
        src.mkdir(exist_ok=True)
        (src / "main.py").write_text("# main\n")
        (src / "in_prog.py").write_text("# in progress\n")
        (src / "needs.py").write_text("# needs review\n")

        registry = Registry()
        registry.set(
            ModuleStatus(
                path="src/main.py",
                status="reviewed",
                last_reviewed_commit="abc123",
                last_reviewed_date="2024-01-15",
                reviewers=["@alice"],
                lines=100,
            )
        )
        registry.set(
            ModuleStatus(
                path="src/in_prog.py",
                status="in_progress",
                review_issue="https://gitlab.com/org/project/-/issues/42",
                assigned_reviewer="@bob",
                lines=50,
            )
        )
        registry.set(
            ModuleStatus(
                path="src/needs.py",
                status="needs_review",
                lines=75,
            )
        )
        save_registry(registry, project_dir / ".glreview/registry.json")
        return project_dir / ".glreview/registry.json"

    def test_report_empty_registry(self, runner, project_dir, monkeypatch):
        """Report with empty registry."""
        registry = Registry()
        save_registry(registry, project_dir / ".glreview/registry.json")
        monkeypatch.chdir(project_dir)

        result = runner.invoke(main, ["report"])

        assert result.exit_code == 1
        assert "no modules" in result.output.lower()

    @patch("glreview.cli_reports.get_current_commit")
    @patch("glreview.review_state.has_changes_since")
    def test_report_with_sync(
        self, mock_has_changes, mock_commit, runner, project_dir, registry_file, monkeypatch
    ):
        """Report with --sync option."""
        mock_has_changes.return_value = False
        mock_commit.return_value = "abc123"
        monkeypatch.chdir(project_dir)

        result = runner.invoke(main, ["report", "--sync"])

        assert result.exit_code == 0
        # Should contain report content
        assert "coverage" in result.output.lower()

    @patch("glreview.review_state.has_changes_since")
    def test_report_markdown_with_in_progress(
        self, mock_has_changes, runner, project_dir, registry_file, monkeypatch
    ):
        """Report includes in-progress section."""
        mock_has_changes.return_value = False
        monkeypatch.chdir(project_dir)

        result = runner.invoke(main, ["report", "--format", "markdown"])

        assert result.exit_code == 0
        assert "in progress" in result.output.lower()
        assert "@bob" in result.output or "bob" in result.output

    @patch("glreview.review_state.has_changes_since")
    def test_report_shows_stale_modules(
        self, mock_has_changes, runner, project_dir, registry_file, monkeypatch
    ):
        """Report shows stale (changed since review) modules."""
        mock_has_changes.return_value = True  # Module has changed
        monkeypatch.chdir(project_dir)

        result = runner.invoke(main, ["report", "--format", "markdown"])

        assert result.exit_code == 0
        # Should show the changed section
        assert "changed" in result.output.lower() or "stale" in result.output.lower()

    @patch("glreview.review_state.has_changes_since")
    def test_report_badge_high_coverage(
        self, mock_has_changes, runner, project_dir, monkeypatch
    ):
        """Report badge shows green for high coverage."""
        mock_has_changes.return_value = False

        # Create registry with 95% coverage (19/20 lines reviewed)
        src = project_dir / "src"
        src.mkdir(exist_ok=True)
        for i in range(10):
            (src / f"mod{i}.py").write_text(f"# mod {i}\n")

        registry = Registry()
        # 9 reviewed, 1 needs review (90% coverage)
        for i in range(9):
            registry.set(ModuleStatus(
                path=f"src/mod{i}.py", status="reviewed",
                last_reviewed_commit="abc", lines=10
            ))
        registry.set(ModuleStatus(path="src/mod9.py", status="needs_review", lines=10))
        save_registry(registry, project_dir / ".glreview/registry.json")
        monkeypatch.chdir(project_dir)

        result = runner.invoke(main, ["report", "--format", "badge"])

        assert result.exit_code == 0
        assert "brightgreen" in result.output

    @patch("glreview.review_state.has_changes_since")
    def test_report_badge_medium_coverage(
        self, mock_has_changes, runner, project_dir, monkeypatch
    ):
        """Report badge shows green for medium coverage."""
        mock_has_changes.return_value = False

        src = project_dir / "src"
        src.mkdir(exist_ok=True)
        for i in range(10):
            (src / f"mod{i}.py").write_text(f"# mod {i}\n")

        registry = Registry()
        # 7 reviewed, 3 needs review (70% coverage)
        for i in range(7):
            registry.set(ModuleStatus(
                path=f"src/mod{i}.py", status="reviewed",
                last_reviewed_commit="abc", lines=10
            ))
        for i in range(7, 10):
            registry.set(ModuleStatus(path=f"src/mod{i}.py", status="needs_review", lines=10))
        save_registry(registry, project_dir / ".glreview/registry.json")
        monkeypatch.chdir(project_dir)

        result = runner.invoke(main, ["report", "--format", "badge"])

        assert result.exit_code == 0
        assert "green" in result.output

    @patch("glreview.review_state.has_changes_since")
    def test_report_badge_low_coverage(
        self, mock_has_changes, runner, project_dir, monkeypatch
    ):
        """Report badge shows yellow for low coverage."""
        mock_has_changes.return_value = False

        src = project_dir / "src"
        src.mkdir(exist_ok=True)
        for i in range(10):
            (src / f"mod{i}.py").write_text(f"# mod {i}\n")

        registry = Registry()
        # 5 reviewed, 5 needs review (50% coverage)
        for i in range(5):
            registry.set(ModuleStatus(
                path=f"src/mod{i}.py", status="reviewed",
                last_reviewed_commit="abc", lines=10
            ))
        for i in range(5, 10):
            registry.set(ModuleStatus(path=f"src/mod{i}.py", status="needs_review", lines=10))
        save_registry(registry, project_dir / ".glreview/registry.json")
        monkeypatch.chdir(project_dir)

        result = runner.invoke(main, ["report", "--format", "badge"])

        assert result.exit_code == 0
        assert "yellow" in result.output

    @patch("glreview.review_state.has_changes_since")
    def test_report_badge_very_low_coverage(
        self, mock_has_changes, runner, project_dir, monkeypatch
    ):
        """Report badge shows red for very low coverage."""
        mock_has_changes.return_value = False

        src = project_dir / "src"
        src.mkdir(exist_ok=True)
        for i in range(10):
            (src / f"mod{i}.py").write_text(f"# mod {i}\n")

        registry = Registry()
        # 2 reviewed, 8 needs review (20% coverage)
        for i in range(2):
            registry.set(ModuleStatus(
                path=f"src/mod{i}.py", status="reviewed",
                last_reviewed_commit="abc", lines=10
            ))
        for i in range(2, 10):
            registry.set(ModuleStatus(path=f"src/mod{i}.py", status="needs_review", lines=10))
        save_registry(registry, project_dir / ".glreview/registry.json")
        monkeypatch.chdir(project_dir)

        result = runner.invoke(main, ["report", "--format", "badge"])

        assert result.exit_code == 0
        assert "red" in result.output

    @patch("glreview.review_state.has_changes_since")
    def test_report_markdown_high_coverage(
        self, mock_has_changes, runner, project_dir, monkeypatch
    ):
        """Report markdown shows brightgreen badge for high coverage."""
        mock_has_changes.return_value = False

        src = project_dir / "src"
        src.mkdir(exist_ok=True)
        for i in range(10):
            (src / f"mod{i}.py").write_text(f"# mod {i}\n")

        registry = Registry()
        # 9 reviewed, 1 needs review (90% coverage)
        for i in range(9):
            registry.set(ModuleStatus(
                path=f"src/mod{i}.py", status="reviewed",
                last_reviewed_commit="abc", lines=10
            ))
        registry.set(ModuleStatus(path="src/mod9.py", status="needs_review", lines=10))
        save_registry(registry, project_dir / ".glreview/registry.json")
        monkeypatch.chdir(project_dir)

        result = runner.invoke(main, ["report", "--format", "markdown"])

        assert result.exit_code == 0
        assert "brightgreen" in result.output

    @patch("glreview.review_state.has_changes_since")
    def test_report_markdown_medium_coverage(
        self, mock_has_changes, runner, project_dir, monkeypatch
    ):
        """Report markdown shows green badge for medium coverage."""
        mock_has_changes.return_value = False

        src = project_dir / "src"
        src.mkdir(exist_ok=True)
        for i in range(10):
            (src / f"mod{i}.py").write_text(f"# mod {i}\n")

        registry = Registry()
        # 7 reviewed, 3 needs review (70% coverage)
        for i in range(7):
            registry.set(ModuleStatus(
                path=f"src/mod{i}.py", status="reviewed",
                last_reviewed_commit="abc", lines=10
            ))
        for i in range(7, 10):
            registry.set(ModuleStatus(path=f"src/mod{i}.py", status="needs_review", lines=10))
        save_registry(registry, project_dir / ".glreview/registry.json")
        monkeypatch.chdir(project_dir)

        result = runner.invoke(main, ["report", "--format", "markdown"])

        assert result.exit_code == 0
        assert "green" in result.output

    @patch("glreview.review_state.has_changes_since")
    def test_report_markdown_low_coverage(
        self, mock_has_changes, runner, project_dir, monkeypatch
    ):
        """Report markdown shows yellow badge for low coverage."""
        mock_has_changes.return_value = False

        src = project_dir / "src"
        src.mkdir(exist_ok=True)
        for i in range(10):
            (src / f"mod{i}.py").write_text(f"# mod {i}\n")

        registry = Registry()
        # 5 reviewed, 5 needs review (50% coverage)
        for i in range(5):
            registry.set(ModuleStatus(
                path=f"src/mod{i}.py", status="reviewed",
                last_reviewed_commit="abc", lines=10
            ))
        for i in range(5, 10):
            registry.set(ModuleStatus(path=f"src/mod{i}.py", status="needs_review", lines=10))
        save_registry(registry, project_dir / ".glreview/registry.json")
        monkeypatch.chdir(project_dir)

        result = runner.invoke(main, ["report", "--format", "markdown"])

        assert result.exit_code == 0
        assert "yellow" in result.output


class TestCheckEdgeCases:
    """Additional tests for check command edge cases."""

    @pytest.fixture
    def registry_file(self, project_dir):
        """Create a registry with reviewed module marked critical."""
        registry = Registry()
        registry.set(
            ModuleStatus(
                path="src/critical.py",
                status="reviewed",
                last_reviewed_commit="abc123",
                lines=50,
            )
        )
        save_registry(registry, project_dir / ".glreview/registry.json")
        return project_dir / ".glreview/registry.json"

    @patch("glreview.review_state.has_changes_since")
    def test_check_reviewed_module_changed(
        self, mock_has_changes, runner, project_dir, registry_file, monkeypatch
    ):
        """Check reports reviewed module changes."""
        mock_has_changes.return_value = True
        monkeypatch.chdir(project_dir)

        result = runner.invoke(main, ["check"])

        assert result.exit_code == 1
        assert "changed" in result.output.lower()


class TestClaudeInit:
    """Tests for claude-init command."""

    @pytest.fixture
    def project_with_modules(self, project_dir):
        """Create a project directory with source files and registry."""
        # Source files already created by project_dir fixture
        # Create a registry
        registry = Registry()
        registry.set(ModuleStatus(path="src/main.py", status="needs_review"))
        registry.set(ModuleStatus(path="src/utils.py", status="needs_review"))
        save_registry(registry, project_dir / ".glreview/registry.json")
        return project_dir

    def test_claude_init_context_exists(
        self, runner, project_with_modules, monkeypatch
    ):
        """Claude-init exits when context already exists."""
        project_dir = project_with_modules
        # Create existing context file (directory already exists from save_registry)
        context_dir = project_dir / ".glreview"
        (context_dir / "context.json").write_text("{}")
        monkeypatch.chdir(project_dir)

        result = runner.invoke(main, ["claude-init"])

        assert result.exit_code == 1
        assert "already exists" in result.output.lower()

    @patch("glreview.claude.claude_available")
    def test_claude_init_no_claude(
        self, mock_available, runner, project_with_modules, monkeypatch
    ):
        """Claude-init exits when claude CLI not found."""
        mock_available.return_value = False
        monkeypatch.chdir(project_with_modules)

        result = runner.invoke(main, ["claude-init"])

        assert result.exit_code == 1
        assert "not found" in result.output.lower()

    def test_claude_init_no_modules(self, runner, temp_dir, monkeypatch):
        """Claude-init exits when no modules found."""
        # Create pyproject but no source files matching pattern
        pyproject = temp_dir / "pyproject.toml"
        pyproject.write_text("""[tool.glreview]
sources = ["nonexistent/**/*.py"]
""")
        monkeypatch.chdir(temp_dir)

        result = runner.invoke(main, ["claude-init"])

        assert result.exit_code == 1
        assert "no modules" in result.output.lower()

    def test_claude_init_dry_run(
        self, runner, project_with_modules, monkeypatch
    ):
        """Claude-init dry run shows prompt."""
        monkeypatch.chdir(project_with_modules)

        result = runner.invoke(main, ["claude-init", "--dry-run"])

        assert result.exit_code == 0
        assert "prompt" in result.output.lower()
        # Should show module paths in prompt
        assert "src/main.py" in result.output

    @patch("glreview.cli_claude.run_claude_review")
    @patch("glreview.claude.claude_available")
    @patch("glreview.cli_claude.get_current_commit")
    def test_claude_init_success(
        self, mock_commit, mock_available, mock_run,
        runner, project_with_modules, monkeypatch
    ):
        """Claude-init succeeds with valid response."""
        mock_commit.return_value = "abc123"
        mock_available.return_value = True
        mock_run.return_value = (
            json.dumps({
                "project": {
                    "description": "Test project",
                    "conventions": "PEP8",
                },
                "modules": {
                    "src/main.py": {"purpose": "Entry point"}
                }
            }),
            True
        )
        monkeypatch.chdir(project_with_modules)

        result = runner.invoke(main, ["claude-init"])

        assert result.exit_code == 0
        assert "context generated" in result.output.lower()

    @patch("glreview.cli_claude.run_claude_review")
    @patch("glreview.claude.claude_available")
    @patch("glreview.cli_claude.get_current_commit")
    def test_claude_init_claude_error(
        self, mock_commit, mock_available, mock_run,
        runner, project_with_modules, monkeypatch
    ):
        """Claude-init handles Claude error."""
        mock_commit.return_value = "abc123"
        mock_available.return_value = True
        mock_run.return_value = ("API Error", False)
        monkeypatch.chdir(project_with_modules)

        result = runner.invoke(main, ["claude-init"])

        assert result.exit_code == 1
        assert "error" in result.output.lower()

    @patch("glreview.cli_claude.run_claude_review")
    @patch("glreview.claude.claude_available")
    @patch("glreview.cli_claude.get_current_commit")
    def test_claude_init_invalid_json(
        self, mock_commit, mock_available, mock_run,
        runner, project_with_modules, monkeypatch
    ):
        """Claude-init handles invalid JSON response."""
        mock_commit.return_value = "abc123"
        mock_available.return_value = True
        mock_run.return_value = ("not valid json {{{", True)
        monkeypatch.chdir(project_with_modules)

        result = runner.invoke(main, ["claude-init"])

        assert result.exit_code == 1
        assert "json" in result.output.lower()


class TestClaudeSync:
    """Tests for claude-sync command."""

    @pytest.fixture
    def git_project_with_context(self, temp_git_repo):
        """Create a git project with existing context and commits."""
        import subprocess

        # Create source files
        src = temp_git_repo / "src"
        src.mkdir(exist_ok=True)
        (src / "main.py").write_text("def main(): pass\n")
        (src / "utils.py").write_text("def util(): pass\n")

        # Create pyproject
        pyproject = temp_git_repo / "pyproject.toml"
        pyproject.write_text("""[tool.glreview]
sources = ["src/**/*.py"]
""")

        # Commit initial files
        subprocess.run(["git", "add", "."], cwd=temp_git_repo, check=True)
        subprocess.run(
            ["git", "commit", "-m", "Initial commit"],
            cwd=temp_git_repo, check=True
        )

        # Get commit SHA
        result = subprocess.run(
            ["git", "rev-parse", "--short", "HEAD"],
            cwd=temp_git_repo, capture_output=True, text=True, check=True
        )
        commit_sha = result.stdout.strip()

        # Create context
        context_dir = temp_git_repo / ".glreview"
        context_dir.mkdir()
        context_data = {
            "version": "1",
            "generated_at": "2024-01-01T00:00:00Z",
            "generated_commit": commit_sha,
            "project": {"description": "Test project"},
            "modules": {}
        }
        (context_dir / "context.json").write_text(json.dumps(context_data))

        return temp_git_repo, commit_sha

    def test_claude_sync_no_context(self, runner, project_dir, monkeypatch):
        """Claude-sync exits when no context exists."""
        pyproject = project_dir / "pyproject.toml"
        pyproject.write_text("""[tool.glreview]
sources = ["src/**/*.py"]
""")
        monkeypatch.chdir(project_dir)

        result = runner.invoke(main, ["claude-sync"])

        assert result.exit_code == 1
        assert "no context" in result.output.lower()

    def test_claude_sync_up_to_date(
        self, runner, git_project_with_context, monkeypatch
    ):
        """Claude-sync reports when context is up to date."""
        project_dir, commit_sha = git_project_with_context
        monkeypatch.chdir(project_dir)

        result = runner.invoke(main, ["claude-sync"])

        assert result.exit_code == 0
        assert "up to date" in result.output.lower()

    @patch("glreview.claude.claude_available")
    def test_claude_sync_no_claude(
        self, mock_available, runner, git_project_with_context, monkeypatch
    ):
        """Claude-sync exits when claude CLI not found."""
        import subprocess

        project_dir, commit_sha = git_project_with_context
        mock_available.return_value = False

        # Make a change and commit to trigger sync
        (project_dir / "src" / "main.py").write_text("def main(): return 1\n")
        subprocess.run(["git", "add", "."], cwd=project_dir, check=True)
        subprocess.run(
            ["git", "commit", "-m", "Update main"],
            cwd=project_dir, check=True
        )

        monkeypatch.chdir(project_dir)
        result = runner.invoke(main, ["claude-sync"])

        assert result.exit_code == 1
        assert "not found" in result.output.lower()

    def test_claude_sync_dry_run(
        self, runner, git_project_with_context, monkeypatch
    ):
        """Claude-sync dry run shows prompt without running claude."""
        import subprocess

        project_dir, commit_sha = git_project_with_context

        # Make a change and commit to trigger sync
        (project_dir / "src" / "main.py").write_text("def main(): return 1\n")
        subprocess.run(["git", "add", "."], cwd=project_dir, check=True)
        subprocess.run(
            ["git", "commit", "-m", "Update main"],
            cwd=project_dir, check=True
        )

        monkeypatch.chdir(project_dir)
        result = runner.invoke(main, ["claude-sync", "--dry-run"])

        # Dry run should show changes
        assert result.exit_code == 0
        assert "modified" in result.output.lower() or "changes" in result.output.lower()

    @patch("glreview.cli_claude.run_claude_review")
    @patch("glreview.claude.claude_available")
    def test_claude_sync_force_rebuild(
        self, mock_available, mock_run,
        runner, git_project_with_context, monkeypatch
    ):
        """Claude-sync --force triggers full rebuild."""
        project_dir, commit_sha = git_project_with_context
        mock_available.return_value = True
        mock_run.return_value = (
            json.dumps({
                "project": {"description": "Updated project"},
                "modules": {}
            }),
            True
        )
        monkeypatch.chdir(project_dir)

        result = runner.invoke(main, ["claude-sync", "--force"])

        assert result.exit_code == 0


class TestClaudeContext:
    """Tests for claude-context command."""

    @pytest.fixture
    def project_with_context(self, project_dir):
        """Create a project with context."""
        context_dir = project_dir / ".glreview"
        context_dir.mkdir()
        context_data = {
            "version": "1",
            "generated_at": "2024-01-01T00:00:00Z",
            "generated_commit": "abc123",
            "project": {
                "description": "Test project description",
                "conventions": "Use PEP8",
                "architecture": "Layered design",
                "test_patterns": "pytest"
            },
            "modules": {
                "src/main.py": {
                    "purpose": "Main entry point",
                    "test_files": ["tests/test_main.py"],
                    "imports_from": ["src/utils.py"],
                    "imported_by": ["src/cli.py"],
                    "related_files": ["src/config.py"],
                    "notes": "Critical module"
                }
            }
        }
        (context_dir / "context.json").write_text(json.dumps(context_data))

        # Create pyproject
        pyproject = project_dir / "pyproject.toml"
        pyproject.write_text("""[tool.glreview]
sources = ["src/**/*.py"]
""")
        return project_dir

    def test_claude_context_no_context(self, runner, project_dir, monkeypatch):
        """Claude-context exits when no context exists."""
        pyproject = project_dir / "pyproject.toml"
        pyproject.write_text("""[tool.glreview]
sources = ["src/**/*.py"]
""")
        monkeypatch.chdir(project_dir)

        result = runner.invoke(main, ["claude-context"])

        assert result.exit_code == 1
        assert "no context" in result.output.lower()

    def test_claude_context_project(
        self, runner, project_with_context, monkeypatch
    ):
        """Claude-context shows project info."""
        monkeypatch.chdir(project_with_context)

        result = runner.invoke(main, ["claude-context"])

        assert result.exit_code == 0
        assert "project context" in result.output.lower()
        assert "test project description" in result.output.lower()
        assert "pep8" in result.output.lower()
        assert "layered" in result.output.lower()

    def test_claude_context_module(
        self, runner, project_with_context, monkeypatch
    ):
        """Claude-context shows module info."""
        monkeypatch.chdir(project_with_context)

        result = runner.invoke(main, ["claude-context", "src/main.py"])

        assert result.exit_code == 0
        assert "main entry point" in result.output.lower()
        assert "test_main.py" in result.output
        assert "utils.py" in result.output

    def test_claude_context_module_not_found(
        self, runner, project_with_context, monkeypatch
    ):
        """Claude-context handles missing module."""
        monkeypatch.chdir(project_with_context)

        result = runner.invoke(main, ["claude-context", "nonexistent.py"])

        assert result.exit_code == 1
        assert "no context found" in result.output.lower()


class TestReviewersTeams:
    """Tests for reviewers command with teams."""

    @pytest.fixture
    def project_with_teams(self, project_dir):
        """Create a project with team configuration."""
        pyproject = project_dir / "pyproject.toml"
        pyproject.write_text("""[tool.glreview]
sources = ["src/**/*.py"]

[tool.glreview.teams]
core = ["@alice", "@bob"]
frontend = ["@charlie"]
""")
        return project_dir

    @patch("glreview.cli.gitlab_available")
    def test_reviewers_with_teams(
        self, mock_gitlab, runner, project_with_teams, monkeypatch
    ):
        """Reviewers shows configured teams."""
        mock_gitlab.return_value = False
        monkeypatch.chdir(project_with_teams)

        result = runner.invoke(main, ["reviewers"])

        assert result.exit_code == 0
        assert "configured teams" in result.output.lower()

    @patch("glreview.cli.gitlab_available")
    def test_reviewers_filter_team(
        self, mock_gitlab, runner, project_with_teams, monkeypatch
    ):
        """Reviewers filters by team."""
        mock_gitlab.return_value = False
        monkeypatch.chdir(project_with_teams)

        result = runner.invoke(main, ["reviewers", "--team", "core"])

        assert result.exit_code == 0
        assert "alice" in result.output.lower() or "bob" in result.output.lower()

    @patch("glreview.cli.gitlab_available")
    def test_reviewers_unknown_team(
        self, mock_gitlab, runner, project_with_teams, monkeypatch
    ):
        """Reviewers fails for unknown team."""
        mock_gitlab.return_value = False
        monkeypatch.chdir(project_with_teams)

        result = runner.invoke(main, ["reviewers", "--team", "unknown"])

        assert result.exit_code == 1
        assert "unknown team" in result.output.lower()


class TestDiff:
    """Tests for diff command."""

    @pytest.fixture
    def registry_file(self, project_dir):
        """Create a registry with modules in various states."""
        registry = Registry()
        registry.set(
            ModuleStatus(
                path="src/main.py",
                status="reviewed",
                last_reviewed_commit="rev123",
                reviewers=["@alice"],
                lines=50,
            )
        )
        registry.set(
            ModuleStatus(
                path="src/utils.py",
                status="needs_review",
                lines=30,
            )
        )
        save_registry(registry, project_dir / ".glreview/registry.json")
        return project_dir / ".glreview/registry.json"

    @patch("glreview.cli.has_changes_since")
    @patch("glreview.cli.get_current_commit")
    @patch("glreview.cli.get_gitlab_host")
    @patch("glreview.cli.get_gitlab_project")
    def test_diff_no_changes(
        self, mock_project, mock_host, mock_commit, mock_has_changes,
        runner, project_dir, registry_file, monkeypatch
    ):
        """Diff reports no changes when module is unchanged."""
        mock_project.return_value = "group/project"
        mock_host.return_value = "gitlab.com"
        mock_commit.return_value = "current123"
        mock_has_changes.return_value = False
        monkeypatch.chdir(project_dir)

        result = runner.invoke(main, ["diff", "src/main.py"])

        assert result.exit_code == 0
        assert "no changes" in result.output.lower()

    def test_diff_unknown_module(self, runner, project_dir, registry_file, monkeypatch):
        """Diff fails for module not in registry."""
        monkeypatch.chdir(project_dir)

        result = runner.invoke(main, ["diff", "nonexistent.py"])

        assert result.exit_code == 1
        assert "not in registry" in result.output.lower()

    @patch("glreview.cli.get_current_commit")
    @patch("glreview.cli.get_gitlab_host")
    @patch("glreview.cli.get_gitlab_project")
    def test_diff_never_reviewed(
        self, mock_project, mock_host, mock_commit,
        runner, project_dir, registry_file, monkeypatch
    ):
        """Diff fails for module that has never been reviewed."""
        mock_project.return_value = "group/project"
        mock_host.return_value = "gitlab.com"
        mock_commit.return_value = "current123"
        monkeypatch.chdir(project_dir)

        result = runner.invoke(main, ["diff", "src/utils.py"])

        assert result.exit_code == 1
        assert "never been reviewed" in result.output.lower()

    @patch("glreview.cli.has_changes_since")
    @patch("glreview.cli.get_current_commit")
    @patch("glreview.cli.get_gitlab_host")
    @patch("glreview.cli.get_gitlab_project")
    def test_diff_since_start_not_in_progress(
        self, mock_project, mock_host, mock_commit, mock_has_changes,
        runner, project_dir, registry_file, monkeypatch
    ):
        """Diff --since-start fails for module not in progress."""
        mock_project.return_value = "group/project"
        mock_host.return_value = "gitlab.com"
        mock_commit.return_value = "current123"
        monkeypatch.chdir(project_dir)

        result = runner.invoke(main, ["diff", "src/main.py", "--since-start"])

        assert result.exit_code == 1
        assert "not in progress" in result.output.lower()


class TestSignoffGitLabUnavailable:
    """Tests for signoff command when GitLab is unavailable."""

    @pytest.fixture
    def registry_file(self, project_dir):
        """Create a registry with in-progress module that has an issue."""
        registry = Registry()
        registry.set(
            ModuleStatus(
                path="src/main.py",
                status="in_progress",
                review_issue="#42",
                review_started_commit="start123",
                assigned_reviewer="@alice",
                lines=50,
            )
        )
        save_registry(registry, project_dir / ".glreview/registry.json")
        return project_dir / ".glreview/registry.json"

    @patch("glreview.cli.gitlab_available")
    @patch("glreview.cli.has_changes_since")
    @patch("glreview.cli.get_current_commit")
    def test_signoff_no_gitlab_skips_verify(
        self, mock_commit, mock_has_changes, mock_gitlab,
        runner, project_dir, registry_file, monkeypatch
    ):
        """Signoff succeeds without GitLab when --skip-verify is used."""
        mock_commit.return_value = "current123"
        mock_has_changes.return_value = False
        mock_gitlab.return_value = False
        monkeypatch.chdir(project_dir)

        result = runner.invoke(
            main, ["signoff", "src/main.py", "--reviewer", "@bob", "--skip-verify"]
        )

        assert result.exit_code == 0
        assert "reviewed" in result.output.lower()

    @patch("glreview.cli.get_issue")
    @patch("glreview.cli.gitlab_available")
    @patch("glreview.cli.has_changes_since")
    @patch("glreview.cli.get_current_commit")
    def test_signoff_gitlab_issue_fetch_fails(
        self, mock_commit, mock_has_changes, mock_gitlab, mock_get_issue,
        runner, project_dir, registry_file, monkeypatch
    ):
        """Signoff handles GitLab issue fetch failure gracefully."""
        mock_commit.return_value = "current123"
        mock_has_changes.return_value = False
        mock_gitlab.return_value = True
        mock_get_issue.return_value = None
        monkeypatch.chdir(project_dir)

        result = runner.invoke(main, ["signoff", "src/main.py", "--reviewer", "@bob"])

        # Should warn but not crash
        assert result.exit_code == 0 or "could not" in result.output.lower()


class TestCancelGitLabUnavailable:
    """Tests for cancel command when GitLab is unavailable."""

    @pytest.fixture
    def registry_file(self, project_dir):
        """Create a registry with in-progress module that has an issue."""
        registry = Registry()
        registry.set(
            ModuleStatus(
                path="src/main.py",
                status="in_progress",
                review_issue="#42",
                review_started_commit="start123",
                lines=50,
            )
        )
        save_registry(registry, project_dir / ".glreview/registry.json")
        return project_dir / ".glreview/registry.json"

    @patch("glreview.cli.close_issue")
    @patch("glreview.cli.gitlab_available")
    @patch("glreview.cli.get_current_commit")
    def test_cancel_close_issue_fails(
        self, mock_commit, mock_gitlab, mock_close,
        runner, project_dir, registry_file, monkeypatch
    ):
        """Cancel aborts when issue close fails."""
        mock_commit.return_value = "abc123"
        mock_gitlab.return_value = True
        mock_close.return_value = False
        monkeypatch.chdir(project_dir)

        result = runner.invoke(main, ["cancel", "src/main.py"])

        assert result.exit_code == 1
        assert "could not close issue" in result.output.lower()

    @patch("glreview.cli.gitlab_available")
    @patch("glreview.cli.get_current_commit")
    def test_cancel_no_gitlab_with_leave_open(
        self, mock_commit, mock_gitlab,
        runner, project_dir, registry_file, monkeypatch
    ):
        """Cancel with --leave-open works without GitLab."""
        mock_commit.return_value = "abc123"
        mock_gitlab.return_value = False
        monkeypatch.chdir(project_dir)

        result = runner.invoke(main, ["cancel", "src/main.py", "--leave-open"])

        assert result.exit_code == 0
        assert "review canceled" in result.output.lower()

    @patch("glreview.cli.gitlab_available")
    @patch("glreview.cli.get_current_commit")
    def test_cancel_no_gitlab_without_leave_open_fails(
        self, mock_commit, mock_gitlab,
        runner, project_dir, registry_file, monkeypatch
    ):
        """Cancel without --leave-open fails when GitLab unavailable."""
        mock_commit.return_value = "abc123"
        mock_gitlab.return_value = False
        monkeypatch.chdir(project_dir)

        result = runner.invoke(main, ["cancel", "src/main.py"])

        assert result.exit_code == 1
        assert "cannot close issue" in result.output.lower()

    @patch("glreview.cli.add_issue_comment")
    @patch("glreview.cli.gitlab_available")
    @patch("glreview.cli.get_current_commit")
    def test_cancel_restart_comment_fails(
        self, mock_commit, mock_gitlab, mock_comment,
        runner, project_dir, registry_file, monkeypatch
    ):
        """Restart aborts when GitLab comment fails."""
        mock_commit.return_value = "new456"
        mock_gitlab.return_value = True
        mock_comment.return_value = False
        monkeypatch.chdir(project_dir)

        result = runner.invoke(main, ["cancel", "src/main.py", "--restart"])

        assert result.exit_code == 1
        assert "could not add restart comment" in result.output.lower()


class TestClaudeFixCreateMR:
    """Tests for claude-fix --create-mr flow."""

    @pytest.fixture
    def fix_project_dir(self, project_dir):
        """Create a project with in-progress module and review issue."""
        registry = Registry()
        registry.set(
            ModuleStatus(
                path="src/main.py",
                status="in_progress",
                review_issue="#42",
                lines=50,
            )
        )
        save_registry(registry, project_dir / ".glreview/registry.json")
        return project_dir

    @patch("glreview.claude.claude_available")
    @patch("glreview.cli_claude.has_uncommitted_changes")
    def test_rejects_dirty_working_tree(
        self, mock_dirty, mock_claude, runner, fix_project_dir, monkeypatch
    ):
        """Rejects --create-mr when working tree is dirty."""
        mock_claude.return_value = True
        mock_dirty.return_value = True
        monkeypatch.chdir(fix_project_dir)

        result = runner.invoke(main, ["claude-fix", "src/main.py", "--create-mr", "--batch"])

        assert result.exit_code == 1
        assert "uncommitted changes" in result.output.lower()

    @patch("glreview.claude.claude_available")
    @patch("glreview.cli_claude.has_uncommitted_changes")
    @patch("glreview.cli_claude.gitlab_available")
    def test_rejects_when_gitlab_unavailable(
        self, mock_gitlab, mock_dirty, mock_claude,
        runner, fix_project_dir, monkeypatch
    ):
        """Rejects --create-mr when GitLab is unavailable."""
        mock_claude.return_value = True
        mock_dirty.return_value = False
        mock_gitlab.return_value = False
        monkeypatch.chdir(fix_project_dir)

        result = runner.invoke(main, ["claude-fix", "src/main.py", "--create-mr", "--batch"])

        assert result.exit_code == 1
        assert "gitlab not available" in result.output.lower()

    @patch("glreview.cli_claude.checkout_branch")
    @patch("glreview.cli_claude.add_issue_comment")
    @patch("glreview.cli_claude.create_merge_request")
    @patch("glreview.cli_claude.push_branch")
    @patch("glreview.cli_claude.commit_all_changes")
    @patch("glreview.cli_claude.has_uncommitted_changes")
    @patch("glreview.cli_claude.create_branch")
    @patch("glreview.cli_claude.get_current_branch")
    @patch("glreview.cli_claude.gitlab_available")
    @patch("glreview.cli_claude.find_claude_review_comment")
    @patch("glreview.cli_claude.get_gitlab_host")
    @patch("glreview.cli_claude.get_gitlab_project")
    @patch("glreview.claude.claude_available")
    @patch("glreview.cli_claude.run_claude_review")
    def test_no_changes_after_claude(
        self, mock_run, mock_claude, mock_project, mock_host,
        mock_review, mock_gitlab, mock_branch, mock_create_br,
        mock_dirty, mock_commit, mock_push, mock_mr, mock_comment,
        mock_checkout,
        runner, fix_project_dir, monkeypatch
    ):
        """Reports no changes and restores branch when Claude makes no changes."""
        mock_run.return_value = ("Fixed things", True)
        mock_claude.return_value = True
        mock_project.return_value = "org/project"
        mock_host.return_value = "gitlab.com"
        mock_review.return_value = (
            "## Review\n\n### Correctness: REQUIRED\nFix the bug in the main function.\n"
        )
        mock_gitlab.return_value = True
        mock_branch.return_value = "main"
        # First call (pre-check) returns False (clean), second call (_create_fix_mr) returns False (no changes)
        mock_dirty.side_effect = [False, False]
        monkeypatch.chdir(fix_project_dir)

        result = runner.invoke(main, ["claude-fix", "src/main.py", "--create-mr", "--batch"])

        assert "no changes" in result.output.lower()
        mock_checkout.assert_called()  # Should restore original branch

    @patch("glreview.cli_claude.checkout_branch")
    @patch("glreview.cli_claude.add_issue_comment")
    @patch("glreview.cli_claude.create_merge_request")
    @patch("glreview.cli_claude.push_branch")
    @patch("glreview.cli_claude.commit_all_changes")
    @patch("glreview.cli_claude.has_uncommitted_changes")
    @patch("glreview.cli_claude.create_branch")
    @patch("glreview.cli_claude.get_current_branch")
    @patch("glreview.cli_claude.gitlab_available")
    @patch("glreview.cli_claude.find_claude_review_comment")
    @patch("glreview.cli_claude.get_gitlab_host")
    @patch("glreview.cli_claude.get_gitlab_project")
    @patch("glreview.claude.claude_available")
    @patch("glreview.cli_claude.run_claude_review")
    def test_happy_path_batch(
        self, mock_run, mock_claude, mock_project, mock_host,
        mock_review, mock_gitlab, mock_branch, mock_create_br,
        mock_dirty, mock_commit, mock_push, mock_mr, mock_comment,
        mock_checkout,
        runner, fix_project_dir, monkeypatch
    ):
        """Full happy path: branch, commit, push, MR, URL printed."""
        mock_run.return_value = ("Fixed things", True)
        mock_claude.return_value = True
        mock_project.return_value = "org/project"
        mock_host.return_value = "gitlab.com"
        mock_review.return_value = (
            "## Review\n\n### Correctness: REQUIRED\nFix the bug in the main function.\n"
        )
        mock_gitlab.return_value = True
        mock_branch.return_value = "main"
        # First call (pre-check) returns False (clean), second call (_create_fix_mr) returns True (changes)
        mock_dirty.side_effect = [False, True]
        mock_commit.return_value = "abc1234"

        mock_mr_obj = MagicMock()
        mock_mr_obj.url = "https://gitlab.com/org/project/-/merge_requests/10"
        mock_mr.return_value = mock_mr_obj
        mock_comment.return_value = True
        monkeypatch.chdir(fix_project_dir)

        result = runner.invoke(main, ["claude-fix", "src/main.py", "--create-mr", "--batch"])

        assert result.exit_code == 0
        assert "merge_requests/10" in result.output
        mock_create_br.assert_called_once()
        mock_push.assert_called_once()
        mock_mr.assert_called_once()

    @patch("glreview.cli_claude.checkout_branch")
    @patch("glreview.cli_claude.add_issue_comment")
    @patch("glreview.cli_claude.create_merge_request")
    @patch("glreview.cli_claude.push_branch")
    @patch("glreview.cli_claude.commit_all_changes")
    @patch("glreview.cli_claude.has_uncommitted_changes")
    @patch("glreview.cli_claude.create_branch")
    @patch("glreview.cli_claude.get_current_branch")
    @patch("glreview.cli_claude.gitlab_available")
    @patch("glreview.cli_claude.find_claude_review_comment")
    @patch("glreview.cli_claude.get_gitlab_host")
    @patch("glreview.cli_claude.get_gitlab_project")
    @patch("glreview.claude.claude_available")
    @patch("glreview.cli_claude.run_claude_review")
    def test_push_failure(
        self, mock_run, mock_claude, mock_project, mock_host,
        mock_review, mock_gitlab, mock_branch, mock_create_br,
        mock_dirty, mock_commit, mock_push, mock_mr, mock_comment,
        mock_checkout,
        runner, fix_project_dir, monkeypatch
    ):
        """Push failure reports error with branch name."""
        from glreview.git import GitError

        mock_run.return_value = ("Fixed things", True)
        mock_claude.return_value = True
        mock_project.return_value = "org/project"
        mock_host.return_value = "gitlab.com"
        mock_review.return_value = (
            "## Review\n\n### Correctness: REQUIRED\nFix the bug in the main function.\n"
        )
        mock_gitlab.return_value = True
        mock_branch.return_value = "main"
        mock_dirty.side_effect = [False, True]
        mock_commit.return_value = "abc1234"
        mock_push.side_effect = GitError("push failed: permission denied")
        monkeypatch.chdir(fix_project_dir)

        result = runner.invoke(main, ["claude-fix", "src/main.py", "--create-mr", "--batch"])

        assert "failed to push" in result.output.lower()
        assert "fix/review-main-42" in result.output

    @patch("glreview.cli_claude.checkout_branch")
    @patch("glreview.cli_claude.add_issue_comment")
    @patch("glreview.cli_claude.create_merge_request")
    @patch("glreview.cli_claude.push_branch")
    @patch("glreview.cli_claude.commit_all_changes")
    @patch("glreview.cli_claude.has_uncommitted_changes")
    @patch("glreview.cli_claude.create_branch")
    @patch("glreview.cli_claude.get_current_branch")
    @patch("glreview.cli_claude.gitlab_available")
    @patch("glreview.cli_claude.find_claude_review_comment")
    @patch("glreview.cli_claude.get_gitlab_host")
    @patch("glreview.cli_claude.get_gitlab_project")
    @patch("glreview.claude.claude_available")
    @patch("glreview.cli_claude.run_claude_review")
    def test_mr_creation_failure(
        self, mock_run, mock_claude, mock_project, mock_host,
        mock_review, mock_gitlab, mock_branch, mock_create_br,
        mock_dirty, mock_commit, mock_push, mock_mr, mock_comment,
        mock_checkout,
        runner, fix_project_dir, monkeypatch
    ):
        """MR creation failure suggests manual creation."""
        mock_run.return_value = ("Fixed things", True)
        mock_claude.return_value = True
        mock_project.return_value = "org/project"
        mock_host.return_value = "gitlab.com"
        mock_review.return_value = (
            "## Review\n\n### Correctness: REQUIRED\nFix the bug in the main function.\n"
        )
        mock_gitlab.return_value = True
        mock_branch.return_value = "main"
        mock_dirty.side_effect = [False, True]
        mock_commit.return_value = "abc1234"
        mock_mr.return_value = None  # MR creation fails
        monkeypatch.chdir(fix_project_dir)

        result = runner.invoke(main, ["claude-fix", "src/main.py", "--create-mr", "--batch"])

        assert "failed to create merge request" in result.output.lower()
        assert "manually" in result.output.lower()

    @patch("glreview.cli_claude.checkout_branch")
    @patch("glreview.cli_claude.add_issue_comment")
    @patch("glreview.cli_claude.create_merge_request")
    @patch("glreview.cli_claude.push_branch")
    @patch("glreview.cli_claude.commit_all_changes")
    @patch("glreview.cli_claude.has_uncommitted_changes")
    @patch("glreview.cli_claude.create_branch")
    @patch("glreview.cli_claude.get_current_branch")
    @patch("glreview.cli_claude.gitlab_available")
    @patch("glreview.cli_claude.find_claude_review_comment")
    @patch("glreview.cli_claude.get_gitlab_host")
    @patch("glreview.cli_claude.get_gitlab_project")
    @patch("glreview.claude.claude_available")
    @patch("glreview.cli_claude.run_claude_interactive")
    def test_interactive_confirmation_prompt(
        self, mock_run, mock_claude, mock_project, mock_host,
        mock_review, mock_gitlab, mock_branch, mock_create_br,
        mock_dirty, mock_commit, mock_push, mock_mr, mock_comment,
        mock_checkout,
        runner, fix_project_dir, monkeypatch
    ):
        """Interactive mode shows confirmation prompt."""
        mock_run.return_value = True
        mock_claude.return_value = True
        mock_project.return_value = "org/project"
        mock_host.return_value = "gitlab.com"
        mock_review.return_value = (
            "## Review\n\n### Correctness: REQUIRED\nFix the bug in the main function.\n"
        )
        mock_gitlab.return_value = True
        mock_branch.return_value = "main"
        mock_dirty.side_effect = [False]
        monkeypatch.chdir(fix_project_dir)

        # User selects default items but declines MR creation
        result = runner.invoke(
            main,
            ["claude-fix", "src/main.py", "--create-mr"],
            input="1\nn\n",  # Select item 1, decline MR
        )

        assert "fix/review-main-42" in result.output


class TestReportCommit:
    """Tests for the --commit flag on the report command."""

    @pytest.fixture
    def registry_file(self, project_dir):
        """Create a registry with at least one module for report to work."""
        registry = Registry()
        registry.set(
            ModuleStatus(
                path="src/main.py",
                status="reviewed",
                last_reviewed_commit="abc123",
                lines=50,
            )
        )
        registry.set(
            ModuleStatus(
                path="src/utils.py",
                status="needs_review",
                lines=30,
            )
        )
        save_registry(registry, project_dir / ".glreview/registry.json")
        return project_dir / ".glreview/registry.json"

    @patch("glreview.cli_reports.subprocess.run")
    @patch("glreview.review_state.has_changes_since")
    def test_report_commit_no_changes(
        self,
        mock_has_changes,
        mock_subprocess_run,
        runner,
        project_dir,
        registry_file,
        monkeypatch,
    ):
        """When git status shows no changes, outputs 'No changes to commit'."""
        mock_has_changes.return_value = False
        monkeypatch.chdir(project_dir)

        # git status --porcelain returns empty (no changes)
        status_result = MagicMock()
        status_result.stdout = ""
        status_result.returncode = 0
        mock_subprocess_run.return_value = status_result

        result = runner.invoke(main, ["report", "--commit"])

        assert result.exit_code == 0
        assert "No changes to commit" in result.output

    @patch("glreview.cli_reports.subprocess.run")
    @patch("glreview.review_state.has_changes_since")
    def test_report_commit_success(
        self,
        mock_has_changes,
        mock_subprocess_run,
        runner,
        project_dir,
        registry_file,
        monkeypatch,
    ):
        """When there are changes, commits them and shows success message."""
        mock_has_changes.return_value = False
        monkeypatch.chdir(project_dir)

        # First call: git status --porcelain shows changes
        status_result = MagicMock()
        status_result.stdout = " M .glreview/registry.json\n"
        status_result.returncode = 0

        # Second call: git add (returns successfully)
        add_result = MagicMock()
        add_result.returncode = 0

        # Third call: git commit (success)
        commit_result = MagicMock()
        commit_result.returncode = 0
        commit_result.stdout = "1 file changed"
        commit_result.stderr = ""

        mock_subprocess_run.side_effect = [status_result, add_result, commit_result]

        result = runner.invoke(main, ["report", "--commit"])

        assert result.exit_code == 0
        assert "Committed: Update review registry" in result.output

    @patch("glreview.cli_reports.subprocess.run")
    @patch("glreview.review_state.has_changes_since")
    def test_report_commit_failure(
        self,
        mock_has_changes,
        mock_subprocess_run,
        runner,
        project_dir,
        registry_file,
        monkeypatch,
    ):
        """When git commit fails, shows warning."""
        mock_has_changes.return_value = False
        monkeypatch.chdir(project_dir)

        # First call: git status --porcelain shows changes
        status_result = MagicMock()
        status_result.stdout = " M .glreview/registry.json\n"
        status_result.returncode = 0

        # Second call: git add (returns successfully)
        add_result = MagicMock()
        add_result.returncode = 0

        # Third call: git commit (fails)
        commit_result = MagicMock()
        commit_result.returncode = 1
        commit_result.stdout = ""
        commit_result.stderr = "nothing to commit"

        mock_subprocess_run.side_effect = [status_result, add_result, commit_result]

        result = runner.invoke(main, ["report", "--commit"])

        assert result.exit_code == 0
        assert "Warning: Commit failed" in result.output
        assert "nothing to commit" in result.output

    @patch("glreview.cli_reports.subprocess.run")
    @patch("glreview.review_state.has_changes_since")
    def test_report_commit_with_output_file(
        self,
        mock_has_changes,
        mock_subprocess_run,
        runner,
        project_dir,
        registry_file,
        monkeypatch,
    ):
        """When --output is provided, stages the output file too."""
        mock_has_changes.return_value = False
        monkeypatch.chdir(project_dir)

        output_path = str(project_dir / "report.md")

        # First call: git status --porcelain shows changes
        status_result = MagicMock()
        status_result.stdout = " M .glreview/registry.json\n"
        status_result.returncode = 0

        # Second call: git add registry
        add_registry_result = MagicMock()
        add_registry_result.returncode = 0

        # Third call: git add output file
        add_output_result = MagicMock()
        add_output_result.returncode = 0

        # Fourth call: git commit (success)
        commit_result = MagicMock()
        commit_result.returncode = 0
        commit_result.stdout = "2 files changed"
        commit_result.stderr = ""

        mock_subprocess_run.side_effect = [
            status_result,
            add_registry_result,
            add_output_result,
            commit_result,
        ]

        result = runner.invoke(
            main, ["report", "--commit", "--output", output_path]
        )

        assert result.exit_code == 0
        assert "Committed: Update review registry" in result.output

        # Verify that subprocess.run was called 4 times (status, add registry,
        # add output, commit)
        assert mock_subprocess_run.call_count == 4

        # Verify the output file was staged (third call)
        third_call_args = mock_subprocess_run.call_args_list[2]
        assert third_call_args[0][0] == ["git", "add", output_path]


class TestInitNoSources:
    """Tests for init command when no sources are configured."""

    def test_init_no_sources_configured(self, runner, temp_dir, monkeypatch):
        """Init shows error when no sources configured in pyproject.toml."""
        pyproject = temp_dir / "pyproject.toml"
        pyproject.write_text("""[tool.glreview]
""")
        monkeypatch.chdir(temp_dir)

        result = runner.invoke(main, ["init"])

        assert result.exit_code == 1
        assert "no source patterns configured" in result.output.lower()
        assert "pyproject.toml" in result.output
        assert "[tool.glreview]" in result.output
        assert 'sources = ["src/**/*.py"]' in result.output

    def test_init_no_sources_shows_exclude_example(self, runner, temp_dir, monkeypatch):
        """Init error shows exclude pattern example."""
        pyproject = temp_dir / "pyproject.toml"
        pyproject.write_text("""[tool.glreview]
""")
        monkeypatch.chdir(temp_dir)

        result = runner.invoke(main, ["init"])

        assert result.exit_code == 1
        assert "exclude" in result.output
        assert "glreview init" in result.output

    def test_init_empty_sources_list(self, runner, temp_dir, monkeypatch):
        """Init fails when sources is an empty list."""
        pyproject = temp_dir / "pyproject.toml"
        pyproject.write_text("""[tool.glreview]
sources = []
""")
        monkeypatch.chdir(temp_dir)

        result = runner.invoke(main, ["init"])

        # Empty sources raises ConfigurationError before CLI code runs
        assert result.exit_code != 0


class TestSignoffEdgeCases:
    """Tests for signoff edge cases: no reviewer determined, AlreadyInStateError."""

    @pytest.fixture
    def registry_file(self, project_dir):
        """Create a registry with in-progress module without issue."""
        registry = Registry()
        registry.set(
            ModuleStatus(
                path="src/main.py",
                status="in_progress",
                review_issue=None,
                review_started_commit="start123",
                lines=50,
            )
        )
        registry.set(
            ModuleStatus(
                path="src/reviewed.py",
                status="reviewed",
                last_reviewed_commit="abc123",
                reviewers=["@alice"],
                lines=30,
            )
        )
        save_registry(registry, project_dir / ".glreview/registry.json")
        return project_dir / ".glreview/registry.json"

    @patch("glreview.cli.has_changes_since")
    @patch("glreview.cli.get_current_commit")
    def test_signoff_no_reviewer_determined(
        self, mock_commit, mock_has_changes,
        runner, project_dir, registry_file, monkeypatch
    ):
        """Signoff fails when reviewer cannot be determined."""
        mock_commit.return_value = "current123"
        mock_has_changes.return_value = False
        monkeypatch.chdir(project_dir)

        # No issue, no --reviewer, so no reviewer can be determined
        result = runner.invoke(main, ["signoff", "src/main.py", "--skip-verify"])

        assert result.exit_code == 1
        assert "could not determine reviewer" in result.output.lower()
        assert "--reviewer" in result.output

    @patch("glreview.cli.state_machine")
    @patch("glreview.cli.has_changes_since")
    @patch("glreview.cli.get_current_commit")
    def test_signoff_already_in_state_error(
        self, mock_commit, mock_has_changes, mock_sm,
        runner, project_dir, registry_file, monkeypatch
    ):
        """Signoff handles AlreadyInStateError from state machine."""
        from glreview.errors import AlreadyInStateError

        mock_commit.return_value = "current123"
        mock_has_changes.return_value = False
        mock_sm.signoff.side_effect = AlreadyInStateError("src/main.py", "reviewed")
        monkeypatch.chdir(project_dir)

        result = runner.invoke(
            main, ["signoff", "src/main.py", "--reviewer", "@bob", "--skip-verify"]
        )

        assert result.exit_code == 1
        assert "already" in result.output.lower()


class TestCancelExtended:
    """Extended tests for cancel command covering uncovered lines."""

    @pytest.fixture
    def registry_file(self, project_dir):
        """Create a registry with in-progress modules."""
        registry = Registry()
        # Module with issue, no previous review
        registry.set(
            ModuleStatus(
                path="src/main.py",
                status="in_progress",
                review_issue="#42",
                review_started_commit="start123",
                lines=50,
            )
        )
        # Module with issue AND previous review (was reviewed before)
        registry.set(
            ModuleStatus(
                path="src/prev.py",
                status="in_progress",
                review_issue="#99",
                last_reviewed_commit="prev123",
                reviewers=["@old"],
                lines=30,
            )
        )
        # Module without issue
        registry.set(
            ModuleStatus(
                path="src/noissue.py",
                status="in_progress",
                review_issue=None,
                lines=20,
            )
        )
        save_registry(registry, project_dir / ".glreview/registry.json")
        return project_dir / ".glreview/registry.json"

    @patch("glreview.cli.add_issue_comment")
    @patch("glreview.cli.gitlab_available")
    @patch("glreview.cli.get_current_commit")
    def test_restart_with_issue_comment_and_reason(
        self, mock_commit, mock_gitlab, mock_comment,
        runner, project_dir, registry_file, monkeypatch
    ):
        """Restart with reason adds comment with reason to issue."""
        mock_commit.return_value = "new456"
        mock_gitlab.return_value = True
        mock_comment.return_value = True
        monkeypatch.chdir(project_dir)

        result = runner.invoke(
            main, ["cancel", "src/main.py", "--restart", "--reason", "Need to rebase"]
        )

        assert result.exit_code == 0
        assert "review restarted" in result.output.lower()
        assert "new456" in result.output
        # Verify the comment was posted with reason
        comment_arg = mock_comment.call_args[0][2]
        assert "restarted" in comment_arg.lower()
        assert "Need to rebase" in comment_arg

    @patch("glreview.cli.gitlab_available")
    @patch("glreview.cli.get_current_commit")
    def test_restart_without_api_access(
        self, mock_commit, mock_gitlab,
        runner, project_dir, registry_file, monkeypatch
    ):
        """Restart without GitLab API warns but proceeds."""
        mock_commit.return_value = "new456"
        mock_gitlab.return_value = False
        monkeypatch.chdir(project_dir)

        result = runner.invoke(main, ["cancel", "src/main.py", "--restart"])

        assert result.exit_code == 0
        assert "review restarted" in result.output.lower()
        assert "could not update issue" in result.output.lower() or "no api access" in result.output.lower()

    @patch("glreview.cli.close_issue")
    @patch("glreview.cli.gitlab_available")
    @patch("glreview.cli.get_current_commit")
    def test_cancel_close_issue_success(
        self, mock_commit, mock_gitlab, mock_close,
        runner, project_dir, registry_file, monkeypatch
    ):
        """Cancel closes issue and shows proper message."""
        mock_commit.return_value = "abc123"
        mock_gitlab.return_value = True
        mock_close.return_value = True
        monkeypatch.chdir(project_dir)

        result = runner.invoke(main, ["cancel", "src/main.py"])

        assert result.exit_code == 0
        assert "closed issue" in result.output.lower()
        assert "review canceled" in result.output.lower()
        assert "needs_review" in result.output.lower()

    @patch("glreview.cli.close_issue")
    @patch("glreview.cli.gitlab_available")
    @patch("glreview.cli.get_current_commit")
    def test_cancel_close_issue_fails(
        self, mock_commit, mock_gitlab, mock_close,
        runner, project_dir, registry_file, monkeypatch
    ):
        """Cancel aborts when close_issue returns False."""
        mock_commit.return_value = "abc123"
        mock_gitlab.return_value = True
        mock_close.return_value = False
        monkeypatch.chdir(project_dir)

        result = runner.invoke(main, ["cancel", "src/main.py"])

        assert result.exit_code == 1
        assert "could not close issue" in result.output.lower()
        assert "--leave-open" in result.output

    @patch("glreview.cli.gitlab_available")
    @patch("glreview.cli.get_current_commit")
    def test_cancel_no_gitlab_access(
        self, mock_commit, mock_gitlab,
        runner, project_dir, registry_file, monkeypatch
    ):
        """Cancel fails without GitLab access when issue needs closing."""
        mock_commit.return_value = "abc123"
        mock_gitlab.return_value = False
        monkeypatch.chdir(project_dir)

        result = runner.invoke(main, ["cancel", "src/main.py"])

        assert result.exit_code == 1
        assert "cannot close issue" in result.output.lower()
        assert "--leave-open" in result.output
        assert "GITLAB_PRIVATE_TOKEN" in result.output

    @patch("glreview.cli.gitlab_available")
    @patch("glreview.cli.get_current_commit")
    def test_cancel_leave_open_shows_note(
        self, mock_commit, mock_gitlab,
        runner, project_dir, registry_file, monkeypatch
    ):
        """Cancel --leave-open shows note about open issue."""
        mock_commit.return_value = "abc123"
        mock_gitlab.return_value = False
        monkeypatch.chdir(project_dir)

        result = runner.invoke(main, ["cancel", "src/main.py", "--leave-open"])

        assert result.exit_code == 0
        assert "review canceled" in result.output.lower()
        # Issue was left open, so note should appear
        assert "left open" in result.output.lower() or "close manually" in result.output.lower()

    @patch("glreview.cli.close_issue")
    @patch("glreview.cli.gitlab_available")
    @patch("glreview.cli.get_current_commit")
    def test_cancel_restores_to_reviewed_state(
        self, mock_commit, mock_gitlab, mock_close,
        runner, project_dir, registry_file, monkeypatch
    ):
        """Cancel restores module with previous review to reviewed state."""
        mock_commit.return_value = "abc123"
        mock_gitlab.return_value = True
        mock_close.return_value = True
        monkeypatch.chdir(project_dir)

        result = runner.invoke(main, ["cancel", "src/prev.py"])

        assert result.exit_code == 0
        assert "restored to reviewed" in result.output.lower()
        assert "prev123" in result.output
        assert "@old" in result.output

    @patch("glreview.cli.close_issue")
    @patch("glreview.cli.gitlab_available")
    @patch("glreview.cli.get_current_commit")
    def test_cancel_no_previous_review_needs_review(
        self, mock_commit, mock_gitlab, mock_close,
        runner, project_dir, registry_file, monkeypatch
    ):
        """Cancel module with no previous review goes to needs_review."""
        mock_commit.return_value = "abc123"
        mock_gitlab.return_value = True
        mock_close.return_value = True
        monkeypatch.chdir(project_dir)

        result = runner.invoke(main, ["cancel", "src/main.py"])

        assert result.exit_code == 0
        assert "needs_review" in result.output.lower()
        assert "no previous review" in result.output.lower()


class TestDiffExtended:
    """Extended tests for diff command covering uncovered lines."""

    @pytest.fixture
    def registry_file(self, project_dir):
        """Create a registry with modules in various states."""
        registry = Registry()
        # Reviewed module
        registry.set(
            ModuleStatus(
                path="src/main.py",
                status="reviewed",
                last_reviewed_commit="rev123",
                reviewers=["@alice"],
                lines=50,
            )
        )
        # In-progress module with start commit
        registry.set(
            ModuleStatus(
                path="src/inprog.py",
                status="in_progress",
                review_issue="#42",
                review_started_commit="start123",
                lines=40,
            )
        )
        # In-progress module WITHOUT start commit
        registry.set(
            ModuleStatus(
                path="src/nostart.py",
                status="in_progress",
                review_issue="#55",
                review_started_commit=None,
                lines=25,
            )
        )
        # Needs review module (never reviewed)
        registry.set(
            ModuleStatus(
                path="src/utils.py",
                status="needs_review",
                lines=30,
            )
        )
        save_registry(registry, project_dir / ".glreview/registry.json")
        return project_dir / ".glreview/registry.json"

    @patch("glreview.cli.get_current_commit")
    @patch("glreview.cli.get_gitlab_host")
    @patch("glreview.cli.get_gitlab_project")
    def test_diff_since_start_no_start_commit(
        self, mock_project, mock_host, mock_commit,
        runner, project_dir, registry_file, monkeypatch
    ):
        """Diff --since-start fails when no review start commit recorded."""
        mock_project.return_value = "group/project"
        mock_host.return_value = "gitlab.com"
        mock_commit.return_value = "current123"
        monkeypatch.chdir(project_dir)

        result = runner.invoke(main, ["diff", "src/nostart.py", "--since-start"])

        assert result.exit_code == 1
        assert "no review start commit" in result.output.lower()

    @patch("glreview.cli.get_compare_url")
    @patch("glreview.cli.has_changes_since")
    @patch("glreview.cli.get_current_commit")
    @patch("glreview.cli.get_gitlab_host")
    @patch("glreview.cli.get_gitlab_project")
    def test_diff_web_mode_with_url(
        self, mock_project, mock_host, mock_commit, mock_has_changes, mock_compare,
        runner, project_dir, registry_file, monkeypatch
    ):
        """Diff --web opens browser with compare URL."""
        mock_project.return_value = "group/project"
        mock_host.return_value = "gitlab.com"
        mock_commit.return_value = "current123"
        mock_has_changes.return_value = True
        mock_compare.return_value = "https://gitlab.com/group/project/-/compare/rev123...current123"
        monkeypatch.chdir(project_dir)

        with patch("webbrowser.open") as mock_browser:
            result = runner.invoke(main, ["diff", "src/main.py", "--web"])

        assert result.exit_code == 0
        assert "opening diff" in result.output.lower()
        assert "gitlab.com" in result.output
        mock_browser.assert_called_once()

    @patch("glreview.cli.get_compare_url")
    @patch("glreview.cli.has_changes_since")
    @patch("glreview.cli.get_current_commit")
    @patch("glreview.cli.get_gitlab_host")
    @patch("glreview.cli.get_gitlab_project")
    def test_diff_web_mode_no_url(
        self, mock_project, mock_host, mock_commit, mock_has_changes, mock_compare,
        runner, project_dir, registry_file, monkeypatch
    ):
        """Diff --web fails when compare URL cannot be generated."""
        mock_project.return_value = "group/project"
        mock_host.return_value = "gitlab.com"
        mock_commit.return_value = "current123"
        mock_has_changes.return_value = True
        mock_compare.return_value = None
        monkeypatch.chdir(project_dir)

        result = runner.invoke(main, ["diff", "src/main.py", "--web"])

        assert result.exit_code == 1
        assert "could not generate" in result.output.lower()

    @patch("subprocess.run")
    @patch("glreview.cli.has_changes_since")
    @patch("glreview.cli.get_current_commit")
    @patch("glreview.cli.get_gitlab_host")
    @patch("glreview.cli.get_gitlab_project")
    def test_diff_terminal_output(
        self, mock_project, mock_host, mock_commit, mock_has_changes, mock_subprocess,
        runner, project_dir, registry_file, monkeypatch
    ):
        """Diff in terminal mode shows git diff output."""
        mock_project.return_value = "group/project"
        mock_host.return_value = "gitlab.com"
        mock_commit.return_value = "current123"
        mock_has_changes.return_value = True
        mock_result = MagicMock()
        mock_result.returncode = 0
        mock_result.stdout = "diff --git a/src/main.py b/src/main.py\n+new line\n"
        mock_subprocess.return_value = mock_result
        monkeypatch.chdir(project_dir)

        result = runner.invoke(main, ["diff", "src/main.py"])

        assert result.exit_code == 0
        assert "diff for src/main.py" in result.output.lower()
        assert "rev123" in result.output
        assert "current" in result.output
        assert "+new line" in result.output

    @patch("subprocess.run")
    @patch("glreview.cli.has_changes_since")
    @patch("glreview.cli.get_current_commit")
    @patch("glreview.cli.get_gitlab_host")
    @patch("glreview.cli.get_gitlab_project")
    def test_diff_terminal_git_error(
        self, mock_project, mock_host, mock_commit, mock_has_changes, mock_subprocess,
        runner, project_dir, registry_file, monkeypatch
    ):
        """Diff handles git diff failure in terminal mode."""
        mock_project.return_value = "group/project"
        mock_host.return_value = "gitlab.com"
        mock_commit.return_value = "current123"
        mock_has_changes.return_value = True
        mock_result = MagicMock()
        mock_result.returncode = 1
        mock_result.stderr = "fatal: bad revision"
        mock_subprocess.return_value = mock_result
        monkeypatch.chdir(project_dir)

        result = runner.invoke(main, ["diff", "src/main.py"])

        assert result.exit_code == 1
        assert "error running git diff" in result.output.lower()

    @patch("subprocess.run")
    @patch("glreview.cli.has_changes_since")
    @patch("glreview.cli.get_current_commit")
    @patch("glreview.cli.get_gitlab_host")
    @patch("glreview.cli.get_gitlab_project")
    def test_diff_terminal_no_output(
        self, mock_project, mock_host, mock_commit, mock_has_changes, mock_subprocess,
        runner, project_dir, registry_file, monkeypatch
    ):
        """Diff shows 'no diff output' when git diff returns empty."""
        mock_project.return_value = "group/project"
        mock_host.return_value = "gitlab.com"
        mock_commit.return_value = "current123"
        mock_has_changes.return_value = True
        mock_result = MagicMock()
        mock_result.returncode = 0
        mock_result.stdout = ""
        mock_subprocess.return_value = mock_result
        monkeypatch.chdir(project_dir)

        result = runner.invoke(main, ["diff", "src/main.py"])

        assert result.exit_code == 0
        assert "no diff output" in result.output.lower()

    @patch("glreview.cli.has_changes_since")
    @patch("glreview.cli.get_current_commit")
    @patch("glreview.cli.get_gitlab_host")
    @patch("glreview.cli.get_gitlab_project")
    def test_diff_since_start_no_changes(
        self, mock_project, mock_host, mock_commit, mock_has_changes,
        runner, project_dir, registry_file, monkeypatch
    ):
        """Diff --since-start works for in-progress module with start commit."""
        mock_project.return_value = "group/project"
        mock_host.return_value = "gitlab.com"
        mock_commit.return_value = "current123"
        mock_has_changes.return_value = False
        monkeypatch.chdir(project_dir)

        result = runner.invoke(main, ["diff", "src/inprog.py", "--since-start"])

        assert result.exit_code == 0
        assert "no changes" in result.output.lower()


class TestBatchStart:
    """Tests for batch-start command."""

    @pytest.fixture
    def registry_file(self, project_dir):
        """Create a registry with modules needing review."""
        registry = Registry()
        registry.set(
            ModuleStatus(path="src/main.py", status="needs_review", lines=50)
        )
        registry.set(
            ModuleStatus(path="src/utils.py", status="needs_review", lines=30)
        )
        registry.set(
            ModuleStatus(
                path="src/done.py",
                status="reviewed",
                last_reviewed_commit="abc123",
                lines=20,
            )
        )
        save_registry(registry, project_dir / ".glreview/registry.json")
        return project_dir / ".glreview/registry.json"

    @pytest.fixture
    def empty_registry_file(self, project_dir):
        """Create a registry where no modules need review."""
        registry = Registry()
        registry.set(
            ModuleStatus(
                path="src/main.py",
                status="reviewed",
                last_reviewed_commit="abc123",
                lines=50,
            )
        )
        save_registry(registry, project_dir / ".glreview/registry.json")
        return project_dir / ".glreview/registry.json"

    def test_batch_start_no_modules_need_review(
        self, runner, project_dir, empty_registry_file, monkeypatch
    ):
        """Batch-start exits when no modules need review."""
        monkeypatch.chdir(project_dir)

        result = runner.invoke(main, ["batch-start"])

        assert result.exit_code == 0
        assert "no modules need review" in result.output.lower()

    @patch("glreview.cli.gitlab_available")
    @patch("glreview.cli.get_gitlab_host")
    def test_batch_start_dry_run(
        self, mock_host, mock_gitlab,
        runner, project_dir, registry_file, monkeypatch
    ):
        """Batch-start --dry-run shows what would be done."""
        mock_host.return_value = "gitlab.com"
        mock_gitlab.return_value = True
        monkeypatch.chdir(project_dir)

        result = runner.invoke(main, ["batch-start", "--dry-run"])

        assert result.exit_code == 0
        assert "dry-run" in result.output.lower()
        assert "would start" in result.output.lower()
        assert "src/main.py" in result.output
        assert "src/utils.py" in result.output

    @patch("glreview.cli.gitlab_available")
    @patch("glreview.cli.get_gitlab_host")
    def test_batch_start_gitlab_unavailable(
        self, mock_host, mock_gitlab,
        runner, project_dir, registry_file, monkeypatch
    ):
        """Batch-start fails when GitLab is not available."""
        mock_host.return_value = "gitlab.com"
        mock_gitlab.return_value = False
        monkeypatch.chdir(project_dir)

        result = runner.invoke(main, ["batch-start"])

        assert result.exit_code == 1
        assert "gitlab" in result.output.lower()
        assert "GITLAB_PRIVATE_TOKEN" in result.output

    @patch("glreview.claude.claude_available")
    @patch("glreview.cli.gitlab_available")
    @patch("glreview.cli.get_gitlab_host")
    def test_batch_start_claude_not_available(
        self, mock_host, mock_gitlab, mock_claude,
        runner, project_dir, registry_file, monkeypatch
    ):
        """Batch-start with --claude-review fails when claude is not available."""
        mock_host.return_value = "gitlab.com"
        mock_gitlab.return_value = True
        mock_claude.return_value = False
        monkeypatch.chdir(project_dir)

        result = runner.invoke(main, ["batch-start", "--claude-review"])

        assert result.exit_code == 1
        assert "claude" in result.output.lower()

    @patch("glreview.cli.get_current_commit")
    @patch("glreview.review_service.ReviewService.batch_start_reviews")
    @patch("glreview.cli.gitlab_available")
    @patch("glreview.cli.get_gitlab_host")
    def test_batch_start_full_execution(
        self, mock_host, mock_gitlab, mock_batch, mock_commit,
        runner, project_dir, registry_file, monkeypatch
    ):
        """Batch-start runs full execution with progress callbacks."""
        from glreview.review_service import BatchStartResult

        mock_host.return_value = "gitlab.com"
        mock_gitlab.return_value = True
        mock_commit.return_value = "abc123"

        batch_result = BatchStartResult(started=2, reviewed=0, errors=0)
        mock_batch.return_value = batch_result
        monkeypatch.chdir(project_dir)

        result = runner.invoke(main, ["batch-start"])

        assert result.exit_code == 0
        assert "summary" in result.output.lower()
        assert "started: 2" in result.output.lower()
        mock_batch.assert_called_once()

    @patch("glreview.cli.get_current_commit")
    @patch("glreview.review_service.ReviewService.batch_start_reviews")
    @patch("glreview.cli.gitlab_available")
    @patch("glreview.cli.get_gitlab_host")
    def test_batch_start_with_errors(
        self, mock_host, mock_gitlab, mock_batch, mock_commit,
        runner, project_dir, registry_file, monkeypatch
    ):
        """Batch-start reports errors in summary."""
        from glreview.review_service import BatchStartResult

        mock_host.return_value = "gitlab.com"
        mock_gitlab.return_value = True
        mock_commit.return_value = "abc123"

        batch_result = BatchStartResult(started=1, errors=1)
        mock_batch.return_value = batch_result
        monkeypatch.chdir(project_dir)

        result = runner.invoke(main, ["batch-start"])

        assert result.exit_code == 0
        assert "errors: 1" in result.output.lower()

    @patch("glreview.cli.get_current_commit")
    @patch("glreview.review_service.ReviewService.batch_start_reviews")
    @patch("glreview.cli.gitlab_available")
    @patch("glreview.cli.get_gitlab_host")
    def test_batch_start_keyboard_interrupt(
        self, mock_host, mock_gitlab, mock_batch, mock_commit,
        runner, project_dir, registry_file, monkeypatch
    ):
        """Batch-start handles keyboard interrupt gracefully."""
        from glreview.review_service import BatchStartResult

        mock_host.return_value = "gitlab.com"
        mock_gitlab.return_value = True
        mock_commit.return_value = "abc123"
        mock_batch.side_effect = KeyboardInterrupt()
        monkeypatch.chdir(project_dir)

        result = runner.invoke(main, ["batch-start"])

        assert result.exit_code == 130
        assert "interrupted" in result.output.lower()

    @patch("glreview.cli.get_current_commit")
    @patch("glreview.review_service.ReviewService.batch_start_reviews")
    @patch("glreview.cli.gitlab_available")
    @patch("glreview.cli.get_gitlab_host")
    def test_batch_start_with_claude_review_summary(
        self, mock_host, mock_gitlab, mock_batch, mock_commit,
        runner, project_dir, registry_file, monkeypatch
    ):
        """Batch-start with --claude-review shows review counts in summary."""
        from glreview.review_service import BatchStartResult

        mock_host.return_value = "gitlab.com"
        mock_gitlab.return_value = True
        mock_commit.return_value = "abc123"

        batch_result = BatchStartResult(started=2, reviewed=2, skipped=0, errors=0)
        mock_batch.return_value = batch_result
        monkeypatch.chdir(project_dir)

        # Need to also mock claude_available for the require_claude_cli check
        with patch("glreview.claude.claude_available", return_value=True):
            result = runner.invoke(main, ["batch-start", "--claude-review"])

        assert result.exit_code == 0
        assert "reviewed: 2" in result.output.lower()

    @patch("glreview.cli.gitlab_available")
    @patch("glreview.cli.get_gitlab_host")
    def test_batch_start_dry_run_with_claude_review(
        self, mock_host, mock_gitlab,
        runner, project_dir, registry_file, monkeypatch
    ):
        """Batch-start --dry-run --claude-review shows what would be done."""
        mock_host.return_value = "gitlab.com"
        mock_gitlab.return_value = True
        monkeypatch.chdir(project_dir)

        result = runner.invoke(main, ["batch-start", "--dry-run", "--claude-review"])

        assert result.exit_code == 0
        assert "dry-run" in result.output.lower()
        assert "would run claude-review" in result.output.lower()


# ---------------------------------------------------------------------------
# Tests for additional coverage of cli_claude.py
# ---------------------------------------------------------------------------


class TestClaudeReviewProgress:
    """Tests for claude-review progress callbacks and posting paths."""

    @pytest.fixture
    def registry_file(self, project_dir):
        """Create a registry with in-progress module."""
        registry = Registry()
        registry.set(
            ModuleStatus(
                path="src/main.py",
                status="in_progress",
                review_issue="#42",
                lines=50,
            )
        )
        save_registry(registry, project_dir / ".glreview/registry.json")
        return project_dir / ".glreview/registry.json"

    @patch("glreview.review_service.ReviewService.run_claude_review")
    @patch("glreview.claude.claude_available")
    def test_progress_context_loaded(
        self, mock_available, mock_service_review,
        runner, project_dir, registry_file, monkeypatch,
    ):
        """Progress callback outputs context loaded message."""
        from glreview.review_service import ClaudeReviewResult

        mock_available.return_value = True

        def fake_review(**kwargs):
            cb = kwargs.get("on_progress")
            if cb:
                cb("context_loaded", {"path": "src/main.py", "test_files": 2})
            return ClaudeReviewResult(
                success=True, message="ok",
                review_text="looks good", posted=False,
            )

        mock_service_review.side_effect = fake_review
        monkeypatch.chdir(project_dir)

        result = runner.invoke(main, ["claude-review", "src/main.py", "--no-post"])

        assert "Using context for src/main.py" in result.output
        assert "Including 2 test file(s)" in result.output

    @patch("glreview.review_service.ReviewService.run_claude_review")
    @patch("glreview.claude.claude_available")
    def test_progress_context_loaded_no_test_files(
        self, mock_available, mock_service_review,
        runner, project_dir, registry_file, monkeypatch,
    ):
        """Progress callback with zero test files does not show test line."""
        from glreview.review_service import ClaudeReviewResult

        mock_available.return_value = True

        def fake_review(**kwargs):
            cb = kwargs.get("on_progress")
            if cb:
                cb("context_loaded", {"path": "src/main.py", "test_files": 0})
            return ClaudeReviewResult(
                success=True, message="ok",
                review_text="looks good", posted=False,
            )

        mock_service_review.side_effect = fake_review
        monkeypatch.chdir(project_dir)

        result = runner.invoke(main, ["claude-review", "src/main.py", "--no-post"])

        assert "Using context for src/main.py" in result.output
        assert "test file(s)" not in result.output

    @patch("glreview.review_service.ReviewService.run_claude_review")
    @patch("glreview.claude.claude_available")
    def test_progress_large_file(
        self, mock_available, mock_service_review,
        runner, project_dir, registry_file, monkeypatch,
    ):
        """Progress callback outputs large file splitting message."""
        from glreview.review_service import ClaudeReviewResult

        mock_available.return_value = True

        def fake_review(**kwargs):
            cb = kwargs.get("on_progress")
            if cb:
                cb("large_file", {"chunk_count": 3})
            return ClaudeReviewResult(
                success=True, message="ok",
                review_text="looks good", posted=False,
            )

        mock_service_review.side_effect = fake_review
        monkeypatch.chdir(project_dir)

        result = runner.invoke(main, ["claude-review", "src/main.py", "--no-post"])

        assert "splitting into 3 chunks" in result.output

    @patch("glreview.review_service.ReviewService.run_claude_review")
    @patch("glreview.claude.claude_available")
    def test_progress_chunk_start_and_done(
        self, mock_available, mock_service_review,
        runner, project_dir, registry_file, monkeypatch,
    ):
        """Progress callback outputs chunk start info."""
        from glreview.review_service import ClaudeReviewResult

        mock_available.return_value = True

        def fake_review(**kwargs):
            cb = kwargs.get("on_progress")
            if cb:
                cb("chunk_start", {
                    "chunk_num": 1, "total_chunks": 2,
                    "start_line": 1, "end_line": 50,
                })
                cb("chunk_done", {"chunk_num": 1})
                cb("chunk_start", {
                    "chunk_num": 2, "total_chunks": 2,
                    "start_line": 51, "end_line": 100,
                })
                cb("chunk_done", {"chunk_num": 2})
            return ClaudeReviewResult(
                success=True, message="ok",
                review_text="looks good", posted=False,
            )

        mock_service_review.side_effect = fake_review
        monkeypatch.chdir(project_dir)

        result = runner.invoke(main, ["claude-review", "src/main.py", "--no-post"])

        assert "Processing chunk 1/2 (lines 1-50)..." in result.output
        assert "Processing chunk 2/2 (lines 51-100)..." in result.output

    @patch("glreview.review_service.ReviewService.run_claude_review")
    @patch("glreview.claude.claude_available")
    def test_dry_run_chunk_count_note(
        self, mock_available, mock_service_review,
        runner, project_dir, registry_file, monkeypatch,
    ):
        """Dry-run with multiple chunks shows note about chunk count."""
        from glreview.review_service import ClaudeReviewResult

        mock_available.return_value = True
        mock_service_review.return_value = ClaudeReviewResult(
            success=True,
            message="Dry run",
            prompt="review prompt here",
            chunk_count=3,
        )
        monkeypatch.chdir(project_dir)

        result = runner.invoke(
            main, ["claude-review", "src/main.py", "--dry-run"]
        )

        assert result.exit_code == 0
        assert "3 chunks would be processed" in result.output

    @patch("glreview.review_service.ReviewService.run_claude_review")
    @patch("glreview.claude.claude_available")
    def test_posted_review_with_required_items(
        self, mock_available, mock_service_review,
        runner, project_dir, registry_file, monkeypatch,
    ):
        """Post mode shows posted message with required label."""
        from glreview.claude import ReviewItem
        from glreview.review_service import ClaudeReviewResult

        mock_available.return_value = True
        mock_service_review.return_value = ClaudeReviewResult(
            success=True,
            message="ok",
            review_text="some review",
            posted=True,
            required_items=[
                ReviewItem("Correctness", "REQUIRED", "Fix this"),
            ],
        )
        monkeypatch.chdir(project_dir)

        result = runner.invoke(main, ["claude-review", "src/main.py", "--post"])

        assert result.exit_code == 0
        assert "Posted review to GitLab issue" in result.output
        assert "review::required" in result.output

    @patch("glreview.review_service.ReviewService.run_claude_review")
    @patch("glreview.claude.claude_available")
    def test_posted_review_ok_label(
        self, mock_available, mock_service_review,
        runner, project_dir, registry_file, monkeypatch,
    ):
        """Post mode shows ok label when no required items."""
        from glreview.review_service import ClaudeReviewResult

        mock_available.return_value = True
        mock_service_review.return_value = ClaudeReviewResult(
            success=True,
            message="ok",
            review_text="looks good",
            posted=True,
            required_items=[],
        )
        monkeypatch.chdir(project_dir)

        result = runner.invoke(main, ["claude-review", "src/main.py", "--post"])

        assert result.exit_code == 0
        assert "review::ok" in result.output

    @patch("glreview.review_service.ReviewService.run_claude_review")
    @patch("glreview.claude.claude_available")
    def test_post_failure_warning(
        self, mock_available, mock_service_review,
        runner, project_dir, registry_file, monkeypatch,
    ):
        """Post mode warns when posting fails."""
        from glreview.review_service import ClaudeReviewResult

        mock_available.return_value = True
        mock_service_review.return_value = ClaudeReviewResult(
            success=True,
            message="ok",
            review_text="review text",
            posted=False,
        )
        monkeypatch.chdir(project_dir)

        result = runner.invoke(main, ["claude-review", "src/main.py", "--post"])

        assert result.exit_code == 0
        assert "Could not post to GitLab issue" in result.output


class TestClaudeSyncExtended:
    """Additional tests for claude-sync command edge cases."""

    @pytest.fixture
    def project_with_context_no_commit(self, project_dir):
        """Create a project with context but no generated_commit."""
        context_dir = project_dir / ".glreview"
        context_dir.mkdir(exist_ok=True)
        context_data = {
            "version": "1",
            "generated_at": "2024-01-01T00:00:00Z",
            "generated_commit": "",
            "project": {"description": "Test project"},
            "modules": {},
        }
        (context_dir / "context.json").write_text(json.dumps(context_data))
        return project_dir

    @pytest.fixture
    def git_project_with_context(self, temp_git_repo):
        """Create a git project with existing context and commits."""
        import subprocess

        src = temp_git_repo / "src"
        src.mkdir(exist_ok=True)
        (src / "main.py").write_text("def main(): pass\n")
        (src / "utils.py").write_text("def util(): pass\n")

        pyproject = temp_git_repo / "pyproject.toml"
        pyproject.write_text("[tool.glreview]\nsources = [\"src/**/*.py\"]\n")

        subprocess.run(["git", "add", "."], cwd=temp_git_repo, check=True)
        subprocess.run(
            ["git", "commit", "-m", "Initial commit"],
            cwd=temp_git_repo, check=True,
        )

        res = subprocess.run(
            ["git", "rev-parse", "--short", "HEAD"],
            cwd=temp_git_repo, capture_output=True, text=True, check=True,
        )
        commit_sha = res.stdout.strip()

        context_dir = temp_git_repo / ".glreview"
        context_dir.mkdir()
        context_data = {
            "version": "1",
            "generated_at": "2024-01-01T00:00:00Z",
            "generated_commit": commit_sha,
            "project": {"description": "Test project"},
            "modules": {},
        }
        (context_dir / "context.json").write_text(json.dumps(context_data))
        return temp_git_repo, commit_sha

    @patch("glreview.cli_claude.run_claude_review")
    @patch("glreview.claude.claude_available")
    def test_sync_no_context_with_force(
        self, mock_available, mock_run,
        runner, git_project_with_context, monkeypatch,
    ):
        """Sync without context but with --force delegates to claude-init."""
        import os

        project_dir, _commit_sha = git_project_with_context
        mock_available.return_value = True
        mock_run.return_value = (
            json.dumps({
                "project": {"description": "New project"},
                "modules": {},
            }),
            True,
        )

        # Remove the context file so --force triggers full init path
        context_file = project_dir / ".glreview" / "context.json"
        if context_file.exists():
            os.remove(context_file)

        monkeypatch.chdir(project_dir)

        result = runner.invoke(main, ["claude-sync", "--force"])

        assert result.exit_code == 0

    def test_sync_no_commit_reference(
        self, runner, project_with_context_no_commit, monkeypatch,
    ):
        """Sync with context but no generated_commit triggers full rebuild path."""
        monkeypatch.chdir(project_with_context_no_commit)

        result = runner.invoke(main, ["claude-sync"])

        # The rebuild path is entered (message is printed), then claude_init
        # sees existing context and exits
        assert "no commit reference" in result.output.lower()

    def test_sync_no_module_changes(
        self, runner, git_project_with_context, monkeypatch,
    ):
        """Sync reports no changes when only non-source files changed."""
        import subprocess

        project_dir, _commit_sha = git_project_with_context

        # Change a non-source file and commit
        (project_dir / "README.md").write_text("updated readme\n")
        subprocess.run(["git", "add", "."], cwd=project_dir, check=True)
        subprocess.run(
            ["git", "commit", "-m", "Update readme"],
            cwd=project_dir, check=True,
        )
        monkeypatch.chdir(project_dir)

        result = runner.invoke(main, ["claude-sync"])

        assert result.exit_code == 0
        assert "no source modules changed" in result.output.lower()

    @patch("glreview.cli_claude.run_claude_review")
    @patch("glreview.claude.claude_available")
    def test_sync_runs_claude(
        self, mock_available, mock_run,
        runner, git_project_with_context, monkeypatch,
    ):
        """Sync runs Claude when modules have changed."""
        import subprocess

        project_dir, _commit_sha = git_project_with_context
        mock_available.return_value = True
        mock_run.return_value = (
            json.dumps({
                "project": {"description": "Updated project"},
                "modules": {},
            }),
            True,
        )

        (project_dir / "src" / "main.py").write_text("def main(): return 1\n")
        subprocess.run(["git", "add", "."], cwd=project_dir, check=True)
        subprocess.run(
            ["git", "commit", "-m", "Update main"],
            cwd=project_dir, check=True,
        )
        monkeypatch.chdir(project_dir)

        result = runner.invoke(main, ["claude-sync"])

        assert result.exit_code == 0
        assert "context updated" in result.output.lower()

    @patch("glreview.cli_claude.run_claude_review")
    @patch("glreview.claude.claude_available")
    def test_sync_claude_failure(
        self, mock_available, mock_run,
        runner, git_project_with_context, monkeypatch,
    ):
        """Sync exits with error when Claude fails."""
        import subprocess

        project_dir, _commit_sha = git_project_with_context
        mock_available.return_value = True
        mock_run.return_value = ("API Error", False)

        (project_dir / "src" / "main.py").write_text("def main(): return 1\n")
        subprocess.run(["git", "add", "."], cwd=project_dir, check=True)
        subprocess.run(
            ["git", "commit", "-m", "Update main"],
            cwd=project_dir, check=True,
        )
        monkeypatch.chdir(project_dir)

        result = runner.invoke(main, ["claude-sync"])

        assert result.exit_code == 1
        assert "error" in result.output.lower()

    @patch("glreview.cli_claude.run_claude_review")
    @patch("glreview.claude.claude_available")
    def test_sync_parse_failure(
        self, mock_available, mock_run,
        runner, git_project_with_context, monkeypatch,
    ):
        """Sync exits with error when Claude returns unparseable JSON."""
        import subprocess

        project_dir, _commit_sha = git_project_with_context
        mock_available.return_value = True
        mock_run.return_value = ("this is not json at all {{{}}", True)

        (project_dir / "src" / "main.py").write_text("def main(): return 1\n")
        subprocess.run(["git", "add", "."], cwd=project_dir, check=True)
        subprocess.run(
            ["git", "commit", "-m", "Update main"],
            cwd=project_dir, check=True,
        )
        monkeypatch.chdir(project_dir)

        result = runner.invoke(main, ["claude-sync"])

        assert result.exit_code == 1

    @patch("glreview.cli_claude.run_claude_review")
    @patch("glreview.claude.claude_available")
    def test_sync_large_file_truncation(
        self, mock_available, mock_run,
        runner, git_project_with_context, monkeypatch,
    ):
        """Sync truncates large source files in samples."""
        import subprocess

        project_dir, _commit_sha = git_project_with_context
        mock_available.return_value = True
        mock_run.return_value = (
            json.dumps({
                "project": {"description": "Updated"},
                "modules": {},
            }),
            True,
        )

        # Write a very large file
        large_content = "x = 1\n" * 5000
        (project_dir / "src" / "main.py").write_text(large_content)
        subprocess.run(["git", "add", "."], cwd=project_dir, check=True)
        subprocess.run(
            ["git", "commit", "-m", "Update main"],
            cwd=project_dir, check=True,
        )
        monkeypatch.chdir(project_dir)

        result = runner.invoke(main, ["claude-sync"])

        assert result.exit_code == 0
        assert "context updated" in result.output.lower()


class TestClaudeFixEdgeCases:
    """Tests for claude-fix edge cases and error paths."""

    @pytest.fixture
    def fix_project_dir(self, project_dir):
        """Create a project with in-progress module and review issue."""
        registry = Registry()
        registry.set(
            ModuleStatus(
                path="src/main.py",
                status="in_progress",
                review_issue="#42",
                lines=50,
            )
        )
        save_registry(registry, project_dir / ".glreview/registry.json")
        (project_dir / "src" / "main.py").write_text(
            "def hello():\n    return 'Hello'\n"
        )
        return project_dir

    @pytest.fixture
    def fix_project_not_in_progress(self, project_dir):
        """Create a project with a module that is NOT in_progress."""
        registry = Registry()
        registry.set(
            ModuleStatus(
                path="src/main.py",
                status="needs_review",
                lines=50,
            )
        )
        save_registry(registry, project_dir / ".glreview/registry.json")
        return project_dir

    def test_fix_not_in_progress(
        self, runner, fix_project_not_in_progress, monkeypatch,
    ):
        """claude-fix exits with error when module is not in_progress."""
        monkeypatch.chdir(fix_project_not_in_progress)

        result = runner.invoke(main, ["claude-fix", "src/main.py", "--batch"])

        assert result.exit_code == 1
        assert "not in progress" in result.output.lower()
        assert "glreview start" in result.output

    @patch("glreview.cli_claude.find_claude_review_comment")
    @patch("glreview.cli_claude.gitlab_available")
    @patch("glreview.cli_claude.get_gitlab_host")
    @patch("glreview.cli_claude.get_gitlab_project")
    @patch("glreview.claude.claude_available")
    def test_fix_no_review_found(
        self, mock_claude, mock_project, mock_host, mock_gitlab, mock_find,
        runner, fix_project_dir, monkeypatch,
    ):
        """claude-fix exits when no review is found in issue comments."""
        mock_claude.return_value = True
        mock_project.return_value = "org/project"
        mock_host.return_value = "gitlab.com"
        mock_gitlab.return_value = True
        mock_find.return_value = None
        monkeypatch.chdir(fix_project_dir)

        result = runner.invoke(main, ["claude-fix", "src/main.py", "--batch"])

        assert result.exit_code == 1
        assert "no claude review found" in result.output.lower()

    @patch("glreview.cli_claude.find_claude_review_comment")
    @patch("glreview.cli_claude.gitlab_available")
    @patch("glreview.cli_claude.get_gitlab_host")
    @patch("glreview.cli_claude.get_gitlab_project")
    @patch("glreview.claude.claude_available")
    def test_fix_no_issues_to_fix(
        self, mock_claude, mock_project, mock_host, mock_gitlab, mock_find,
        runner, fix_project_dir, monkeypatch,
    ):
        """claude-fix exits cleanly when review has no issues."""
        mock_claude.return_value = True
        mock_project.return_value = "org/project"
        mock_host.return_value = "gitlab.com"
        mock_gitlab.return_value = True
        mock_find.return_value = "## Review\n\nAll good!\n"
        monkeypatch.chdir(fix_project_dir)

        result = runner.invoke(main, ["claude-fix", "src/main.py", "--batch"])

        assert result.exit_code == 0
        assert "no issues to fix" in result.output.lower()

    @patch("glreview.cli_claude.find_claude_review_comment")
    @patch("glreview.cli_claude.gitlab_available")
    @patch("glreview.cli_claude.get_gitlab_host")
    @patch("glreview.cli_claude.get_gitlab_project")
    @patch("glreview.claude.claude_available")
    def test_fix_batch_no_required(
        self, mock_claude, mock_project, mock_host, mock_gitlab, mock_find,
        runner, fix_project_dir, monkeypatch,
    ):
        """Batch mode skips when only SUGGESTED items exist."""
        mock_claude.return_value = True
        mock_project.return_value = "org/project"
        mock_host.return_value = "gitlab.com"
        mock_gitlab.return_value = True
        mock_find.return_value = (
            "## Review\n\n### Style: SUGGESTED\nConsider renaming variables.\n"
        )
        monkeypatch.chdir(fix_project_dir)

        result = runner.invoke(main, ["claude-fix", "src/main.py", "--batch"])

        assert result.exit_code == 0
        assert "no required items" in result.output.lower()
        assert "1 SUGGESTED items skipped" in result.output

    @patch("glreview.cli_claude.run_claude_review")
    @patch("glreview.cli_claude.find_claude_review_comment")
    @patch("glreview.cli_claude.gitlab_available")
    @patch("glreview.cli_claude.get_gitlab_host")
    @patch("glreview.cli_claude.get_gitlab_project")
    @patch("glreview.claude.claude_available")
    def test_fix_dry_run(
        self, mock_claude, mock_project, mock_host, mock_gitlab, mock_find,
        mock_run,
        runner, fix_project_dir, monkeypatch,
    ):
        """Dry-run shows prompt and exits without running Claude."""
        mock_claude.return_value = True
        mock_project.return_value = "org/project"
        mock_host.return_value = "gitlab.com"
        mock_gitlab.return_value = True
        mock_find.return_value = (
            "## Review\n\n### Correctness: REQUIRED\nFix the bug.\n"
        )
        monkeypatch.chdir(fix_project_dir)

        result = runner.invoke(
            main, ["claude-fix", "src/main.py", "--batch", "--dry-run"],
        )

        assert result.exit_code == 0
        assert "dry-run" in result.output.lower()
        mock_run.assert_not_called()

    @patch("glreview.cli_claude.run_claude_review")
    @patch("glreview.cli_claude.find_claude_review_comment")
    @patch("glreview.cli_claude.gitlab_available")
    @patch("glreview.cli_claude.get_gitlab_host")
    @patch("glreview.cli_claude.get_gitlab_project")
    @patch("glreview.claude.claude_available")
    def test_fix_batch_success_next_steps(
        self, mock_claude, mock_project, mock_host, mock_gitlab, mock_find,
        mock_run,
        runner, fix_project_dir, monkeypatch,
    ):
        """Batch fix shows next steps after successful run."""
        mock_claude.return_value = True
        mock_project.return_value = "org/project"
        mock_host.return_value = "gitlab.com"
        mock_gitlab.return_value = True
        mock_find.return_value = (
            "## Review\n\n### Correctness: REQUIRED\nFix the bug.\n"
        )
        mock_run.return_value = ("Fixed the issue", True)
        monkeypatch.chdir(fix_project_dir)

        result = runner.invoke(main, ["claude-fix", "src/main.py", "--batch"])

        assert result.exit_code == 0
        assert "next steps" in result.output.lower()
        assert "git diff" in result.output

    @patch("glreview.cli_claude.run_claude_review")
    @patch("glreview.cli_claude.find_claude_review_comment")
    @patch("glreview.cli_claude.gitlab_available")
    @patch("glreview.cli_claude.get_gitlab_host")
    @patch("glreview.cli_claude.get_gitlab_project")
    @patch("glreview.claude.claude_available")
    def test_fix_batch_claude_error(
        self, mock_claude, mock_project, mock_host, mock_gitlab, mock_find,
        mock_run,
        runner, fix_project_dir, monkeypatch,
    ):
        """Batch fix exits on Claude error."""
        mock_claude.return_value = True
        mock_project.return_value = "org/project"
        mock_host.return_value = "gitlab.com"
        mock_gitlab.return_value = True
        mock_find.return_value = (
            "## Review\n\n### Correctness: REQUIRED\nFix the bug.\n"
        )
        mock_run.return_value = ("API Error", False)
        monkeypatch.chdir(fix_project_dir)

        result = runner.invoke(main, ["claude-fix", "src/main.py", "--batch"])

        assert result.exit_code == 1
        assert "error" in result.output.lower()

    @patch("glreview.cli_claude.run_claude_interactive")
    @patch("glreview.cli_claude.find_claude_review_comment")
    @patch("glreview.cli_claude.gitlab_available")
    @patch("glreview.cli_claude.get_gitlab_host")
    @patch("glreview.cli_claude.get_gitlab_project")
    @patch("glreview.claude.claude_available")
    def test_fix_interactive_error(
        self, mock_claude, mock_project, mock_host, mock_gitlab, mock_find,
        mock_interactive,
        runner, fix_project_dir, monkeypatch,
    ):
        """Interactive fix exits on Claude error."""
        mock_claude.return_value = True
        mock_project.return_value = "org/project"
        mock_host.return_value = "gitlab.com"
        mock_gitlab.return_value = True
        mock_find.return_value = (
            "## Review\n\n### Correctness: REQUIRED\nFix the bug.\n"
        )
        mock_interactive.return_value = False
        monkeypatch.chdir(fix_project_dir)

        result = runner.invoke(
            main,
            ["claude-fix", "src/main.py"],
            input="1\n",
        )

        assert result.exit_code == 1
        assert "errors" in result.output.lower()

    @patch("glreview.cli_claude.find_claude_review_comment")
    @patch("glreview.cli_claude.gitlab_available")
    @patch("glreview.cli_claude.get_gitlab_host")
    @patch("glreview.cli_claude.get_gitlab_project")
    @patch("glreview.claude.claude_available")
    def test_fix_interactive_no_selection(
        self, mock_claude, mock_project, mock_host, mock_gitlab, mock_find,
        runner, fix_project_dir, monkeypatch,
    ):
        """Interactive fix exits when user selects nothing."""
        mock_claude.return_value = True
        mock_project.return_value = "org/project"
        mock_host.return_value = "gitlab.com"
        mock_gitlab.return_value = True
        mock_find.return_value = (
            "## Review\n\n### Correctness: REQUIRED\nFix the bug.\n"
        )
        monkeypatch.chdir(fix_project_dir)

        result = runner.invoke(
            main,
            ["claude-fix", "src/main.py"],
            input="none\n",
        )

        assert result.exit_code == 0
        assert "no items selected" in result.output.lower()

    @patch("glreview.cli_claude.find_claude_review_comment")
    @patch("glreview.cli_claude.gitlab_available")
    @patch("glreview.cli_claude.get_gitlab_host")
    @patch("glreview.cli_claude.get_gitlab_project")
    @patch("glreview.claude.claude_available")
    def test_fix_file_not_found(
        self, mock_claude, mock_project, mock_host, mock_gitlab, mock_find,
        runner, fix_project_dir, monkeypatch,
    ):
        """claude-fix exits when source file does not exist."""
        mock_claude.return_value = True
        mock_project.return_value = "org/project"
        mock_host.return_value = "gitlab.com"
        mock_gitlab.return_value = True
        mock_find.return_value = (
            "## Review\n\n### Correctness: REQUIRED\nFix the bug.\n"
        )
        (fix_project_dir / "src" / "main.py").unlink()
        monkeypatch.chdir(fix_project_dir)

        result = runner.invoke(main, ["claude-fix", "src/main.py", "--batch"])

        assert result.exit_code == 1
        assert "file not found" in result.output.lower()

    @patch("glreview.cli_claude.checkout_branch")
    @patch("glreview.cli_claude.create_branch")
    @patch("glreview.cli_claude.get_current_branch")
    @patch("glreview.cli_claude.gitlab_available")
    @patch("glreview.cli_claude.get_gitlab_host")
    @patch("glreview.cli_claude.get_gitlab_project")
    @patch("glreview.cli_claude.has_uncommitted_changes")
    @patch("glreview.claude.claude_available")
    def test_fix_branch_creation_error(
        self, mock_claude, mock_dirty, mock_project, mock_host, mock_gitlab,
        mock_branch, mock_create_br, mock_checkout,
        runner, fix_project_dir, monkeypatch,
    ):
        """claude-fix --create-mr exits when branch creation fails."""
        from glreview.git import GitError

        mock_claude.return_value = True
        mock_dirty.return_value = False
        mock_project.return_value = "org/project"
        mock_host.return_value = "gitlab.com"
        mock_gitlab.return_value = True
        mock_branch.return_value = "main"
        mock_create_br.side_effect = GitError("branch already exists")
        monkeypatch.chdir(fix_project_dir)

        result = runner.invoke(
            main, ["claude-fix", "src/main.py", "--create-mr", "--batch"],
        )

        assert result.exit_code == 1
        assert "could not create branch" in result.output.lower()

    @patch("glreview.cli_claude.get_current_branch")
    @patch("glreview.cli_claude.gitlab_available")
    @patch("glreview.cli_claude.get_gitlab_host")
    @patch("glreview.cli_claude.get_gitlab_project")
    @patch("glreview.cli_claude.has_uncommitted_changes")
    @patch("glreview.claude.claude_available")
    def test_fix_get_current_branch_error(
        self, mock_claude, mock_dirty, mock_project, mock_host, mock_gitlab,
        mock_branch,
        runner, fix_project_dir, monkeypatch,
    ):
        """claude-fix --create-mr exits when get_current_branch fails."""
        from glreview.git import GitError

        mock_claude.return_value = True
        mock_dirty.return_value = False
        mock_project.return_value = "org/project"
        mock_host.return_value = "gitlab.com"
        mock_gitlab.return_value = True
        mock_branch.side_effect = GitError("detached HEAD")
        monkeypatch.chdir(fix_project_dir)

        result = runner.invoke(
            main, ["claude-fix", "src/main.py", "--create-mr", "--batch"],
        )

        assert result.exit_code == 1
        assert "cannot determine current branch" in result.output.lower()

    @patch("glreview.cli_claude.checkout_branch")
    @patch("glreview.cli_claude.find_claude_review_comment")
    @patch("glreview.cli_claude.create_branch")
    @patch("glreview.cli_claude.get_current_branch")
    @patch("glreview.cli_claude.gitlab_available")
    @patch("glreview.cli_claude.get_gitlab_host")
    @patch("glreview.cli_claude.get_gitlab_project")
    @patch("glreview.cli_claude.has_uncommitted_changes")
    @patch("glreview.claude.claude_available")
    def test_fix_review_text_not_found_restores_branch(
        self, mock_claude, mock_dirty, mock_project, mock_host, mock_gitlab,
        mock_branch, mock_create_br, mock_find, mock_checkout,
        runner, fix_project_dir, monkeypatch,
    ):
        """claude-fix --create-mr restores branch when review not found."""
        mock_claude.return_value = True
        mock_dirty.return_value = False
        mock_project.return_value = "org/project"
        mock_host.return_value = "gitlab.com"
        mock_gitlab.return_value = True
        mock_branch.return_value = "main"
        mock_find.return_value = None
        monkeypatch.chdir(fix_project_dir)

        result = runner.invoke(
            main, ["claude-fix", "src/main.py", "--create-mr", "--batch"],
        )

        assert result.exit_code == 1
        mock_checkout.assert_called_once()
        assert mock_checkout.call_args[0][0] == "main"

    @patch("glreview.cli_claude.checkout_branch")
    @patch("glreview.cli_claude.add_issue_comment")
    @patch("glreview.cli_claude.create_merge_request")
    @patch("glreview.cli_claude.push_branch")
    @patch("glreview.cli_claude.commit_all_changes")
    @patch("glreview.cli_claude.has_uncommitted_changes")
    @patch("glreview.cli_claude.create_branch")
    @patch("glreview.cli_claude.get_current_branch")
    @patch("glreview.cli_claude.gitlab_available")
    @patch("glreview.cli_claude.find_claude_review_comment")
    @patch("glreview.cli_claude.get_gitlab_host")
    @patch("glreview.cli_claude.get_gitlab_project")
    @patch("glreview.claude.claude_available")
    @patch("glreview.cli_claude.run_claude_review")
    def test_fix_commit_failure(
        self, mock_run, mock_claude, mock_project, mock_host,
        mock_find, mock_gitlab, mock_branch, mock_create_br,
        mock_dirty, mock_commit, mock_push, mock_mr, mock_comment,
        mock_checkout,
        runner, fix_project_dir, monkeypatch,
    ):
        """MR creation handles commit failure."""
        from glreview.git import GitError

        mock_run.return_value = ("Fixed things", True)
        mock_claude.return_value = True
        mock_project.return_value = "org/project"
        mock_host.return_value = "gitlab.com"
        mock_find.return_value = (
            "## Review\n\n### Correctness: REQUIRED\nFix the bug.\n"
        )
        mock_gitlab.return_value = True
        mock_branch.return_value = "main"
        mock_dirty.side_effect = [False, True]
        mock_commit.side_effect = GitError("commit failed")
        monkeypatch.chdir(fix_project_dir)

        result = runner.invoke(
            main, ["claude-fix", "src/main.py", "--create-mr", "--batch"],
        )

        assert "failed to commit" in result.output.lower()
        mock_checkout.assert_called()

    @patch("glreview.cli_claude.checkout_branch")
    @patch("glreview.cli_claude.add_issue_comment")
    @patch("glreview.cli_claude.create_merge_request")
    @patch("glreview.cli_claude.push_branch")
    @patch("glreview.cli_claude.commit_all_changes")
    @patch("glreview.cli_claude.has_uncommitted_changes")
    @patch("glreview.cli_claude.create_branch")
    @patch("glreview.cli_claude.get_current_branch")
    @patch("glreview.cli_claude.gitlab_available")
    @patch("glreview.cli_claude.find_claude_review_comment")
    @patch("glreview.cli_claude.get_gitlab_host")
    @patch("glreview.cli_claude.get_gitlab_project")
    @patch("glreview.claude.claude_available")
    @patch("glreview.cli_claude.run_claude_review")
    def test_fix_issue_comment_failure(
        self, mock_run, mock_claude, mock_project, mock_host,
        mock_find, mock_gitlab, mock_branch, mock_create_br,
        mock_dirty, mock_commit, mock_push, mock_mr, mock_comment,
        mock_checkout,
        runner, fix_project_dir, monkeypatch,
    ):
        """MR creation succeeds even when issue comment fails."""
        from glreview.gitlab import GitLabMergeRequest

        mock_run.return_value = ("Fixed things", True)
        mock_claude.return_value = True
        mock_project.return_value = "org/project"
        mock_host.return_value = "gitlab.com"
        mock_find.return_value = (
            "## Review\n\n### Correctness: REQUIRED\nFix the bug.\n"
        )
        mock_gitlab.return_value = True
        mock_branch.return_value = "main"
        mock_dirty.side_effect = [False, True]
        mock_commit.return_value = "abc1234"
        mock_mr.return_value = GitLabMergeRequest(
            iid=1,
            url="https://gitlab.com/org/project/-/merge_requests/1",
            title="Fix",
            state="opened",
            source_branch="fix/review-main-42",
            target_branch="main",
        )
        mock_comment.return_value = False
        monkeypatch.chdir(fix_project_dir)

        result = runner.invoke(
            main, ["claude-fix", "src/main.py", "--create-mr", "--batch"],
        )

        assert "could not add comment" in result.output.lower()
        assert "mr created" in result.output.lower()


class TestSelectItemsToFix:
    """Tests for _select_items_to_fix function."""

    def _make_parsed(self, required=None, suggested=None):
        """Helper to create a ParsedReview-like object."""
        from glreview.claude import ParsedReview, ReviewItem

        items = []
        if required:
            for crit, desc in required:
                items.append(
                    ReviewItem(criterion=crit, rating="REQUIRED", description=desc)
                )
        if suggested:
            for crit, desc in suggested:
                items.append(
                    ReviewItem(criterion=crit, rating="SUGGESTED", description=desc)
                )
        return ParsedReview(overall_assessment="REQUIRED", items=items)

    def test_select_all(self):
        """User types 'all' to select everything."""
        from glreview.cli_claude import _select_items_to_fix

        parsed = self._make_parsed(
            required=[("Correctness", "Fix bug")],
            suggested=[("Style", "Rename var")],
        )

        with patch("glreview.cli_claude.click") as mock_click:
            mock_click.echo = lambda *a, **kw: None
            mock_click.secho = lambda *a, **kw: None
            mock_click.prompt.return_value = "all"
            selected = _select_items_to_fix(parsed)

        assert len(selected) == 2

    def test_select_none(self):
        """User types 'none' to select nothing."""
        from glreview.cli_claude import _select_items_to_fix

        parsed = self._make_parsed(
            required=[("Correctness", "Fix bug")],
        )

        with patch("glreview.cli_claude.click") as mock_click:
            mock_click.echo = lambda *a, **kw: None
            mock_click.secho = lambda *a, **kw: None
            mock_click.prompt.return_value = "none"
            selected = _select_items_to_fix(parsed)

        assert len(selected) == 0

    def test_select_empty_string(self):
        """User submits empty string to select nothing."""
        from glreview.cli_claude import _select_items_to_fix

        parsed = self._make_parsed(
            required=[("Correctness", "Fix bug")],
        )

        with patch("glreview.cli_claude.click") as mock_click:
            mock_click.echo = lambda *a, **kw: None
            mock_click.secho = lambda *a, **kw: None
            mock_click.prompt.return_value = ""
            selected = _select_items_to_fix(parsed)

        assert len(selected) == 0

    def test_select_range(self):
        """User types a range like '1-3'."""
        from glreview.cli_claude import _select_items_to_fix

        parsed = self._make_parsed(
            required=[("Correctness", "Fix bug"), ("Safety", "Fix security")],
            suggested=[("Style", "Rename var")],
        )

        with patch("glreview.cli_claude.click") as mock_click:
            mock_click.echo = lambda *a, **kw: None
            mock_click.secho = lambda *a, **kw: None
            mock_click.prompt.return_value = "1-3"
            selected = _select_items_to_fix(parsed)

        assert len(selected) == 3

    def test_select_individual(self):
        """User types individual numbers like '1,3'."""
        from glreview.cli_claude import _select_items_to_fix

        parsed = self._make_parsed(
            required=[("Correctness", "Fix bug"), ("Safety", "Fix security")],
            suggested=[("Style", "Rename var")],
        )

        with patch("glreview.cli_claude.click") as mock_click:
            mock_click.echo = lambda *a, **kw: None
            mock_click.secho = lambda *a, **kw: None
            mock_click.prompt.return_value = "1,3"
            selected = _select_items_to_fix(parsed)

        assert len(selected) == 2
        assert selected[0].criterion == "Correctness"
        assert selected[1].criterion == "Style"

    def test_select_invalid_range(self):
        """Invalid range is handled gracefully."""
        from glreview.cli_claude import _select_items_to_fix

        parsed = self._make_parsed(
            required=[("Correctness", "Fix bug")],
        )

        with patch("glreview.cli_claude.click") as mock_click:
            mock_click.echo = lambda *a, **kw: None
            mock_click.secho = lambda *a, **kw: None
            mock_click.prompt.return_value = "a-b"
            selected = _select_items_to_fix(parsed)

        assert len(selected) == 0

    def test_select_invalid_number(self):
        """Invalid number is handled gracefully."""
        from glreview.cli_claude import _select_items_to_fix

        parsed = self._make_parsed(
            required=[("Correctness", "Fix bug")],
        )

        with patch("glreview.cli_claude.click") as mock_click:
            mock_click.echo = lambda *a, **kw: None
            mock_click.secho = lambda *a, **kw: None
            mock_click.prompt.return_value = "abc"
            selected = _select_items_to_fix(parsed)

        assert len(selected) == 0

    def test_empty_items(self):
        """Empty parsed review returns empty list."""
        from glreview.cli_claude import _select_items_to_fix

        parsed = self._make_parsed()
        selected = _select_items_to_fix(parsed)

        assert selected == []

    def test_display_fix_summary_with_both(self, capsys):
        """_display_fix_summary outputs correct formatting with both types."""
        from glreview.cli_claude import _display_fix_summary

        parsed = self._make_parsed(
            required=[("Correctness", "Fix the critical bug in the main function")],
            suggested=[("Style", "Consider renaming the variable for clarity")],
        )

        _display_fix_summary(parsed, fix_all=True)
        captured = capsys.readouterr()

        assert "1 REQUIRED" in captured.out
        assert "1 SUGGESTED" in captured.out
        assert "Correctness" in captured.out
        assert "Style" in captured.out

    def test_display_fix_summary_no_fix_all(self, capsys):
        """_display_fix_summary without fix_all skips suggested items section."""
        from glreview.cli_claude import _display_fix_summary

        parsed = self._make_parsed(
            required=[("Correctness", "Fix the critical bug in the main function")],
            suggested=[("Style", "Consider renaming the variable for clarity")],
        )

        _display_fix_summary(parsed, fix_all=False)
        captured = capsys.readouterr()

        assert "1 REQUIRED" in captured.out
        assert "SUGGESTED fixes:" not in captured.out


class TestGetReviewText:
    """Tests for _get_review_text function."""

    @pytest.fixture
    def module_with_issue(self):
        """Create a module status with a review issue."""
        return ModuleStatus(
            path="src/main.py",
            status="in_progress",
            review_issue="#42",
            lines=50,
        )

    @pytest.fixture
    def module_no_issue(self):
        """Create a module status without a review issue."""
        return ModuleStatus(
            path="src/main.py",
            status="in_progress",
            review_issue=None,
            lines=50,
        )

    def test_from_file(self, tmp_path):
        """_get_review_text loads from file when --review is given."""
        from glreview.cli_claude import _get_review_text

        review_path = tmp_path / "review.md"
        review_path.write_text("## Review\n\n### Correctness: REQUIRED\nFix it.\n")

        config = MagicMock()
        module = MagicMock()

        result = _get_review_text(config, module, str(review_path))

        assert result is not None
        assert "Fix it" in result

    def test_no_issue_number(self, module_no_issue):
        """_get_review_text returns None when no issue is associated."""
        from glreview.cli_claude import _get_review_text

        config = MagicMock()
        result = _get_review_text(config, module_no_issue, None)

        assert result is None

    @patch("glreview.cli_claude.gitlab_available")
    @patch("glreview.cli_claude.get_gitlab_host")
    @patch("glreview.cli_claude.get_gitlab_project")
    def test_gitlab_not_available(
        self, mock_project, mock_host, mock_gitlab, module_with_issue,
    ):
        """_get_review_text returns None when GitLab is not available."""
        from glreview.cli_claude import _get_review_text

        mock_project.return_value = "org/project"
        mock_host.return_value = "gitlab.com"
        mock_gitlab.return_value = False
        config = MagicMock()

        result = _get_review_text(config, module_with_issue, None)

        assert result is None

    @patch("glreview.cli_claude.find_claude_review_comment")
    @patch("glreview.cli_claude.gitlab_available")
    @patch("glreview.cli_claude.get_gitlab_host")
    @patch("glreview.cli_claude.get_gitlab_project")
    def test_no_review_comment_found(
        self, mock_project, mock_host, mock_gitlab, mock_find,
        module_with_issue,
    ):
        """_get_review_text returns None when no review comment exists."""
        from glreview.cli_claude import _get_review_text

        mock_project.return_value = "org/project"
        mock_host.return_value = "gitlab.com"
        mock_gitlab.return_value = True
        mock_find.return_value = None
        config = MagicMock()

        result = _get_review_text(config, module_with_issue, None)

        assert result is None

    @patch("glreview.cli_claude.find_claude_review_comment")
    @patch("glreview.cli_claude.gitlab_available")
    @patch("glreview.cli_claude.get_gitlab_host")
    @patch("glreview.cli_claude.get_gitlab_project")
    def test_review_found_from_gitlab(
        self, mock_project, mock_host, mock_gitlab, mock_find,
        module_with_issue,
    ):
        """_get_review_text returns text from GitLab issue comment."""
        from glreview.cli_claude import _get_review_text

        mock_project.return_value = "org/project"
        mock_host.return_value = "gitlab.com"
        mock_gitlab.return_value = True
        mock_find.return_value = "## Review\n\nLooks good\n"
        config = MagicMock()

        result = _get_review_text(config, module_with_issue, None)

        assert result == "## Review\n\nLooks good\n"


# ---------------------------------------------------------------------------
# Additional coverage tests for remaining uncovered lines
# ---------------------------------------------------------------------------


class TestCancelWithReason:
    """Tests for cancel --reason flag (covers cli.py lines 549, 577)."""

    @pytest.fixture
    def registry_file(self, project_dir):
        """Create a registry with a previously-reviewed in-progress module."""
        registry = Registry()
        registry.set(
            ModuleStatus(
                path="src/main.py",
                status="in_progress",
                review_issue="#42",
                review_started_commit="start123",
                last_reviewed_commit="prev123",
                reviewers=["@old"],
                lines=50,
            )
        )
        save_registry(registry, project_dir / ".glreview/registry.json")
        return project_dir / ".glreview/registry.json"

    @patch("glreview.cli.close_issue")
    @patch("glreview.cli.gitlab_available")
    @patch("glreview.cli.get_current_commit")
    def test_cancel_with_reason_includes_reason_in_comment(
        self, mock_commit, mock_gitlab, mock_close,
        runner, project_dir, registry_file, monkeypatch
    ):
        """Cancel with --reason includes reason in issue comment."""
        mock_commit.return_value = "abc123"
        mock_gitlab.return_value = True
        mock_close.return_value = True
        monkeypatch.chdir(project_dir)

        result = runner.invoke(
            main, ["cancel", "src/main.py", "--reason", "scope changed"]
        )

        assert result.exit_code == 0
        # Verify close_issue was called with comment containing reason
        call_kwargs = mock_close.call_args
        comment = call_kwargs[1].get("comment", "") if call_kwargs[1] else ""
        assert "scope changed" in comment
        assert "Restoring to previous review" in comment

    @patch("glreview.cli.close_issue")
    @patch("glreview.cli.gitlab_available")
    @patch("glreview.cli.get_current_commit")
    def test_cancel_with_reason_stores_notes(
        self, mock_commit, mock_gitlab, mock_close,
        runner, project_dir, registry_file, monkeypatch
    ):
        """Cancel with --reason stores reason in module notes."""
        mock_commit.return_value = "abc123"
        mock_gitlab.return_value = True
        mock_close.return_value = True
        monkeypatch.chdir(project_dir)

        result = runner.invoke(
            main, ["cancel", "src/main.py", "--reason", "scope changed"]
        )

        assert result.exit_code == 0
        # Load registry and check notes
        from glreview.registry import load_registry
        reg = load_registry(project_dir / ".glreview/registry.json")
        mod = reg.get("src/main.py")
        assert mod.notes == "Canceled: scope changed"


class TestBatchStartProgressCallbacks:
    """Tests for batch-start progress callbacks (covers cli.py lines 836-850, 885, 889)."""

    @pytest.fixture
    def registry_file(self, project_dir):
        """Create a registry with modules needing review."""
        registry = Registry()
        registry.set(ModuleStatus(path="src/main.py", status="needs_review", lines=50))
        registry.set(ModuleStatus(path="src/utils.py", status="needs_review", lines=30))
        save_registry(registry, project_dir / ".glreview/registry.json")
        return project_dir / ".glreview/registry.json"

    @patch("glreview.cli.get_current_commit")
    @patch("glreview.review_service.ReviewService.batch_start_reviews")
    @patch("glreview.cli.gitlab_available")
    @patch("glreview.cli.get_gitlab_host")
    def test_progress_callback_start_success(
        self, mock_host, mock_gitlab, mock_batch, mock_commit,
        runner, project_dir, registry_file, monkeypatch
    ):
        """Progress callback shows started message for successful start."""
        from glreview.review_service import BatchStartResult, ReviewResult

        mock_host.return_value = "gitlab.com"
        mock_gitlab.return_value = True
        mock_commit.return_value = "abc123"

        def fake_batch(**kwargs):
            on_progress = kwargs.get("on_progress")
            if on_progress:
                success = ReviewResult(success=True, message="ok", issue_url="#1")
                on_progress("src/main.py", "start", success)
                fail = ReviewResult(success=False, message="API error")
                on_progress("src/utils.py", "start", fail)
            return BatchStartResult(started=1, errors=1)

        mock_batch.side_effect = fake_batch
        monkeypatch.chdir(project_dir)

        result = runner.invoke(main, ["batch-start"])

        assert "src/main.py" in result.output
        assert "#1" in result.output
        assert "API error" in result.output

    @patch("glreview.cli.get_current_commit")
    @patch("glreview.review_service.ReviewService.batch_start_reviews")
    @patch("glreview.cli.gitlab_available")
    @patch("glreview.cli.get_gitlab_host")
    def test_progress_callback_skip_and_review(
        self, mock_host, mock_gitlab, mock_batch, mock_commit,
        runner, project_dir, registry_file, monkeypatch
    ):
        """Progress callback shows skip and review messages."""
        from glreview.review_service import BatchStartResult, ReviewResult, ClaudeReviewResult

        mock_host.return_value = "gitlab.com"
        mock_gitlab.return_value = True
        mock_commit.return_value = "abc123"

        def fake_batch(**kwargs):
            on_progress = kwargs.get("on_progress")
            if on_progress:
                start_result = ReviewResult(success=True, message="ok", issue_url="#1")
                on_progress("src/main.py", "start", start_result)
                on_progress("src/main.py", "skip", None)
                review_ok = ClaudeReviewResult(success=True, message="ok", posted=True)
                on_progress("src/utils.py", "review", review_ok)
                review_fail = ClaudeReviewResult(success=False, message="err", posted=False)
                on_progress("src/utils.py", "review", review_fail)
            return BatchStartResult(started=2, reviewed=1, skipped=1)

        mock_batch.side_effect = fake_batch
        monkeypatch.chdir(project_dir)

        with patch("glreview.claude.claude_available", return_value=True):
            result = runner.invoke(main, ["batch-start", "--claude-review"])

        assert "Skipped (already has review)" in result.output
        assert "Review posted" in result.output
        assert "Review failed" in result.output

    @patch("glreview.cli.get_current_commit")
    @patch("glreview.review_service.ReviewService.batch_start_reviews")
    @patch("glreview.cli.gitlab_available")
    @patch("glreview.cli.get_gitlab_host")
    def test_batch_start_skipped_count_in_summary(
        self, mock_host, mock_gitlab, mock_batch, mock_commit,
        runner, project_dir, registry_file, monkeypatch
    ):
        """Batch summary shows skipped count when > 0."""
        from glreview.review_service import BatchStartResult

        mock_host.return_value = "gitlab.com"
        mock_gitlab.return_value = True
        mock_commit.return_value = "abc123"
        mock_batch.return_value = BatchStartResult(
            started=2, reviewed=1, skipped=1
        )
        monkeypatch.chdir(project_dir)

        with patch("glreview.claude.claude_available", return_value=True):
            result = runner.invoke(main, ["batch-start", "--claude-review"])

        assert "skipped: 1" in result.output.lower()

    @patch("glreview.cli.get_current_commit")
    @patch("glreview.review_service.ReviewService.batch_start_reviews")
    @patch("glreview.cli.gitlab_available")
    @patch("glreview.cli.get_gitlab_host")
    def test_batch_start_interrupted_in_summary(
        self, mock_host, mock_gitlab, mock_batch, mock_commit,
        runner, project_dir, registry_file, monkeypatch
    ):
        """Batch summary shows interrupted flag."""
        from glreview.review_service import BatchStartResult

        mock_host.return_value = "gitlab.com"
        mock_gitlab.return_value = True
        mock_commit.return_value = "abc123"
        mock_batch.return_value = BatchStartResult(
            started=1, interrupted=True
        )
        monkeypatch.chdir(project_dir)

        result = runner.invoke(main, ["batch-start"])

        assert "(interrupted)" in result.output


# ---------------------------------------------------------------------------
# Tests for cli_claude.py internal functions and remaining uncovered paths
# ---------------------------------------------------------------------------


class TestParseContextResponse:
    """Tests for _parse_context_response covering JSON parsing edge cases."""

    def test_parses_json_in_code_block(self):
        """Parses JSON from ```json ... ``` code blocks."""
        from glreview.cli_claude import _parse_context_response

        response = '```json\n{"project": {"description": "test"}, "modules": {}}\n```'
        result = _parse_context_response(response)
        assert result is not None
        assert result.description == "test"

    def test_parses_json_in_triple_backtick_no_json_tag(self):
        """Parses JSON from ``` ... ``` code blocks without json tag."""
        from glreview.cli_claude import _parse_context_response

        response = '```\n{"project": {"description": "test"}, "modules": {}}\n```'
        result = _parse_context_response(response)
        assert result is not None

    def test_parses_triple_backtick_without_closing(self):
        """Handles code block that starts with ``` but no closing fence."""
        from glreview.cli_claude import _parse_context_response

        response = '```json\n{"project": {"description": "test"}, "modules": {}}'
        result = _parse_context_response(response)
        assert result is not None

    def test_two_line_code_block(self):
        """Handles 2-line code block with closing backtick."""
        from glreview.cli_claude import _parse_context_response

        response = '```\n```'
        result = _parse_context_response(response)
        assert result is None  # Empty JSON

    def test_single_line_code_block(self):
        """Handles single-line code block."""
        from glreview.cli_claude import _parse_context_response

        response = '```'
        result = _parse_context_response(response)
        assert result is None  # Empty content


class TestDiscoverOtherFiles:
    """Tests for _discover_other_files covering dotfile and venv filtering."""

    def test_excludes_dotfiles_and_venv(self, temp_dir):
        """Filters out dotfiles, venv, and __pycache__ entries."""
        from glreview.cli_claude import _discover_other_files
        from glreview.config import Config

        # Create structure
        src = temp_dir / "src"
        src.mkdir()
        (src / "main.py").write_text("pass")
        (temp_dir / "README.md").write_text("# Test")

        # Dotfile
        dot_dir = temp_dir / ".hidden"
        dot_dir.mkdir()
        (dot_dir / "config.py").write_text("pass")

        # Venv
        venv = temp_dir / "venv"
        venv.mkdir()
        (venv / "lib.py").write_text("pass")

        # Pycache
        pycache = src / "__pycache__"
        pycache.mkdir()
        (pycache / "main.cpython-311.pyc").write_text("pass")

        config = Config(root=temp_dir, sources=["src/**/*.py"])
        modules = ["src/main.py"]

        result = _discover_other_files(config, modules)

        # README.md should be included, but not dotfiles/venv/pycache
        assert "README.md" in result
        assert not any(".hidden" in f for f in result)
        assert not any("venv" in f for f in result)
        assert not any("__pycache__" in f for f in result)


class TestSampleModules:
    """Tests for _sample_modules covering truncation and limits."""

    def test_truncates_large_files(self, temp_dir):
        """Truncates files larger than 10000 chars."""
        from glreview.cli_claude import _sample_modules
        from glreview.config import Config

        src = temp_dir / "src"
        src.mkdir()
        (src / "big.py").write_text("x = 1\n" * 5000)  # >10000 chars

        config = Config(root=temp_dir, sources=["src/**/*.py"])
        result = _sample_modules(config, ["src/big.py"], max_samples=5)

        assert len(result) == 1
        assert "truncated" in result[0]["content"]

    def test_limits_samples(self, temp_dir):
        """Stops after max_samples."""
        from glreview.cli_claude import _sample_modules
        from glreview.config import Config

        src = temp_dir / "src"
        src.mkdir()
        for i in range(10):
            d = src / f"pkg{i}"
            d.mkdir()
            (d / "mod.py").write_text(f"x = {i}\n")

        config = Config(root=temp_dir, sources=["src/**/*.py"])
        modules = [f"src/pkg{i}/mod.py" for i in range(10)]
        result = _sample_modules(config, modules, max_samples=3)

        assert len(result) <= 3


class TestClaudeFixWithCreateMREdgeCases:
    """Test remaining --create-mr edge cases in claude-fix."""

    @pytest.fixture
    def fix_project_dir(self, project_dir):
        """Create a project with in-progress module and .glreview/context.json."""
        registry = Registry()
        registry.set(
            ModuleStatus(
                path="src/main.py",
                status="in_progress",
                lines=50,
                review_issue="https://gitlab.com/org/project/-/issues/42",
                review_started_commit="abc123",
            )
        )
        save_registry(
            registry, project_dir / ".glreview/registry.json", commit="abc123"
        )
        (project_dir / "src" / "main.py").write_text("def hello():\n    return 'Hello'\n")

        # Create context file
        context_data = {
            "version": "1",
            "generated_at": "2024-01-01",
            "generated_commit": "abc123",
            "project": {"description": "Test"},
            "modules": {
                "src/main.py": {
                    "description": "Main module",
                    "test_files": ["tests/test_main.py"],
                    "dependencies": [],
                }
            },
        }
        (project_dir / ".glreview" / "context.json").write_text(
            json.dumps(context_data)
        )
        return project_dir

    @patch("glreview.cli_claude.checkout_branch")
    @patch("glreview.cli_claude.create_branch")
    @patch("glreview.cli_claude.get_current_branch")
    @patch("glreview.cli_claude.gitlab_available")
    @patch("glreview.cli_claude.find_claude_review_comment")
    @patch("glreview.cli_claude.get_gitlab_host")
    @patch("glreview.cli_claude.get_gitlab_project")
    @patch("glreview.claude.claude_available")
    @patch("glreview.cli_claude.run_claude_review")
    @patch("glreview.cli_claude.has_uncommitted_changes")
    def test_fix_no_issues_with_create_mr_restores_branch(
        self, mock_dirty, mock_run, mock_claude, mock_project, mock_host,
        mock_find, mock_gitlab, mock_branch, mock_create_br, mock_checkout,
        runner, fix_project_dir, monkeypatch,
    ):
        """No issues to fix with --create-mr restores original branch."""
        mock_dirty.return_value = False
        mock_run.return_value = ("Fixed", True)
        mock_claude.return_value = True
        mock_project.return_value = "org/project"
        mock_host.return_value = "gitlab.com"
        mock_find.return_value = "## Review\n\nLooks good.\n"  # No REQUIRED/SUGGESTED items
        mock_gitlab.return_value = True
        mock_branch.return_value = "main"
        monkeypatch.chdir(fix_project_dir)

        result = runner.invoke(
            main, ["claude-fix", "src/main.py", "--create-mr", "--batch"]
        )

        assert result.exit_code == 0
        assert "No issues to fix" in result.output or "No REQUIRED items" in result.output
        mock_checkout.assert_called_once()

    @patch("glreview.cli_claude.find_claude_review_comment")
    @patch("glreview.cli_claude.get_gitlab_host")
    @patch("glreview.cli_claude.get_gitlab_project")
    @patch("glreview.cli_claude.gitlab_available")
    @patch("glreview.claude.claude_available")
    @patch("glreview.cli_claude.run_claude_review")
    def test_fix_loads_context_and_shows_next_steps(
        self, mock_run, mock_claude, mock_gitlab, mock_project, mock_host,
        mock_find,
        runner, fix_project_dir, monkeypatch,
    ):
        """Fix with context loaded shows next steps when not --create-mr."""
        mock_run.return_value = ("Fixed things", True)
        mock_claude.return_value = True
        mock_project.return_value = "org/project"
        mock_host.return_value = "gitlab.com"
        mock_gitlab.return_value = True
        mock_find.return_value = (
            "## Review\n\n### Correctness: REQUIRED\n"
            "Fix the bug in the main function.\n"
        )
        monkeypatch.chdir(fix_project_dir)

        result = runner.invoke(
            main, ["claude-fix", "src/main.py", "--batch"]
        )

        assert "Next steps:" in result.output
        assert "git diff" in result.output


class TestDiscoverOtherFilesValueError:
    """Test _discover_other_files handles ValueError from relative_to."""

    def test_skips_files_that_raise_value_error(self):
        """Files that raise ValueError on relative_to are skipped."""
        from glreview.cli_claude import _discover_other_files

        bad_file = MagicMock()
        bad_file.relative_to.side_effect = ValueError("not relative")

        config = MagicMock()
        config.root.rglob.return_value = [bad_file]

        result = _discover_other_files(config, [])
        assert result == []


class TestSampleModulesDirectorySkip:
    """Test _sample_modules skips same-directory modules when half-sampled."""

    def test_skips_same_dir_when_half_sampled(self, temp_dir):
        """Skips modules in already-sampled dirs once half the samples are taken."""
        from glreview.cli_claude import _sample_modules
        from glreview.config import Config

        src = temp_dir / "pkg"
        src.mkdir()
        (src / "mod1.py").write_text("x = 1\n")
        (src / "mod2.py").write_text("x = 2\n")

        config = Config(root=temp_dir, sources=["pkg/**/*.py"])
        # max_samples=2, so max_samples//2 = 1
        # mod1 sampled (samples=1, dir "pkg" in dirs_sampled)
        # mod2: dir "pkg" already sampled and samples(1) >= 1 → skip
        result = _sample_modules(config, ["pkg/mod1.py", "pkg/mod2.py"], max_samples=2)

        assert len(result) == 1
        assert result[0]["path"] == "pkg/mod1.py"


class TestParseContextResponseFallback:
    """Test _parse_context_response fallback code block parsing (lines 340-343)."""

    def test_fallback_multiline_with_closing_backticks(self):
        """Handles >2 line code block with non-json tag and closing backticks."""
        from glreview.cli_claude import _parse_context_response

        # Starts with ``` but tag is not "json", so regex won't match
        # >2 lines, last line is ```
        response = (
            '```text\n'
            '{"project": {"description": "fallback"}, "modules": {}}\n'
            '```'
        )
        result = _parse_context_response(response)
        assert result is not None
        assert result.description == "fallback"

    def test_fallback_multiline_without_closing_backticks(self):
        """Handles >2 line code block without closing backticks."""
        from glreview.cli_claude import _parse_context_response

        # Starts with ```, >2 lines, last line is NOT ```
        # JSON spans multiple lines so lines[1:] joined forms valid JSON
        response = (
            '```text\n'
            '{"project": {"description": "no-close"},\n'
            ' "modules": {}}'
        )
        result = _parse_context_response(response)
        assert result is not None
        assert result.description == "no-close"


class TestGetGitChanges:
    """Tests for _get_git_changes internal function."""

    @patch("glreview.cli_claude.subprocess.run")
    def test_returns_none_on_git_diff_failure(self, mock_run):
        """Returns None when git diff returns non-zero exit code."""
        from glreview.cli_claude import _get_git_changes

        mock_run.return_value = MagicMock(returncode=1, stderr="error")
        config = MagicMock()
        config.root = Path("/tmp/fake")

        result = _get_git_changes(config, "abc123")
        assert result is None

    @patch("glreview.cli_claude.subprocess.run")
    def test_exits_on_os_error(self, mock_run):
        """Exits with code 1 when git is not available (OSError)."""
        from glreview.cli_claude import _get_git_changes

        mock_run.side_effect = OSError("No such file")
        config = MagicMock()
        config.root = Path("/tmp/fake")

        with pytest.raises(SystemExit) as exc_info:
            _get_git_changes(config, "abc123")
        assert exc_info.value.code == 1

    @patch("glreview.cli_claude.load_context")
    @patch("glreview.cli_claude.get_default_context_path")
    @patch("glreview.cli_claude.discover_modules")
    @patch("glreview.cli_claude.subprocess.run")
    def test_handles_delete_status(self, mock_run, mock_discover, mock_ctx_path, mock_load):
        """Handles 'D' (delete) status in git diff output."""
        from glreview.cli_claude import _get_git_changes

        mock_run.return_value = MagicMock(
            returncode=0,
            stdout="D\tdeleted_file.py\n",
        )
        mock_discover.return_value = []
        mock_ctx = MagicMock()
        mock_ctx.modules = {"deleted_file.py": MagicMock()}
        mock_load.return_value = mock_ctx
        mock_ctx_path.return_value = Path("/tmp/fake/.glreview/context.json")

        config = MagicMock()
        config.root = Path("/tmp/fake")
        config.sources = ["**/*.py"]
        config.exclude = []

        changed, added, removed = _get_git_changes(config, "abc123")
        assert "deleted_file.py" in removed

    @patch("glreview.cli_claude.load_context")
    @patch("glreview.cli_claude.get_default_context_path")
    @patch("glreview.cli_claude.discover_modules")
    @patch("glreview.cli_claude.subprocess.run")
    def test_handles_rename_status(self, mock_run, mock_discover, mock_ctx_path, mock_load):
        """Handles 'R' (rename) status in git diff output."""
        from glreview.cli_claude import _get_git_changes

        mock_run.return_value = MagicMock(
            returncode=0,
            stdout="R100\told_file.py\tnew_file.py\n",
        )
        mock_discover.return_value = ["new_file.py"]
        mock_ctx = MagicMock()
        mock_ctx.modules = {"old_file.py": MagicMock()}
        mock_load.return_value = mock_ctx
        mock_ctx_path.return_value = Path("/tmp/fake/.glreview/context.json")

        config = MagicMock()
        config.root = Path("/tmp/fake")
        config.sources = ["**/*.py"]
        config.exclude = []

        changed, added, removed = _get_git_changes(config, "abc123")
        assert "new_file.py" in added
        assert "old_file.py" in removed


class TestClaudeSyncNoCommitDryRun:
    """Test claude-sync --dry-run when context has no generated_commit (line 430)."""

    def test_no_commit_dry_run_invokes_init_and_returns(
        self, runner, project_dir, monkeypatch,
    ):
        """Sync --dry-run with no generated_commit delegates to claude-init."""
        # Create context with empty generated_commit
        context_dir = project_dir / ".glreview"
        context_dir.mkdir(exist_ok=True)
        context_data = {
            "version": "1",
            "generated_at": "2024-01-01T00:00:00Z",
            "generated_commit": "",
            "project": {"description": "Test project"},
            "modules": {},
        }
        (context_dir / "context.json").write_text(json.dumps(context_data))
        monkeypatch.chdir(project_dir)

        result = runner.invoke(main, ["claude-sync", "--dry-run"])

        # Should print "no commit reference" and then delegate to claude_init
        assert "no commit reference" in result.output.lower()
        # claude_init with --dry-run prints the prompt and returns normally
        assert result.exit_code == 0


class TestClaudeSyncChangesNone:
    """Test claude-sync when _get_git_changes returns None (lines 443-446)."""

    def test_changes_none_triggers_rebuild(
        self, runner, temp_git_repo, monkeypatch,
    ):
        """When git changes can't be determined, triggers full rebuild."""
        import subprocess as sp

        src = temp_git_repo / "src"
        src.mkdir(exist_ok=True)
        (src / "main.py").write_text("def main(): pass\n")
        pyproject = temp_git_repo / "pyproject.toml"
        pyproject.write_text('[tool.glreview]\nsources = ["src/**/*.py"]\n')

        sp.run(["git", "add", "."], cwd=temp_git_repo, check=True)
        sp.run(["git", "commit", "-m", "init"], cwd=temp_git_repo, check=True)

        # Create context with invalid commit SHA (causes _get_git_changes to return None)
        context_dir = temp_git_repo / ".glreview"
        context_dir.mkdir()
        context_data = {
            "version": "1",
            "generated_at": "2024-01-01T00:00:00Z",
            "generated_commit": "deadbeef",  # Invalid commit
            "project": {"description": "Test"},
            "modules": {},
        }
        (context_dir / "context.json").write_text(json.dumps(context_data))

        # Make a new commit so current != generated
        (src / "extra.py").write_text("pass\n")
        sp.run(["git", "add", "."], cwd=temp_git_repo, check=True)
        sp.run(["git", "commit", "-m", "extra"], cwd=temp_git_repo, check=True)

        monkeypatch.chdir(temp_git_repo)

        # With --dry-run, after context unlink, claude_init prints prompt and returns
        result = runner.invoke(main, ["claude-sync", "--dry-run"])

        assert "full rebuild" in result.output.lower() or "Running" in result.output
        assert result.exit_code == 0


class TestClaudeContextManyModules:
    """Test claude-context with >10 modules shows '... and N more' (line 603)."""

    def test_shows_and_more_for_many_modules(
        self, runner, project_dir, monkeypatch,
    ):
        """Shows '... and N more' when module not found and >10 modules exist."""
        context_dir = project_dir / ".glreview"
        context_dir.mkdir(exist_ok=True)

        modules = {}
        for i in range(15):
            modules[f"src/mod{i:02d}.py"] = {
                "description": f"Module {i}",
                "test_files": [],
                "dependencies": [],
            }

        context_data = {
            "version": "1",
            "generated_at": "2024-01-01T00:00:00Z",
            "generated_commit": "abc123",
            "project": {"description": "Test"},
            "modules": modules,
        }
        (context_dir / "context.json").write_text(json.dumps(context_data))
        monkeypatch.chdir(project_dir)

        result = runner.invoke(main, ["claude-context", "nonexistent.py"])

        assert result.exit_code == 1
        assert "... and 5 more" in result.output


class TestClaudeFixBatchNoRequiredWithCreateMR:
    """Test claude-fix batch mode with only SUGGESTED items restores branch (line 790)."""

    @pytest.fixture
    def fix_project_dir(self, project_dir):
        """Create a project with in-progress module."""
        registry = Registry()
        registry.set(
            ModuleStatus(
                path="src/main.py",
                status="in_progress",
                lines=50,
                review_issue="https://gitlab.com/org/project/-/issues/42",
                review_started_commit="abc123",
            )
        )
        save_registry(
            registry, project_dir / ".glreview/registry.json", commit="abc123"
        )
        (project_dir / "src" / "main.py").write_text("def hello():\n    return 'Hello'\n")
        return project_dir

    @patch("glreview.cli_claude.checkout_branch")
    @patch("glreview.cli_claude.create_branch")
    @patch("glreview.cli_claude.get_current_branch")
    @patch("glreview.cli_claude.gitlab_available")
    @patch("glreview.cli_claude.find_claude_review_comment")
    @patch("glreview.cli_claude.get_gitlab_host")
    @patch("glreview.cli_claude.get_gitlab_project")
    @patch("glreview.claude.claude_available")
    @patch("glreview.cli_claude.has_uncommitted_changes")
    def test_batch_only_suggested_restores_branch(
        self, mock_dirty, mock_claude, mock_project, mock_host,
        mock_find, mock_gitlab, mock_branch, mock_create_br, mock_checkout,
        runner, fix_project_dir, monkeypatch,
    ):
        """Batch mode with only SUGGESTED items (no REQUIRED) restores branch."""
        mock_dirty.return_value = False
        mock_claude.return_value = True
        mock_project.return_value = "org/project"
        mock_host.return_value = "gitlab.com"
        # Review with SUGGESTED but no REQUIRED
        mock_find.return_value = (
            "## Review\n\n"
            "### Code Style: SUGGESTED\n"
            "Consider using f-strings.\n"
        )
        mock_gitlab.return_value = True
        mock_branch.return_value = "main"
        monkeypatch.chdir(fix_project_dir)

        result = runner.invoke(
            main, ["claude-fix", "src/main.py", "--create-mr", "--batch"]
        )

        assert result.exit_code == 0
        assert "No REQUIRED items" in result.output
        mock_checkout.assert_called_once()


class TestClaudeFixInteractiveNoSelectionWithCreateMR:
    """Test claude-fix interactive no selection restores branch (line 800)."""

    @pytest.fixture
    def fix_project_dir(self, project_dir):
        """Create a project with in-progress module."""
        registry = Registry()
        registry.set(
            ModuleStatus(
                path="src/main.py",
                status="in_progress",
                lines=50,
                review_issue="https://gitlab.com/org/project/-/issues/42",
                review_started_commit="abc123",
            )
        )
        save_registry(
            registry, project_dir / ".glreview/registry.json", commit="abc123"
        )
        (project_dir / "src" / "main.py").write_text("def hello():\n    return 'Hello'\n")
        return project_dir

    @patch("glreview.cli_claude.checkout_branch")
    @patch("glreview.cli_claude.create_branch")
    @patch("glreview.cli_claude.get_current_branch")
    @patch("glreview.cli_claude.gitlab_available")
    @patch("glreview.cli_claude.find_claude_review_comment")
    @patch("glreview.cli_claude.get_gitlab_host")
    @patch("glreview.cli_claude.get_gitlab_project")
    @patch("glreview.claude.claude_available")
    @patch("glreview.cli_claude.has_uncommitted_changes")
    @patch("glreview.cli_claude._select_items_to_fix")
    def test_interactive_no_selection_restores_branch(
        self, mock_select, mock_dirty, mock_claude, mock_project, mock_host,
        mock_find, mock_gitlab, mock_branch, mock_create_br, mock_checkout,
        runner, fix_project_dir, monkeypatch,
    ):
        """Interactive mode with no selection restores branch."""
        mock_dirty.return_value = False
        mock_claude.return_value = True
        mock_project.return_value = "org/project"
        mock_host.return_value = "gitlab.com"
        mock_find.return_value = (
            "## Review\n\n"
            "### Correctness: REQUIRED\n"
            "Fix the bug.\n"
        )
        mock_gitlab.return_value = True
        mock_branch.return_value = "main"
        mock_select.return_value = []  # User selected nothing
        monkeypatch.chdir(fix_project_dir)

        result = runner.invoke(
            main, ["claude-fix", "src/main.py", "--create-mr"]
        )

        assert result.exit_code == 0
        assert "No items selected" in result.output
        mock_checkout.assert_called_once()


class TestClaudeFixSourceNotFoundWithCreateMR:
    """Test claude-fix source file not found restores branch (line 817)."""

    @pytest.fixture
    def fix_project_dir_no_source(self, project_dir):
        """Create a project with in-progress module but no source file."""
        registry = Registry()
        registry.set(
            ModuleStatus(
                path="src/missing.py",
                status="in_progress",
                lines=50,
                review_issue="https://gitlab.com/org/project/-/issues/42",
                review_started_commit="abc123",
            )
        )
        save_registry(
            registry, project_dir / ".glreview/registry.json", commit="abc123"
        )
        # Don't create src/missing.py!
        return project_dir

    @patch("glreview.cli_claude.checkout_branch")
    @patch("glreview.cli_claude.create_branch")
    @patch("glreview.cli_claude.get_current_branch")
    @patch("glreview.cli_claude.gitlab_available")
    @patch("glreview.cli_claude.find_claude_review_comment")
    @patch("glreview.cli_claude.get_gitlab_host")
    @patch("glreview.cli_claude.get_gitlab_project")
    @patch("glreview.claude.claude_available")
    @patch("glreview.cli_claude.has_uncommitted_changes")
    @patch("glreview.cli_claude.run_claude_review")
    def test_source_not_found_restores_branch(
        self, mock_run, mock_dirty, mock_claude, mock_project, mock_host,
        mock_find, mock_gitlab, mock_branch, mock_create_br, mock_checkout,
        runner, fix_project_dir_no_source, monkeypatch,
    ):
        """Source file not found restores branch when --create-mr."""
        mock_dirty.return_value = False
        mock_run.return_value = ("Fixed", True)
        mock_claude.return_value = True
        mock_project.return_value = "org/project"
        mock_host.return_value = "gitlab.com"
        mock_find.return_value = (
            "## Review\n\n"
            "### Correctness: REQUIRED\n"
            "Fix the bug.\n"
        )
        mock_gitlab.return_value = True
        mock_branch.return_value = "main"
        monkeypatch.chdir(fix_project_dir_no_source)

        result = runner.invoke(
            main, ["claude-fix", "src/missing.py", "--create-mr", "--batch"]
        )

        assert result.exit_code == 1
        assert "File not found" in result.output
        mock_checkout.assert_called_once()


class TestSelectItemsLongDescriptions:
    """Test _select_items_to_fix with long descriptions (lines 983, 993)."""

    def test_truncates_long_required_description(self):
        """Long REQUIRED item descriptions are truncated with ellipsis."""
        from glreview.cli_claude import _select_items_to_fix
        from glreview.claude import ReviewItem
        from unittest.mock import MagicMock

        parsed = MagicMock()
        parsed.required_items = [
            ReviewItem(
                "Correctness", "REQUIRED",
                "A" * 100,  # > 60 chars
            )
        ]
        parsed.suggested_items = [
            ReviewItem(
                "Quality", "SUGGESTED",
                "B" * 100,  # > 60 chars
            )
        ]

        # User selects default (all required)
        with patch("click.prompt", return_value="1"):
            items = _select_items_to_fix(parsed)

        assert len(items) == 1
        assert items[0].criterion == "Correctness"
