# Licensed to the Apache Software Foundation (ASF) under one
# or more contributor license agreements.  See the NOTICE file
# distributed with this work for additional information
# regarding copyright ownership.  The ASF licenses this file
# to you under the Apache License, Version 2.0 (the
# "License"); you may not use this file except in compliance
# with the License.  You may obtain a copy of the License at
#
#   http://www.apache.org/licenses/LICENSE-2.0
#
# Unless required by applicable law or agreed to in writing,
# software distributed under the License is distributed on an
# "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
# KIND, either express or implied.  See the License for the
# specific language governing permissions and limitations
# under the License.

from __future__ import annotations

import json
import urllib.request
from typing import Any
from unittest.mock import MagicMock, patch

import pytest

from magpie_bitbucket import cloud, datacenter
from magpie_bitbucket.cli import main
from magpie_bitbucket.client import BitbucketError, SameHostRedirectHandler, load_config, make_auth_header
from magpie_bitbucket.normalize import (
    pull_request,
    pull_request_commits,
    pull_request_diff,
    pull_request_discussion,
    pull_request_list,
    pull_request_reviews,
    pull_request_status,
    repository,
)


@pytest.fixture
def cloud_env(monkeypatch: pytest.MonkeyPatch) -> None:
    monkeypatch.setenv("BITBUCKET_KIND", "cloud")
    monkeypatch.setenv("BITBUCKET_CLOUD_USER", "alice@example.test")
    monkeypatch.setenv("BITBUCKET_TOKEN", "token-123")
    monkeypatch.setenv("BITBUCKET_WORKSPACE", "apache")
    monkeypatch.setenv("BITBUCKET_REPO_SLUG", "magpie")


@pytest.fixture
def datacenter_env(monkeypatch: pytest.MonkeyPatch) -> None:
    monkeypatch.setenv("BITBUCKET_KIND", "datacenter")
    monkeypatch.setenv("BITBUCKET_TOKEN", "token-123")
    monkeypatch.setenv("BITBUCKET_BASE_URL", "https://bitbucket.example.test/")
    monkeypatch.setenv("BITBUCKET_PROJECT_KEY", "MAGPIE")
    monkeypatch.setenv("BITBUCKET_REPO_SLUG", "magpie")


def make_mock_response(body: dict[str, Any]) -> MagicMock:
    response = MagicMock()
    response.read.return_value = json.dumps(body).encode()
    return response


def make_mock_text_response(
    body: str,
    content_type: str = "text/x-diff",
    url: str = "https://bitbucket.example.test/diff",
) -> MagicMock:
    response = MagicMock()
    response.read.return_value = body.encode()
    response.headers.get_content_charset.return_value = "utf-8"
    response.headers.get.return_value = content_type
    response.geturl.return_value = url
    return response


def mock_opener(mock_build_opener: MagicMock, *bodies: dict[str, Any]) -> MagicMock:
    opener = MagicMock()
    opener.open.side_effect = [
        MagicMock(
            __enter__=MagicMock(return_value=make_mock_response(body)), __exit__=MagicMock(return_value=None)
        )
        for body in bodies
    ]
    mock_build_opener.return_value = opener
    return opener


def urllib_request(url: str) -> urllib.request.Request:
    return urllib.request.Request(
        url,
        headers={"Authorization": "Bearer token-123"},
        method="GET",
    )


def test_same_host_redirect_handler_allows_same_origin() -> None:
    handler = SameHostRedirectHandler()
    request = urllib_request("https://bitbucket.example.test/rest/api/1.0/foo")

    redirected = handler.redirect_request(
        request,
        None,
        302,
        "Found",
        {},
        "https://bitbucket.example.test/rest/api/1.0/bar",
    )

    assert redirected is not None
    assert redirected.full_url == "https://bitbucket.example.test/rest/api/1.0/bar"


def test_same_host_redirect_handler_rejects_different_host() -> None:
    handler = SameHostRedirectHandler()
    request = urllib_request("https://bitbucket.example.test/rest/api/1.0/foo")

    with pytest.raises(BitbucketError, match="refusing to forward credentials"):
        handler.redirect_request(
            request,
            None,
            302,
            "Found",
            {},
            "https://evil.example.test/rest/api/1.0/bar",
        )


def test_same_host_redirect_handler_rejects_different_port() -> None:
    handler = SameHostRedirectHandler()
    request = urllib_request("https://bitbucket.example.test:8443/rest/api/1.0/foo")

    with pytest.raises(BitbucketError, match="refusing to forward credentials"):
        handler.redirect_request(
            request,
            None,
            302,
            "Found",
            {},
            "https://bitbucket.example.test:9443/rest/api/1.0/bar",
        )


def test_load_config_defaults_to_cloud(monkeypatch: pytest.MonkeyPatch) -> None:
    monkeypatch.delenv("BITBUCKET_KIND", raising=False)
    config = load_config()
    assert config.kind == "cloud"
    assert config.auth_scheme == "Basic"


def test_load_config_rejects_unknown_kind(monkeypatch: pytest.MonkeyPatch) -> None:
    monkeypatch.setenv("BITBUCKET_KIND", "server")
    with pytest.raises(BitbucketError, match="BITBUCKET_KIND must be 'cloud' or 'datacenter'"):
        load_config()


def test_make_auth_header_basic(cloud_env: None) -> None:
    config = load_config()
    assert make_auth_header(config).startswith("Basic ")


def test_make_auth_header_rejects_unknown_scheme(cloud_env: None, monkeypatch: pytest.MonkeyPatch) -> None:
    monkeypatch.setenv("BITBUCKET_AUTH_SCHEME", "Digest")
    config = load_config()

    with pytest.raises(BitbucketError, match="BITBUCKET_AUTH_SCHEME must be 'Basic' or 'Bearer'"):
        make_auth_header(config)


def test_make_auth_header_bearer(datacenter_env: None) -> None:
    config = load_config()
    assert make_auth_header(config) == "Bearer token-123"


@patch("urllib.request.build_opener")
def test_cloud_get_repository_url(mock_build_opener: MagicMock, cloud_env: None) -> None:
    opener = mock_opener(mock_build_opener, {"name": "Magpie", "slug": "magpie"})
    result = cloud.get_repository(load_config())

    request = opener.open.call_args.args[0]
    assert request.full_url == "https://api.bitbucket.org/2.0/repositories/apache/magpie"
    assert opener.open.call_args.kwargs["timeout"] == 30
    assert result["name"] == "Magpie"


