"""Comprehensive unit tests for reviews fetch module.

This test suite covers:
- detect_source() author classification
- classify_priority() keyword matching
- check_dependencies() validation
- GraphQL pagination handling
- URL pattern matching for review types
- Thread merging and deduplication
- process_and_categorize() thread enrichment
"""

import re
import subprocess
from pathlib import Path
from typing import Any
from unittest.mock import MagicMock, patch

import pytest

from myk_claude_tools.reviews import fetch as get_all_reviews

# =============================================================================
# Tests for detect_source() - Author Classification
# =============================================================================


class TestDetectSource:
    """Tests for detect_source() author classification."""

    def test_qodo_user(self) -> None:
        """Qodo user should return 'qodo'."""
        assert get_all_reviews.detect_source("qodo-code-review") == "qodo"

    def test_qodo_bot(self) -> None:
        """Qodo bot should return 'qodo'."""
        assert get_all_reviews.detect_source("qodo-code-review[bot]") == "qodo"

    def test_coderabbit_user(self) -> None:
        """CodeRabbit user should return 'coderabbit'."""
        assert get_all_reviews.detect_source("coderabbitai") == "coderabbit"

    def test_coderabbit_bot(self) -> None:
        """CodeRabbit bot should return 'coderabbit'."""
        assert get_all_reviews.detect_source("coderabbitai[bot]") == "coderabbit"

    def test_human_user(self) -> None:
        """Regular user should return 'human'."""
        assert get_all_reviews.detect_source("myusername") == "human"

    def test_none_author(self) -> None:
        """None author should return 'human'."""
        assert get_all_reviews.detect_source(None) == "human"

    def test_empty_string_author(self) -> None:
        """Empty string author should return 'human'."""
        assert get_all_reviews.detect_source("") == "human"

    def test_case_sensitive_matching(self) -> None:
        """Author matching should be case-sensitive."""
        assert get_all_reviews.detect_source("QODO-CODE-REVIEW") == "human"
        assert get_all_reviews.detect_source("CodeRabbitAI") == "human"

    def test_partial_match_not_accepted(self) -> None:
        """Partial matches should not be accepted."""
        assert get_all_reviews.detect_source("qodo") == "human"
        assert get_all_reviews.detect_source("coderabbit") == "human"


# =============================================================================
# Tests for classify_priority() - Keyword Matching
# =============================================================================


class TestClassifyPriority:
    """Tests for classify_priority() keyword matching."""

    # --- HIGH priority tests ---

    def test_high_priority_security(self) -> None:
        """Security keyword should return HIGH."""
        assert get_all_reviews.classify_priority("This is a security vulnerability") == "HIGH"

    def test_high_priority_critical(self) -> None:
        """Critical keyword should return HIGH."""
        assert get_all_reviews.classify_priority("Critical bug found") == "HIGH"

    def test_high_priority_bug(self) -> None:
        """Bug keyword should return HIGH."""
        assert get_all_reviews.classify_priority("This is a bug in the code") == "HIGH"

    def test_high_priority_error(self) -> None:
        """Error keyword should return HIGH."""
        assert get_all_reviews.classify_priority("Error handling is missing") == "HIGH"

    def test_high_priority_crash(self) -> None:
        """Crash keyword should return HIGH."""
        assert get_all_reviews.classify_priority("This will crash the application") == "HIGH"

    def test_high_priority_must(self) -> None:
        """Must keyword should return HIGH."""
        assert get_all_reviews.classify_priority("You must fix this") == "HIGH"

    def test_high_priority_required(self) -> None:
        """Required keyword should return HIGH."""
        assert get_all_reviews.classify_priority("This change is required") == "HIGH"

    def test_high_priority_breaking(self) -> None:
        """Breaking keyword should return HIGH."""
        assert get_all_reviews.classify_priority("Breaking change detected") == "HIGH"

    def test_high_priority_urgent(self) -> None:
        """Urgent keyword should return HIGH."""
        assert get_all_reviews.classify_priority("Urgent: fix needed") == "HIGH"

    def test_high_priority_injection(self) -> None:
        """Injection keyword should return HIGH."""
        assert get_all_reviews.classify_priority("SQL injection risk") == "HIGH"

    def test_high_priority_xss(self) -> None:
        """XSS keyword should return HIGH."""
        assert get_all_reviews.classify_priority("XSS vulnerability here") == "HIGH"

    def test_high_priority_csrf(self) -> None:
        """CSRF keyword should return HIGH."""
        assert get_all_reviews.classify_priority("CSRF protection missing") == "HIGH"

    def test_high_priority_auth(self) -> None:
        """Auth keyword should return HIGH."""
        assert get_all_reviews.classify_priority("Auth bypass possible") == "HIGH"

    def test_high_priority_case_insensitive(self) -> None:
        """HIGH priority matching should be case insensitive."""
        assert get_all_reviews.classify_priority("SECURITY issue") == "HIGH"
        assert get_all_reviews.classify_priority("Security Issue") == "HIGH"

    # --- LOW priority tests ---

    def test_low_priority_style(self) -> None:
        """Style keyword should return LOW."""
        assert get_all_reviews.classify_priority("Style improvement needed") == "LOW"

    def test_low_priority_formatting(self) -> None:
        """Formatting keyword should return LOW."""
        assert get_all_reviews.classify_priority("Fix the formatting") == "LOW"

    def test_low_priority_typo(self) -> None:
        """Typo keyword should return LOW."""
        assert get_all_reviews.classify_priority("There's a typo here") == "LOW"

    def test_low_priority_nitpick(self) -> None:
        """Nitpick keyword should return LOW."""
        assert get_all_reviews.classify_priority("This is a nitpick") == "LOW"

    def test_low_priority_nit_colon(self) -> None:
        """nit: prefix should return LOW."""
        assert get_all_reviews.classify_priority("nit: use better naming") == "LOW"

    def test_low_priority_minor(self) -> None:
        """Minor keyword should return LOW."""
        assert get_all_reviews.classify_priority("Minor improvement") == "LOW"

    def test_low_priority_optional(self) -> None:
        """Optional keyword should return LOW."""
        assert get_all_reviews.classify_priority("Optional: consider this") == "LOW"

    def test_low_priority_cosmetic(self) -> None:
        """Cosmetic keyword should return LOW."""
        assert get_all_reviews.classify_priority("Cosmetic change only") == "LOW"

    def test_low_priority_whitespace(self) -> None:
        """Whitespace keyword should return LOW."""
        assert get_all_reviews.classify_priority("Trailing whitespace") == "LOW"

    def test_low_priority_indentation(self) -> None:
        """Indentation keyword should return LOW."""
        assert get_all_reviews.classify_priority("Fix indentation") == "LOW"

    def test_low_priority_case_insensitive(self) -> None:
        """LOW priority matching should be case insensitive."""
        assert get_all_reviews.classify_priority("STYLE change") == "LOW"
        assert get_all_reviews.classify_priority("Style Change") == "LOW"

    # --- MEDIUM priority tests ---

    def test_medium_priority_default(self) -> None:
        """Regular comment should return MEDIUM."""
        assert get_all_reviews.classify_priority("Consider refactoring this") == "MEDIUM"

    def test_medium_priority_suggestion(self) -> None:
        """Suggestion without priority keywords should return MEDIUM."""
        assert get_all_reviews.classify_priority("Maybe use a different approach") == "MEDIUM"

    def test_medium_priority_none_body(self) -> None:
        """None body should return MEDIUM."""
        assert get_all_reviews.classify_priority(None) == "MEDIUM"

    def test_medium_priority_empty_body(self) -> None:
        """Empty body should return MEDIUM."""
        assert get_all_reviews.classify_priority("") == "MEDIUM"

    # --- Priority precedence tests ---

    def test_high_takes_precedence_over_low(self) -> None:
        """HIGH priority should take precedence over LOW keywords."""
        # Contains both 'security' (HIGH) and 'minor' (LOW)
        assert get_all_reviews.classify_priority("Minor security issue") == "HIGH"


