"""Tests for BitbucketProvider — mocked atlassian-python-api Cloud objects."""

from __future__ import annotations

from unittest.mock import MagicMock, patch

import pytest
from requests import HTTPError

from project_hub.forge.bitbucket import BitbucketProvider
from project_hub.forge.provider import AuthenticationError


# ---------------------------------------------------------------------------
# Mock builders
# ---------------------------------------------------------------------------

def _http_error(status_code: int) -> HTTPError:
    """Build a requests.HTTPError with a fake response."""
    resp = MagicMock()
    resp.status_code = status_code
    err = HTTPError(response=resp)
    return err


def _make_pr_mock(
    pr_id=1,
    title="Fix the thing",
    state="OPEN",
    author="alice",
    source_branch="fix/thing",
    target_branch="main",
    sha="abc123",
    comments=3,
    created_on="2024-01-01T00:00:00+00:00",
    updated_on="2024-01-02T00:00:00+00:00",
    url="https://bitbucket.org/ws/repo/pull-requests/1",
    participants=None,
):
    pr = MagicMock()
    pr.id = pr_id
    pr.title = title
    pr.state = state
    pr.author.nickname = author
    pr.source_branch = source_branch
    pr.destination_branch = target_branch
    pr.source_commit = sha
    pr.comment_count = comments
    pr.created_on = created_on
    pr.updated_on = updated_on
    pr.data = {
        "participants": participants or [],
        "reviewers": [],
        "links": {"html": {"href": url}},
    }
    return pr


def _make_issue_mock(
    issue_id="10",
    title="Login bug",
    state="new",
    author_nickname="alice",
    url="https://bitbucket.org/ws/repo/issues/10",
    created_on="2024-01-01T00:00:00+00:00",
    updated_on="2024-01-02T00:00:00+00:00",
):
    iss = MagicMock()
    iss.id = issue_id
    iss.title = title
    iss.state = state
    iss.data = {
        "reporter": {"nickname": author_nickname},
        "links": {"html": {"href": url}},
        "created_on": created_on,
        "updated_on": updated_on,
    }
    return iss


def _make_pipeline_mock(
    uuid="{aaaa-1111}",
    state_name="COMPLETED",
    result_name="SUCCESSFUL",
    ref="main",
    url="https://bitbucket.org/ws/repo/pipelines/{aaaa-1111}",
    created_on="2024-01-01T00:00:00+00:00",
):
    p = MagicMock()
    p.uuid = uuid
    p.created_on = created_on
    p.data = {
        "state": {
            "name": state_name,
            "result": {"name": result_name} if result_name else {},
        },
        "target": {"ref_name": ref},
        "links": {"html": {"href": url}},
    }
    return p


def _make_step_mock(
    uuid="{step-0001}",
    name="Build",
    state_name="COMPLETED",
    result_name="SUCCESSFUL",
    url="https://bitbucket.org/ws/repo/pipelines/{aaaa-1111}/steps/{step-0001}",
):
    step = MagicMock()
    step.uuid = uuid
    step.data = {
        "name": name,
        "state": {
            "name": state_name,
            "result": {"name": result_name} if result_name else {},
        },
        "links": {"html": {"href": url}},
    }
    return step


def _make_provider() -> BitbucketProvider:
    """Create a BitbucketProvider with a mocked Cloud instance."""
    with patch("project_hub.forge.bitbucket.Cloud") as mock_cloud:
        mock_instance = MagicMock()
        mock_cloud.return_value = mock_instance
        provider = BitbucketProvider("bitbucket.org", "test-token")
    provider._bb = mock_instance
    return provider


# ---------------------------------------------------------------------------
# get_username
# ---------------------------------------------------------------------------

class TestGetUsername:
    def test_returns_username(self):
        provider = _make_provider()
        provider._bb.get.return_value = {"username": "alice"}

        result = provider.get_username()
        assert result == "alice"

    def test_caches_username(self):
        provider = _make_provider()
        provider._bb.get.return_value = {"username": "alice"}

        provider.get_username()
        provider.get_username()
        provider._bb.get.assert_called_once()

    def test_handles_auth_error(self):
        provider = _make_provider()
        provider._bb.get.side_effect = _http_error(401)

        result = provider.get_username()
        assert result is None
        assert provider._auth_failed is True
        # Subsequent call must not retry
        provider.get_username()
        provider._bb.get.assert_called_once()


