"""
BDD tests for tools/pr_comments.py — rich comment posting with formatting and praise.

Covers:
- TestPostRichComments: batch-post structured review comments with enum coercion

Public API surface (from src/ado_workflows_mcp/tools/pr_comments.py):
    post_rich_comments(pr_url_or_id, comments, *, dry_run, batch_size,
        filter_self_praise, working_directory)
        -> RichPostingResult | ActionableError

Library API surface:
    ado_workflows.comments.post_rich_comments(client, repository, pr_id,
        comments, project, *, dry_run, batch_size, filter_self_praise, formatter)
        -> RichPostingResult

I/O boundaries:
    ado_workflows.discovery.Repo (GitPython)
    ado_workflows.auth.ConnectionFactory / DefaultAzureCredential (auth)
    client.git.create_thread, client.git.create_comment,
    client.git.get_pull_request_iterations,
    client.git.get_pull_request_iteration_changes (SDK REST calls)
    ado_workflows.auth.get_current_user, ado_workflows.pr.get_pr_author (identity)
"""

from __future__ import annotations

from typing import Any
from unittest.mock import MagicMock, Mock, patch

from actionable_errors import ActionableError, AIGuidance
from ado_workflows.context import RepositoryContext
from ado_workflows.models import RichPostingResult

from ado_workflows_mcp.tools.pr_comments import post_rich_comments

# ---------------------------------------------------------------------------
# Constants
# ---------------------------------------------------------------------------

_ADO_REMOTE = "https://dev.azure.com/TestOrg/TestProject/_git/TestRepo"
_PR_URL = "https://dev.azure.com/TestOrg/TestProject/_git/TestRepo/pullrequest/42"
_REPO_PATCH = "ado_workflows.discovery.Repo"
_CONN_FACTORY_PATCH = "ado_workflows_mcp.tools._helpers.ConnectionFactory"
_ADO_CLIENT_PATCH = "ado_workflows_mcp.tools._helpers.AdoClient"
_GET_PR_AUTHOR_PATCH = "ado_workflows.pr.get_pr_author"
_GET_CURRENT_USER_PATCH = "ado_workflows.auth.get_current_user"

_ESTABLISH_PR_PATCH = "ado_workflows_mcp.tools.pr_comments._lib_establish_pr"


# ---------------------------------------------------------------------------
# Helpers
# ---------------------------------------------------------------------------


def _mock_git_repo(remote_url: str = _ADO_REMOTE) -> MagicMock:
    """Return a mock GitPython Repo with an origin remote."""
    repo = MagicMock()
    repo.remotes.origin.url = remote_url

    def _bool(_self: object) -> bool:
        return True

    def _len(_self: object) -> int:
        return 1

    repo.remotes.__bool__ = _bool
    repo.remotes.__len__ = _len
    return repo


def _setup_context(tmp_path: Any) -> None:
    """Set up repository context with git.Repo mocked at the I/O edge."""
    (tmp_path / ".git").mkdir()
    with patch(_REPO_PATCH, return_value=_mock_git_repo()):
        RepositoryContext.set(working_directory=str(tmp_path))


def _mock_connection_factory() -> MagicMock:
    """Return a mock ConnectionFactory that produces a mock Connection."""
    factory = MagicMock()
    factory.return_value.get_connection.return_value = MagicMock()
    return factory


def _error_with_guidance() -> ActionableError:
    """Return an ActionableError that already has ai_guidance set."""
    return ActionableError.connection(
        service="AzureDevOps",
        url="https://dev.azure.com",
        raw_error="test error",
        suggestion="test suggestion",
        ai_guidance=AIGuidance(action_required="pre-set guidance"),
    )


def _make_comment_dict(
    *,
    comment_id: str = "c1",
    title: str = "Fix null check",
    content: str = "Handle None return value",
    severity: str = "warning",
    comment_type: str = "line",
    file_path: str | None = None,
    line_number: int | None = None,
    parent_thread_id: int | None = None,
) -> dict[str, object]:
    """Build a comment dict matching the MCP tool input schema."""
    d: dict[str, object] = {
        "comment_id": comment_id,
        "title": title,
        "content": content,
        "severity": severity,
        "comment_type": comment_type,
    }
    if file_path is not None:
        d["file_path"] = file_path
    if line_number is not None:
        d["line_number"] = line_number
    if parent_thread_id is not None:
        d["parent_thread_id"] = parent_thread_id
    return d