# =============================================================================
# Tests for check_dependencies()
# =============================================================================


class TestCheckDependencies:
    """Tests for check_dependencies() validation."""

    @patch("shutil.which")
    def test_gh_not_installed(self, mock_which: Any) -> None:
        """Missing gh should exit with error."""
        mock_which.return_value = None

        with pytest.raises(SystemExit) as excinfo:
            get_all_reviews.check_dependencies()

        assert excinfo.value.code == 1

    @patch("shutil.which")
    def test_all_dependencies_available(self, mock_which: Any) -> None:
        """All dependencies available should not raise."""
        mock_which.return_value = "/usr/bin/gh"

        # Should not raise
        get_all_reviews.check_dependencies()


# =============================================================================
# Tests for run_gh_graphql()
# =============================================================================


class TestRunGhGraphql:
    """Tests for run_gh_graphql() GraphQL execution."""

    @patch("subprocess.run")
    def test_successful_query(self, mock_run: Any) -> None:
        """Successful GraphQL query should return parsed data."""
        mock_run.return_value = MagicMock(returncode=0, stdout='{"data": {"test": "value"}}', stderr="")

        result = get_all_reviews.run_gh_graphql("query { test }", {})

        assert result == {"data": {"test": "value"}}

    @patch("subprocess.run")
    def test_failed_query(self, mock_run: Any) -> None:
        """Failed GraphQL query should return None."""
        mock_run.return_value = MagicMock(returncode=1, stdout="", stderr="error")

        result = get_all_reviews.run_gh_graphql("query { test }", {})

        assert result is None

    @patch("subprocess.run")
    def test_invalid_json_response(self, mock_run: Any) -> None:
        """Invalid JSON response should return None."""
        mock_run.return_value = MagicMock(returncode=0, stdout="not json", stderr="")

        result = get_all_reviews.run_gh_graphql("query { test }", {})

        assert result is None

    @patch("subprocess.run")
    def test_variables_passed_via_stdin(self, mock_run: Any) -> None:
        """Variables should be passed via stdin as JSON payload."""
        mock_run.return_value = MagicMock(returncode=0, stdout="{}", stderr="")

        get_all_reviews.run_gh_graphql("query", {"name": "value", "count": 42})

        # New implementation uses --input - to pass JSON payload via stdin
        call_args = mock_run.call_args[0][0]
        assert "--input" in call_args
        assert "-" in call_args

    @patch("subprocess.run")
    def test_timeout_returns_none(self, mock_run: Any) -> None:
        """Timed out query should return None."""
        mock_run.side_effect = subprocess.TimeoutExpired(cmd=["gh"], timeout=120)

        result = get_all_reviews.run_gh_graphql("query { test }", {})

        assert result is None


# =============================================================================
# Tests for run_gh_api()
# =============================================================================


class TestRunGhApi:
    """Tests for run_gh_api() REST API execution."""

    @patch("subprocess.run")
    def test_successful_api_call(self, mock_run: Any) -> None:
        """Successful API call should return parsed data."""
        mock_run.return_value = MagicMock(returncode=0, stdout='{"id": 123}', stderr="")

        result = get_all_reviews.run_gh_api("/repos/owner/repo")

        assert result == {"id": 123}

    @patch("subprocess.run")
    def test_failed_api_call(self, mock_run: Any) -> None:
        """Failed API call should return None."""
        mock_run.return_value = MagicMock(returncode=1, stdout="", stderr="error")

        result = get_all_reviews.run_gh_api("/repos/owner/repo")

        assert result is None

    @patch("subprocess.run")
    def test_paginated_response(self, mock_run: Any) -> None:
        """Paginated response should merge arrays."""
        # With --slurp, gh wraps pages in an outer array: [[page1], [page2]]
        mock_run.return_value = MagicMock(returncode=0, stdout='[[{"id": 1}], [{"id": 2}]]', stderr="")

        result = get_all_reviews.run_gh_api("/repos/owner/repo/comments", paginate=True)

        assert result == [{"id": 1}, {"id": 2}]

    @patch("subprocess.run")
    def test_paginate_flag(self, mock_run: Any) -> None:
        """Paginate=True should add --paginate and --slurp flags."""
        mock_run.return_value = MagicMock(returncode=0, stdout="[]", stderr="")

        get_all_reviews.run_gh_api("/test", paginate=True)

        call_args = mock_run.call_args[0][0]
        assert "--paginate" in call_args
        assert "--slurp" in call_args


# =============================================================================
# Tests for fetch_unresolved_threads()
# =============================================================================