# ---------------------------------------------------------------------------
# list_merge_requests
# ---------------------------------------------------------------------------

class TestListMergeRequests:
    def test_open_pr_maps_to_opened(self):
        pr = _make_pr_mock(state="OPEN")
        provider = _make_provider()
        repo = MagicMock()
        repo.pullrequests.each.return_value = [pr]
        provider._bb.repositories.get.return_value = repo

        mrs = provider.list_merge_requests("ws/repo")
        assert len(mrs) == 1
        assert mrs[0].state == "opened"

    def test_merged_pr_maps_to_merged(self):
        pr = _make_pr_mock(state="MERGED")
        provider = _make_provider()
        repo = MagicMock()
        repo.pullrequests.each.return_value = [pr]
        provider._bb.repositories.get.return_value = repo

        mrs = provider.list_merge_requests("ws/repo", state="merged")
        assert len(mrs) == 1
        assert mrs[0].state == "merged"

    def test_declined_maps_to_closed(self):
        pr = _make_pr_mock(state="DECLINED")
        provider = _make_provider()
        repo = MagicMock()
        repo.pullrequests.each.return_value = [pr]
        provider._bb.repositories.get.return_value = repo

        mrs = provider.list_merge_requests("ws/repo", state="closed")
        assert len(mrs) == 1
        assert mrs[0].state == "closed"

    def test_superseded_maps_to_closed(self):
        pr = _make_pr_mock(state="SUPERSEDED")
        provider = _make_provider()
        repo = MagicMock()
        repo.pullrequests.each.return_value = [pr]
        provider._bb.repositories.get.return_value = repo

        mrs = provider.list_merge_requests("ws/repo", state="closed")
        assert len(mrs) == 1
        assert mrs[0].state == "closed"

    def test_approval_via_reviewer_role(self):
        participants = [
            {"user": {"nickname": "bob"}, "role": "REVIEWER", "approved": True},
        ]
        pr = _make_pr_mock(participants=participants)
        provider = _make_provider()
        repo = MagicMock()
        repo.pullrequests.each.return_value = [pr]
        provider._bb.repositories.get.return_value = repo

        mrs = provider.list_merge_requests("ws/repo")
        assert mrs[0].approved is True

    def test_not_approved_when_reviewer_not_approved(self):
        participants = [
            {"user": {"nickname": "bob"}, "role": "REVIEWER", "approved": False},
        ]
        pr = _make_pr_mock(participants=participants)
        provider = _make_provider()
        repo = MagicMock()
        repo.pullrequests.each.return_value = [pr]
        provider._bb.repositories.get.return_value = repo

        mrs = provider.list_merge_requests("ws/repo")
        assert mrs[0].approved is False

    def test_participant_role_not_counted_as_reviewer(self):
        """Only REVIEWER role counts for approval."""
        participants = [
            {"user": {"nickname": "bob"}, "role": "PARTICIPANT", "approved": True},
        ]
        pr = _make_pr_mock(participants=participants)
        provider = _make_provider()
        repo = MagicMock()
        repo.pullrequests.each.return_value = [pr]
        provider._bb.repositories.get.return_value = repo

        mrs = provider.list_merge_requests("ws/repo")
        assert mrs[0].approved is False

    def test_reviewers_extracted(self):
        participants = [
            {"user": {"nickname": "charlie"}, "role": "REVIEWER", "approved": False},
            {"user": {"nickname": "dana"}, "role": "REVIEWER", "approved": True},
        ]
        pr = _make_pr_mock(participants=participants)
        provider = _make_provider()
        repo = MagicMock()
        repo.pullrequests.each.return_value = [pr]
        provider._bb.repositories.get.return_value = repo

        mrs = provider.list_merge_requests("ws/repo")
        assert mrs[0].reviewers == ["charlie", "dana"]

    def test_fields_mapped_correctly(self):
        pr = _make_pr_mock(
            pr_id=42,
            title="Add feature",
            state="OPEN",
            author="bob",
            source_branch="feat/new",
            target_branch="main",
            sha="deadbeef",
            comments=5,
            url="https://bitbucket.org/ws/repo/pull-requests/42",
        )
        provider = _make_provider()
        repo = MagicMock()
        repo.pullrequests.each.return_value = [pr]
        provider._bb.repositories.get.return_value = repo

        mrs = provider.list_merge_requests("ws/repo")
        mr = mrs[0]
        assert mr.id == 42
        assert mr.iid == 42
        assert mr.title == "Add feature"
        assert mr.author == "bob"
        assert mr.source_branch == "feat/new"
        assert mr.target_branch == "main"
        assert mr.sha == "deadbeef"
        assert mr.user_notes_count == 5
        assert mr.web_url == "https://bitbucket.org/ws/repo/pull-requests/42"
        assert mr.repo_name == ""

    def test_auth_error_raises(self):
        provider = _make_provider()
        provider._bb.repositories.get.side_effect = _http_error(401)

        with pytest.raises(AuthenticationError):
            provider.list_merge_requests("ws/repo")

    def test_other_http_error_returns_empty(self):
        provider = _make_provider()
        provider._bb.repositories.get.side_effect = _http_error(500)

        result = provider.list_merge_requests("ws/repo")
        assert result == []