@patch("urllib.request.build_opener")
def test_cloud_list_open_pull_requests_follows_next(mock_build_opener: MagicMock, cloud_env: None) -> None:
    opener = mock_opener(
        mock_build_opener,
        {
            "values": [{"id": 1, "title": "One"}],
            "next": "https://api.bitbucket.org/2.0/repositories/apache/magpie/pullrequests?page=2",
        },
        {"values": [{"id": 2, "title": "Two"}]},
    )
    result = cloud.list_open_pull_requests(load_config())

    first_request = opener.open.call_args_list[0].args[0]
    second_request = opener.open.call_args_list[1].args[0]
    assert (
        first_request.full_url
        == "https://api.bitbucket.org/2.0/repositories/apache/magpie/pullrequests?state=OPEN"
    )
    assert (
        second_request.full_url
        == "https://api.bitbucket.org/2.0/repositories/apache/magpie/pullrequests?page=2"
    )
    assert [item["id"] for item in result["values"]] == [1, 2]


@patch("urllib.request.build_opener")
def test_cloud_get_pull_request_url(mock_build_opener: MagicMock, cloud_env: None) -> None:
    opener = mock_opener(mock_build_opener, {"id": 7, "title": "Fix docs"})
    result = cloud.get_pull_request(load_config(), "7")

    request = opener.open.call_args.args[0]
    assert request.full_url == "https://api.bitbucket.org/2.0/repositories/apache/magpie/pullrequests/7"
    assert result["title"] == "Fix docs"


@patch("urllib.request.build_opener")
def test_datacenter_get_repository_url(mock_build_opener: MagicMock, datacenter_env: None) -> None:
    opener = mock_opener(mock_build_opener, {"name": "Magpie", "slug": "magpie"})
    result = datacenter.get_repository(load_config())

    request = opener.open.call_args.args[0]
    assert request.full_url == "https://bitbucket.example.test/rest/api/1.0/projects/MAGPIE/repos/magpie"
    assert result["slug"] == "magpie"


@patch("urllib.request.build_opener")
def test_datacenter_list_open_pull_requests_follows_next_page_start(
    mock_build_opener: MagicMock,
    datacenter_env: None,
) -> None:
    opener = mock_opener(
        mock_build_opener,
        {"values": [{"id": 1, "title": "One"}], "isLastPage": False, "nextPageStart": 25},
        {"values": [{"id": 2, "title": "Two"}], "isLastPage": True},
    )
    result = datacenter.list_open_pull_requests(load_config())

    first_request = opener.open.call_args_list[0].args[0]
    second_request = opener.open.call_args_list[1].args[0]
    assert (
        first_request.full_url
        == "https://bitbucket.example.test/rest/api/1.0/projects/MAGPIE/repos/magpie/pull-requests?state=OPEN&start=0"
    )
    assert (
        second_request.full_url
        == "https://bitbucket.example.test/rest/api/1.0/projects/MAGPIE/repos/magpie/pull-requests?state=OPEN&start=25"
    )
    assert [item["id"] for item in result["values"]] == [1, 2]


@patch("urllib.request.build_opener")
def test_datacenter_get_pull_request_url(mock_build_opener: MagicMock, datacenter_env: None) -> None:
    opener = mock_opener(mock_build_opener, {"id": 9, "title": "Fix tests"})
    result = datacenter.get_pull_request(load_config(), "9")

    request = opener.open.call_args.args[0]
    assert (
        request.full_url
        == "https://bitbucket.example.test/rest/api/1.0/projects/MAGPIE/repos/magpie/pull-requests/9"
    )
    assert result["title"] == "Fix tests"


@patch("urllib.request.build_opener")
def test_cloud_get_pull_request_status_follows_next(
    mock_build_opener: MagicMock,
    cloud_env: None,
) -> None:
    opener = mock_opener(
        mock_build_opener,
        {
            "values": [{"key": "build", "state": "SUCCESSFUL"}],
            "next": "https://api.bitbucket.org/2.0/repositories/apache/magpie/pullrequests/7/statuses?page=2",
        },
        {"values": [{"key": "lint", "state": "INPROGRESS"}]},
    )

    result = cloud.get_pull_request_status(load_config(), "7")

    first_request = opener.open.call_args_list[0].args[0]
    second_request = opener.open.call_args_list[1].args[0]
    assert (
        first_request.full_url
        == "https://api.bitbucket.org/2.0/repositories/apache/magpie/pullrequests/7/statuses"
    )
    assert (
        second_request.full_url
        == "https://api.bitbucket.org/2.0/repositories/apache/magpie/pullrequests/7/statuses?page=2"
    )
    assert result["pull_request_id"] == "7"
    assert [item["key"] for item in result["values"]] == ["build", "lint"]


@patch("urllib.request.build_opener")
def test_datacenter_get_pull_request_status_uses_latest_commit_and_paginates(
    mock_build_opener: MagicMock,
    datacenter_env: None,
) -> None:
    opener = mock_opener(
        mock_build_opener,
        {"id": 9, "fromRef": {"latestCommit": "def456"}},
        {"values": [{"key": "build", "state": "SUCCESSFUL"}], "isLastPage": False, "nextPageStart": 25},
        {"values": [{"key": "lint", "state": "FAILED"}], "isLastPage": True},
    )

    result = datacenter.get_pull_request_status(load_config(), "9")

    first_request = opener.open.call_args_list[0].args[0]
    second_request = opener.open.call_args_list[1].args[0]
    third_request = opener.open.call_args_list[2].args[0]
    assert (
        first_request.full_url
        == "https://bitbucket.example.test/rest/api/1.0/projects/MAGPIE/repos/magpie/pull-requests/9"
    )
    assert (
        second_request.full_url
        == "https://bitbucket.example.test/rest/build-status/1.0/commits/def456?start=0"
    )
    assert (
        third_request.full_url
        == "https://bitbucket.example.test/rest/build-status/1.0/commits/def456?start=25"
    )
    assert result["commit"] == "def456"
    assert [item["key"] for item in result["values"]] == ["build", "lint"]


def test_normalize_cloud_pull_request_status() -> None:
    raw = {
        "pull_request_id": "7",
        "commit": "abc123",
        "values": [
            {
                "key": "build",
                "name": "Build",
                "state": "SUCCESSFUL",
                "url": "https://ci.example.test/build/1",
                "description": "Build passed",
                "created_on": "2026-07-09T10:00:00+00:00",
                "updated_on": "2026-07-09T10:02:00+00:00",
            }
        ],
    }

    result = pull_request_status("cloud", raw)

    assert result["backend"] == "bitbucket-cloud"
    assert result["coverage"] == "partial-read-only"
    assert result["pull_request_id"] == "7"
    assert result["commit"] == "abc123"
    assert result["state"] == "unknown"
    assert result["checks"] == "passing"
    assert result["mergeable"] == "unknown"
    assert result["check_details"][0]["key"] == "build"
    assert result["check_details"][0]["state"] == "success"
    assert result["check_details"][0]["url"] == "https://ci.example.test/build/1"


