"""pr_target must scope its task lookup by project_id — always.

GitHub numbers PRs per-repo, so ``pr_number`` is ambiguous across projects: a
backend repo's PR #132 and a frontend repo's PR #132 are different PRs. The
bare ``WHERE pr_number == N LIMIT 1`` query returns whichever task row comes
first — the wrong repo's task, whose ``_project_for_task`` then resolves the
wrong project and the GitHub fetch hits the wrong repo.

``project_id`` is therefore MANDATORY: the caller can never resolve a PR
without scoping it to a project, so a same-numbered PR in another project's
repo is unreachable by accident — the pattern ``pr_merge`` already
established (and ``close_pull_request`` follows).
"""

from __future__ import annotations

from pathlib import Path
from typing import TYPE_CHECKING, Any
from unittest.mock import AsyncMock, MagicMock, patch
from uuid import uuid4

import pytest
from roboco.services.base import NotFoundError
from roboco.services.git import GitService

if TYPE_CHECKING:
    from contextlib import AbstractContextManager

_PR_NUMBER = 132


def _make_session(recorder: list[object]) -> MagicMock:
    session = MagicMock()

    async def _execute(stmt: object) -> MagicMock:
        recorder.append(stmt)
        result = MagicMock()
        result.scalar_one_or_none.return_value = None
        return result

    session.execute = AsyncMock(side_effect=_execute)
    return session


def _service(recorder: list[object]) -> GitService:
    return GitService(_make_session(recorder))


def _patch_project_service(project: object | None) -> AbstractContextManager[object]:
    fake_service = MagicMock()
    fake_service.get = AsyncMock(return_value=project)
    fake_service.get_by_slug = AsyncMock(return_value=project)
    return patch("roboco.services.git.get_project_service", return_value=fake_service)


def _compiled_sql(stmt: Any) -> str:
    """Render a SQLAlchemy stmt to literal-bound SQL for assertion."""
    return str(stmt.compile(compile_kwargs={"literal_binds": True}))


@pytest.mark.asyncio
async def test_pr_target_scopes_task_lookup_by_project_id() -> None:
    """The task lookup WHERE clause filters on BOTH pr_number and project_id
    — a same-numbered PR in another project's repo can't be resolved by
    accident."""
    recorder: list[object] = []
    svc = _service(recorder)
    project_id = uuid4()

    # Task lookup returns None → NotFoundError, but we only care about the SQL
    # the lookup was issued with.
    with _patch_project_service(MagicMock(slug="roboco")), pytest.raises(NotFoundError):
        await svc.pr_target(_PR_NUMBER, project_id=project_id)

    assert len(recorder) == 1
    sql = _compiled_sql(recorder[0])
    assert "tasks.pr_number =" in sql
    # The WHERE clause filters on project_id (the select list renders the
    # column as ``tasks.project_id,`` with a trailing comma; the WHERE
    # comparison renders as ``tasks.project_id =``).
    assert "tasks.project_id =" in sql


@pytest.mark.asyncio
async def test_pr_target_requires_project_id() -> None:
    """``project_id`` is mandatory — a caller can NEVER resolve a PR without
    scoping it to a project. Omitting it is a programming error (TypeError at
    call time), not a silent unscoped lookup that could hit another project's
    same-numbered PR (the cross-repo #132 collision)."""
    recorder: list[object] = []
    svc = _service(recorder)

    # Bind to an Any-typed local so mypy doesn't flag the missing project_id;
    # the call still reaches the runtime, where it raises TypeError as asserted.
    target: Any = svc.pr_target
    with pytest.raises(TypeError):
        await target(_PR_NUMBER)

    # The unscoped lookup was never issued — no SQL reached the session.
    assert recorder == []


@pytest.mark.asyncio
async def test_pr_target_with_project_id_skips_wrong_repo_task() -> None:
    """Two tasks share pr_number #132 but live in different projects. With the
    correct ``project_id`` the scoped query returns ONLY the matching task (the
    other repo's task is filtered out), so the GitHub fetch hits the right
    repo. (Here the scoped lookup finds nothing in the requested project →
    NotFoundError, NOT a silent wrong-repo resolution.)"""
    project_id = uuid4()
    other_project_id = uuid4()
    # The wrong-repo task (other project) shares the pr_number.
    wrong_repo_task = MagicMock(
        id=uuid4(), project_id=other_project_id, assigned_to=uuid4()
    )
    recorder: list[object] = []

    session = MagicMock()

    async def _execute(stmt: object) -> MagicMock:
        recorder.append(stmt)
        result = MagicMock()
        # Simulate the DB applying the project_id filter: the scoped query
        # finds no row in the requested project (the only matching pr_number
        # belongs to the OTHER project).
        result.scalar_one_or_none.return_value = None
        return result

    session.execute = AsyncMock(side_effect=_execute)
    svc = GitService(session)
    _bind = object.__setattr__
    _bind(svc, "get_workspace", AsyncMock(return_value=Path("/tmp/ws")))
    _bind(svc, "_parse_github_remote", MagicMock(return_value=("acme", "repo")))
    _bind(svc, "_get_project_token_or_raise", AsyncMock(return_value="token"))

    with _patch_project_service(MagicMock(slug="roboco")), pytest.raises(NotFoundError):
        await svc.pr_target(_PR_NUMBER, project_id=project_id)

    # The query WAS scoped by project_id (the wrong-repo task did not leak in).
    sql = _compiled_sql(recorder[0])
    assert "tasks.project_id =" in sql
    _ = wrong_repo_task  # exists to document the cross-repo collision scenario