class TestFetchUnresolvedThreads:
    """Tests for fetch_unresolved_threads() GraphQL fetching."""

    @patch.object(get_all_reviews, "run_gh_graphql")
    def test_filters_resolved_threads(self, mock_graphql: Any) -> None:
        """Resolved threads should be filtered out."""
        mock_graphql.return_value = {
            "data": {
                "repository": {
                    "pullRequest": {
                        "reviewThreads": {
                            "pageInfo": {"hasNextPage": False, "endCursor": None},
                            "nodes": [
                                {
                                    "id": "thread1",
                                    "isResolved": True,
                                    "comments": {"nodes": [{"id": "c1", "body": "test"}]},
                                },
                                {
                                    "id": "thread2",
                                    "isResolved": False,
                                    "comments": {
                                        "nodes": [
                                            {
                                                "id": "c2",
                                                "databaseId": 123,
                                                "author": {"login": "user"},
                                                "path": "file.py",
                                                "line": 10,
                                                "body": "Comment",
                                                "createdAt": "2024-01-01",
                                            }
                                        ]
                                    },
                                },
                            ],
                        }
                    }
                }
            }
        }

        result = get_all_reviews.fetch_unresolved_threads("owner", "repo", "1")

        assert len(result) == 1
        assert result[0]["thread_id"] == "thread2"

    @patch.object(get_all_reviews, "run_gh_graphql")
    def test_extracts_thread_data(self, mock_graphql: Any) -> None:
        """Thread data should be correctly extracted."""
        mock_graphql.return_value = {
            "data": {
                "repository": {
                    "pullRequest": {
                        "reviewThreads": {
                            "pageInfo": {"hasNextPage": False, "endCursor": None},
                            "nodes": [
                                {
                                    "id": "thread1",
                                    "isResolved": False,
                                    "comments": {
                                        "nodes": [
                                            {
                                                "id": "node1",
                                                "databaseId": 456,
                                                "author": {"login": "reviewer"},
                                                "path": "src/main.py",
                                                "line": 42,
                                                "body": "Please fix this",
                                                "createdAt": "2024-01-15",
                                            }
                                        ]
                                    },
                                }
                            ],
                        }
                    }
                }
            }
        }

        result = get_all_reviews.fetch_unresolved_threads("owner", "repo", "1")

        assert result[0]["thread_id"] == "thread1"
        assert result[0]["node_id"] == "node1"
        assert result[0]["comment_id"] == 456
        assert result[0]["author"] == "reviewer"
        assert result[0]["path"] == "src/main.py"
        assert result[0]["line"] == 42
        assert result[0]["body"] == "Please fix this"

    @patch.object(get_all_reviews, "run_gh_graphql")
    def test_includes_replies(self, mock_graphql: Any) -> None:
        """Thread replies should be included."""
        mock_graphql.return_value = {
            "data": {
                "repository": {
                    "pullRequest": {
                        "reviewThreads": {
                            "pageInfo": {"hasNextPage": False, "endCursor": None},
                            "nodes": [
                                {
                                    "id": "thread1",
                                    "isResolved": False,
                                    "comments": {
                                        "nodes": [
                                            {
                                                "id": "c1",
                                                "databaseId": 100,
                                                "author": {"login": "reviewer"},
                                                "path": "file.py",
                                                "line": 1,
                                                "body": "Original",
                                                "createdAt": "2024-01-01",
                                            },
                                            {
                                                "id": "c2",
                                                "databaseId": 101,
                                                "author": {"login": "author"},
                                                "path": "file.py",
                                                "line": 1,
                                                "body": "Reply 1",
                                                "createdAt": "2024-01-02",
                                            },
                                            {
                                                "id": "c3",
                                                "databaseId": 102,
                                                "author": {"login": "reviewer"},
                                                "path": "file.py",
                                                "line": 1,
                                                "body": "Reply 2",
                                                "createdAt": "2024-01-03",
                                            },
                                        ]
                                    },
                                }
                            ],
                        }
                    }
                }
            }
        }

        result = get_all_reviews.fetch_unresolved_threads("owner", "repo", "1")

        assert len(result[0]["replies"]) == 2
        assert result[0]["replies"][0]["author"] == "author"
        assert result[0]["replies"][0]["body"] == "Reply 1"
        assert result[0]["replies"][1]["author"] == "reviewer"
        assert result[0]["replies"][1]["body"] == "Reply 2"

    @patch.object(get_all_reviews, "run_gh_graphql")
    def test_handles_pagination(self, mock_graphql: Any) -> None:
        """Pagination should fetch multiple pages."""
        # First page
        first_response = {
            "data": {
                "repository": {
                    "pullRequest": {
                        "reviewThreads": {
                            "pageInfo": {"hasNextPage": True, "endCursor": "cursor1"},
                            "nodes": [
                                {
                                    "id": "thread1",
                                    "isResolved": False,
                                    "comments": {
                                        "nodes": [
                                            {
                                                "id": "c1",
                                                "databaseId": 1,
                                                "author": {"login": "user"},
                                                "path": "a.py",
                                                "line": 1,
                                                "body": "Comment 1",
                                            }
                                        ]
                                    },
                                }
                            ],
                        }
                    }
                }
            }
        }
        # Second page
        second_response = {
            "data": {
                "repository": {
                    "pullRequest": {
                        "reviewThreads": {
                            "pageInfo": {"hasNextPage": False, "endCursor": None},
                            "nodes": [
                                {
                                    "id": "thread2",
                                    "isResolved": False,
                                    "comments": {
                                        "nodes": [
                                            {
                                                "id": "c2",
                                                "databaseId": 2,
                                                "author": {"login": "user"},
                                                "path": "b.py",
                                                "line": 2,
                                                "body": "Comment 2",
                                            }
                                        ]
                                    },
                                }
                            ],
                        }
                    }
                }
            }
        }

        mock_graphql.side_effect = [first_response, second_response]

        result = get_all_reviews.fetch_unresolved_threads("owner", "repo", "1")

        assert len(result) == 2
        assert result[0]["thread_id"] == "thread1"
        assert result[1]["thread_id"] == "thread2"
        assert mock_graphql.call_count == 2

    @patch.object(get_all_reviews, "run_gh_graphql")
    def test_handles_graphql_error(self, mock_graphql: Any) -> None:
        """GraphQL error should return empty list."""
        mock_graphql.return_value = None

        result = get_all_reviews.fetch_unresolved_threads("owner", "repo", "1")

        assert result == []

    @patch.object(get_all_reviews, "run_gh_graphql")
    def test_handles_graphql_errors_field(self, mock_graphql: Any) -> None:
        """GraphQL response with errors field should return partial results."""
        mock_graphql.return_value = {
            "errors": [{"message": "Something went wrong"}],
            "data": None,
        }

        result = get_all_reviews.fetch_unresolved_threads("owner", "repo", "1")

        assert result == []

    @patch.object(get_all_reviews, "run_gh_graphql")
    def test_handles_empty_comments(self, mock_graphql: Any) -> None:
        """Thread with no comments should be skipped."""
        mock_graphql.return_value = {
            "data": {
                "repository": {
                    "pullRequest": {
                        "reviewThreads": {
                            "pageInfo": {"hasNextPage": False, "endCursor": None},
                            "nodes": [
                                {
                                    "id": "thread1",
                                    "isResolved": False,
                                    "comments": {"nodes": []},
                                }
                            ],
                        }
                    }
                }
            }
        }

        result = get_all_reviews.fetch_unresolved_threads("owner", "repo", "1")

        assert result == []


# =============================================================================
# Tests for process_and_categorize()
# =============================================================================


class TestProcessAndCategorize:
    """Tests for process_and_categorize() thread enrichment."""

    def test_categorizes_human_threads(self) -> None:
        """Human threads should be categorized correctly."""
        threads = [
            {"author": "regular-user", "body": "Please fix this"},
        ]

        result = get_all_reviews.process_and_categorize(threads, "test-owner", "test-repo")

        assert len(result["human"]) == 1
        assert len(result["qodo"]) == 0
        assert len(result["coderabbit"]) == 0

    def test_categorizes_qodo_threads(self) -> None:
        """Qodo threads should be categorized correctly."""
        threads = [
            {"author": "qodo-code-review[bot]", "body": "Consider this change"},
        ]

        result = get_all_reviews.process_and_categorize(threads, "test-owner", "test-repo")

        assert len(result["human"]) == 0
        assert len(result["qodo"]) == 1
        assert len(result["coderabbit"]) == 0

    def test_categorizes_coderabbit_threads(self) -> None:
        """CodeRabbit threads should be categorized correctly."""
        threads = [
            {"author": "coderabbitai[bot]", "body": "Suggestion here"},
        ]

        result = get_all_reviews.process_and_categorize(threads, "test-owner", "test-repo")

        assert len(result["human"]) == 0
        assert len(result["qodo"]) == 0
        assert len(result["coderabbit"]) == 1

    def test_adds_source_field(self) -> None:
        """Source field should be added to threads."""
        threads = [
            {"author": "user", "body": "Comment"},
        ]

        result = get_all_reviews.process_and_categorize(threads, "test-owner", "test-repo")

        assert result["human"][0]["source"] == "human"

    def test_adds_priority_field(self) -> None:
        """Priority field should be added based on body."""
        threads = [
            {"author": "user", "body": "Security vulnerability here"},
        ]

        result = get_all_reviews.process_and_categorize(threads, "test-owner", "test-repo")

        assert result["human"][0]["priority"] == "HIGH"

    def test_adds_reply_and_status_fields(self) -> None:
        """Reply and status fields should be initialized."""
        threads = [
            {"author": "user", "body": "Comment"},
        ]

        result = get_all_reviews.process_and_categorize(threads, "test-owner", "test-repo")

        assert result["human"][0]["reply"] is None
        assert result["human"][0]["status"] == "pending"

    def test_preserves_original_fields(self) -> None:
        """Original thread fields should be preserved."""
        threads = [
            {
                "author": "user",
                "body": "Comment",
                "thread_id": "t1",
                "path": "file.py",
                "line": 10,
            },
        ]

        result = get_all_reviews.process_and_categorize(threads, "test-owner", "test-repo")

        assert result["human"][0]["thread_id"] == "t1"
        assert result["human"][0]["path"] == "file.py"
        assert result["human"][0]["line"] == 10