def test_normalize_datacenter_pull_request_status_failure() -> None:
    raw = {
        "pull_request_id": "9",
        "commit": "def456",
        "values": [
            {
                "key": "build",
                "name": "Build",
                "state": "FAILED",
                "url": "https://ci.example.test/build/2",
                "description": "Build failed",
                "dateAdded": 1783428000000,
                "dateUpdated": 1783428300000,
            }
        ],
    }

    result = pull_request_status("datacenter", raw)

    assert result["backend"] == "bitbucket-datacenter"
    assert result["pull_request_id"] == "9"
    assert result["commit"] == "def456"
    assert result["state"] == "unknown"
    assert result["checks"] == "failing"
    assert result["mergeable"] == "unknown"
    assert result["check_details"][0]["state"] == "failure"
    assert result["check_details"][0]["created"] == "2026-07-07T12:40:00Z"
    assert result["check_details"][0]["updated"] == "2026-07-07T12:45:00Z"


@patch("urllib.request.build_opener")
def test_cloud_get_pull_request_reviews_url(mock_build_opener: MagicMock, cloud_env: None) -> None:
    opener = mock_opener(
        mock_build_opener,
        {"id": 7, "reviewers": [{"display_name": "Reviewer One"}]},
        {
            "values": [
                {
                    "approval": {
                        "user": {"display_name": "Reviewer One"},
                        "date": "2026-07-01T00:00:00+00:00",
                    }
                }
            ]
        },
    )

    result = cloud.get_pull_request_reviews(load_config(), "7")

    first_request = opener.open.call_args_list[0].args[0]
    second_request = opener.open.call_args_list[1].args[0]
    assert first_request.full_url == "https://api.bitbucket.org/2.0/repositories/apache/magpie/pullrequests/7"
    assert (
        second_request.full_url
        == "https://api.bitbucket.org/2.0/repositories/apache/magpie/pullrequests/7/activity"
    )
    assert result["pull_request"]["id"] == 7
    assert result["values"][0]["approval"]["user"]["display_name"] == "Reviewer One"


@patch("urllib.request.build_opener")
def test_datacenter_get_pull_request_reviews_url(mock_build_opener: MagicMock, datacenter_env: None) -> None:
    opener = mock_opener(
        mock_build_opener,
        {
            "id": 9,
            "reviewers": [
                {
                    "user": {"displayName": "Reviewer One"},
                    "approved": True,
                    "status": "APPROVED",
                }
            ],
        },
        {
            "values": [
                {
                    "action": "APPROVED",
                    "user": {"displayName": "Reviewer One"},
                    "createdDate": 1710000000000,
                }
            ],
            "isLastPage": True,
        },
    )

    result = datacenter.get_pull_request_reviews(load_config(), "9")

    first_request = opener.open.call_args_list[0].args[0]
    second_request = opener.open.call_args_list[1].args[0]
    assert (
        first_request.full_url
        == "https://bitbucket.example.test/rest/api/1.0/projects/MAGPIE/repos/magpie/pull-requests/9"
    )
    assert (
        second_request.full_url
        == "https://bitbucket.example.test/rest/api/1.0/projects/MAGPIE/repos/magpie/pull-requests/9/activities?start=0"
    )
    assert result["pull_request"]["reviewers"][0]["user"]["displayName"] == "Reviewer One"
    assert result["values"][0]["action"] == "APPROVED"


def test_normalize_pull_request_reviews_cloud() -> None:
    normalized = pull_request_reviews(
        "cloud",
        {
            "pull_request_id": "7",
            "pull_request": {"reviewers": [{"display_name": "Reviewer One"}]},
            "values": [
                {
                    "approval": {
                        "user": {"display_name": "Reviewer One"},
                        "date": "2026-07-01T00:00:00+00:00",
                    }
                },
                {
                    "changes_requested": {
                        "user": {"display_name": "Reviewer Two"},
                        "date": "2026-07-02T00:00:00+00:00",
                    }
                },
            ],
        },
    )

    assert normalized["backend"] == "bitbucket-cloud"
    assert normalized["review_decision"] == "changes_requested"
    assert normalized["reviewers"][0]["user"] == "Reviewer One"
    assert normalized["approvals"][0]["author"] == "Reviewer One"
    assert normalized["changes_requested"][0]["author"] == "Reviewer Two"


def test_normalize_pull_request_reviews_datacenter() -> None:
    normalized = pull_request_reviews(
        "datacenter",
        {
            "pull_request_id": "9",
            "pull_request": {
                "reviewers": [
                    {
                        "user": {"displayName": "Reviewer One"},
                        "approved": True,
                        "status": "APPROVED",
                    }
                ]
            },
            "values": [
                {
                    "action": "APPROVED",
                    "user": {"displayName": "Reviewer One"},
                    "createdDate": 1710000000000,
                }
            ],
        },
    )

    assert normalized["backend"] == "bitbucket-datacenter"
    assert normalized["review_decision"] == "approved"
    assert normalized["reviewers"][0]["user"] == "Reviewer One"
    assert normalized["approvals"][0]["author"] == "Reviewer One"


@patch("urllib.request.build_opener")
def test_cloud_get_pull_request_reviews_follows_next(mock_build_opener: MagicMock, cloud_env: None) -> None:
    opener = mock_opener(
        mock_build_opener,
        {"id": 7, "reviewers": [{"display_name": "Reviewer One"}]},
        {
            "values": [
                {"approval": {"user": {"display_name": "Reviewer One"}, "date": "2026-07-01T00:00:00+00:00"}}
            ],
            "next": "https://api.bitbucket.org/2.0/repositories/apache/magpie/pullrequests/7/activity?page=2",
        },
        {
            "values": [
                {
                    "changes_requested": {
                        "user": {"display_name": "Reviewer Two"},
                        "date": "2026-07-02T00:00:00+00:00",
                    }
                }
            ]
        },
    )

    result = cloud.get_pull_request_reviews(load_config(), "7")

    third_request = opener.open.call_args_list[2].args[0]
    assert (
        third_request.full_url
        == "https://api.bitbucket.org/2.0/repositories/apache/magpie/pullrequests/7/activity?page=2"
    )
    assert len(result["values"]) == 2


@patch("urllib.request.build_opener")
def test_datacenter_get_pull_request_reviews_paginates(
    mock_build_opener: MagicMock, datacenter_env: None
) -> None:
    opener = mock_opener(
        mock_build_opener,
        {"id": 9, "reviewers": []},
        {
            "values": [
                {"action": "APPROVED", "user": {"displayName": "Reviewer One"}, "createdDate": 1710000000000}
            ],
            "isLastPage": False,
            "nextPageStart": 25,
        },
        {
            "values": [
                {
                    "action": "NEEDS_WORK",
                    "user": {"displayName": "Reviewer Two"},
                    "createdDate": 1710000100000,
                }
            ],
            "isLastPage": True,
        },
    )

    result = datacenter.get_pull_request_reviews(load_config(), "9")

    third_request = opener.open.call_args_list[2].args[0]
    assert (
        third_request.full_url
        == "https://bitbucket.example.test/rest/api/1.0/projects/MAGPIE/repos/magpie/pull-requests/9/activities?start=25"
    )
    assert len(result["values"]) == 2


