"""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,
    CHARS_PER_TOKEN,
    MAX_SOURCE_CHARS,
    build_review_prompt,
    chunk_source_code,
    claude_available,
    get_file_diff,
    get_priority_description,
    run_claude_review,
)
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 TestGetPriorityDescription:
    """Tests for get_priority_description function."""

    def test_critical(self):
        """Returns description for critical."""
        desc = get_priority_description("critical")
        assert "thorough" in desc.lower()

    def test_high(self):
        """Returns description for high."""
        desc = get_priority_description("high")
        assert "careful" in desc.lower()

    def test_medium(self):
        """Returns description for medium."""
        desc = get_priority_description("medium")
        assert "normal" in desc.lower()

    def test_low(self):
        """Returns description for low."""
        desc = get_priority_description("low")
        assert "basic" in desc.lower()

    def test_unknown(self):
        """Returns default for unknown priority."""
        desc = get_priority_description("unknown")
        assert "Standard" in desc


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",
            priority="high",
            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
        assert "high" in prompt.lower()

    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",
            priority="medium",
            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.run")
    @patch("glreview.claude.claude_available")
    def test_runs_claude_cli(self, mock_available, mock_run):
        """Runs claude CLI successfully."""
        mock_available.return_value = True
        mock_run.return_value = MagicMock(
            returncode=0,
            stdout="Review complete: LGTM",
            stderr="",
        )

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

        assert success is True
        assert "LGTM" in response
        mock_run.assert_called_once()
        # Check that model is passed
        call_args = mock_run.call_args
        assert "sonnet" in call_args[0][0]

    @patch("glreview.claude.subprocess.run")
    @patch("glreview.claude.claude_available")
    def test_handles_cli_error(self, mock_available, mock_run):
        """Handles CLI errors."""
        mock_available.return_value = True
        mock_run.return_value = MagicMock(
            returncode=1,
            stdout="",
            stderr="API error",
        )

        response, success = run_claude_review("test prompt")

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

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

        mock_available.return_value = True
        mock_run.side_effect = subprocess.TimeoutExpired(cmd="claude", timeout=300)

        response, success = run_claude_review("test prompt")

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

    @patch("glreview.claude.subprocess.run")
    @patch("glreview.claude.claude_available")
    def test_handles_general_exception(self, mock_available, mock_run):
        """Handles general exceptions."""
        mock_available.return_value = True
        mock_run.side_effect = RuntimeError("Unexpected error")

        response, success = run_claude_review("test prompt")

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


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
