"""GitService inbound-PR reads — list_open_prs + get_pr_diff (httpx fully mocked).

``list_open_prs`` is the discovery surface for the inbound-PR reviewer: it lists
ALL open PRs and normalizes each (number, url, title, head_ref, head_sha,
is_fork, user_login, author_association). ``get_pr_diff`` fetches a PR's unified
diff read-only. Both return safe empties ([] / "") on a missing token or any
GitHub error so the poll/claim never crash.
"""

from __future__ import annotations

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

import pytest
from roboco.services.git import _CI_RUN_WINDOW, GitService

_PR = 7


def _service() -> GitService:
    session = MagicMock()
    session.execute = AsyncMock()
    svc = GitService(session)
    object.__setattr__(svc, "_token_for_project", AsyncMock(return_value="tok"))
    return svc


def _resp(status_code: int, *, json_payload: Any = None, text: str = "") -> MagicMock:
    resp = MagicMock()
    resp.status_code = status_code
    resp.is_success = 200 <= status_code < 300  # noqa: PLR2004
    resp.json.return_value = json_payload
    resp.text = text
    return resp


def _client(get_resp: MagicMock) -> MagicMock:
    client = MagicMock()
    client.__aenter__ = AsyncMock(return_value=client)
    client.__aexit__ = AsyncMock(return_value=False)
    client.get = AsyncMock(return_value=get_resp)
    return client


def _patch_project() -> Any:
    fake = MagicMock()
    fake.get_by_slug = AsyncMock(
        return_value=MagicMock(git_url="https://github.com/acme/repo.git")
    )
    return patch("roboco.services.git.get_project_service", return_value=fake)


def _pr(*, number: int, head_full: str, login: str, assoc: str) -> dict[str, Any]:
    return {
        "number": number,
        "html_url": f"https://github.com/acme/repo/pull/{number}",
        "title": f"PR {number}",
        "head": {
            "ref": "feature-x",
            "sha": "deadbeef",
            "repo": {"full_name": head_full},
        },
        "user": {"login": login},
        "author_association": assoc,
    }


# ---------------------------------------------------------------------------
# list_open_prs
# ---------------------------------------------------------------------------


@pytest.mark.asyncio
async def test_list_open_prs_normalizes_and_flags_fork() -> None:
    svc = _service()
    payload = [
        _pr(number=7, head_full="forkowner/repo", login="corey", assoc="CONTRIBUTOR"),
        _pr(number=8, head_full="acme/repo", login="be-dev-1", assoc="MEMBER"),
    ]
    client = _client(_resp(200, json_payload=payload))
    with (
        _patch_project(),
        patch("roboco.services.git.httpx.AsyncClient", return_value=client),
    ):
        out = await svc.list_open_prs("roboco")
    assert len(out) == 2  # noqa: PLR2004
    fork, internal = out
    assert fork == {
        "number": 7,
        "url": "https://github.com/acme/repo/pull/7",
        "title": "PR 7",
        "head_ref": "feature-x",
        "head_sha": "deadbeef",
        "is_fork": True,
        "user_login": "corey",
        "author_is_owner": False,
        "author_association": "CONTRIBUTOR",
    }
    # Same-repo head → not a fork.
    assert internal["is_fork"] is False
    assert internal["author_association"] == "MEMBER"
    # A contributor (not the repo owner) is not flagged as the owner.
    assert internal["author_is_owner"] is False


@pytest.mark.asyncio
async def test_list_open_prs_flags_owner_authored_pr() -> None:
    """A PR opened by the repo-owner account is flagged author_is_owner."""
    svc = _service()
    payload = [
        # Author login == repo owner ("acme") → the org's own PR.
        _pr(number=9, head_full="acme/repo", login="acme", assoc="OWNER"),
    ]
    client = _client(_resp(200, json_payload=payload))
    with (
        _patch_project(),
        patch("roboco.services.git.httpx.AsyncClient", return_value=client),
    ):
        out = await svc.list_open_prs("roboco")
    assert out[0]["author_is_owner"] is True


@pytest.mark.asyncio
async def test_list_open_prs_empty_on_missing_token() -> None:
    svc = _service()
    object.__setattr__(svc, "_token_for_project", AsyncMock(return_value=None))
    with _patch_project():
        assert await svc.list_open_prs("roboco") == []