def test_normalize_pull_request_reviews_prefers_current_approval_over_old_changes_requested() -> None:
    normalized = pull_request_reviews(
        "cloud",
        {
            "pull_request_id": "7",
            "pull_request": {
                "reviewers": [
                    {
                        "display_name": "Reviewer One",
                        "approved": True,
                    }
                ]
            },
            "values": [
                {
                    "changes_requested": {
                        "user": {"display_name": "Reviewer One"},
                        "date": "2026-07-01T00:00:00+00:00",
                    }
                }
            ],
        },
    )

    assert normalized["review_decision"] == "approved"
    assert normalized["approvals"][0]["author"] == "Reviewer One"
    assert normalized["changes_requested"] == []


def test_normalize_pull_request_reviews_datacenter_needs_work_is_not_pending_request() -> None:
    normalized = pull_request_reviews(
        "datacenter",
        {
            "pull_request_id": "9",
            "pull_request": {
                "reviewers": [
                    {
                        "user": {"displayName": "Reviewer One"},
                        "status": "NEEDS_WORK",
                    }
                ]
            },
            "values": [],
        },
    )

    assert normalized["review_decision"] == "changes_requested"
    assert normalized["changes_requested"][0]["author"] == "Reviewer One"
    assert normalized["review_requests"] == []


def test_normalize_pull_request_reviews_cloud_pending_reviewer_survives_participant_merge() -> None:
    # A requested reviewer who has not acted appears in BOTH reviewers[] (pending)
    # and participants[] (approved:false, state:null → "unknown"). The participant
    # merge must not downgrade the pending request to "unknown", or the reviewer
    # drops out of review_requests and review_decision collapses to "unknown".
    normalized = pull_request_reviews(
        "cloud",
        {
            "pull_request_id": "7",
            "pull_request": {
                "reviewers": [{"display_name": "Reviewer One"}],
                "participants": [
                    {
                        "user": {"display_name": "Reviewer One"},
                        "role": "REVIEWER",
                        "approved": False,
                        "state": None,
                    }
                ],
            },
            "values": [],
        },
    )

    assert normalized["review_decision"] == "review_required"
    assert [r["user"] for r in normalized["review_requests"]] == ["Reviewer One"]
    assert [r["review_state"] for r in normalized["reviewers"]] == ["pending"]


def test_normalize_pull_request_reviews_cloud_participant_approval_refines_pending() -> None:
    # The complement of the case above: when the participant carries a definite
    # signal (approved), it SHOULD refine the pending request to approved.
    normalized = pull_request_reviews(
        "cloud",
        {
            "pull_request_id": "7",
            "pull_request": {
                "reviewers": [{"display_name": "Reviewer One"}],
                "participants": [
                    {"user": {"display_name": "Reviewer One"}, "role": "REVIEWER", "approved": True}
                ],
            },
            "values": [],
        },
    )

    assert normalized["review_decision"] == "approved"
    assert normalized["approvals"][0]["author"] == "Reviewer One"
    assert normalized["review_requests"] == []


def test_normalize_pull_request_reviews_latest_event_overrides_old_request_changes() -> None:
    normalized = pull_request_reviews(
        "cloud",
        {
            "pull_request_id": "7",
            "pull_request": {},
            "values": [
                {
                    "changes_requested": {
                        "user": {"display_name": "Reviewer One"},
                        "date": "2026-07-01T00:00:00+00:00",
                    }
                },
                {
                    "approval": {
                        "user": {"display_name": "Reviewer One"},
                        "date": "2026-07-02T00:00:00+00:00",
                    }
                },
            ],
        },
    )

    assert normalized["review_decision"] == "approved"
    assert normalized["approvals"][0]["author"] == "Reviewer One"
    assert normalized["changes_requested"] == []


def test_normalize_pull_request_reviews_approval_removed_clears_fallback_approval() -> None:
    normalized = pull_request_reviews(
        "datacenter",
        {
            "pull_request_id": "9",
            "pull_request": {},
            "values": [
                {
                    "action": "APPROVED",
                    "user": {"displayName": "Reviewer One"},
                    "createdDate": 1710000000000,
                },
                {
                    "action": "UNAPPROVED",
                    "user": {"displayName": "Reviewer One"},
                    "createdDate": 1710000100000,
                },
            ],
        },
    )

    assert normalized["review_decision"] == "review_required"
    assert normalized["approvals"] == []
    assert normalized["changes_requested"] == []


def test_cli_pr_reviews_cloud(cloud_env: None, capsys: pytest.CaptureFixture[str]) -> None:
    with patch(
        "magpie_bitbucket.cloud.get_pull_request_reviews",
        return_value={
            "pull_request_id": "7",
            "pull_request": {"reviewers": [{"display_name": "Reviewer One"}]},
            "values": [
                {
                    "approval": {
                        "user": {"display_name": "Reviewer One"},
                        "date": "2026-07-01T00:00:00+00:00",
                    }
                }
            ],
        },
    ) as get_reviews:
        assert main(["pr", "reviews", "7"]) == 0

    get_reviews.assert_called_once()
    output = json.loads(capsys.readouterr().out)
    assert output["backend"] == "bitbucket-cloud"
    assert output["review_decision"] == "approved"


def test_cli_pr_reviews_datacenter(datacenter_env: None, capsys: pytest.CaptureFixture[str]) -> None:
    with patch(
        "magpie_bitbucket.datacenter.get_pull_request_reviews",
        return_value={
            "pull_request_id": "9",
            "pull_request": {
                "reviewers": [
                    {
                        "user": {"displayName": "Reviewer One"},
                        "approved": True,
                    }
                ]
            },
            "values": [
                {
                    "action": "APPROVED",
                    "user": {"displayName": "Reviewer One"},
                    "createdDate": 1710000000000,
                }
            ],
        },
    ) as get_reviews:
        assert main(["pr", "reviews", "9"]) == 0

    get_reviews.assert_called_once()
    output = json.loads(capsys.readouterr().out)
    assert output["backend"] == "bitbucket-datacenter"
    assert output["review_decision"] == "approved"


@patch("magpie_bitbucket.cloud.get_pull_request_status")
def test_cli_pr_status_cloud(
    mock_get_pull_request_status: MagicMock,
    cloud_env: None,
    capsys: pytest.CaptureFixture[str],
) -> None:
    mock_get_pull_request_status.return_value = {
        "pull_request_id": "7",
        "commit": "abc123",
        "values": [{"key": "build", "state": "SUCCESSFUL"}],
    }

    exit_code = main(["pr", "status", "7"])

    captured = capsys.readouterr()
    output = json.loads(captured.out)
    assert exit_code == 0
    assert output["pull_request_id"] == "7"
    assert output["commit"] == "abc123"
    assert output["checks"] == "passing"
    assert output["check_details"][0]["key"] == "build"