# ---------------------------------------------------------------------------
# get_mrs_by_iids
# ---------------------------------------------------------------------------

class TestGetMrsByIids:
    def test_fetches_individual_prs(self):
        pr = _make_pr_mock(pr_id=5, title="PR Five")
        provider = _make_provider()
        repo = MagicMock()
        repo.pullrequests.get.return_value = pr
        provider._bb.repositories.get.return_value = repo

        mrs = provider.get_mrs_by_iids("ws/repo", [5])
        assert len(mrs) == 1
        assert mrs[0].id == 5

    def test_empty_iids_returns_empty(self):
        provider = _make_provider()
        assert provider.get_mrs_by_iids("ws/repo", []) == []

    def test_skips_missing_pr(self):
        provider = _make_provider()
        repo = MagicMock()
        repo.pullrequests.get.side_effect = _http_error(404)
        provider._bb.repositories.get.return_value = repo

        result = provider.get_mrs_by_iids("ws/repo", [999])
        assert result == []

    def test_auth_error_propagates_from_inner(self):
        provider = _make_provider()
        repo = MagicMock()
        repo.pullrequests.get.side_effect = _http_error(401)
        provider._bb.repositories.get.return_value = repo

        with pytest.raises(AuthenticationError):
            provider.get_mrs_by_iids("ws/repo", [1])


# ---------------------------------------------------------------------------
# list_issues
# ---------------------------------------------------------------------------

class TestListIssues:
    def test_new_state_maps_to_opened(self):
        iss = _make_issue_mock(state="new")
        provider = _make_provider()
        repo = MagicMock()
        repo.issues.each.return_value = [iss]
        provider._bb.repositories.get.return_value = repo

        issues = provider.list_issues("ws/repo")
        assert len(issues) == 1
        assert issues[0].state == "opened"

    def test_on_hold_maps_to_opened(self):
        iss = _make_issue_mock(state="on hold")
        provider = _make_provider()
        repo = MagicMock()
        repo.issues.each.return_value = [iss]
        provider._bb.repositories.get.return_value = repo

        issues = provider.list_issues("ws/repo")
        assert issues[0].state == "opened"

    def test_resolved_maps_to_closed(self):
        iss = _make_issue_mock(state="resolved")
        provider = _make_provider()
        repo = MagicMock()
        repo.issues.each.return_value = [iss]
        provider._bb.repositories.get.return_value = repo

        issues = provider.list_issues("ws/repo", state="closed")
        assert issues[0].state == "closed"

    def test_wontfix_maps_to_closed(self):
        iss = _make_issue_mock(state="wontfix")
        provider = _make_provider()
        repo = MagicMock()
        repo.issues.each.return_value = [iss]
        provider._bb.repositories.get.return_value = repo

        issues = provider.list_issues("ws/repo", state="closed")
        assert issues[0].state == "closed"

    def test_tracker_disabled_404_returns_empty(self):
        provider = _make_provider()
        repo = MagicMock()
        repo.issues.each.side_effect = _http_error(404)
        provider._bb.repositories.get.return_value = repo

        result = provider.list_issues("ws/repo")
        assert result == []

    def test_issue_fields_mapped(self):
        iss = _make_issue_mock(
            issue_id="7",
            title="Crash on login",
            state="new",
            author_nickname="bob",
            url="https://bitbucket.org/ws/repo/issues/7",
        )
        provider = _make_provider()
        repo = MagicMock()
        repo.issues.each.return_value = [iss]
        provider._bb.repositories.get.return_value = repo

        issues = provider.list_issues("ws/repo")
        issue = issues[0]
        assert issue.id == 7
        assert issue.iid == 7
        assert issue.title == "Crash on login"
        assert issue.author == "bob"
        assert issue.state == "opened"
        assert issue.web_url == "https://bitbucket.org/ws/repo/issues/7"
        assert issue.repo_name == ""

    def test_auth_error_raises(self):
        provider = _make_provider()
        provider._bb.repositories.get.side_effect = _http_error(401)

        with pytest.raises(AuthenticationError):
            provider.list_issues("ws/repo")