# =============================================================================
# Tests for get_thread_key() and merge_threads()
# =============================================================================


class TestGetThreadKey:
    """Tests for get_thread_key() deduplication key generation."""

    def test_thread_id_key(self) -> None:
        """Thread ID should be used first."""
        thread = {"thread_id": "t123", "node_id": "n456", "comment_id": 789}
        assert get_all_reviews.get_thread_key(thread) == "t:t123"

    def test_node_id_key(self) -> None:
        """Node ID should be used if thread_id is missing."""
        thread = {"thread_id": None, "node_id": "n456", "comment_id": 789}
        assert get_all_reviews.get_thread_key(thread) == "n:n456"

    def test_comment_id_key(self) -> None:
        """Comment ID should be used if thread_id and node_id are missing."""
        thread = {"thread_id": None, "node_id": None, "comment_id": 789}
        assert get_all_reviews.get_thread_key(thread) == "c:789"

    def test_no_key(self) -> None:
        """None should be returned if no IDs are available."""
        thread = {"thread_id": None, "node_id": None, "comment_id": None}
        assert get_all_reviews.get_thread_key(thread) is None

    def test_empty_thread(self) -> None:
        """Empty thread should return None."""
        assert get_all_reviews.get_thread_key({}) is None

    def test_empty_string_ids(self) -> None:
        """Empty string IDs should be treated as missing."""
        thread = {"thread_id": "", "node_id": "", "comment_id": 789}
        assert get_all_reviews.get_thread_key(thread) == "c:789"


class TestMergeThreads:
    """Tests for merge_threads() deduplication."""

    def test_empty_specific_threads(self) -> None:
        """Empty specific threads should return all threads unchanged."""
        all_threads = [{"thread_id": "t1", "body": "comment1"}]

        result = get_all_reviews.merge_threads(all_threads, [])

        assert result == all_threads

    def test_no_duplicates(self) -> None:
        """Non-duplicate threads should be merged."""
        all_threads = [{"thread_id": "t1", "body": "comment1"}]
        specific = [{"thread_id": "t2", "body": "comment2"}]

        result = get_all_reviews.merge_threads(all_threads, specific)

        assert len(result) == 2

    def test_deduplicates_by_thread_id(self) -> None:
        """Duplicate thread_id should be deduplicated."""
        all_threads = [{"thread_id": "t1", "body": "comment1"}]
        specific = [{"thread_id": "t1", "body": "same thread"}]

        result = get_all_reviews.merge_threads(all_threads, specific)

        assert len(result) == 1

    def test_threads_without_keys_are_added(self) -> None:
        """Threads without any key should be added."""
        all_threads = [{"thread_id": "t1", "body": "comment1"}]
        specific = [{"thread_id": None, "node_id": None, "comment_id": None, "body": "orphan"}]

        result = get_all_reviews.merge_threads(all_threads, specific)

        assert len(result) == 2


# =============================================================================
# Tests for URL pattern matching
# =============================================================================


class TestUrlPatternMatching:
    """Tests for URL pattern matching in main()."""

    def test_pullrequestreview_pattern(self) -> None:
        """pullrequestreview-NNN pattern should match."""
        url = "https://github.com/owner/repo/pull/1#pullrequestreview-12345"
        match = re.search(r"pullrequestreview-(\d+)", url)

        assert match is not None
        assert match.group(1) == "12345"

    def test_discussion_r_pattern(self) -> None:
        """discussion_rNNN pattern should match."""
        url = "https://github.com/owner/repo/pull/1#discussion_r67890"
        match = re.search(r"discussion_r(\d+)", url)

        assert match is not None
        assert match.group(1) == "67890"

    def test_issuecomment_pattern(self) -> None:
        """issuecomment-NNN pattern should match."""
        url = "https://github.com/owner/repo/pull/1#issuecomment-11111"
        match = re.search(r"issuecomment-(\d+)", url)

        assert match is not None
        assert match.group(1) == "11111"

    def test_raw_numeric_id(self) -> None:
        """Raw numeric ID should be detected."""
        review_url = "12345"
        assert review_url.isdigit()


# =============================================================================
# Tests for cleanup()
# =============================================================================


class TestCleanup:
    """Tests for cleanup() temp file removal."""

    def test_removes_temp_files(self, tmp_path: Path) -> None:
        """Temp files should be removed."""
        temp_file = tmp_path / "test.json"
        temp_file.write_text("{}")

        get_all_reviews.TEMP_FILES.clear()
        get_all_reviews.TEMP_FILES.append(temp_file)

        get_all_reviews.cleanup()

        assert not temp_file.exists()

    def test_removes_new_files(self, tmp_path: Path) -> None:
        """Orphaned .new files should be removed."""
        temp_file = tmp_path / "test.json"
        new_file = tmp_path / "test.json.new"
        new_file.write_text("{}")

        get_all_reviews.TEMP_FILES.clear()
        get_all_reviews.TEMP_FILES.append(temp_file)

        get_all_reviews.cleanup()

        assert not new_file.exists()

    def test_handles_missing_files(self, tmp_path: Path) -> None:
        """Missing files should not raise errors."""
        temp_file = tmp_path / "nonexistent.json"

        get_all_reviews.TEMP_FILES.clear()
        get_all_reviews.TEMP_FILES.append(temp_file)

        # Should not raise
        get_all_reviews.cleanup()


# =============================================================================
# Tests for edge cases
# =============================================================================


class TestEdgeCases:
    """Edge cases and error handling tests."""

    def test_detect_source_with_whitespace(self) -> None:
        """Author with leading/trailing whitespace should be human."""
        assert get_all_reviews.detect_source(" qodo-code-review ") == "human"
        assert get_all_reviews.detect_source(" coderabbitai ") == "human"

    @patch.object(get_all_reviews, "run_gh_graphql")
    def test_handles_none_author_in_thread(self, mock_graphql: Any) -> None:
        """Thread with None author should be treated as human."""
        mock_graphql.return_value = {
            "data": {
                "repository": {
                    "pullRequest": {
                        "reviewThreads": {
                            "pageInfo": {"hasNextPage": False, "endCursor": None},
                            "nodes": [
                                {
                                    "id": "thread1",
                                    "isResolved": False,
                                    "comments": {
                                        "nodes": [
                                            {
                                                "id": "c1",
                                                "databaseId": 1,
                                                "author": None,
                                                "path": "file.py",
                                                "line": 1,
                                                "body": "Comment",
                                            }
                                        ]
                                    },
                                }
                            ],
                        }
                    }
                }
            }
        }

        result = get_all_reviews.fetch_unresolved_threads("owner", "repo", "1")

        assert result[0]["author"] is None


# =============================================================================
# Tests for parse_pr_url()
# =============================================================================