def test_normalize_pull_request_status_aggregate_values() -> None:
    assert pull_request_status("cloud", {"values": []})["checks"] == "none"
    assert pull_request_status("cloud", {"values": [{"state": "SUCCESSFUL"}]})["checks"] == "passing"
    assert pull_request_status("cloud", {"values": [{"state": "FAILED"}]})["checks"] == "failing"
    assert pull_request_status("cloud", {"values": [{"state": "INPROGRESS"}]})["checks"] == "pending"


@patch("magpie_bitbucket.datacenter.get_pull_request_status")
def test_cli_pr_status_datacenter(
    mock_get_pull_request_status: MagicMock,
    datacenter_env: None,
    capsys: pytest.CaptureFixture[str],
) -> None:
    mock_get_pull_request_status.return_value = {
        "pull_request_id": "9",
        "commit": "def456",
        "values": [{"key": "build", "state": "SUCCESSFUL"}],
    }

    exit_code = main(["pr", "status", "9"])

    captured = capsys.readouterr()
    output = json.loads(captured.out)
    assert exit_code == 0
    assert output["backend"] == "bitbucket-datacenter"
    assert output["pull_request_id"] == "9"
    assert output["checks"] == "passing"
    assert output["check_details"][0]["key"] == "build"


@patch("urllib.request.build_opener")
def test_datacenter_get_pull_request_status_requires_latest_commit(
    mock_build_opener: MagicMock,
    datacenter_env: None,
) -> None:
    mock_opener(mock_build_opener, {"id": 9, "fromRef": {}})

    with pytest.raises(BitbucketError, match=r"fromRef\.latestCommit"):
        datacenter.get_pull_request_status(load_config(), "9")


@patch("urllib.request.build_opener")
def test_cloud_get_pull_request_commits_follows_next(
    mock_build_opener: MagicMock,
    cloud_env: None,
) -> None:
    opener = mock_opener(
        mock_build_opener,
        {
            "values": [{"hash": "abc123", "message": "First commit"}],
            "next": "https://api.bitbucket.org/2.0/repositories/apache/magpie/pullrequests/7/commits?page=2",
        },
        {"values": [{"hash": "def456", "message": "Second commit"}]},
    )

    result = cloud.get_pull_request_commits(load_config(), "7")

    first_request = opener.open.call_args_list[0].args[0]
    second_request = opener.open.call_args_list[1].args[0]
    assert (
        first_request.full_url
        == "https://api.bitbucket.org/2.0/repositories/apache/magpie/pullrequests/7/commits"
    )
    assert (
        second_request.full_url
        == "https://api.bitbucket.org/2.0/repositories/apache/magpie/pullrequests/7/commits?page=2"
    )
    assert result["pull_request_id"] == "7"
    assert [item["hash"] for item in result["values"]] == ["abc123", "def456"]


@patch("urllib.request.build_opener")
def test_datacenter_get_pull_request_commits_follows_next_page_start(
    mock_build_opener: MagicMock,
    datacenter_env: None,
) -> None:
    opener = mock_opener(
        mock_build_opener,
        {"values": [{"id": "abc123", "message": "First commit"}], "isLastPage": False, "nextPageStart": 25},
        {"values": [{"id": "def456", "message": "Second commit"}], "isLastPage": True},
    )

    result = datacenter.get_pull_request_commits(load_config(), "9")

    first_request = opener.open.call_args_list[0].args[0]
    second_request = opener.open.call_args_list[1].args[0]
    assert (
        first_request.full_url
        == "https://bitbucket.example.test/rest/api/1.0/projects/MAGPIE/repos/magpie/pull-requests/9/commits?start=0"
    )
    assert (
        second_request.full_url
        == "https://bitbucket.example.test/rest/api/1.0/projects/MAGPIE/repos/magpie/pull-requests/9/commits?start=25"
    )
    assert result["pull_request_id"] == "9"
    assert [item["id"] for item in result["values"]] == ["abc123", "def456"]


def test_normalize_cloud_pull_request_commits() -> None:
    raw = {
        "pull_request_id": "7",
        "values": [
            {
                "hash": "abc123",
                "message": "Fix docs",
                "author": {"raw": "Alice <alice@example.test>", "user": {"display_name": "Alice"}},
                "date": "2026-07-09T10:00:00+00:00",
                "links": {"html": {"href": "https://bitbucket.org/apache/magpie/commits/abc123"}},
            }
        ],
    }

    result = pull_request_commits("cloud", raw)

    assert result["backend"] == "bitbucket-cloud"
    assert result["coverage"] == "partial-read-only"
    assert result["pull_request_id"] == "7"
    assert result["commits"][0]["hash"] == "abc123"
    assert result["commits"][0]["message"] == "Fix docs"
    assert result["commits"][0]["author"] == "Alice"
    assert result["commits"][0]["date"] == "2026-07-09T10:00:00+00:00"
    assert result["commits"][0]["links"]["html"] == "https://bitbucket.org/apache/magpie/commits/abc123"


def test_normalize_datacenter_pull_request_commits() -> None:
    raw = {
        "pull_request_id": "9",
        "values": [
            {
                "id": "def456789",
                "displayId": "def4567",
                "message": "Fix tests",
                "author": {"displayName": "Bob"},
                "authorTimestamp": 1783428000000,
                "links": {"self": [{"href": "https://bitbucket.example.test/commits/def456789"}]},
            }
        ],
    }

    result = pull_request_commits("datacenter", raw)

    assert result["backend"] == "bitbucket-datacenter"
    assert result["pull_request_id"] == "9"
    assert result["commits"][0]["hash"] == "def456789"
    assert result["commits"][0]["display_hash"] == "def4567"
    assert result["commits"][0]["message"] == "Fix tests"
    assert result["commits"][0]["author"] == "Bob"
    assert result["commits"][0]["date"] == "2026-07-07T12:40:00Z"
    assert result["commits"][0]["links"]["self"] == "https://bitbucket.example.test/commits/def456789"


@patch("magpie_bitbucket.cloud.get_pull_request_commits")
def test_cli_pr_commits_cloud(
    mock_get_pull_request_commits: MagicMock,
    cloud_env: None,
    capsys: pytest.CaptureFixture[str],
) -> None:
    mock_get_pull_request_commits.return_value = {
        "pull_request_id": "7",
        "values": [{"hash": "abc123", "message": "Fix docs"}],
    }

    exit_code = main(["pr", "commits", "7"])

    captured = capsys.readouterr()
    output = json.loads(captured.out)
    assert exit_code == 0
    assert output["backend"] == "bitbucket-cloud"
    assert output["pull_request_id"] == "7"
    assert output["commits"][0]["hash"] == "abc123"