# ---------------------------------------------------------------------------
# get_issues_by_iids
# ---------------------------------------------------------------------------

class TestGetIssuesByIids:
    def test_fetches_individual_issues(self):
        iss = _make_issue_mock(issue_id="10", title="Issue Ten")
        provider = _make_provider()
        repo = MagicMock()
        repo.issues.get.return_value = iss
        provider._bb.repositories.get.return_value = repo

        issues = provider.get_issues_by_iids("ws/repo", [10])
        assert len(issues) == 1
        assert issues[0].id == 10

    def test_empty_iids_returns_empty(self):
        provider = _make_provider()
        assert provider.get_issues_by_iids("ws/repo", []) == []

    def test_skips_missing_issue(self):
        provider = _make_provider()
        repo = MagicMock()
        repo.issues.get.side_effect = _http_error(404)
        provider._bb.repositories.get.return_value = repo

        result = provider.get_issues_by_iids("ws/repo", [999])
        assert result == []

    def test_auth_error_propagates_from_inner(self):
        provider = _make_provider()
        repo = MagicMock()
        repo.issues.get.side_effect = _http_error(401)
        provider._bb.repositories.get.return_value = repo

        with pytest.raises(AuthenticationError):
            provider.get_issues_by_iids("ws/repo", [1])


# ---------------------------------------------------------------------------
# list_pipelines
# ---------------------------------------------------------------------------

