"""Tests for glreview.claude module."""

from __future__ import annotations

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

import pytest

from glreview.claude import (
    ChunkInfo,
    ReviewItem,
    _restore_terminal_state,
    _save_terminal_state,
    build_fix_prompt,
    build_review_prompt,
    chunk_source_code,
    claude_available,
    get_file_diff,
    parse_review_output,
    run_claude_interactive,
    run_claude_review,
)
from glreview.constants import CHARS_PER_TOKEN, MAX_SOURCE_CHARS
from glreview.config import Config
from glreview.registry import ModuleStatus


class TestChunkInfo:
    """Tests for ChunkInfo dataclass."""

    def test_creates_chunk(self):
        """Creates chunk info correctly."""
        chunk = ChunkInfo(
            content="def foo(): pass",
            start_line=1,
            end_line=1,
            chunk_num=1,
            total_chunks=1,
        )
        assert chunk.content == "def foo(): pass"
        assert chunk.start_line == 1
        assert chunk.end_line == 1
        assert chunk.chunk_num == 1
        assert chunk.total_chunks == 1


class TestClaudeAvailable:
    """Tests for claude_available function."""

    @patch("glreview.claude.shutil.which")
    def test_returns_true_when_available(self, mock_which):
        """Returns True when claude is in PATH."""
        mock_which.return_value = "/usr/local/bin/claude"
        assert claude_available() is True

    @patch("glreview.claude.shutil.which")
    def test_returns_false_when_missing(self, mock_which):
        """Returns False when claude is not in PATH."""
        mock_which.return_value = None
        assert claude_available() is False


class TestChunkSourceCode:
    """Tests for chunk_source_code function."""

    def test_small_source_single_chunk(self):
        """Small source returns single chunk."""
        source = "def foo():\n    pass\n"
        chunks = chunk_source_code(source)

        assert len(chunks) == 1
        assert chunks[0].content == source
        assert chunks[0].start_line == 1
        assert chunks[0].end_line == 3
        assert chunks[0].chunk_num == 1
        assert chunks[0].total_chunks == 1

    def test_large_source_multiple_chunks(self):
        """Large source is split into multiple chunks."""
        # Create a source that exceeds max_chars
        lines = [f"def func_{i}():\n    pass\n" for i in range(1000)]
        source = "\n".join(lines)

        chunks = chunk_source_code(source, max_chars=500)

        assert len(chunks) > 1
        assert all(chunk.total_chunks == len(chunks) for chunk in chunks)
        assert chunks[0].chunk_num == 1
        assert chunks[-1].chunk_num == len(chunks)

    def test_preserves_line_numbers(self):
        """Line numbers are correct across chunks."""
        lines = [f"# line {i}" for i in range(100)]
        source = "\n".join(lines)

        chunks = chunk_source_code(source, max_chars=200)

        # First chunk starts at line 1
        assert chunks[0].start_line == 1

        # Chunks should be contiguous
        for i in range(1, len(chunks)):
            assert chunks[i].start_line == chunks[i - 1].end_line + 1