@patch("magpie_bitbucket.datacenter.get_pull_request_commits")
def test_cli_pr_commits_datacenter(
    mock_get_pull_request_commits: MagicMock,
    datacenter_env: None,
    capsys: pytest.CaptureFixture[str],
) -> None:
    mock_get_pull_request_commits.return_value = {
        "pull_request_id": "9",
        "values": [{"id": "def456789", "message": "Fix tests"}],
    }

    exit_code = main(["pr", "commits", "9"])

    captured = capsys.readouterr()
    output = json.loads(captured.out)
    assert exit_code == 0
    assert output["backend"] == "bitbucket-datacenter"
    assert output["pull_request_id"] == "9"
    assert output["commits"][0]["hash"] == "def456789"


@patch("urllib.request.build_opener")
def test_cloud_get_pull_request_diff_url(
    mock_build_opener: MagicMock,
    cloud_env: None,
) -> None:
    response = make_mock_text_response(
        "diff --git a/a.txt b/a.txt\n",
        url="https://api.bitbucket.org/2.0/repositories/apache/magpie/diff/source..dest",
    )
    opener = MagicMock()
    opener.open.return_value.__enter__.return_value = response
    mock_build_opener.return_value = opener

    result = cloud.get_pull_request_diff(load_config(), "7")

    request = opener.open.call_args.args[0]
    assert request.full_url == "https://api.bitbucket.org/2.0/repositories/apache/magpie/pullrequests/7/diff"
    assert request.headers["Accept"] == "text/x-diff"
    assert result["pull_request_id"] == "7"
    assert result["body"] == "diff --git a/a.txt b/a.txt\n"
    assert result["content_type"] == "text/x-diff"


@patch("urllib.request.build_opener")
def test_datacenter_get_pull_request_diff_url(
    mock_build_opener: MagicMock,
    datacenter_env: None,
) -> None:
    response = make_mock_text_response(
        "diff --git a/a.txt b/a.txt\n",
        url="https://bitbucket.example.test/rest/api/1.0/projects/MAGPIE/repos/magpie/pull-requests/9/diff",
    )
    opener = MagicMock()
    opener.open.return_value.__enter__.return_value = response
    mock_build_opener.return_value = opener

    result = datacenter.get_pull_request_diff(load_config(), "9")

    request = opener.open.call_args.args[0]
    assert (
        request.full_url
        == "https://bitbucket.example.test/rest/api/1.0/projects/MAGPIE/repos/magpie/pull-requests/9/diff"
    )
    assert request.headers["Accept"] == "text/plain"
    assert result["pull_request_id"] == "9"
    assert result["body"] == "diff --git a/a.txt b/a.txt\n"
    assert result["content_type"] == "text/x-diff"


def test_normalize_pull_request_diff() -> None:
    raw = {
        "pull_request_id": "7",
        "body": "diff --git a/a.txt b/a.txt\n",
        "content_type": "text/x-diff",
        "url": "https://api.bitbucket.org/example",
    }

    result = pull_request_diff("cloud", raw)

    assert result["backend"] == "bitbucket-cloud"
    assert result["coverage"] == "partial-read-only"
    assert result["pull_request_id"] == "7"
    assert result["diff"] == "diff --git a/a.txt b/a.txt\n"
    assert result["content_type"] == "text/x-diff"
    assert result["url"] == "https://api.bitbucket.org/example"


@patch("magpie_bitbucket.cloud.get_pull_request_diff")
def test_cli_pr_diff_cloud(
    mock_get_pull_request_diff: MagicMock,
    cloud_env: None,
    capsys: pytest.CaptureFixture[str],
) -> None:
    mock_get_pull_request_diff.return_value = {
        "pull_request_id": "7",
        "body": "diff --git a/a.txt b/a.txt\n",
        "content_type": "text/x-diff",
        "url": "https://api.bitbucket.org/example",
    }

    exit_code = main(["pr", "diff", "7"])

    captured = capsys.readouterr()
    output = json.loads(captured.out)
    assert exit_code == 0
    assert output["backend"] == "bitbucket-cloud"
    assert output["pull_request_id"] == "7"
    assert output["diff"] == "diff --git a/a.txt b/a.txt\n"


@patch("magpie_bitbucket.datacenter.get_pull_request_diff")
def test_cli_pr_diff_datacenter(
    mock_get_pull_request_diff: MagicMock,
    datacenter_env: None,
    capsys: pytest.CaptureFixture[str],
) -> None:
    mock_get_pull_request_diff.return_value = {
        "pull_request_id": "9",
        "body": "diff --git a/a.txt b/a.txt\n",
        "content_type": "text/x-diff",
        "url": "https://bitbucket.example.test/example",
    }

    exit_code = main(["pr", "diff", "9"])

    captured = capsys.readouterr()
    output = json.loads(captured.out)
    assert exit_code == 0
    assert output["backend"] == "bitbucket-datacenter"
    assert output["pull_request_id"] == "9"
    assert output["diff"] == "diff --git a/a.txt b/a.txt\n"


def test_normalize_cloud_repository() -> None:
    raw = {
        "uuid": "{abc}",
        "name": "Magpie",
        "slug": "magpie",
        "description": "Agent-assisted maintainership",
        "is_private": False,
        "mainbranch": {"name": "main"},
        "links": {"html": {"href": "https://bitbucket.org/apache/magpie"}},
    }

    result = repository("cloud", raw)

    assert result["backend"] == "bitbucket-cloud"
    assert result["id"] == "{abc}"
    assert result["main_branch"] == "main"
    assert result["links"]["html"] == "https://bitbucket.org/apache/magpie"
    assert result["capabilities"]["issues"] == "not_implemented"


def test_normalize_datacenter_repository_accepts_string_default_branch() -> None:
    raw = {
        "id": 101,
        "name": "Magpie",
        "slug": "magpie",
        "public": False,
        "defaultBranch": "refs/heads/main",
        "links": {"self": [{"href": "https://bitbucket.example.test/projects/MAGPIE/repos/magpie"}]},
    }

    result = repository("datacenter", raw)

    assert result["backend"] == "bitbucket-datacenter"
    assert result["id"] == "101"
    assert result["is_private"] is True
    assert result["main_branch"] == "refs/heads/main"


def test_normalize_cloud_pull_request() -> None:
    raw = {
        "id": 12,
        "title": "Fix docs",
        "state": "OPEN",
        "created_on": "2026-07-01T00:00:00Z",
        "updated_on": "2026-07-02T00:00:00Z",
        "author": {"display_name": "Alice"},
        "source": {"branch": {"name": "fix-docs"}},
        "destination": {"branch": {"name": "main"}},
        "links": {"html": {"href": "https://bitbucket.org/apache/magpie/pull-requests/12"}},
        "description": "Updates docs.",
    }

    result = pull_request("cloud", raw)

    assert result["backend"] == "bitbucket-cloud"
    assert result["id"] == "12"
    assert result["state"] == "open"
    assert result["author"] == "Alice"
    assert result["source"] == "fix-docs"
    assert result["target"] == "main"
    assert result["mergeable"] == "unknown"
    assert result["checks"] == "none"
    assert result["diff"] is None
    assert result["commits"] is None
    assert result["labels"] == ["bitbucket", "read-only", "partial-change-request"]