class TestParsePrUrl:
    """Tests for parse_pr_url()."""

    def test_valid_https_url(self) -> None:
        assert get_all_reviews.parse_pr_url("https://github.com/owner/repo/pull/42") == ("owner", "repo", "42")

    def test_valid_http_url(self) -> None:
        assert get_all_reviews.parse_pr_url("http://github.com/owner/repo/pull/1") == ("owner", "repo", "1")

    def test_url_with_review_fragment(self) -> None:
        assert get_all_reviews.parse_pr_url("https://github.com/owner/repo/pull/42#pullrequestreview-123") == (
            "owner",
            "repo",
            "42",
        )

    def test_url_with_discussion_fragment(self) -> None:
        assert get_all_reviews.parse_pr_url("https://github.com/owner/repo/pull/42#discussion_r456") == (
            "owner",
            "repo",
            "42",
        )

    def test_url_with_issuecomment_fragment(self) -> None:
        assert get_all_reviews.parse_pr_url("https://github.com/owner/repo/pull/42#issuecomment-789") == (
            "owner",
            "repo",
            "42",
        )

    def test_invalid_url_returns_none(self) -> None:
        assert get_all_reviews.parse_pr_url("not-a-url") is None

    def test_non_github_url_returns_none(self) -> None:
        assert get_all_reviews.parse_pr_url("https://gitlab.com/owner/repo/pull/1") is None

    def test_empty_string_returns_none(self) -> None:
        assert get_all_reviews.parse_pr_url("") is None

    def test_github_issue_url_returns_none(self) -> None:
        assert get_all_reviews.parse_pr_url("https://github.com/owner/repo/issues/42") is None

    def test_owner_with_hyphens_and_dots(self) -> None:
        assert get_all_reviews.parse_pr_url("https://github.com/my-org.name/my-repo.name/pull/99") == (
            "my-org.name",
            "my-repo.name",
            "99",
        )

    def test_path_traversal_rejected(self) -> None:
        assert get_all_reviews.parse_pr_url("https://github.com/../../evil/pull/1") is None
        assert get_all_reviews.parse_pr_url("https://github.com/../my-repo/pull/1") is None
        assert get_all_reviews.parse_pr_url("https://github.com/./repo/pull/1") is None

    def test_owner_must_start_with_alphanumeric(self) -> None:
        assert get_all_reviews.parse_pr_url("https://github.com/-owner/repo/pull/1") is None
        assert get_all_reviews.parse_pr_url("https://github.com/.owner/repo/pull/1") is None
        assert get_all_reviews.parse_pr_url("https://github.com/_owner/repo/pull/1") is None


# =============================================================================
# Tests for _get_upstream_repo()
# =============================================================================


class TestGetUpstreamRepo:
    """Tests for _get_upstream_repo()."""

    @patch("subprocess.run")
    def test_ssh_shorthand_url(self, mock_run: Any) -> None:
        mock_run.return_value = MagicMock(returncode=0, stdout="git@github.com:upstream-org/repo.git\n")
        assert get_all_reviews._get_upstream_repo() == "upstream-org/repo"

    @patch("subprocess.run")
    def test_ssh_shorthand_url_no_git_suffix(self, mock_run: Any) -> None:
        mock_run.return_value = MagicMock(returncode=0, stdout="git@github.com:upstream-org/repo\n")
        assert get_all_reviews._get_upstream_repo() == "upstream-org/repo"

    @patch("subprocess.run")
    def test_ssh_url_format(self, mock_run: Any) -> None:
        mock_run.return_value = MagicMock(returncode=0, stdout="ssh://git@github.com/upstream-org/repo.git\n")
        assert get_all_reviews._get_upstream_repo() == "upstream-org/repo"

    @patch("subprocess.run")
    def test_https_url(self, mock_run: Any) -> None:
        mock_run.return_value = MagicMock(returncode=0, stdout="https://github.com/upstream-org/repo.git\n")
        assert get_all_reviews._get_upstream_repo() == "upstream-org/repo"

    @patch("subprocess.run")
    def test_https_url_no_git_suffix(self, mock_run: Any) -> None:
        mock_run.return_value = MagicMock(returncode=0, stdout="https://github.com/upstream-org/repo\n")
        assert get_all_reviews._get_upstream_repo() == "upstream-org/repo"

    @patch("subprocess.run")
    def test_no_upstream_remote(self, mock_run: Any) -> None:
        mock_run.return_value = MagicMock(returncode=1, stdout="", stderr="fatal: No such remote 'upstream'")
        assert get_all_reviews._get_upstream_repo() is None

    @patch("subprocess.run")
    def test_timeout(self, mock_run: Any) -> None:
        mock_run.side_effect = subprocess.TimeoutExpired(cmd=["git"], timeout=10)
        assert get_all_reviews._get_upstream_repo() is None

    @patch("subprocess.run")
    def test_unparseable_url(self, mock_run: Any) -> None:
        mock_run.return_value = MagicMock(returncode=0, stdout="file:///some/local/path\n")
        assert get_all_reviews._get_upstream_repo() is None


# =============================================================================
# Tests for get_pr_info() with pr_url parameter
# =============================================================================


class TestGetPrInfoWithUrl:
    """Tests for get_pr_info() with pr_url parameter."""

    def test_pr_url_returns_parsed_info(self) -> None:
        result = get_all_reviews.get_pr_info(pr_url="https://github.com/RedHatQE/mtv-api-tests/pull/293")
        assert result == ("RedHatQE", "mtv-api-tests", "293")

    def test_pr_url_with_fragment_returns_parsed_info(self) -> None:
        result = get_all_reviews.get_pr_info(pr_url="https://github.com/owner/repo/pull/42#pullrequestreview-123")
        assert result == ("owner", "repo", "42")

    @patch.object(get_all_reviews, "_get_upstream_repo", return_value=None)
    @patch("subprocess.run")
    def test_invalid_url_falls_back_to_branch_detection(self, mock_run: Any, _mock_upstream: Any) -> None:
        """When an invalid URL is passed, get_pr_info should fall back to branch detection."""
        mock_run.side_effect = [
            # git rev-parse --abbrev-ref HEAD
            MagicMock(returncode=0, stdout="my-branch\n"),
            # gh pr view my-branch --json number --jq .number (origin)
            MagicMock(returncode=0, stdout="42\n"),
            # gh repo view --json owner,name
            MagicMock(returncode=0, stdout="owner/repo\n"),
        ]
        result = get_all_reviews.get_pr_info(pr_url="not-a-url")
        assert result == ("owner", "repo", "42")

    @patch.object(get_all_reviews, "_get_upstream_repo", return_value="upstream-org/upstream-repo")
    @patch("subprocess.run")
    def test_falls_back_to_upstream_when_origin_fails(self, mock_run: Any, _mock_upstream: Any) -> None:
        """When origin has no PR, should try upstream remote."""
        mock_run.side_effect = [
            # git rev-parse --abbrev-ref HEAD
            MagicMock(returncode=0, stdout="my-branch\n"),
            # gh pr view my-branch --json number --jq .number (origin - fails)
            MagicMock(returncode=1, stdout="", stderr="no pull requests found"),
            # gh pr view my-branch --json number --jq .number -R upstream-org/upstream-repo (succeeds)
            MagicMock(returncode=0, stdout="99\n"),
        ]
        result = get_all_reviews.get_pr_info()
        assert result == ("upstream-org", "upstream-repo", "99")

    @patch.object(get_all_reviews, "_get_upstream_repo", return_value=None)
    @patch("subprocess.run")
    def test_exits_when_no_pr_found_anywhere(self, mock_run: Any, _mock_upstream: Any) -> None:
        """When no PR found on origin and no upstream, should sys.exit."""
        mock_run.side_effect = [
            # git rev-parse --abbrev-ref HEAD
            MagicMock(returncode=0, stdout="my-branch\n"),
            # gh pr view my-branch (origin - fails)
            MagicMock(returncode=1, stdout="", stderr="no pull requests found"),
        ]
        with pytest.raises(SystemExit):
            get_all_reviews.get_pr_info()