class TestBuildReviewPrompt:
    """Tests for build_review_prompt function."""

    @pytest.fixture
    def config(self, temp_dir):
        """Create config with temp directory."""
        return Config(root=temp_dir)

    @pytest.fixture
    def module_status(self):
        """Create a module status for testing."""
        return ModuleStatus(
            path="src/main.py",
            status="needs_review",
            lines=50,
        )

    @pytest.fixture
    def source_file(self, temp_dir):
        """Create a source file."""
        src = temp_dir / "src"
        src.mkdir()
        path = src / "main.py"
        path.write_text("def hello():\n    return 'Hello'\n")
        return "src/main.py"

    def test_builds_prompt(self, source_file, module_status, config):
        """Builds a review prompt."""
        source = "def hello():\n    return 'Hello'\n"
        prompt = build_review_prompt(
            path=source_file,
            source_code=source,
            module_status=module_status,
            config=config,
        )

        assert source_file in prompt
        assert source in prompt

    def test_includes_chunk_info(self, source_file, module_status, config):
        """Includes chunk header when chunked."""
        source = "def hello():\n    pass\n"
        chunk_info = ChunkInfo(
            content=source,
            start_line=10,
            end_line=20,
            chunk_num=2,
            total_chunks=3,
        )

        prompt = build_review_prompt(
            path=source_file,
            source_code=source,
            module_status=module_status,
            config=config,
            chunk_info=chunk_info,
        )

        assert "chunk 2 of 3" in prompt.lower()
        assert "lines 10-20" in prompt.lower()

    def test_rereview_context(self, source_file, config):
        """Includes re-review context when applicable."""
        module_status = ModuleStatus(
            path="src/main.py",
            status="in_progress",
            lines=50,
            last_reviewed_commit="abc123",
            last_reviewed_date="2024-01-15",
            reviewers=["@alice"],
        )

        prompt = build_review_prompt(
            path=source_file,
            source_code="def hello(): pass",
            module_status=module_status,
            config=config,
            diff="+ new line",
        )

        # Should mention it's a re-review or include previous context
        assert "abc123" in prompt or "re-review" in prompt.lower()

    def test_with_project_context(self, source_file, module_status, config):
        """Includes project context when provided."""
        from glreview.claude_context import ProjectContext

        project_context = ProjectContext(
            conventions="Use black formatting",
            architecture="Layered architecture",
        )

        prompt = build_review_prompt(
            path=source_file,
            source_code="def hello(): pass",
            module_status=module_status,
            config=config,
            project_context=project_context,
        )

        assert "black" in prompt.lower() or "conventions" in prompt.lower()
        assert "layered" in prompt.lower() or "architecture" in prompt.lower()

    def test_with_module_context(self, source_file, module_status, config):
        """Includes module context when provided."""
        from glreview.claude_context import ModuleContext

        module_context = ModuleContext(
            path="src/main.py",
            purpose="Main entry point for the application",
        )

        prompt = build_review_prompt(
            path=source_file,
            source_code="def hello(): pass",
            module_status=module_status,
            config=config,
            module_context=module_context,
        )

        assert "entry point" in prompt.lower() or "main" in prompt.lower()

    def test_with_module_context_test_files(self, source_file, module_status, temp_dir):
        """Loads test files when module context specifies them."""
        from glreview.claude_context import ModuleContext

        # Create test file
        tests_dir = temp_dir / "tests"
        tests_dir.mkdir()
        test_file = tests_dir / "test_main.py"
        test_file.write_text("def test_hello(): assert True\n")

        config = Config(root=temp_dir)
        module_context = ModuleContext(
            path="src/main.py",
            purpose="Main entry point",
            test_files=["tests/test_main.py"],
        )

        prompt = build_review_prompt(
            path=source_file,
            source_code="def hello(): pass",
            module_status=module_status,
            config=config,
            module_context=module_context,
        )

        # Test file content should be included
        assert "test_hello" in prompt or "test_main.py" in prompt

    def test_with_large_test_files_truncated(self, source_file, module_status, temp_dir):
        """Truncates large test files."""
        from glreview.claude_context import ModuleContext

        # Create a large test file
        tests_dir = temp_dir / "tests"
        tests_dir.mkdir()
        test_file = tests_dir / "test_main.py"
        large_content = "def test_hello(): pass\n" * 1000  # Very large file
        test_file.write_text(large_content)

        config = Config(root=temp_dir)
        module_context = ModuleContext(
            path="src/main.py",
            test_files=["tests/test_main.py"],
        )

        prompt = build_review_prompt(
            path=source_file,
            source_code="def hello(): pass",
            module_status=module_status,
            config=config,
            module_context=module_context,
        )

        # Check that truncation marker is present
        assert "truncated" in prompt.lower()

    def test_with_missing_test_files(self, source_file, module_status, temp_dir):
        """Handles missing test files gracefully."""
        from glreview.claude_context import ModuleContext

        config = Config(root=temp_dir)
        module_context = ModuleContext(
            path="src/main.py",
            test_files=["tests/nonexistent.py"],
        )

        # Should not raise, just skip missing file
        prompt = build_review_prompt(
            path=source_file,
            source_code="def hello(): pass",
            module_status=module_status,
            config=config,
            module_context=module_context,
        )

        assert source_file in prompt

    def test_with_unreadable_test_files(self, source_file, module_status, temp_dir):
        """Handles unreadable test files gracefully."""
        from glreview.claude_context import ModuleContext

        # Create test file with binary content that causes decode error
        tests_dir = temp_dir / "tests"
        tests_dir.mkdir()
        test_file = tests_dir / "test_main.py"
        test_file.write_bytes(b"\xff\xfe invalid bytes")

        config = Config(root=temp_dir)
        module_context = ModuleContext(
            path="src/main.py",
            test_files=["tests/test_main.py"],
        )

        # Should not raise, just skip the file
        prompt = build_review_prompt(
            path=source_file,
            source_code="def hello(): pass",
            module_status=module_status,
            config=config,
            module_context=module_context,
        )

        assert source_file in prompt