def test_normalize_datacenter_pull_request_timestamp() -> None:
    raw = {
        "id": 13,
        "title": "Fix tests",
        "state": "OPEN",
        "createdDate": 1780000000000,
        "updatedDate": 1780000001000,
        "author": {"user": {"displayName": "Bob"}},
        "fromRef": {"displayId": "fix-tests"},
        "toRef": {"displayId": "main"},
        "links": {
            "self": [{"href": "https://bitbucket.example.test/projects/MAGPIE/repos/magpie/pull-requests/13"}]
        },
        "description": "Updates tests.",
    }

    result = pull_request("datacenter", raw)

    assert result["backend"] == "bitbucket-datacenter"
    assert result["id"] == "13"
    assert result["state"] == "open"
    assert result["author"] == "Bob"
    assert result["source"] == "fix-tests"
    assert result["target"] == "main"
    assert result["created"] == "2026-05-28T20:26:40Z"
    assert result["updated"] == "2026-05-28T20:26:41Z"


def test_normalize_pull_request_list() -> None:
    raw = {
        "values": [
            {"id": 1, "title": "One", "state": "OPEN"},
            {"id": 2, "title": "Two", "state": "MERGED"},
        ]
    }

    result = pull_request_list("cloud", raw)

    assert result["backend"] == "bitbucket-cloud"
    assert result["coverage"] == "read-only-partial-change-request"
    assert [item["id"] for item in result["pull_requests"]] == ["1", "2"]
    assert [item["state"] for item in result["pull_requests"]] == ["open", "merged"]


@patch("magpie_bitbucket.cloud.get_repository")
def test_cli_auth_check_cloud(
    mock_get_repository: MagicMock, cloud_env: None, capsys: pytest.CaptureFixture[str]
) -> None:
    mock_get_repository.return_value = {
        "uuid": "{abc}",
        "name": "Magpie",
        "slug": "magpie",
    }

    exit_code = main(["auth-check"])

    captured = capsys.readouterr()
    output = json.loads(captured.out)
    assert exit_code == 0
    assert output["ok"] is True
    assert output["backend"] == "bitbucket-cloud"
    assert output["repository"]["name"] == "Magpie"


@patch("magpie_bitbucket.cloud.list_open_pull_requests")
def test_cli_pr_list_open_cloud(
    mock_list_open_pull_requests: MagicMock,
    cloud_env: None,
    capsys: pytest.CaptureFixture[str],
) -> None:
    mock_list_open_pull_requests.return_value = {"values": [{"id": 1, "title": "One", "state": "OPEN"}]}

    exit_code = main(["pr", "list-open"])

    captured = capsys.readouterr()
    output = json.loads(captured.out)
    assert exit_code == 0
    assert output["pull_requests"][0]["id"] == "1"
    assert output["pull_requests"][0]["state"] == "open"


@patch("urllib.request.build_opener")
def test_cloud_get_pull_request_discussion_follows_next(
    mock_build_opener: MagicMock,
    cloud_env: None,
) -> None:
    opener = mock_opener(
        mock_build_opener,
        {
            "values": [{"id": 101, "content": {"raw": "First comment"}}],
            "next": "https://api.bitbucket.org/2.0/repositories/apache/magpie/pullrequests/7/comments?page=2",
        },
        {"values": [{"id": 102, "content": {"raw": "Second comment"}}]},
    )

    result = cloud.get_pull_request_discussion(load_config(), "7")

    first_request = opener.open.call_args_list[0].args[0]
    second_request = opener.open.call_args_list[1].args[0]
    assert (
        first_request.full_url
        == "https://api.bitbucket.org/2.0/repositories/apache/magpie/pullrequests/7/comments"
    )
    assert (
        second_request.full_url
        == "https://api.bitbucket.org/2.0/repositories/apache/magpie/pullrequests/7/comments?page=2"
    )
    assert result["pull_request_id"] == "7"
    assert [item["id"] for item in result["values"]] == [101, 102]


@patch("urllib.request.build_opener")
def test_datacenter_get_pull_request_discussion_follows_next_page_start(
    mock_build_opener: MagicMock,
    datacenter_env: None,
) -> None:
    opener = mock_opener(
        mock_build_opener,
        {"values": [{"id": 201, "comment": {"text": "First"}}], "isLastPage": False, "nextPageStart": 25},
        {"values": [{"id": 202, "comment": {"text": "Second"}}], "isLastPage": True},
    )

    result = datacenter.get_pull_request_discussion(load_config(), "9")

    first_request = opener.open.call_args_list[0].args[0]
    second_request = opener.open.call_args_list[1].args[0]
    assert (
        first_request.full_url
        == "https://bitbucket.example.test/rest/api/1.0/projects/MAGPIE/repos/magpie/pull-requests/9/activities?start=0"
    )
    assert (
        second_request.full_url
        == "https://bitbucket.example.test/rest/api/1.0/projects/MAGPIE/repos/magpie/pull-requests/9/activities?start=25"
    )
    assert result["pull_request_id"] == "9"
    assert [item["id"] for item in result["values"]] == [201, 202]


def test_normalize_cloud_pull_request_discussion() -> None:
    raw = {
        "pull_request_id": "7",
        "values": [
            {
                "id": 101,
                "content": {"raw": "Looks good."},
                "user": {"display_name": "Alice"},
                "created_on": "2026-07-01T00:00:00Z",
                "updated_on": "2026-07-01T01:00:00Z",
                "state": "active",
                "deleted": False,
                "inline": {"path": "README.md", "to": 10},
            }
        ],
    }

    result = pull_request_discussion("cloud", raw)

    assert result["backend"] == "bitbucket-cloud"
    assert result["coverage"] == "partial-read-only"
    assert result["pull_request_id"] == "7"
    assert result["comments"][0]["id"] == "101"
    assert result["comments"][0]["author"] == "Alice"
    assert result["comments"][0]["body"] == "Looks good."
    assert result["comments"][0]["date"] == "2026-07-01T00:00:00Z"
    assert result["comments"][0]["kind"] == "comment"
    assert result["comments"][0]["deleted"] is False
    assert result["comments"][0]["inline"] == {"path": "README.md", "to_line": 10}
    assert result["participants"] == ["Alice"]
    assert result["unresolved_count"] is None