def _mock_client_for_posting() -> Mock:
    """Return a mock AdoClient wired for comment posting."""
    client = Mock()
    # Iterations (for positioned comments)
    iter_mock = MagicMock()
    iter_mock.id = 1
    iter_mock.created_date = "2026-03-18T00:00:00Z"
    iter_mock.source_ref_commit = MagicMock(commit_id="abc123")
    iter_mock.target_ref_commit = MagicMock(commit_id="def456")
    client.git.get_pull_request_iterations.return_value = [iter_mock]
    # Iteration changes
    changes_response = MagicMock()
    changes_response.change_entries = []
    client.git.get_pull_request_iteration_changes.return_value = changes_response
    # Thread creation (new threads)
    thread = MagicMock()
    thread.id = 101
    client.git.create_thread.return_value = thread
    # Reply creation (parent_thread_id replies)
    reply = MagicMock()
    reply.id = 201
    client.git.create_comment.return_value = reply
    return client


# ---------------------------------------------------------------------------
# TestPostRichComments
# ---------------------------------------------------------------------------


class TestPostRichComments:
    """
    REQUIREMENT: An MCP consumer can batch-post structured review comments
    with severity, type, and formatting metadata.

    WHO: AI agents performing code review via MCP.
    WHAT: (1) valid comments with string severity/type are coerced to enums
              and posted successfully
          (2) dry_run=True validates without posting and returns skipped indices
          (3) an invalid severity string returns ActionableError with guidance
          (4) an invalid comment_type string returns ActionableError with guidance
          (5) filter_self_praise=False disables praise filtering
          (6) an SDK exception during posting returns ActionableError.internal
          (7) an ActionableError from the library is enriched with ai_guidance
          (8) an unexpected non-ActionableError returns ActionableError.internal
          (9) optional metadata fields are coerced into the RichComment
          (10) omitting severity/comment_type uses defaults (info/general)
          (11) custom batch_size is forwarded to the library
    WHY: MCP tools receive JSON dicts — string-to-enum coercion at this layer
         prevents invalid data from reaching the library and produces
         structured, actionable errors for AI consumers.

    MOCK BOUNDARY:
        Mock:  git.Repo (GitPython — context), ConnectionFactory (auth),
               AdoClient (SDK REST calls),
               get_pr_author / get_current_user (identity lookups)
        Real:  tool function, establish_pr_context, dict→RichComment coercion,
               enum validation, post_rich_comments library orchestration,
               RichPostingResult construction
        Never: FastMCP framework
    """

    def setup_method(self) -> None:
        """Reset global context between tests."""
        RepositoryContext.clear()

    def test_valid_comments_coerced_and_posted(self, tmp_path: Any) -> None:
        """
        Given a PR URL and comments with string severity/type values
        When post_rich_comments is called
        Then string values are coerced to enums and comments are posted
        """
        # Given: context is set
        _setup_context(tmp_path)

        mock_factory = _mock_connection_factory()
        with (
            patch(_CONN_FACTORY_PATCH, mock_factory),
            patch(_ADO_CLIENT_PATCH) as mock_ado_client_cls,
            patch(_GET_PR_AUTHOR_PATCH, return_value="other@example.com"),
            patch(_GET_CURRENT_USER_PATCH, return_value="me@example.com"),
        ):
            mock_ado_client_cls.return_value = _mock_client_for_posting()

            # When: post_rich_comments with string enum values
            result = post_rich_comments(
                pr_url_or_id=_PR_URL,
                comments=[
                    _make_comment_dict(
                        comment_id="c1",
                        title="Fix null check",
                        content="Handle None return",
                        severity="warning",
                        comment_type="general",
                    ),
                    _make_comment_dict(
                        comment_id="c2",
                        title="Add logging",
                        content="Log the response",
                        severity="info",
                        comment_type="suggestion",
                    ),
                ],
            )

        # Then: returns RichPostingResult with successes
        assert isinstance(result, RichPostingResult), (
            f"Expected RichPostingResult, got {type(result).__name__}: {result}"
        )
        assert len(result.posted) == 2, f"Expected 2 posted, got {len(result.posted)}"
        assert len(result.failures) == 0, f"Expected 0 failures, got {len(result.failures)}"

    def test_dry_run_validates_without_posting(self, tmp_path: Any) -> None:
        """
        Given dry_run=True
        When post_rich_comments is called
        Then returns skipped indices without making API calls
        """
        # Given: context is set
        _setup_context(tmp_path)

        mock_factory = _mock_connection_factory()
        with (
            patch(_CONN_FACTORY_PATCH, mock_factory),
            patch(_ADO_CLIENT_PATCH) as mock_ado_client_cls,
            patch(_GET_PR_AUTHOR_PATCH, return_value="other@example.com"),
            patch(_GET_CURRENT_USER_PATCH, return_value="me@example.com"),
        ):
            mock_client = _mock_client_for_posting()
            mock_ado_client_cls.return_value = mock_client

            # When: dry_run=True
            result = post_rich_comments(
                pr_url_or_id=_PR_URL,
                comments=[
                    _make_comment_dict(comment_id="c1"),
                ],
                dry_run=True,
            )

        # Then: no actual posting, skipped indices present
        assert isinstance(result, RichPostingResult), (
            f"Expected RichPostingResult, got {type(result).__name__}: {result}"
        )
        assert result.dry_run is True, "Expected dry_run=True in result"
        assert len(result.posted) == 0, "Expected 0 posted in dry run"
        assert len(result.skipped) == 1, f"Expected 1 skipped, got {len(result.skipped)}"
        # Verify no create_thread calls were made
        mock_client.git.create_thread.assert_not_called()

    def test_invalid_severity_returns_error(self, tmp_path: Any) -> None:
        """
        Given a comment with an invalid severity string
        When post_rich_comments is called
        Then returns ActionableError with guidance listing valid values
        """
        # Given: context is set
        _setup_context(tmp_path)

        mock_factory = _mock_connection_factory()
        with (
            patch(_CONN_FACTORY_PATCH, mock_factory),
            patch(_ADO_CLIENT_PATCH) as mock_ado_client_cls,
        ):
            mock_ado_client_cls.return_value = _mock_client_for_posting()

            # When: post_rich_comments with invalid severity
            result = post_rich_comments(
                pr_url_or_id=_PR_URL,
                comments=[
                    _make_comment_dict(severity="catastrophic"),
                ],
            )

        # Then: ActionableError for invalid enum value
        assert isinstance(result, ActionableError), (
            f"Expected ActionableError for invalid severity, got {type(result).__name__}"
        )
        assert "severity" in str(result).lower(), (
            f"Expected 'severity' mentioned in error, got: {result}"
        )

    def test_invalid_comment_type_returns_error(self, tmp_path: Any) -> None:
        """
        Given a comment with an invalid comment_type string
        When post_rich_comments is called
        Then returns ActionableError with guidance listing valid values
        """
        # Given: context is set
        _setup_context(tmp_path)

        mock_factory = _mock_connection_factory()
        with (
            patch(_CONN_FACTORY_PATCH, mock_factory),
            patch(_ADO_CLIENT_PATCH) as mock_ado_client_cls,
        ):
            mock_ado_client_cls.return_value = _mock_client_for_posting()

            # When: post_rich_comments with invalid comment_type
            result = post_rich_comments(
                pr_url_or_id=_PR_URL,
                comments=[
                    _make_comment_dict(comment_type="unknown_type"),
                ],
            )

        # Then: ActionableError for invalid enum value
        assert isinstance(result, ActionableError), (
            f"Expected ActionableError for invalid comment_type, got {type(result).__name__}"
        )
        assert "comment_type" in str(result).lower(), (
            f"Expected 'comment_type' mentioned in error, got: {result}"
        )

    def test_filter_self_praise_disabled(self, tmp_path: Any) -> None:
        """
        Given filter_self_praise=False
        When post_rich_comments is called
        Then praise comments are not filtered out
        """
        # Given: context is set
        _setup_context(tmp_path)

        mock_factory = _mock_connection_factory()
        with (
            patch(_CONN_FACTORY_PATCH, mock_factory),
            patch(_ADO_CLIENT_PATCH) as mock_ado_client_cls,
        ):
            mock_ado_client_cls.return_value = _mock_client_for_posting()

            # When: filter_self_praise=False with a praise-like comment
            result = post_rich_comments(
                pr_url_or_id=_PR_URL,
                comments=[
                    _make_comment_dict(
                        comment_id="praise1",
                        title="Great work!",
                        content="Excellent implementation",
                        severity="info",
                    ),
                ],
                filter_self_praise=False,
            )

        # Then: comment is posted (not filtered)
        assert isinstance(result, RichPostingResult), (
            f"Expected RichPostingResult, got {type(result).__name__}: {result}"
        )
        assert len(result.posted) == 1, f"Expected 1 posted, got {len(result.posted)}"
        assert len(result.local_praise) == 0, (
            f"Expected 0 local_praise when filtering disabled, got {len(result.local_praise)}"
        )

    def test_optional_metadata_coerced_into_rich_comment(self, tmp_path: Any) -> None:
        """
        Given a comment dict with optional metadata fields populated
        When post_rich_comments is called
        Then suggested_code, reasoning, business_impact, tags, and
             parent_thread_id are coerced into the RichComment
        """
        # Given: context is set
        _setup_context(tmp_path)

        mock_factory = _mock_connection_factory()
        with (
            patch(_CONN_FACTORY_PATCH, mock_factory),
            patch(_ADO_CLIENT_PATCH) as mock_ado_client_cls,
            patch(_GET_PR_AUTHOR_PATCH, return_value="other@example.com"),
            patch(_GET_CURRENT_USER_PATCH, return_value="me@example.com"),
        ):
            mock_client = _mock_client_for_posting()
            mock_ado_client_cls.return_value = mock_client

            # When: comment includes all optional metadata
            result = post_rich_comments(
                pr_url_or_id=_PR_URL,
                comments=[
                    {
                        "comment_id": "m1",
                        "title": "Refactor",
                        "content": "Extract method",
                        "severity": "suggestion",
                        "comment_type": "suggestion",
                        "file_path": "/src/app.py",
                        "line_number": 42,
                        "suggested_code": "def helper(): ...",
                        "reasoning": "Reduces duplication",
                        "business_impact": "Easier maintenance",
                        "tags": ["refactor", "dry"],
                        "status": "closed",
                        "parent_thread_id": 7,
                    },
                ],
            )

        # Then: the comment was posted via reply (parent_thread_id=7)
        assert isinstance(result, RichPostingResult), (
            f"Expected RichPostingResult, got {type(result).__name__}: {result}"
        )
        assert len(result.posted) == 1, f"Expected 1 posted, got {len(result.posted)}"
        detail = result.posted[0]
        assert detail.comment_id == "m1", f"Expected comment_id='m1', got '{detail.comment_id}'"
        assert detail.file_path == "/src/app.py", (
            f"Expected file_path='/src/app.py', got '{detail.file_path}'"
        )
        assert detail.line_number == 42, f"Expected line_number=42, got {detail.line_number}"
        assert detail.thread_id == 7, (
            f"Expected thread_id=7 (reply to parent), got {detail.thread_id}"
        )

    def test_omitted_severity_and_type_use_defaults(self, tmp_path: Any) -> None:
        """
        Given a comment dict without severity or comment_type keys
        When post_rich_comments is called
        Then defaults to severity=info and comment_type=general
        """
        # Given: context is set
        _setup_context(tmp_path)

        mock_factory = _mock_connection_factory()
        with (
            patch(_CONN_FACTORY_PATCH, mock_factory),
            patch(_ADO_CLIENT_PATCH) as mock_ado_client_cls,
            patch(_GET_PR_AUTHOR_PATCH, return_value="other@example.com"),
            patch(_GET_CURRENT_USER_PATCH, return_value="me@example.com"),
        ):
            mock_ado_client_cls.return_value = _mock_client_for_posting()

            # When: comment dict omits severity and comment_type
            result = post_rich_comments(
                pr_url_or_id=_PR_URL,
                comments=[
                    {"comment_id": "d1", "title": "Note", "content": "FYI"},
                ],
            )

        # Then: posting succeeds (defaults are valid enum values)
        assert isinstance(result, RichPostingResult), (
            f"Expected RichPostingResult, got {type(result).__name__}: {result}"
        )
        assert len(result.posted) == 1, (
            f"Expected 1 posted (defaults valid), got {len(result.posted)}"
        )
        assert len(result.failures) == 0, (
            f"Expected 0 failures with defaults, got {len(result.failures)}"
        )

    def test_custom_batch_size_forwarded(self, tmp_path: Any) -> None:
        """
        Given a custom batch_size value
        When post_rich_comments is called
        Then batch_size is forwarded to the library
        """
        # Given: context is set
        _setup_context(tmp_path)

        mock_factory = _mock_connection_factory()
        with (
            patch(_CONN_FACTORY_PATCH, mock_factory),
            patch(_ADO_CLIENT_PATCH) as mock_ado_client_cls,
            patch(_GET_PR_AUTHOR_PATCH, return_value="other@example.com"),
            patch(_GET_CURRENT_USER_PATCH, return_value="me@example.com"),
        ):
            mock_ado_client_cls.return_value = _mock_client_for_posting()

            # When: batch_size=10
            result = post_rich_comments(
                pr_url_or_id=_PR_URL,
                comments=[_make_comment_dict()],
                batch_size=10,
            )

        # Then: posting completes successfully with custom batch_size
        assert isinstance(result, RichPostingResult), (
            f"Expected RichPostingResult, got {type(result).__name__}: {result}"
        )
        assert len(result.posted) == 1, f"Expected 1 posted, got {len(result.posted)}"

    def test_sdk_exception_returns_internal_error(self, tmp_path: Any) -> None:
        """
        Given an SDK exception during posting
        When post_rich_comments is called
        Then returns ActionableError.internal with ai_guidance
        """
        # Given: context is set
        _setup_context(tmp_path)

        mock_factory = _mock_connection_factory()
        with (
            patch(_CONN_FACTORY_PATCH, mock_factory),
            patch(_ADO_CLIENT_PATCH) as mock_ado_client_cls,
        ):
            mock_ado_client_cls.side_effect = RuntimeError("Connection refused")

            # When: post_rich_comments with failing SDK
            result = post_rich_comments(
                pr_url_or_id=_PR_URL,
                comments=[
                    _make_comment_dict(comment_id="c1"),
                ],
            )

        # Then: ActionableError with ai_guidance
        assert isinstance(result, ActionableError), (
            f"Expected ActionableError, got {type(result).__name__}: {result}"
        )
        assert result.ai_guidance is not None, "Expected ai_guidance on error"

    def test_actionable_error_enriched_with_guidance(self, tmp_path: Any) -> None:
        """
        Given an invalid PR identifier
        When post_rich_comments is called
        Then the resulting ActionableError has ai_guidance enriched
        """
        # Given/When: invalid id triggers ActionableError.validation(ai_guidance=None)
        result = post_rich_comments(
            pr_url_or_id="not-a-valid-id",
            comments=[_make_comment_dict()],
        )

        # Then: ai_guidance is enriched
        assert isinstance(result, ActionableError), (
            f"Expected ActionableError, got {type(result).__name__}: {result}"
        )
        assert result.ai_guidance is not None, (
            "Expected ai_guidance to be enriched on bare ActionableError"
        )

    def test_unexpected_exception_returns_internal_error(self, tmp_path: Any) -> None:
        """
        Given an unexpected non-ActionableError exception from the I/O layer
        When post_rich_comments is called
        Then returns ActionableError.internal with ai_guidance
        """
        # Given: context is set, but ConnectionFactory().get_connection raises
        _setup_context(tmp_path)

        mock_factory = _mock_connection_factory()
        mock_factory.return_value.get_connection.side_effect = TypeError("Unexpected type error")
        with patch(_CONN_FACTORY_PATCH, mock_factory):
            # When: post_rich_comments hits unexpected error
            result = post_rich_comments(
                pr_url_or_id=_PR_URL,
                comments=[_make_comment_dict()],
            )

        # Then: ActionableError.internal with guidance
        assert isinstance(result, ActionableError), (
            f"Expected ActionableError, got {type(result).__name__}: {result}"
        )
        assert result.ai_guidance is not None, "Expected ai_guidance on internal error"
        assert "post_rich_comments" in str(result), (
            f"Expected operation name in error, got: {result}"
        )

    @patch(_ESTABLISH_PR_PATCH, side_effect=_error_with_guidance())
    def test_actionable_error_with_guidance_passes_through(
        self, mock_establish: MagicMock
    ) -> None:
        """
        Given an ActionableError that already has ai_guidance set
        When post_rich_comments is called
        Then returns the error with original ai_guidance preserved
        """
        result = post_rich_comments(
            pr_url_or_id="https://dev.azure.com/O/P/_git/R/pullrequest/1",
            comments=[{"comment_id": "1", "title": "t", "content": "c"}],
        )
        assert isinstance(result, ActionableError), (
            f"Expected ActionableError, got {type(result).__name__}: {result}"
        )
        assert result.ai_guidance is not None, "Expected ai_guidance preserved"
        assert result.ai_guidance.action_required == "pre-set guidance", (
            f"Expected original guidance, got: {result.ai_guidance.action_required}"
        )