class TestRunClaudeReview:
    """Tests for run_claude_review function."""

    @patch("glreview.claude.claude_available")
    def test_returns_error_when_cli_missing(self, mock_available):
        """Returns error when claude CLI is not available."""
        mock_available.return_value = False

        response, success = run_claude_review("test prompt")

        assert success is False
        assert "not found" in response.lower()

    @patch("glreview.claude.subprocess.Popen")
    @patch("glreview.claude.claude_available")
    def test_runs_claude_cli(self, mock_available, mock_popen):
        """Runs claude CLI successfully with prompt via stdin."""
        mock_available.return_value = True
        mock_proc = MagicMock()
        mock_proc.communicate.return_value = ("Review complete: LGTM", "")
        mock_proc.returncode = 0
        mock_popen.return_value = mock_proc

        response, success = run_claude_review("test prompt", model="sonnet")

        assert success is True
        assert "LGTM" in response
        mock_popen.assert_called_once()
        # Check that model is passed and prompt goes via stdin
        call_args = mock_popen.call_args
        cmd = call_args[0][0]
        assert "sonnet" in cmd
        assert "-p" in cmd and "-" in cmd  # stdin mode
        # Prompt should be passed via input kwarg to communicate()
        mock_proc.communicate.assert_called_once()
        comm_kwargs = mock_proc.communicate.call_args
        assert comm_kwargs[1].get("input") == "test prompt" or comm_kwargs[0] == ()

    @patch("glreview.claude.subprocess.Popen")
    @patch("glreview.claude.claude_available")
    def test_uses_stdin_pipe(self, mock_available, mock_popen):
        """Passes prompt via stdin, not as CLI argument."""
        import subprocess

        mock_available.return_value = True
        mock_proc = MagicMock()
        mock_proc.communicate.return_value = ("ok", "")
        mock_proc.returncode = 0
        mock_popen.return_value = mock_proc

        run_claude_review("test prompt", model="opus")

        call_kwargs = mock_popen.call_args[1]
        assert call_kwargs["stdin"] == subprocess.PIPE
        assert call_kwargs["stdout"] == subprocess.PIPE
        assert call_kwargs["stderr"] == subprocess.PIPE
        # Prompt must NOT appear in the command args
        cmd = mock_popen.call_args[0][0]
        assert "test prompt" not in cmd

    @patch("glreview.claude.subprocess.Popen")
    @patch("glreview.claude.claude_available")
    def test_handles_cli_error(self, mock_available, mock_popen):
        """Handles CLI errors."""
        mock_available.return_value = True
        mock_proc = MagicMock()
        mock_proc.communicate.return_value = ("", "API error")
        mock_proc.returncode = 1
        mock_popen.return_value = mock_proc

        response, success = run_claude_review("test prompt")

        assert success is False
        assert "error" in response.lower()

    @patch("glreview.claude.subprocess.Popen")
    @patch("glreview.claude.claude_available")
    def test_handles_timeout(self, mock_available, mock_popen):
        """Handles timeout."""
        import subprocess

        mock_available.return_value = True
        mock_proc = MagicMock()
        # First call raises TimeoutExpired, second call (after kill) returns normally
        mock_proc.communicate.side_effect = [
            subprocess.TimeoutExpired(cmd="claude", timeout=300),
            ("", ""),  # Return value for cleanup communicate() call
        ]
        mock_proc.kill = MagicMock()
        mock_popen.return_value = mock_proc

        response, success = run_claude_review("test prompt")

        assert success is False
        assert "timed out" in response.lower()
        mock_proc.kill.assert_called_once()

    @patch("glreview.claude.subprocess.Popen")
    @patch("glreview.claude.claude_available")
    def test_handles_general_exception(self, mock_available, mock_popen):
        """Handles general exceptions and reaps process."""
        mock_available.return_value = True
        mock_proc = MagicMock()
        mock_proc.communicate.side_effect = RuntimeError("Unexpected error")
        mock_proc.kill = MagicMock()
        mock_proc.wait = MagicMock()
        mock_popen.return_value = mock_proc

        response, success = run_claude_review("test prompt")

        assert success is False
        assert "error" in response.lower()
        assert "unexpected" in response.lower()
        mock_proc.kill.assert_called_once()
        mock_proc.wait.assert_called_once()  # Zombie must be reaped

    @patch("glreview.claude.subprocess.Popen")
    @patch("glreview.claude.claude_available")
    def test_handles_keyboard_interrupt(self, mock_available, mock_popen):
        """Handles Ctrl+C with graceful shutdown and zombie reap."""
        import subprocess

        mock_available.return_value = True
        mock_proc = MagicMock()
        mock_proc.communicate.side_effect = KeyboardInterrupt()
        mock_proc.terminate = MagicMock()
        mock_proc.wait = MagicMock()  # Graceful shutdown succeeds
        mock_proc.kill = MagicMock()
        mock_popen.return_value = mock_proc

        with pytest.raises(KeyboardInterrupt):
            run_claude_review("test prompt")

        mock_proc.terminate.assert_called_once()
        mock_proc.wait.assert_called_once()

    @patch("glreview.claude.subprocess.Popen")
    @patch("glreview.claude.claude_available")
    def test_keyboard_interrupt_escalates_to_kill(self, mock_available, mock_popen):
        """Escalates to SIGKILL if SIGTERM times out after Ctrl+C."""
        import subprocess

        mock_available.return_value = True
        mock_proc = MagicMock()
        mock_proc.communicate.side_effect = KeyboardInterrupt()
        mock_proc.terminate = MagicMock()
        # First wait (graceful) times out, second wait (after kill) succeeds
        mock_proc.wait.side_effect = [
            subprocess.TimeoutExpired(cmd="claude", timeout=5),
            None,
        ]
        mock_proc.kill = MagicMock()
        mock_popen.return_value = mock_proc

        with pytest.raises(KeyboardInterrupt):
            run_claude_review("test prompt")

        mock_proc.terminate.assert_called_once()
        mock_proc.kill.assert_called_once()
        # wait() called twice: once for graceful, once after kill
        assert mock_proc.wait.call_count == 2