def test_normalize_datacenter_pull_request_discussion() -> None:
    raw = {
        "pull_request_id": "9",
        "values": [
            {
                "id": 201,
                "action": "COMMENTED",
                "createdDate": 1780000000000,
                "user": {"displayName": "Bob"},
                "comment": {
                    "id": 301,
                    "text": "Please update tests.",
                    "author": {"displayName": "Bob"},
                    "createdDate": 1780000000000,
                    "updatedDate": 1780000001000,
                    "anchor": {"path": "tests/test_bitbucket.py", "line": 42},
                    "deleted": False,
                },
            }
        ],
    }

    result = pull_request_discussion("datacenter", raw)

    assert result["backend"] == "bitbucket-datacenter"
    assert result["coverage"] == "partial-read-only"
    assert result["pull_request_id"] == "9"
    assert result["comments"][0]["id"] == "301"
    assert result["comments"][0]["author"] == "Bob"
    assert result["comments"][0]["body"] == "Please update tests."
    assert result["comments"][0]["created"] == "2026-05-28T20:26:40Z"
    assert result["comments"][0]["updated"] == "2026-05-28T20:26:41Z"
    assert result["comments"][0]["date"] == "2026-05-28T20:26:40Z"
    assert result["comments"][0]["kind"] == "comment"
    assert result["comments"][0]["deleted"] is False
    assert result["comments"][0]["inline"] == {"path": "tests/test_bitbucket.py", "to_line": 42}
    assert result["participants"] == ["Bob"]
    assert result["unresolved_count"] is None


@patch("magpie_bitbucket.cloud.get_pull_request_discussion")
def test_cli_pr_discussion_cloud(
    mock_get_pull_request_discussion: MagicMock,
    cloud_env: None,
    capsys: pytest.CaptureFixture[str],
) -> None:
    mock_get_pull_request_discussion.return_value = {
        "pull_request_id": "7",
        "values": [{"id": 101, "content": {"raw": "Looks good."}}],
    }

    exit_code = main(["pr", "discussion", "7"])

    captured = capsys.readouterr()
    output = json.loads(captured.out)
    assert exit_code == 0
    assert output["pull_request_id"] == "7"
    assert output["comments"][0]["id"] == "101"
    assert output["comments"][0]["body"] == "Looks good."


@patch("magpie_bitbucket.cli.load_config")
def test_cli_pr_discussion_datacenter(
    mock_load_config: MagicMock,
    datacenter_env: None,
    capsys: pytest.CaptureFixture[str],
) -> None:
    config = load_config()
    mock_load_config.return_value = config

    with patch.object(datacenter, "get_pull_request_discussion") as mock_discussion:
        mock_discussion.return_value = {
            "pull_request_id": "9",
            "values": [
                {
                    "action": "COMMENTED",
                    "comment": {
                        "id": 1,
                        "text": "Looks good",
                        "author": {"displayName": "Asha"},
                    },
                }
            ],
        }

        exit_code = main(["pr", "discussion", "9"])

    captured = capsys.readouterr()
    output = json.loads(captured.out)
    mock_discussion.assert_called_once_with(config, "9")
    assert exit_code == 0
    assert output["backend"] == "bitbucket-datacenter"
    assert output["comments"][0]["body"] == "Looks good"


def test_normalize_datacenter_discussion_includes_threaded_replies() -> None:
    raw = {
        "pull_request_id": "9",
        "values": [
            {
                "action": "COMMENTED",
                "comment": {
                    "id": 1,
                    "text": "Parent comment",
                    "author": {"displayName": "Asha"},
                    "createdDate": 1783428000000,
                    "comments": [
                        {
                            "id": 2,
                            "text": "Reply comment",
                            "author": {"displayName": "Ravi"},
                            "createdDate": 1783428300000,
                        }
                    ],
                },
            }
        ],
    }

    result = pull_request_discussion("datacenter", raw)

    assert [comment["body"] for comment in result["comments"]] == [
        "Parent comment",
        "Reply comment",
    ]
    assert result["comments"][1]["parent_id"] == "1"
    assert result["participants"] == ["Asha", "Ravi"]


def test_normalize_datacenter_discussion_allows_null_threaded_replies() -> None:
    raw = {
        "pull_request_id": "9",
        "values": [
            {
                "action": "COMMENTED",
                "comment": {
                    "id": 1,
                    "text": "Parent comment",
                    "author": {"displayName": "Asha"},
                    "createdDate": 1783428000000,
                    "comments": None,
                },
            }
        ],
    }

    result = pull_request_discussion("datacenter", raw)

    assert len(result["comments"]) == 1
    assert result["comments"][0]["body"] == "Parent comment"
    assert result["comments"][0]["parent_id"] is None
    assert result["participants"] == ["Asha"]


def test_normalize_datacenter_discussion_filters_non_comment_activities() -> None:
    raw = {
        "pull_request_id": "9",
        "values": [
            {"id": 1, "action": "APPROVED", "user": {"displayName": "Reviewer"}},
            {
                "id": 2,
                "action": "COMMENTED",
                "comment": {
                    "id": 302,
                    "text": "Real comment.",
                    "author": {"displayName": "Alice"},
                    "createdDate": 1780000000000,
                },
            },
        ],
    }

    result = pull_request_discussion("datacenter", raw)

    assert len(result["comments"]) == 1
    assert result["comments"][0]["id"] == "302"
    assert result["comments"][0]["body"] == "Real comment."
    assert result["participants"] == ["Alice"]


def test_normalize_cloud_discussion_preserves_empty_raw_body() -> None:
    raw = {
        "pull_request_id": "7",
        "values": [
            {
                "id": 101,
                "content": {"raw": "", "markup": "markdown fallback", "html": "<p>fallback</p>"},
                "user": {"display_name": "Alice"},
            }
        ],
    }

    result = pull_request_discussion("cloud", raw)

    assert result["comments"][0]["body"] == ""


@patch("urllib.request.build_opener")
def test_cloud_discussion_rejects_next_url_host_change(
    mock_build_opener: MagicMock,
    cloud_env: None,
) -> None:
    mock_opener(
        mock_build_opener,
        {
            "values": [],
            "next": "https://evil.example.test/steal-token",
        },
    )

    with pytest.raises(BitbucketError, match="pagination URL changed scheme or host"):
        cloud.get_pull_request_discussion(load_config(), "7")


@patch("urllib.request.build_opener")
def test_cloud_discussion_rejects_repeated_next_url(
    mock_build_opener: MagicMock,
    cloud_env: None,
) -> None:
    repeated_url = "https://api.bitbucket.org/2.0/repositories/apache/magpie/pullrequests/7/comments?page=2"
    mock_opener(
        mock_build_opener,
        {
            "values": [],
            "next": repeated_url,
        },
        {
            "values": [],
            "next": repeated_url,
        },
    )

    with pytest.raises(BitbucketError, match="pagination returned a repeated URL"):
        cloud.get_pull_request_discussion(load_config(), "7")


@patch("urllib.request.build_opener")
def test_datacenter_discussion_stops_on_non_advancing_next_page_start(
    mock_build_opener: MagicMock,
    datacenter_env: None,
) -> None:
    opener = mock_opener(
        mock_build_opener,
        {
            "values": [{"id": 201, "comment": {"text": "First"}}],
            "isLastPage": False,
            "nextPageStart": 0,
        },
    )

    result = datacenter.get_pull_request_discussion(load_config(), "9")

    assert opener.open.call_count == 1
    assert result["values"][0]["id"] == 201