@pytest.mark.asyncio
async def test_list_open_prs_empty_on_github_error() -> None:
    svc = _service()
    client = _client(_resp(403, text="forbidden"))
    with (
        _patch_project(),
        patch("roboco.services.git.httpx.AsyncClient", return_value=client),
    ):
        assert await svc.list_open_prs("roboco") == []


# ---------------------------------------------------------------------------
# get_pr_diff
# ---------------------------------------------------------------------------


@pytest.mark.asyncio
async def test_get_pr_diff_returns_diff_text() -> None:
    svc = _service()
    diff = "diff --git a/x b/x\n+added line\n"
    client = _client(_resp(200, text=diff))
    with (
        _patch_project(),
        patch("roboco.services.git.httpx.AsyncClient", return_value=client),
    ):
        out = await svc.get_pr_diff("roboco", _PR)
    assert out == diff
    call = client.get.await_args
    assert f"/pulls/{_PR}" in call.args[0]
    assert call.kwargs["headers"]["Accept"] == "application/vnd.github.v3.diff"


@pytest.mark.asyncio
async def test_get_pr_diff_empty_on_missing_token() -> None:
    svc = _service()
    object.__setattr__(svc, "_token_for_project", AsyncMock(return_value=None))
    with _patch_project():
        assert await svc.get_pr_diff("roboco", _PR) == ""


@pytest.mark.asyncio
async def test_get_pr_diff_empty_on_non_2xx() -> None:
    svc = _service()
    client = _client(_resp(404, text="not found"))
    with (
        _patch_project(),
        patch("roboco.services.git.httpx.AsyncClient", return_value=client),
    ):
        assert await svc.get_pr_diff("roboco", _PR) == ""


# ---------------------------------------------------------------------------
# get_latest_ci_conclusion — the self-heal CI telemetry signal
# ---------------------------------------------------------------------------


def _patch_project_ci() -> Any:
    fake = MagicMock()
    fake.get_by_slug = AsyncMock(
        return_value=MagicMock(
            git_url="https://github.com/acme/repo.git", default_branch="master"
        )
    )
    return patch("roboco.services.git.get_project_service", return_value=fake)


def _run(
    conclusion: str, *, head_sha: str = "abc123", attempt: int = 1
) -> dict[str, Any]:
    return {
        "conclusion": conclusion,
        "head_sha": head_sha,
        "html_url": "https://github.com/acme/repo/actions/runs/99",
        "name": "CI",
        "updated_at": "2026-06-17T00:00:00Z",
        "run_attempt": attempt,
    }


@pytest.mark.asyncio
async def test_get_latest_ci_conclusion_normalizes_and_requests_correctly() -> None:
    svc = _service()
    client = _client(_resp(200, json_payload={"workflow_runs": [_run("failure")]}))
    with (
        _patch_project_ci(),
        patch("roboco.services.git.httpx.AsyncClient", return_value=client),
    ):
        out = await svc.get_latest_ci_conclusion("roboco")
    assert out == {
        "conclusion": "failure",
        "head_sha": "abc123",
        "run_url": "https://github.com/acme/repo/actions/runs/99",
        "run_name": "CI",
        "branch": "master",
        "completed_at": "2026-06-17T00:00:00Z",
    }
    call = client.get.await_args
    assert call.args[0].endswith("/repos/acme/repo/actions/runs")
    assert call.kwargs["params"] == {
        "branch": "master",
        "status": "completed",
        "per_page": _CI_RUN_WINDOW,
    }
    assert call.kwargs["headers"]["Authorization"] == "Bearer tok"


@pytest.mark.asyncio
async def test_get_latest_ci_conclusion_none_on_missing_token() -> None:
    svc = _service()
    object.__setattr__(svc, "_token_for_project", AsyncMock(return_value=None))
    with _patch_project_ci():
        assert await svc.get_latest_ci_conclusion("roboco") is None


@pytest.mark.asyncio
async def test_get_latest_ci_conclusion_none_on_github_error() -> None:
    svc = _service()
    client = _client(_resp(403, text="forbidden"))
    with (
        _patch_project_ci(),
        patch("roboco.services.git.httpx.AsyncClient", return_value=client),
    ):
        assert await svc.get_latest_ci_conclusion("roboco") is None