class TestGetFileDiff:
    """Tests for get_file_diff function."""

    def test_returns_diff(self, temp_git_repo):
        """Returns diff for changed file."""
        import subprocess

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

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

        diff = get_file_diff("README.md", initial_commit, cwd=temp_git_repo)

        assert diff is not None
        assert "Updated" in diff or "+" in diff

    def test_returns_none_for_no_changes(self, temp_git_repo):
        """Returns None when no changes."""
        import subprocess

        result = subprocess.run(
            ["git", "rev-parse", "HEAD"],
            cwd=temp_git_repo,
            capture_output=True,
            text=True,
        )
        current_commit = result.stdout.strip()

        diff = get_file_diff("README.md", current_commit, cwd=temp_git_repo)

        assert diff is None

    def test_returns_none_on_error(self, temp_dir):
        """Returns None when git fails."""
        # temp_dir is not a git repo
        diff = get_file_diff("some_file.py", "abc123", cwd=temp_dir)
        assert diff is None

    @patch("glreview.claude.subprocess.run")
    def test_returns_none_on_os_error(self, mock_run, temp_dir):
        """Returns None when OSError occurs."""
        mock_run.side_effect = OSError("git not available")

        diff = get_file_diff("some_file.py", "abc123", cwd=temp_dir)

        assert diff is None