# =============================================================================
# Tests for fetch_coderabbit_outside_diff_comments()
# =============================================================================


class TestFetchCoderabbitOutsideDiffComments:
    """Tests for fetch_coderabbit_outside_diff_comments() (alias for fetch_coderabbit_body_comments)."""

    @patch("myk_claude_tools.reviews.coderabbit_parser.parse_review_body_comments")
    @patch.object(get_all_reviews, "run_gh_api")
    def test_filters_by_coderabbit_author(self, mock_api: Any, mock_parse: Any) -> None:
        """Only CodeRabbit reviews should be processed."""
        mock_api.return_value = [
            {"id": 1, "node_id": "n1", "user": {"login": "random-user"}, "body": "Not coderabbit"},
            {
                "id": 2,
                "node_id": "n2",
                "user": {"login": "coderabbitai[bot]"},
                "body": "review body with outside diff",
            },
        ]
        mock_parse.return_value = {"outside_diff": [], "nitpick": []}

        get_all_reviews.fetch_coderabbit_outside_diff_comments("owner", "repo", "1")

        # parse_review_body_comments should only be called for CodeRabbit reviews
        mock_parse.assert_called_once_with("review body with outside diff")

    @patch("myk_claude_tools.reviews.coderabbit_parser.parse_review_body_comments")
    @patch.object(get_all_reviews, "run_gh_api")
    def test_maps_parsed_comments_to_threads(self, mock_api: Any, mock_parse: Any) -> None:
        """Each parsed comment should become a separate thread-like dict."""
        mock_api.return_value = [
            {
                "id": 200,
                "node_id": "PRR_abc",
                "user": {"login": "coderabbitai[bot]"},
                "body": "review body",
            },
        ]
        mock_parse.return_value = {
            "outside_diff": [
                {
                    "path": "src/main.py",
                    "line": 100,
                    "end_line": 110,
                    "body": "Resource leak",
                    "category": "Potential issue",
                    "severity": "Major",
                },
                {
                    "path": "src/utils.py",
                    "line": 42,
                    "end_line": None,
                    "body": "Unused import",
                    "category": "Nitpick",
                    "severity": "Trivial",
                },
            ],
            "nitpick": [],
        }

        result = get_all_reviews.fetch_coderabbit_outside_diff_comments("owner", "repo", "1")

        assert len(result) == 2

        assert result[0]["type"] == "outside_diff_comment"
        assert result[0]["review_id"] == 200
        assert result[0]["suggestion_index"] == 0
        assert result[0]["path"] == "src/main.py"
        assert result[0]["line"] == 100
        assert result[0]["end_line"] == 110
        assert result[0]["body"] == "Resource leak"
        assert result[0]["category"] == "Potential issue"
        assert result[0]["severity"] == "Major"
        assert result[0]["thread_id"] is None
        assert result[0]["node_id"] == "PRR_abc"
        assert result[0]["comment_id"] == 200
        assert result[0]["author"] == "coderabbitai[bot]"
        assert result[0]["replies"] == []

        assert result[1]["suggestion_index"] == 1
        assert result[1]["path"] == "src/utils.py"
        assert result[1]["line"] == 42
        assert result[1]["end_line"] is None
        assert result[1]["category"] == "Nitpick"
        assert result[1]["severity"] == "Trivial"

    @patch.object(get_all_reviews, "run_gh_api")
    def test_handles_api_failure(self, mock_api: Any) -> None:
        """API failure should return empty list."""
        mock_api.return_value = None

        result = get_all_reviews.fetch_coderabbit_outside_diff_comments("owner", "repo", "1")

        assert result == []

    @patch.object(get_all_reviews, "run_gh_api")
    def test_handles_no_coderabbit_reviews(self, mock_api: Any) -> None:
        """No CodeRabbit reviews should return empty list."""
        mock_api.return_value = [
            {"id": 1, "node_id": "n1", "user": {"login": "human-reviewer"}, "body": "LGTM"},
        ]

        result = get_all_reviews.fetch_coderabbit_outside_diff_comments("owner", "repo", "1")

        assert result == []

    @patch.object(get_all_reviews, "run_gh_api")
    def test_handles_empty_reviews_list(self, mock_api: Any) -> None:
        """Empty reviews list should return empty list."""
        mock_api.return_value = []

        result = get_all_reviews.fetch_coderabbit_outside_diff_comments("owner", "repo", "1")

        assert result == []

    @patch.object(get_all_reviews, "run_gh_api")
    def test_handles_non_list_response(self, mock_api: Any) -> None:
        """Non-list API response should return empty list."""
        mock_api.return_value = {"error": "unexpected"}

        result = get_all_reviews.fetch_coderabbit_outside_diff_comments("owner", "repo", "1")

        assert result == []

    @patch("myk_claude_tools.reviews.coderabbit_parser.parse_review_body_comments")
    @patch.object(get_all_reviews, "run_gh_api")
    def test_handles_reviews_with_no_outside_diff_comments(self, mock_api: Any, mock_parse: Any) -> None:
        """Reviews with no parseable outside-diff comments should yield nothing."""
        mock_api.return_value = [
            {
                "id": 300,
                "node_id": "n3",
                "user": {"login": "coderabbitai[bot]"},
                "body": "Clean review with no outside diff comments",
            },
        ]
        mock_parse.return_value = {"outside_diff": [], "nitpick": []}

        result = get_all_reviews.fetch_coderabbit_outside_diff_comments("owner", "repo", "1")

        assert result == []

    @patch("myk_claude_tools.reviews.coderabbit_parser.parse_review_body_comments")
    @patch.object(get_all_reviews, "run_gh_api")
    def test_skips_reviews_without_id(self, mock_api: Any, mock_parse: Any) -> None:
        """Reviews without an ID should be skipped."""
        mock_api.return_value = [
            {
                "id": None,
                "node_id": "n1",
                "user": {"login": "coderabbitai[bot]"},
                "body": "review body",
            },
        ]
        mock_parse.return_value = {
            "outside_diff": [
                {"path": "f.py", "line": 1, "end_line": 2, "body": "test", "category": "Bug", "severity": "Major"}
            ],
            "nitpick": [],
        }

        result = get_all_reviews.fetch_coderabbit_outside_diff_comments("owner", "repo", "1")

        assert result == []

    @patch("myk_claude_tools.reviews.coderabbit_parser.parse_review_body_comments")
    @patch.object(get_all_reviews, "run_gh_api")
    def test_accepts_coderabbitai_user_without_bot_suffix(self, mock_api: Any, mock_parse: Any) -> None:
        """The 'coderabbitai' username (without [bot]) should also be accepted."""
        mock_api.return_value = [
            {
                "id": 400,
                "node_id": "n4",
                "user": {"login": "coderabbitai"},
                "body": "review body",
            },
        ]
        mock_parse.return_value = {
            "outside_diff": [
                {"path": "f.py", "line": 10, "end_line": 20, "body": "issue", "category": "Bug", "severity": "Major"}
            ],
            "nitpick": [],
        }

        result = get_all_reviews.fetch_coderabbit_outside_diff_comments("owner", "repo", "1")

        assert len(result) == 1
        assert result[0]["author"] == "coderabbitai"