@pytest.mark.asyncio
async def test_get_latest_ci_conclusion_none_when_no_runs() -> None:
    # A repo with no Actions runs (e.g. doesn't use GitHub Actions) → no signal.
    svc = _service()
    client = _client(_resp(200, json_payload={"workflow_runs": []}))
    with (
        _patch_project_ci(),
        patch("roboco.services.git.httpx.AsyncClient", return_value=client),
    ):
        assert await svc.get_latest_ci_conclusion("roboco") is None


@pytest.mark.asyncio
async def test_get_latest_ci_conclusion_scopes_to_workflow() -> None:
    # With a workflow file given, hit the workflow-scoped endpoint (precise signal).
    svc = _service()
    client = _client(_resp(200, json_payload={"workflow_runs": [_run("success")]}))
    with (
        _patch_project_ci(),
        patch("roboco.services.git.httpx.AsyncClient", return_value=client),
    ):
        out = await svc.get_latest_ci_conclusion("roboco", workflow="ci.yml")
    assert out is not None
    assert out["conclusion"] == "success"
    assert client.get.await_args.args[0].endswith("/actions/workflows/ci.yml/runs")


@pytest.mark.asyncio
async def test_get_latest_ci_conclusion_requests_a_window() -> None:
    # The signal pulls a window of recent runs, not just the single newest, so a
    # green run on an older commit can't mask the HEAD's failure.
    svc = _service()
    client = _client(_resp(200, json_payload={"workflow_runs": [_run("failure")]}))
    with (
        _patch_project_ci(),
        patch("roboco.services.git.httpx.AsyncClient", return_value=client),
    ):
        await svc.get_latest_ci_conclusion("roboco", workflow="ci.yml")
    assert client.get.await_args.kwargs["params"]["per_page"] == _CI_RUN_WINDOW


@pytest.mark.asyncio
async def test_get_latest_ci_conclusion_red_head_not_masked_by_older_green() -> None:
    # Newest commit's run FAILED; older commits' runs (different sha) are green.
    # The signal must follow the newest commit — not whatever completed last — so
    # a stale/unrelated green run cannot mask the regression.
    svc = _service()
    payload = {
        "workflow_runs": [
            _run("failure", head_sha="newsha"),
            _run("success", head_sha="oldsha1"),
            _run("success", head_sha="oldsha2"),
        ]
    }
    client = _client(_resp(200, json_payload=payload))
    with (
        _patch_project_ci(),
        patch("roboco.services.git.httpx.AsyncClient", return_value=client),
    ):
        out = await svc.get_latest_ci_conclusion("roboco", workflow="ci.yml")
    assert out is not None
    assert out["conclusion"] == "failure"
    assert out["head_sha"] == "newsha"


@pytest.mark.asyncio
async def test_get_latest_ci_conclusion_green_rerun_supersedes_failure() -> None:
    # Same commit re-run green after a red first attempt → reads green.
    svc = _service()
    payload = {
        "workflow_runs": [
            _run("success", head_sha="sha", attempt=2),
            _run("failure", head_sha="sha", attempt=1),
        ]
    }
    client = _client(_resp(200, json_payload=payload))
    with (
        _patch_project_ci(),
        patch("roboco.services.git.httpx.AsyncClient", return_value=client),
    ):
        out = await svc.get_latest_ci_conclusion("roboco", workflow="ci.yml")
    assert out is not None
    assert out["conclusion"] == "success"


@pytest.mark.asyncio
async def test_get_latest_ci_conclusion_retries_transient_5xx() -> None:
    # A transient 503 must not silently skip the cycle — retry, then succeed.
    svc = _service()
    ok = _resp(200, json_payload={"workflow_runs": [_run("failure")]})
    client = MagicMock()
    client.__aenter__ = AsyncMock(return_value=client)
    client.__aexit__ = AsyncMock(return_value=False)
    client.get = AsyncMock(side_effect=[_resp(503, text="busy"), ok])
    with (
        _patch_project_ci(),
        patch("roboco.services.git.httpx.AsyncClient", return_value=client),
        patch("roboco.services.git.asyncio.sleep", AsyncMock()),
    ):
        out = await svc.get_latest_ci_conclusion("roboco", workflow="ci.yml")
    assert out is not None
    assert out["conclusion"] == "failure"
    expected_get_calls = 2
    assert client.get.await_count == expected_get_calls