class TestTerminalStateHelpers:
    """Tests for _save_terminal_state and _restore_terminal_state."""

    @patch("glreview.claude.sys.stdin")
    @patch("glreview.claude.termios.tcgetattr")
    def test_save_returns_attrs_for_tty(self, mock_tcgetattr, mock_stdin):
        """Saves terminal attributes when stdin is a TTY."""
        mock_stdin.isatty.return_value = True
        mock_tcgetattr.return_value = [1, 2, 3]

        result = _save_terminal_state()

        assert result == [1, 2, 3]
        mock_tcgetattr.assert_called_once_with(mock_stdin)

    @patch("glreview.claude.sys.stdin")
    def test_save_returns_none_for_non_tty(self, mock_stdin):
        """Returns None when stdin is not a TTY."""
        mock_stdin.isatty.return_value = False

        result = _save_terminal_state()

        assert result is None

    @patch("glreview.claude.sys.stdin")
    @patch("glreview.claude.termios.tcgetattr")
    def test_save_returns_none_on_error(self, mock_tcgetattr, mock_stdin):
        """Returns None when termios raises an error."""
        import termios

        mock_stdin.isatty.return_value = True
        mock_tcgetattr.side_effect = termios.error("not a terminal")

        result = _save_terminal_state()

        assert result is None

    @patch("glreview.claude.termios.tcsetattr")
    def test_restore_calls_tcsetattr(self, mock_tcsetattr):
        """Restores terminal attributes when saved state is provided."""
        import termios

        saved = [1, 2, 3]
        _restore_terminal_state(saved)

        mock_tcsetattr.assert_called_once()
        args = mock_tcsetattr.call_args[0]
        assert args[1] == termios.TCSADRAIN
        assert args[2] == saved

    @patch("glreview.claude.termios.tcsetattr")
    def test_restore_noop_for_none(self, mock_tcsetattr):
        """Does nothing when saved state is None."""
        _restore_terminal_state(None)

        mock_tcsetattr.assert_not_called()

    @patch("glreview.claude.termios.tcsetattr")
    def test_restore_handles_error(self, mock_tcsetattr):
        """Does not raise when tcsetattr fails."""
        import termios

        mock_tcsetattr.side_effect = termios.error("not a terminal")

        # Should not raise
        _restore_terminal_state([1, 2, 3])