# =============================================================================
# Tests for get_thread_key() with outside_diff_comment type
# =============================================================================


class TestGetThreadKeyOutsideDiffComment:
    """Tests for get_thread_key() with the outside_diff_comment type."""

    def test_outside_diff_comment_key(self) -> None:
        """Outside diff comment should use composite odc: key with path/line/end_line."""
        thread = {
            "type": "outside_diff_comment",
            "review_id": 200,
            "path": "src/main.py",
            "line": 10,
            "end_line": 20,
            "suggestion_index": 3,
            "thread_id": None,
            "node_id": "n1",
            "comment_id": 200,
        }
        assert get_all_reviews.get_thread_key(thread) == "odc:200:src/main.py:10:20"

    def test_outside_diff_comment_missing_review_id(self) -> None:
        """Missing review_id should fall through to other key strategies."""
        thread = {
            "type": "outside_diff_comment",
            "review_id": None,
            "path": "src/main.py",
            "line": 10,
            "end_line": 20,
            "suggestion_index": 0,
            "thread_id": None,
            "node_id": "n1",
            "comment_id": 200,
        }
        # Falls through to node_id
        assert get_all_reviews.get_thread_key(thread) == "n:n1"

    def test_outside_diff_comment_missing_path(self) -> None:
        """Missing path should fall through to other key strategies."""
        thread = {
            "type": "outside_diff_comment",
            "review_id": 200,
            "path": None,
            "line": 10,
            "end_line": 20,
            "suggestion_index": 0,
            "thread_id": None,
            "node_id": "n1",
            "comment_id": 200,
        }
        # Falls through to node_id
        assert get_all_reviews.get_thread_key(thread) == "n:n1"

    def test_outside_diff_comment_missing_line(self) -> None:
        """Missing line should fall through to other key strategies."""
        thread = {
            "type": "outside_diff_comment",
            "review_id": 200,
            "path": "src/main.py",
            "line": None,
            "end_line": 20,
            "suggestion_index": 0,
            "thread_id": None,
            "node_id": "n1",
            "comment_id": 200,
        }
        # Falls through to node_id
        assert get_all_reviews.get_thread_key(thread) == "n:n1"

    def test_outside_diff_comment_none_end_line(self) -> None:
        """None end_line should still produce a valid key."""
        thread = {
            "type": "outside_diff_comment",
            "review_id": 100,
            "path": "src/utils.py",
            "line": 5,
            "end_line": None,
            "suggestion_index": 0,
            "thread_id": None,
            "node_id": "n1",
            "comment_id": 100,
        }
        assert get_all_reviews.get_thread_key(thread) == "odc:100:src/utils.py:5:None"


# =============================================================================
# Tests for get_thread_key() with nitpick_comment type
# =============================================================================


class TestGetThreadKeyNitpickComment:
    """Tests for get_thread_key() with the nitpick_comment type."""

    def test_nitpick_comment_key(self) -> None:
        """Nitpick comment should use composite npc: key with path/line/end_line."""
        thread = {
            "type": "nitpick_comment",
            "review_id": 300,
            "path": "src/app.py",
            "line": 42,
            "end_line": 50,
            "suggestion_index": 1,
            "thread_id": None,
            "node_id": "n1",
            "comment_id": 300,
        }
        assert get_all_reviews.get_thread_key(thread) == "npc:300:src/app.py:42:50"

    def test_no_collision_with_odc_key(self) -> None:
        """npc: prefix should not collide with odc: prefix."""
        odc_thread = {
            "type": "outside_diff_comment",
            "review_id": 100,
            "path": "src/file.py",
            "line": 5,
            "end_line": 10,
            "suggestion_index": 0,
            "thread_id": None,
            "node_id": "n1",
            "comment_id": 100,
        }
        npc_thread = {
            "type": "nitpick_comment",
            "review_id": 100,
            "path": "src/file.py",
            "line": 5,
            "end_line": 10,
            "suggestion_index": 0,
            "thread_id": None,
            "node_id": "n1",
            "comment_id": 100,
        }
        odc_key = get_all_reviews.get_thread_key(odc_thread)
        npc_key = get_all_reviews.get_thread_key(npc_thread)
        assert odc_key != npc_key
        assert odc_key == "odc:100:src/file.py:5:10"
        assert npc_key == "npc:100:src/file.py:5:10"

    def test_nitpick_comment_missing_review_id(self) -> None:
        """Missing review_id should fall through to other key strategies."""
        thread = {
            "type": "nitpick_comment",
            "review_id": None,
            "path": "src/app.py",
            "line": 42,
            "end_line": 50,
            "suggestion_index": 0,
            "thread_id": None,
            "node_id": "n1",
            "comment_id": 300,
        }
        assert get_all_reviews.get_thread_key(thread) == "n:n1"

    def test_nitpick_comment_missing_path(self) -> None:
        """Missing path should fall through to other key strategies."""
        thread = {
            "type": "nitpick_comment",
            "review_id": 300,
            "path": None,
            "line": 42,
            "end_line": 50,
            "suggestion_index": 0,
            "thread_id": None,
            "node_id": "n1",
            "comment_id": 300,
        }
        assert get_all_reviews.get_thread_key(thread) == "n:n1"

    def test_nitpick_comment_missing_line(self) -> None:
        """Missing line should fall through to other key strategies."""
        thread = {
            "type": "nitpick_comment",
            "review_id": 300,
            "path": "src/app.py",
            "line": None,
            "end_line": 50,
            "suggestion_index": 0,
            "thread_id": None,
            "node_id": "n1",
            "comment_id": 300,
        }
        assert get_all_reviews.get_thread_key(thread) == "n:n1"

    def test_nitpick_comment_none_end_line(self) -> None:
        """None end_line should still produce a valid key."""
        thread = {
            "type": "nitpick_comment",
            "review_id": 100,
            "path": "src/utils.py",
            "line": 5,
            "end_line": None,
            "suggestion_index": 0,
            "thread_id": None,
            "node_id": "n1",
            "comment_id": 100,
        }
        assert get_all_reviews.get_thread_key(thread) == "npc:100:src/utils.py:5:None"


# =============================================================================
# Tests for fetch_coderabbit_body_comments() returning both types
# =============================================================================