class TestListPipelines:
    def test_completed_successful(self):
        p = _make_pipeline_mock(state_name="COMPLETED", result_name="SUCCESSFUL")
        provider = _make_provider()
        repo = MagicMock()
        repo.pipelines.each.return_value = [p]
        provider._bb.repositories.get.return_value = repo

        pipelines = provider.list_pipelines("ws/repo")
        assert pipelines[0].status == "success"

    def test_completed_failed(self):
        p = _make_pipeline_mock(state_name="COMPLETED", result_name="FAILED")
        provider = _make_provider()
        repo = MagicMock()
        repo.pipelines.each.return_value = [p]
        provider._bb.repositories.get.return_value = repo

        pipelines = provider.list_pipelines("ws/repo")
        assert pipelines[0].status == "failed"

    def test_completed_error(self):
        p = _make_pipeline_mock(state_name="COMPLETED", result_name="ERROR")
        provider = _make_provider()
        repo = MagicMock()
        repo.pipelines.each.return_value = [p]
        provider._bb.repositories.get.return_value = repo

        pipelines = provider.list_pipelines("ws/repo")
        assert pipelines[0].status == "failed"

    def test_pending(self):
        p = _make_pipeline_mock(state_name="PENDING", result_name=None)
        provider = _make_provider()
        repo = MagicMock()
        repo.pipelines.each.return_value = [p]
        provider._bb.repositories.get.return_value = repo

        pipelines = provider.list_pipelines("ws/repo")
        assert pipelines[0].status == "pending"

    def test_running(self):
        p = _make_pipeline_mock(state_name="RUNNING", result_name=None)
        provider = _make_provider()
        repo = MagicMock()
        repo.pipelines.each.return_value = [p]
        provider._bb.repositories.get.return_value = repo

        pipelines = provider.list_pipelines("ws/repo")
        assert pipelines[0].status == "running"

    def test_stopped(self):
        p = _make_pipeline_mock(state_name="STOPPED", result_name=None)
        provider = _make_provider()
        repo = MagicMock()
        repo.pipelines.each.return_value = [p]
        provider._bb.repositories.get.return_value = repo

        pipelines = provider.list_pipelines("ws/repo")
        assert pipelines[0].status == "canceled"

    def test_uuid_used_as_id(self):
        p = _make_pipeline_mock(uuid="{bbbb-2222}")
        provider = _make_provider()
        repo = MagicMock()
        repo.pipelines.each.return_value = [p]
        provider._bb.repositories.get.return_value = repo

        pipelines = provider.list_pipelines("ws/repo")
        assert pipelines[0].id == "{bbbb-2222}"

    def test_ref_filter(self):
        p_main = _make_pipeline_mock(uuid="{p1}", ref="main")
        p_feat = _make_pipeline_mock(uuid="{p2}", ref="feature/x")
        provider = _make_provider()
        repo = MagicMock()
        repo.pipelines.each.return_value = [p_main, p_feat]
        provider._bb.repositories.get.return_value = repo

        pipelines = provider.list_pipelines("ws/repo", ref="main")
        assert len(pipelines) == 1
        assert pipelines[0].ref == "main"

    def test_auth_error_raises(self):
        provider = _make_provider()
        provider._bb.repositories.get.side_effect = _http_error(401)

        with pytest.raises(AuthenticationError):
            provider.list_pipelines("ws/repo")


# ---------------------------------------------------------------------------
# get_pipeline_jobs
# ---------------------------------------------------------------------------

class TestGetPipelineJobs:
    def test_steps_mapped_to_jobs(self):
        step = _make_step_mock(
            uuid="{step-1}",
            name="Build",
            state_name="COMPLETED",
            result_name="SUCCESSFUL",
            url="https://bitbucket.org/ws/repo/pipelines/{p1}/steps/{step-1}",
        )
        pipeline_mock = MagicMock()
        pipeline_mock.steps.return_value = [step]

        provider = _make_provider()
        repo = MagicMock()
        repo.pipelines.get.return_value = pipeline_mock
        provider._bb.repositories.get.return_value = repo

        jobs = provider.get_pipeline_jobs("ws/repo", "{p1}")
        assert len(jobs) == 1
        j = jobs[0]
        assert j.id == "{step-1}"
        assert j.name == "Build"
        assert j.status == "success"
        assert j.stage == ""

    def test_failed_step(self):
        step = _make_step_mock(state_name="COMPLETED", result_name="FAILED")
        pipeline_mock = MagicMock()
        pipeline_mock.steps.return_value = [step]

        provider = _make_provider()
        repo = MagicMock()
        repo.pipelines.get.return_value = pipeline_mock
        provider._bb.repositories.get.return_value = repo

        jobs = provider.get_pipeline_jobs("ws/repo", "{p1}")
        assert jobs[0].status == "failed"

    def test_auth_error_raises(self):
        provider = _make_provider()
        provider._bb.repositories.get.side_effect = _http_error(401)

        with pytest.raises(AuthenticationError):
            provider.get_pipeline_jobs("ws/repo", "{p1}")


# ---------------------------------------------------------------------------
# get_job_log
# ---------------------------------------------------------------------------