class TestRunClaudeInteractive:
    """Tests for run_claude_interactive function."""

    @patch("glreview.claude.claude_available")
    def test_returns_false_when_cli_missing(self, mock_available):
        """Returns False when claude CLI is not available."""
        mock_available.return_value = False

        result = run_claude_interactive("test prompt")

        assert result is False

    @patch("glreview.claude._restore_terminal_state")
    @patch("glreview.claude._save_terminal_state")
    @patch("glreview.claude.subprocess.run")
    @patch("glreview.claude.claude_available")
    def test_successful_session(
        self, mock_available, mock_run, mock_save, mock_restore
    ):
        """Returns True on successful interactive session."""
        mock_available.return_value = True
        mock_run.return_value = MagicMock(returncode=0)
        mock_save.return_value = [1, 2, 3]

        result = run_claude_interactive("test prompt", model="sonnet")

        assert result is True
        mock_save.assert_called_once()
        mock_restore.assert_called_once_with([1, 2, 3])

    @patch("glreview.claude._restore_terminal_state")
    @patch("glreview.claude._save_terminal_state")
    @patch("glreview.claude.subprocess.run")
    @patch("glreview.claude.claude_available")
    def test_restores_terminal_on_failure(
        self, mock_available, mock_run, mock_save, mock_restore
    ):
        """Restores terminal even when subprocess returns non-zero."""
        mock_available.return_value = True
        mock_run.return_value = MagicMock(returncode=1)
        mock_save.return_value = [1, 2, 3]

        result = run_claude_interactive("test prompt")

        assert result is False
        mock_restore.assert_called_once_with([1, 2, 3])

    @patch("glreview.claude._restore_terminal_state")
    @patch("glreview.claude._save_terminal_state")
    @patch("glreview.claude.subprocess.run")
    @patch("glreview.claude.claude_available")
    def test_restores_terminal_on_exception(
        self, mock_available, mock_run, mock_save, mock_restore
    ):
        """Restores terminal when subprocess raises an exception."""
        mock_available.return_value = True
        mock_run.side_effect = OSError("Process crashed")
        mock_save.return_value = [1, 2, 3]

        result = run_claude_interactive("test prompt")

        assert result is False
        mock_restore.assert_called_once_with([1, 2, 3])

    @patch("glreview.claude._restore_terminal_state")
    @patch("glreview.claude._save_terminal_state")
    @patch("glreview.claude.subprocess.run")
    @patch("glreview.claude.claude_available")
    def test_restores_terminal_on_keyboard_interrupt(
        self, mock_available, mock_run, mock_save, mock_restore
    ):
        """Restores terminal when user hits Ctrl+C."""
        mock_available.return_value = True
        mock_run.side_effect = KeyboardInterrupt()
        mock_save.return_value = [1, 2, 3]

        result = run_claude_interactive("test prompt")

        assert result is False
        mock_restore.assert_called_once_with([1, 2, 3])

    @patch("glreview.claude._restore_terminal_state")
    @patch("glreview.claude._save_terminal_state")
    @patch("glreview.claude.subprocess.run")
    @patch("glreview.claude.claude_available")
    def test_handles_non_tty(
        self, mock_available, mock_run, mock_save, mock_restore
    ):
        """Works correctly when stdin is not a TTY (e.g. CI)."""
        mock_available.return_value = True
        mock_run.return_value = MagicMock(returncode=0)
        mock_save.return_value = None  # Not a TTY

        result = run_claude_interactive("test prompt")

        assert result is True
        mock_restore.assert_called_once_with(None)  # Called but no-op


class TestParseReviewOutput:
    """Tests for parse_review_output function."""

    def test_extracts_overall_assessment(self):
        """Extracts overall assessment from verdict line."""
        review_text = (
            "## Review\n\n"
            "**Verdict:** REQUIRED\n\n"
            "### Correctness: REQUIRED\n"
            "Fix the bug in the main function.\n"
        )
        parsed = parse_review_output(review_text)
        assert parsed.overall_assessment == "REQUIRED"

    def test_extracts_ok_assessment(self):
        """Extracts OK assessment."""
        review_text = (
            "## Review\n\n"
            "**Verdict:** OK\n\n"
            "No issues found.\n"
        )
        parsed = parse_review_output(review_text)
        assert parsed.overall_assessment == "OK"

    def test_no_assessment_defaults_to_empty(self):
        """Returns empty string when no verdict found."""
        review_text = "## Review\n\nLooks fine.\n"
        parsed = parse_review_output(review_text)
        assert parsed.overall_assessment == ""

    def test_extracts_required_items(self):
        """Extracts REQUIRED items from review."""
        review_text = (
            "### Correctness: REQUIRED\n"
            "Fix the bug in the main function.\n\n"
            "### Error Handling: REQUIRED\n"
            "Add input validation.\n"
        )
        parsed = parse_review_output(review_text)
        assert len(parsed.required_items) == 2
        assert parsed.required_items[0].criterion == "Correctness"
        assert parsed.required_items[0].rating == "REQUIRED"

    def test_extracts_suggested_items(self):
        """Extracts SUGGESTED items from review."""
        review_text = (
            "### Code Quality: SUGGESTED\n"
            "Consider using a dataclass.\n"
        )
        parsed = parse_review_output(review_text)
        assert len(parsed.suggested_items) == 1
        assert parsed.suggested_items[0].criterion == "Code Quality"
        assert parsed.suggested_items[0].rating == "SUGGESTED"