class TestFetchCoderabbitBodyComments:
    """Tests for fetch_coderabbit_body_comments() returning both outside_diff and nitpick types."""

    @patch("myk_claude_tools.reviews.coderabbit_parser.parse_review_body_comments")
    @patch.object(get_all_reviews, "run_gh_api")
    def test_returns_both_types(self, mock_api: Any, mock_parse: Any) -> None:
        """Should return both outside_diff_comment and nitpick_comment threads."""
        mock_api.return_value = [
            {"id": 200, "node_id": "n1", "user": {"login": "coderabbitai[bot]"}, "body": "review body"},
        ]
        mock_parse.return_value = {
            "outside_diff": [
                {
                    "path": "src/a.py",
                    "line": 10,
                    "end_line": 20,
                    "body": "odc issue",
                    "category": "Bug",
                    "severity": "Major",
                },
            ],
            "nitpick": [
                {
                    "path": "src/b.py",
                    "line": 5,
                    "end_line": None,
                    "body": "nit issue",
                    "category": "Nitpick",
                    "severity": "Trivial",
                },
            ],
        }

        result = get_all_reviews.fetch_coderabbit_body_comments("owner", "repo", "1")

        assert len(result) == 2
        assert result[0]["type"] == "outside_diff_comment"
        assert result[0]["path"] == "src/a.py"
        assert result[0]["review_id"] == 200
        assert result[0]["suggestion_index"] == 0

        assert result[1]["type"] == "nitpick_comment"
        assert result[1]["path"] == "src/b.py"
        assert result[1]["review_id"] == 200
        assert result[1]["suggestion_index"] == 0

    @patch("myk_claude_tools.reviews.coderabbit_parser.parse_review_body_comments")
    @patch.object(get_all_reviews, "run_gh_api")
    def test_only_nitpick(self, mock_api: Any, mock_parse: Any) -> None:
        """Should handle reviews with only nitpick comments."""
        mock_api.return_value = [
            {"id": 200, "node_id": "n1", "user": {"login": "coderabbitai[bot]"}, "body": "review body"},
        ]
        mock_parse.return_value = {
            "outside_diff": [],
            "nitpick": [
                {
                    "path": "src/b.py",
                    "line": 5,
                    "end_line": None,
                    "body": "nit",
                    "category": "Nitpick",
                    "severity": "Trivial",
                },
            ],
        }

        result = get_all_reviews.fetch_coderabbit_body_comments("owner", "repo", "1")

        assert len(result) == 1
        assert result[0]["type"] == "nitpick_comment"

    @patch("myk_claude_tools.reviews.coderabbit_parser.parse_review_body_comments")
    @patch.object(get_all_reviews, "run_gh_api")
    def test_empty_both_types(self, mock_api: Any, mock_parse: Any) -> None:
        """Should return empty list when no comments found."""
        mock_api.return_value = [
            {"id": 200, "node_id": "n1", "user": {"login": "coderabbitai[bot]"}, "body": "review body"},
        ]
        mock_parse.return_value = {"outside_diff": [], "nitpick": []}

        result = get_all_reviews.fetch_coderabbit_body_comments("owner", "repo", "1")

        assert result == []


# =============================================================================
# Tests for fetch_review_body()
# =============================================================================


class TestFetchReviewBody:
    """Tests for fetch_review_body()."""

    @patch.object(get_all_reviews, "run_gh_api")
    def test_fetches_review_metadata(self, mock_api: Any) -> None:
        """Should fetch a single review by ID."""
        mock_api.return_value = {
            "id": 12345,
            "node_id": "PRR_abc",
            "user": {"login": "coderabbitai[bot]"},
            "body": "review body text",
        }

        result = get_all_reviews.fetch_review_body("owner", "repo", "1", "12345")

        assert result is not None
        assert result["id"] == 12345
        assert result["body"] == "review body text"
        mock_api.assert_called_once_with("/repos/owner/repo/pulls/1/reviews/12345")

    @patch.object(get_all_reviews, "run_gh_api")
    def test_returns_none_on_failure(self, mock_api: Any) -> None:
        """Should return None when API call fails."""
        mock_api.return_value = None

        result = get_all_reviews.fetch_review_body("owner", "repo", "1", "99999")

        assert result is None


# =============================================================================
# Tests for run() - pullrequestreview-NNN body-embedded comments
# =============================================================================


class TestRunReviewUrlBodyComments:
    """Tests for run() fetching body-embedded comments from a specific review URL."""

    @patch("myk_claude_tools.reviews.coderabbit_parser.parse_review_body_comments")
    @patch.object(get_all_reviews, "fetch_review_body")
    @patch.object(get_all_reviews, "fetch_review_comments")
    @patch.object(get_all_reviews, "fetch_coderabbit_body_comments")
    @patch.object(get_all_reviews, "fetch_unresolved_threads")
    @patch.object(get_all_reviews, "get_pr_info")
    @patch.object(get_all_reviews, "check_dependencies")
    def test_merges_body_comments_for_coderabbit_review(
        self,
        _mock_check_deps: Any,
        mock_pr_info: Any,
        mock_threads: Any,
        mock_body_comments: Any,
        mock_review_comments: Any,
        mock_review_body: Any,
        mock_parse: Any,
    ) -> None:
        """Body-embedded comments should be merged when review is from CodeRabbit."""
        mock_pr_info.return_value = ("owner", "repo", "1")
        mock_threads.return_value = []
        mock_body_comments.return_value = []
        mock_review_comments.return_value = []
        mock_review_body.return_value = {
            "id": 12345,
            "node_id": "PRR_abc",
            "user": {"login": "coderabbitai[bot]"},
            "body": "review with nitpicks",
        }
        mock_parse.return_value = {
            "outside_diff": [],
            "nitpick": [
                {
                    "path": "src/a.py",
                    "line": 10,
                    "end_line": None,
                    "body": "nit",
                    "category": "Nitpick",
                    "severity": "Trivial",
                },
            ],
        }

        result = get_all_reviews.run("https://github.com/owner/repo/pull/1#pullrequestreview-12345")

        assert result == 0
        mock_review_body.assert_called_once_with("owner", "repo", "1", "12345")
        mock_parse.assert_called_once_with("review with nitpicks")

    @patch.object(get_all_reviews, "fetch_review_body")
    @patch.object(get_all_reviews, "fetch_review_comments")
    @patch.object(get_all_reviews, "fetch_coderabbit_body_comments")
    @patch.object(get_all_reviews, "fetch_unresolved_threads")
    @patch.object(get_all_reviews, "get_pr_info")
    @patch.object(get_all_reviews, "check_dependencies")
    @patch("myk_claude_tools.reviews.coderabbit_parser.parse_review_body_comments")
    def test_skips_body_comments_for_non_coderabbit_review(
        self,
        mock_parse: Any,
        _mock_check_deps: Any,
        mock_pr_info: Any,
        mock_threads: Any,
        mock_body_comments: Any,
        mock_review_comments: Any,
        mock_review_body: Any,
    ) -> None:
        """Body-embedded comments should NOT be parsed for non-CodeRabbit reviews."""
        mock_pr_info.return_value = ("owner", "repo", "1")
        mock_threads.return_value = []
        mock_body_comments.return_value = []
        mock_review_comments.return_value = []
        mock_review_body.return_value = {
            "id": 12345,
            "node_id": "PRR_abc",
            "user": {"login": "human-reviewer"},
            "body": "some review text",
        }

        result = get_all_reviews.run("https://github.com/owner/repo/pull/1#pullrequestreview-12345")

        assert result == 0
        mock_review_body.assert_called_once()
        mock_parse.assert_not_called()

    @patch.object(get_all_reviews, "fetch_review_body")
    @patch.object(get_all_reviews, "fetch_review_comments")
    @patch.object(get_all_reviews, "fetch_coderabbit_body_comments")
    @patch.object(get_all_reviews, "fetch_unresolved_threads")
    @patch.object(get_all_reviews, "get_pr_info")
    @patch.object(get_all_reviews, "check_dependencies")
    def test_handles_fetch_review_body_failure(
        self,
        _mock_check_deps: Any,
        mock_pr_info: Any,
        mock_threads: Any,
        mock_body_comments: Any,
        mock_review_comments: Any,
        mock_review_body: Any,
    ) -> None:
        """Should handle gracefully when fetch_review_body returns None."""
        mock_pr_info.return_value = ("owner", "repo", "1")
        mock_threads.return_value = []
        mock_body_comments.return_value = []
        mock_review_comments.return_value = []
        mock_review_body.return_value = None

        result = get_all_reviews.run("https://github.com/owner/repo/pull/1#pullrequestreview-12345")

        assert result == 0