class TestGetJobLog:
    def test_returns_decoded_text(self):
        step_mock = MagicMock()
        step_mock.log.return_value = b"Build passed!\nDone."

        pipeline_mock = MagicMock()
        pipeline_mock.step.return_value = step_mock

        provider = _make_provider()
        repo = MagicMock()
        repo.pipelines.get.return_value = pipeline_mock
        provider._bb.repositories.get.return_value = repo

        log = provider.get_job_log("ws/repo", "{p1}/{step-1}")
        assert log == "Build passed!\nDone."

    def test_none_log_returns_empty(self):
        step_mock = MagicMock()
        step_mock.log.return_value = None

        pipeline_mock = MagicMock()
        pipeline_mock.step.return_value = step_mock

        provider = _make_provider()
        repo = MagicMock()
        repo.pipelines.get.return_value = pipeline_mock
        provider._bb.repositories.get.return_value = repo

        log = provider.get_job_log("ws/repo", "{p1}/{step-1}")
        assert log == ""

    def test_missing_slash_returns_empty(self):
        """If job_id has no slash, cannot resolve step — returns empty string."""
        provider = _make_provider()
        repo = MagicMock()
        provider._bb.repositories.get.return_value = repo

        log = provider.get_job_log("ws/repo", "just-a-uuid")
        assert log == ""


# ---------------------------------------------------------------------------
# Vulnerabilities
# ---------------------------------------------------------------------------

class TestVulnerabilities:
    def test_get_vulnerabilities_returns_empty(self):
        provider = _make_provider()
        assert provider.get_vulnerabilities("ws/repo") == []

    def test_get_vulnerabilities_with_severity_returns_empty(self):
        provider = _make_provider()
        assert provider.get_vulnerabilities("ws/repo", severity="HIGH") == []

    def test_get_pipeline_findings_returns_empty_set(self):
        provider = _make_provider()
        assert provider.get_pipeline_findings("ws/repo", None) == set()

    def test_get_pipeline_findings_with_ref_returns_empty_set(self):
        provider = _make_provider()
        assert provider.get_pipeline_findings("ws/repo", "{p1}", ref="main") == set()


# ---------------------------------------------------------------------------
# supported_features
# ---------------------------------------------------------------------------

class TestSupportedFeatures:
    def test_has_mrs_issues_pipelines(self):
        assert "mrs" in BitbucketProvider.supported_features
        assert "issues" in BitbucketProvider.supported_features
        assert "pipelines" in BitbucketProvider.supported_features

    def test_no_vulnerabilities(self):
        assert "vulnerabilities" not in BitbucketProvider.supported_features


# ---------------------------------------------------------------------------
# Project info
# ---------------------------------------------------------------------------

class TestProjectInfo:
    def test_get_default_branch(self):
        provider = _make_provider()
        repo = MagicMock()
        repo.get_data.return_value = {"name": "main"}
        provider._bb.repositories.get.return_value = repo

        result = provider.get_default_branch("ws/repo")
        assert result == "main"

    def test_get_default_branch_none_when_no_mainbranch(self):
        provider = _make_provider()
        repo = MagicMock()
        repo.get_data.return_value = None
        provider._bb.repositories.get.return_value = repo

        result = provider.get_default_branch("ws/repo")
        assert result is None

    def test_get_default_branch_on_error(self):
        provider = _make_provider()
        provider._bb.repositories.get.side_effect = _http_error(500)

        result = provider.get_default_branch("ws/repo")
        assert result is None

    def test_get_project_info_fields(self):
        provider = _make_provider()
        repo = MagicMock()
        repo.name = "my-repo"
        repo.description = "A cool repo"
        repo.get_data.return_value = {"name": "main"}
        repo.data = {"links": {"html": {"href": "https://bitbucket.org/ws/my-repo"}}}
        provider._bb.repositories.get.return_value = repo

        info = provider.get_project_info("ws/my-repo")
        assert info == {
            "name": "my-repo",
            "description": "A cool repo",
            "web_url": "https://bitbucket.org/ws/my-repo",
            "default_branch": "main",
        }

    def test_get_project_info_auth_error(self):
        provider = _make_provider()
        provider._bb.repositories.get.side_effect = _http_error(401)

        with pytest.raises(AuthenticationError):
            provider.get_project_info("ws/repo")

    def test_get_project_info_other_error_returns_empty(self):
        provider = _make_provider()
        provider._bb.repositories.get.side_effect = _http_error(500)

        result = provider.get_project_info("ws/repo")
        assert result == {}