class TestBuildFixPrompt:
    """Tests for build_fix_prompt function."""

    def test_includes_suggested_items_when_fix_all(self):
        """Includes suggested items when fix_all is True."""
        config = Config(root=Path("/tmp/test"))
        module = ModuleStatus(path="src/main.py", status="in_progress")
        required = [ReviewItem(criterion="Correctness", rating="REQUIRED", description="Fix bug")]
        suggested = [ReviewItem(criterion="Quality", rating="SUGGESTED", description="Improve naming")]

        prompt = build_fix_prompt(
            path="src/main.py",
            source_code="def hello(): pass",
            module_status=module,
            config=config,
            required_items=required,
            suggested_items=suggested,
            fix_all=True,
        )

        assert "Fix bug" in prompt
        assert "Improve naming" in prompt

    def test_excludes_suggested_items_when_not_fix_all(self):
        """Excludes suggested items when fix_all is False."""
        config = Config(root=Path("/tmp/test"))
        module = ModuleStatus(path="src/main.py", status="in_progress")
        required = [ReviewItem(criterion="Correctness", rating="REQUIRED", description="Fix bug")]
        suggested = [ReviewItem(criterion="Quality", rating="SUGGESTED", description="Improve naming")]

        prompt = build_fix_prompt(
            path="src/main.py",
            source_code="def hello(): pass",
            module_status=module,
            config=config,
            required_items=required,
            suggested_items=suggested,
            fix_all=False,
        )

        assert "Fix bug" in prompt
        # Suggested items should not be in the prompt
        assert "Improve naming" not in prompt

    def test_includes_custom_fix_instructions(self):
        """Includes custom fix instructions when set on config."""
        config = Config(
            root=Path("/tmp/test"),
            fix_instructions="Run pytest after fixing.",
        )
        module = ModuleStatus(path="src/main.py", status="in_progress")
        required = [ReviewItem(criterion="Correctness", rating="REQUIRED", description="Fix bug")]

        prompt = build_fix_prompt(
            path="src/main.py",
            source_code="def hello(): pass",
            module_status=module,
            config=config,
            required_items=required,
        )

        assert "Additional Instructions" in prompt
        assert "Run pytest after fixing." in prompt

    def test_excludes_custom_fix_instructions_when_none(self):
        """Does not include additional instructions block when fix_instructions is None."""
        config = Config(root=Path("/tmp/test"))
        module = ModuleStatus(path="src/main.py", status="in_progress")
        required = [ReviewItem(criterion="Correctness", rating="REQUIRED", description="Fix bug")]

        prompt = build_fix_prompt(
            path="src/main.py",
            source_code="def hello(): pass",
            module_status=module,
            config=config,
            required_items=required,
        )

        assert "Additional Instructions" not in prompt


class TestCustomReviewInstructions:
    """Tests for custom review instructions in build_review_prompt."""

    @pytest.fixture
    def config_with_instructions(self, temp_dir):
        """Create config with review instructions."""
        return Config(
            root=temp_dir,
            review_instructions="Use numpy-style docstrings.",
        )

    @pytest.fixture
    def module_status(self):
        return ModuleStatus(path="src/main.py", status="needs_review", lines=50)

    @pytest.fixture
    def source_file(self, temp_dir):
        src = temp_dir / "src"
        src.mkdir()
        path = src / "main.py"
        path.write_text("def hello():\n    return 'Hello'\n")
        return "src/main.py"

    def test_includes_custom_review_instructions(
        self, source_file, module_status, config_with_instructions
    ):
        """Includes custom review instructions when set on config."""
        prompt = build_review_prompt(
            path=source_file,
            source_code="def hello(): pass",
            module_status=module_status,
            config=config_with_instructions,
        )

        assert "Additional Instructions" in prompt
        assert "Use numpy-style docstrings." in prompt

    def test_excludes_custom_review_instructions_when_none(
        self, source_file, module_status, temp_dir
    ):
        """Does not include additional instructions block when review_instructions is None."""
        config = Config(root=temp_dir)

        prompt = build_review_prompt(
            path=source_file,
            source_code="def hello(): pass",
            module_status=module_status,
            config=config,
        )

        assert "Additional Instructions" not in prompt
